@stigmer/react 0.0.68 → 0.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +12 -3
- package/composer/SessionComposer.js.map +1 -1
- package/demo/__tests__/demo-client.test.d.ts +2 -0
- package/demo/__tests__/demo-client.test.d.ts.map +1 -0
- package/demo/__tests__/demo-client.test.js +133 -0
- package/demo/__tests__/demo-client.test.js.map +1 -0
- package/demo/__tests__/fixtures.test.d.ts +2 -0
- package/demo/__tests__/fixtures.test.d.ts.map +1 -0
- package/demo/__tests__/fixtures.test.js +135 -0
- package/demo/__tests__/fixtures.test.js.map +1 -0
- package/demo/__tests__/samples.test.d.ts +2 -0
- package/demo/__tests__/samples.test.d.ts.map +1 -0
- package/demo/__tests__/samples.test.js +152 -0
- package/demo/__tests__/samples.test.js.map +1 -0
- package/demo/client.d.ts +29 -0
- package/demo/client.d.ts.map +1 -0
- package/demo/client.js +52 -0
- package/demo/client.js.map +1 -0
- package/demo/fixtures.d.ts +190 -0
- package/demo/fixtures.d.ts.map +1 -0
- package/demo/fixtures.js +263 -0
- package/demo/fixtures.js.map +1 -0
- package/demo/index.d.ts +6 -0
- package/demo/index.d.ts.map +1 -0
- package/demo/index.js +6 -0
- package/demo/index.js.map +1 -0
- package/demo/samples.d.ts +166 -0
- package/demo/samples.d.ts.map +1 -0
- package/demo/samples.js +308 -0
- package/demo/samples.js.map +1 -0
- package/demo/transport.d.ts +59 -0
- package/demo/transport.d.ts.map +1 -0
- package/demo/transport.js +75 -0
- package/demo/transport.js.map +1 -0
- package/demo/types.d.ts +62 -0
- package/demo/types.d.ts.map +1 -0
- package/demo/types.js +16 -0
- package/demo/types.js.map +1 -0
- package/environment/EnvVarForm.d.ts.map +1 -1
- package/environment/EnvVarForm.js +1 -1
- package/environment/EnvVarForm.js.map +1 -1
- package/environment/__tests__/systemEnvVars.test.d.ts +2 -0
- package/environment/__tests__/systemEnvVars.test.d.ts.map +1 -0
- package/environment/__tests__/systemEnvVars.test.js +76 -0
- package/environment/__tests__/systemEnvVars.test.js.map +1 -0
- package/environment/index.d.ts +1 -0
- package/environment/index.d.ts.map +1 -1
- package/environment/index.js +1 -0
- package/environment/index.js.map +1 -1
- package/environment/systemEnvVars.d.ts +52 -0
- package/environment/systemEnvVars.d.ts.map +1 -0
- package/environment/systemEnvVars.js +91 -0
- package/environment/systemEnvVars.js.map +1 -0
- package/execution/ApprovalCard.d.ts.map +1 -1
- package/execution/ApprovalCard.js +3 -3
- package/execution/ApprovalCard.js.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/Tabs.d.ts +41 -0
- package/internal/Tabs.d.ts.map +1 -0
- package/internal/Tabs.js +65 -0
- package/internal/Tabs.js.map +1 -0
- package/mcp-server/McpServerDetailView.d.ts +33 -7
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +53 -37
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/mcp-server/useMcpServerCredentials.d.ts.map +1 -1
- package/mcp-server/useMcpServerCredentials.js +2 -1
- package/mcp-server/useMcpServerCredentials.js.map +1 -1
- package/models/index.d.ts +1 -1
- package/models/index.d.ts.map +1 -1
- package/models/index.js +1 -1
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +10 -0
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +13 -0
- package/models/registry.js.map +1 -1
- package/models/useModelRegistry.d.ts.map +1 -1
- package/models/useModelRegistry.js +7 -3
- package/models/useModelRegistry.js.map +1 -1
- package/package.json +9 -5
- package/src/composer/SessionComposer.tsx +21 -3
- package/src/demo/__tests__/demo-client.test.tsx +213 -0
- package/src/demo/__tests__/fixtures.test.ts +214 -0
- package/src/demo/__tests__/samples.test.ts +171 -0
- package/src/demo/client.ts +78 -0
- package/src/demo/fixtures.ts +401 -0
- package/src/demo/index.ts +12 -0
- package/src/demo/samples.ts +470 -0
- package/src/demo/transport.ts +116 -0
- package/src/demo/types.ts +69 -0
- package/src/environment/EnvVarForm.tsx +1 -0
- package/src/environment/__tests__/systemEnvVars.test.ts +120 -0
- package/src/environment/index.ts +6 -0
- package/src/environment/systemEnvVars.ts +104 -0
- package/src/execution/ApprovalCard.tsx +4 -0
- package/src/index.ts +5 -1
- package/src/internal/Tabs.tsx +166 -0
- package/src/mcp-server/McpServerDetailView.tsx +273 -204
- package/src/mcp-server/useMcpServerCredentials.ts +4 -1
- package/src/models/index.ts +1 -1
- package/src/models/registry.ts +14 -0
- package/src/models/useModelRegistry.ts +7 -2
- package/styles.css +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
toGrpcAddress,
|
|
4
|
+
buildSystemEnvVars,
|
|
5
|
+
SYSTEM_ENV_VAR_KEYS,
|
|
6
|
+
} from "../systemEnvVars";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// toGrpcAddress
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe("toGrpcAddress", () => {
|
|
13
|
+
it("extracts host and explicit port from http URL", () => {
|
|
14
|
+
expect(toGrpcAddress("http://localhost:7234")).toBe("localhost:7234");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("defaults to port 443 for https without explicit port", () => {
|
|
18
|
+
expect(toGrpcAddress("https://api.stigmer.ai")).toBe(
|
|
19
|
+
"api.stigmer.ai:443",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("preserves explicit port on https URL", () => {
|
|
24
|
+
expect(toGrpcAddress("https://api.stigmer.ai:8443")).toBe(
|
|
25
|
+
"api.stigmer.ai:8443",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("defaults to port 80 for http without explicit port", () => {
|
|
30
|
+
expect(toGrpcAddress("http://api.local")).toBe("api.local:80");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("handles IPv6 localhost", () => {
|
|
34
|
+
expect(toGrpcAddress("http://[::1]:7234")).toBe("[::1]:7234");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns input unchanged for non-URL strings", () => {
|
|
38
|
+
expect(toGrpcAddress("not-a-url")).toBe("not-a-url");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles trailing slash", () => {
|
|
42
|
+
expect(toGrpcAddress("http://localhost:7234/")).toBe("localhost:7234");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("strips path components", () => {
|
|
46
|
+
expect(toGrpcAddress("https://api.stigmer.ai/v1/rpc")).toBe(
|
|
47
|
+
"api.stigmer.ai:443",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// buildSystemEnvVars
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe("buildSystemEnvVars", () => {
|
|
57
|
+
it("returns both system env vars", () => {
|
|
58
|
+
const result = buildSystemEnvVars(
|
|
59
|
+
"http://localhost:7234",
|
|
60
|
+
"test-token",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(Object.keys(result)).toHaveLength(2);
|
|
64
|
+
expect(result).toHaveProperty("STIGMER_SERVER_ADDRESS");
|
|
65
|
+
expect(result).toHaveProperty("STIGMER_API_KEY");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("derives gRPC address from baseUrl", () => {
|
|
69
|
+
const result = buildSystemEnvVars(
|
|
70
|
+
"https://api.stigmer.ai",
|
|
71
|
+
"tok",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(result.STIGMER_SERVER_ADDRESS.value).toBe(
|
|
75
|
+
"api.stigmer.ai:443",
|
|
76
|
+
);
|
|
77
|
+
expect(result.STIGMER_SERVER_ADDRESS.isSecret).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses credential as API key value", () => {
|
|
81
|
+
const result = buildSystemEnvVars(
|
|
82
|
+
"http://localhost:7234",
|
|
83
|
+
"my-api-key",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result.STIGMER_API_KEY.value).toBe("my-api-key");
|
|
87
|
+
expect(result.STIGMER_API_KEY.isSecret).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('uses "unused" placeholder when credential is null', () => {
|
|
91
|
+
const result = buildSystemEnvVars("http://localhost:7234", null);
|
|
92
|
+
|
|
93
|
+
expect(result.STIGMER_API_KEY.value).toBe("unused");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('uses "unused" placeholder when credential is empty string', () => {
|
|
97
|
+
const result = buildSystemEnvVars("http://localhost:7234", "");
|
|
98
|
+
|
|
99
|
+
expect(result.STIGMER_API_KEY.value).toBe("unused");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keys match SYSTEM_ENV_VAR_KEYS constant", () => {
|
|
103
|
+
const result = buildSystemEnvVars("http://localhost:7234", "tok");
|
|
104
|
+
const resultKeys = new Set(Object.keys(result));
|
|
105
|
+
|
|
106
|
+
expect(resultKeys).toEqual(SYSTEM_ENV_VAR_KEYS);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// SYSTEM_ENV_VAR_KEYS
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe("SYSTEM_ENV_VAR_KEYS", () => {
|
|
115
|
+
it("contains exactly the two expected keys", () => {
|
|
116
|
+
expect(SYSTEM_ENV_VAR_KEYS.size).toBe(2);
|
|
117
|
+
expect(SYSTEM_ENV_VAR_KEYS.has("STIGMER_SERVER_ADDRESS")).toBe(true);
|
|
118
|
+
expect(SYSTEM_ENV_VAR_KEYS.has("STIGMER_API_KEY")).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/environment/index.ts
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { EnvVarInput, Stigmer } from "@stigmer/sdk";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Well-known Stigmer platform environment variable keys
|
|
5
|
+
//
|
|
6
|
+
// These env vars configure MCP server subprocesses (and agents) to
|
|
7
|
+
// communicate back to the Stigmer backend. Because the SDK client
|
|
8
|
+
// already knows the server address and auth credential, the setup
|
|
9
|
+
// hooks can skip prompting users for these values and inject them
|
|
10
|
+
// automatically at session creation time.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const STIGMER_SERVER_ADDRESS = "STIGMER_SERVER_ADDRESS";
|
|
14
|
+
const STIGMER_API_KEY = "STIGMER_API_KEY";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Environment variable keys that the SDK can resolve automatically
|
|
18
|
+
* from the current {@link Stigmer} client context.
|
|
19
|
+
*
|
|
20
|
+
* Used by setup hooks to exclude these keys from the "missing
|
|
21
|
+
* variables" prompt and by the session composer to inject their
|
|
22
|
+
* values into runtime env at submit time.
|
|
23
|
+
*
|
|
24
|
+
* Platform builders who manage setup hooks directly can use this
|
|
25
|
+
* set to extend their own `poolKeys`.
|
|
26
|
+
*/
|
|
27
|
+
export const SYSTEM_ENV_VAR_KEYS: ReadonlySet<string> = new Set([
|
|
28
|
+
STIGMER_SERVER_ADDRESS,
|
|
29
|
+
STIGMER_API_KEY,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert an HTTP(S) base URL to a gRPC host:port address.
|
|
34
|
+
*
|
|
35
|
+
* The Stigmer server serves both gRPC and gRPC-Web on the same
|
|
36
|
+
* endpoint, so stripping the protocol and extracting host:port
|
|
37
|
+
* produces a valid gRPC dial target.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* toGrpcAddress("http://localhost:7234") // "localhost:7234"
|
|
42
|
+
* toGrpcAddress("https://api.stigmer.ai") // "api.stigmer.ai:443"
|
|
43
|
+
* toGrpcAddress("https://api.stigmer.ai:8443") // "api.stigmer.ai:8443"
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function toGrpcAddress(httpUrl: string): string {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(httpUrl);
|
|
49
|
+
const host = url.hostname;
|
|
50
|
+
const port =
|
|
51
|
+
url.port || (url.protocol === "https:" ? "443" : "80");
|
|
52
|
+
return `${host}:${port}`;
|
|
53
|
+
} catch {
|
|
54
|
+
return httpUrl;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build system env var entries from raw connection parameters.
|
|
60
|
+
*
|
|
61
|
+
* Pure function — no side effects, no async. Suitable for unit
|
|
62
|
+
* testing without a live Stigmer client.
|
|
63
|
+
*
|
|
64
|
+
* @param baseUrl - The Stigmer client's base URL (HTTP).
|
|
65
|
+
* @param credential - Current auth credential, or `null` for
|
|
66
|
+
* unauthenticated (OSS) backends. When `null`, a placeholder
|
|
67
|
+
* value is used so the MCP server env var is always populated.
|
|
68
|
+
*/
|
|
69
|
+
export function buildSystemEnvVars(
|
|
70
|
+
baseUrl: string,
|
|
71
|
+
credential: string | null,
|
|
72
|
+
): Record<string, EnvVarInput> {
|
|
73
|
+
return {
|
|
74
|
+
[STIGMER_SERVER_ADDRESS]: {
|
|
75
|
+
value: toGrpcAddress(baseUrl),
|
|
76
|
+
isSecret: false,
|
|
77
|
+
description:
|
|
78
|
+
"Auto-resolved from the current Stigmer connection.",
|
|
79
|
+
},
|
|
80
|
+
[STIGMER_API_KEY]: {
|
|
81
|
+
value: credential || "unused",
|
|
82
|
+
isSecret: true,
|
|
83
|
+
description:
|
|
84
|
+
"Auto-resolved from the current Stigmer auth context.",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve system env var values from a live {@link Stigmer} client.
|
|
91
|
+
*
|
|
92
|
+
* Calls {@link Stigmer.getAuthCredential} to obtain the current
|
|
93
|
+
* credential, then delegates to {@link buildSystemEnvVars}.
|
|
94
|
+
*
|
|
95
|
+
* Intended for use at session submit time — the returned values
|
|
96
|
+
* are injected into `runtimeEnv` at the **lowest priority** so
|
|
97
|
+
* any user-provided values (personal env, manual secrets) win.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveSystemEnvVarValues(
|
|
100
|
+
stigmer: Stigmer,
|
|
101
|
+
): Promise<Record<string, EnvVarInput>> {
|
|
102
|
+
const credential = await stigmer.getAuthCredential();
|
|
103
|
+
return buildSystemEnvVars(stigmer.baseUrl, credential);
|
|
104
|
+
}
|
|
@@ -173,6 +173,7 @@ export function ApprovalCard({
|
|
|
173
173
|
isSubmitting={isSubmitting}
|
|
174
174
|
onClick={handleAction}
|
|
175
175
|
variant="approve"
|
|
176
|
+
cursorTarget="approve-button"
|
|
176
177
|
/>
|
|
177
178
|
<ActionButton
|
|
178
179
|
label="Skip"
|
|
@@ -207,6 +208,7 @@ function ActionButton({
|
|
|
207
208
|
isSubmitting,
|
|
208
209
|
onClick,
|
|
209
210
|
variant,
|
|
211
|
+
cursorTarget,
|
|
210
212
|
}: {
|
|
211
213
|
label: string;
|
|
212
214
|
action: ApprovalAction;
|
|
@@ -214,6 +216,7 @@ function ActionButton({
|
|
|
214
216
|
isSubmitting: boolean;
|
|
215
217
|
onClick: (action: ApprovalAction) => void;
|
|
216
218
|
variant: "approve" | "skip" | "reject";
|
|
219
|
+
cursorTarget?: string;
|
|
217
220
|
}) {
|
|
218
221
|
const isActive = activeAction === action;
|
|
219
222
|
const disabled = isSubmitting;
|
|
@@ -239,6 +242,7 @@ function ActionButton({
|
|
|
239
242
|
disabled={disabled}
|
|
240
243
|
onClick={() => onClick(action)}
|
|
241
244
|
aria-label={label}
|
|
245
|
+
data-cursor-target={cursorTarget}
|
|
242
246
|
className={cn(
|
|
243
247
|
"inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
|
244
248
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
package/src/index.ts
CHANGED
|
@@ -327,7 +327,7 @@ export type {
|
|
|
327
327
|
UseDefaultAgentReturn,
|
|
328
328
|
} from "./agent";
|
|
329
329
|
|
|
330
|
-
// Environment — data hooks, list hook, personal convenience hook, secret reveal, variable management, env var form, and styled components
|
|
330
|
+
// Environment — data hooks, list hook, personal convenience hook, secret reveal, variable management, env var form, system env vars, and styled components
|
|
331
331
|
export {
|
|
332
332
|
useEnvironment,
|
|
333
333
|
useEnvironmentList,
|
|
@@ -342,6 +342,10 @@ export {
|
|
|
342
342
|
CreateEnvironmentForm,
|
|
343
343
|
EnvVarForm,
|
|
344
344
|
useSessionEnvPool,
|
|
345
|
+
SYSTEM_ENV_VAR_KEYS,
|
|
346
|
+
toGrpcAddress,
|
|
347
|
+
buildSystemEnvVars,
|
|
348
|
+
resolveSystemEnvVarValues,
|
|
345
349
|
} from "./environment";
|
|
346
350
|
export type {
|
|
347
351
|
UseEnvironmentReturn,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useId, useRef, type KeyboardEvent, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "@stigmer/theme";
|
|
5
|
+
|
|
6
|
+
export interface TabItem {
|
|
7
|
+
/** Unique identifier for the tab, used as the `activeTab` value. */
|
|
8
|
+
readonly id: string;
|
|
9
|
+
/** Display label shown in the tab trigger. */
|
|
10
|
+
readonly label: string;
|
|
11
|
+
/** Optional numeric badge rendered next to the label (e.g. item count). */
|
|
12
|
+
readonly badge?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TabsProps {
|
|
16
|
+
/** Ordered list of tabs to render. Tabs with no matching content are still clickable. */
|
|
17
|
+
readonly tabs: readonly TabItem[];
|
|
18
|
+
/** The `id` of the currently active tab. */
|
|
19
|
+
readonly activeTab: string;
|
|
20
|
+
/** Called when the user selects a different tab. */
|
|
21
|
+
readonly onTabChange: (tabId: string) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Content to render in the active tab panel.
|
|
24
|
+
* Typically a switch/map over `activeTab`.
|
|
25
|
+
*/
|
|
26
|
+
readonly children: ReactNode;
|
|
27
|
+
/** Accessible label for the tab list (e.g. "Capability sections"). */
|
|
28
|
+
readonly "aria-label"?: string;
|
|
29
|
+
/** Additional CSS classes for the root container. */
|
|
30
|
+
readonly className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Accessible tabbed panel with badge support.
|
|
35
|
+
*
|
|
36
|
+
* Implements the WAI-ARIA Tabs pattern:
|
|
37
|
+
* - `role="tablist"` / `role="tab"` / `role="tabpanel"`
|
|
38
|
+
* - Arrow-key navigation (Left/Right), Home/End to jump
|
|
39
|
+
* - `aria-selected`, `aria-controls`, `aria-labelledby` wiring
|
|
40
|
+
*
|
|
41
|
+
* All visual properties flow through `--stgm-*` design tokens.
|
|
42
|
+
* No external dependencies — safe for platform builder embedding.
|
|
43
|
+
*
|
|
44
|
+
* @internal — not yet part of the public `@stigmer/react` API.
|
|
45
|
+
*/
|
|
46
|
+
export function Tabs({
|
|
47
|
+
tabs,
|
|
48
|
+
activeTab,
|
|
49
|
+
onTabChange,
|
|
50
|
+
children,
|
|
51
|
+
"aria-label": ariaLabel,
|
|
52
|
+
className,
|
|
53
|
+
}: TabsProps) {
|
|
54
|
+
const instanceId = useId();
|
|
55
|
+
const tabRefsMap = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
56
|
+
|
|
57
|
+
const tabId = (id: string) => `${instanceId}-tab-${id}`;
|
|
58
|
+
const panelId = (id: string) => `${instanceId}-panel-${id}`;
|
|
59
|
+
|
|
60
|
+
const focusTab = useCallback(
|
|
61
|
+
(id: string) => {
|
|
62
|
+
tabRefsMap.current.get(id)?.focus();
|
|
63
|
+
onTabChange(id);
|
|
64
|
+
},
|
|
65
|
+
[onTabChange],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const handleKeyDown = useCallback(
|
|
69
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
70
|
+
const currentIndex = tabs.findIndex((t) => t.id === activeTab);
|
|
71
|
+
if (currentIndex === -1) return;
|
|
72
|
+
|
|
73
|
+
let nextIndex: number | null = null;
|
|
74
|
+
|
|
75
|
+
switch (e.key) {
|
|
76
|
+
case "ArrowRight":
|
|
77
|
+
nextIndex = (currentIndex + 1) % tabs.length;
|
|
78
|
+
break;
|
|
79
|
+
case "ArrowLeft":
|
|
80
|
+
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
|
81
|
+
break;
|
|
82
|
+
case "Home":
|
|
83
|
+
nextIndex = 0;
|
|
84
|
+
break;
|
|
85
|
+
case "End":
|
|
86
|
+
nextIndex = tabs.length - 1;
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
focusTab(tabs[nextIndex].id);
|
|
94
|
+
},
|
|
95
|
+
[tabs, activeTab, focusTab],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={cn("flex flex-col", className)}>
|
|
100
|
+
<div
|
|
101
|
+
role="tablist"
|
|
102
|
+
aria-label={ariaLabel}
|
|
103
|
+
onKeyDown={handleKeyDown}
|
|
104
|
+
className="flex border-b border-border"
|
|
105
|
+
>
|
|
106
|
+
{tabs.map((tab) => {
|
|
107
|
+
const isActive = tab.id === activeTab;
|
|
108
|
+
return (
|
|
109
|
+
<button
|
|
110
|
+
key={tab.id}
|
|
111
|
+
ref={(el) => {
|
|
112
|
+
if (el) tabRefsMap.current.set(tab.id, el);
|
|
113
|
+
else tabRefsMap.current.delete(tab.id);
|
|
114
|
+
}}
|
|
115
|
+
id={tabId(tab.id)}
|
|
116
|
+
role="tab"
|
|
117
|
+
type="button"
|
|
118
|
+
aria-selected={isActive}
|
|
119
|
+
aria-controls={panelId(tab.id)}
|
|
120
|
+
tabIndex={isActive ? 0 : -1}
|
|
121
|
+
onClick={() => onTabChange(tab.id)}
|
|
122
|
+
data-cursor-target={`tab-${tab.id}`}
|
|
123
|
+
className={cn(
|
|
124
|
+
"relative inline-flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors",
|
|
125
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
126
|
+
isActive
|
|
127
|
+
? "text-foreground"
|
|
128
|
+
: "text-muted-foreground hover:text-foreground",
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{tab.label}
|
|
132
|
+
{tab.badge != null && tab.badge > 0 && (
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
"inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1 py-px text-[10px] font-medium leading-none",
|
|
136
|
+
isActive
|
|
137
|
+
? "bg-primary/10 text-primary"
|
|
138
|
+
: "bg-muted text-muted-foreground",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{tab.badge}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
{isActive && (
|
|
145
|
+
<span
|
|
146
|
+
className="absolute inset-x-0 -bottom-px h-0.5 bg-primary"
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</button>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div
|
|
156
|
+
id={panelId(activeTab)}
|
|
157
|
+
role="tabpanel"
|
|
158
|
+
aria-labelledby={tabId(activeTab)}
|
|
159
|
+
tabIndex={0}
|
|
160
|
+
className="focus-visible:outline-none"
|
|
161
|
+
>
|
|
162
|
+
{children}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|