@fragments-sdk/cli 0.7.14 → 0.7.16
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/dist/bin.js +7 -7
- package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
- package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
- package/dist/chunk-WLXFE6XW.js.map +1 -0
- package/dist/core/index.d.ts +44 -3
- package/dist/core/index.js +11 -3
- package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
- package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/init-DIZ6UNBL.js +806 -0
- package/dist/init-DIZ6UNBL.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
- package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
- package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
- package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
- package/dist/{viewer-CNLZQUFO.js → viewer-QKIAPTPG.js} +126 -15
- package/dist/viewer-QKIAPTPG.js.map +1 -0
- package/package.json +3 -2
- package/src/commands/init-framework.ts +414 -0
- package/src/commands/init.ts +41 -1
- package/src/core/__tests__/preview-runtime.test.tsx +111 -0
- package/src/core/index.ts +13 -0
- package/src/core/preview-runtime.tsx +144 -0
- package/src/viewer/components/App.tsx +8 -3
- package/src/viewer/components/FragmentRenderer.tsx +61 -0
- package/src/viewer/components/HealthDashboard.tsx +1 -1
- package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
- package/src/viewer/components/PreviewFrameHost.tsx +27 -60
- package/src/viewer/components/PropsTable.tsx +2 -2
- package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/viewer/components/SkeletonLoader.tsx +114 -125
- package/src/viewer/components/VariantMatrix.tsx +3 -3
- package/src/viewer/components/ViewerStateSync.tsx +52 -0
- package/src/viewer/components/WebMCPDevTools.tsx +509 -0
- package/src/viewer/components/WebMCPIntegration.tsx +47 -0
- package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/viewer/entry.tsx +32 -5
- package/src/viewer/hooks/useA11yService.ts +1 -135
- package/src/viewer/hooks/useCompiledFragments.ts +42 -0
- package/src/viewer/index.html +1 -1
- package/src/viewer/public/favicon.ico +0 -0
- package/src/viewer/server.ts +59 -3
- package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
- package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
- package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
- package/src/viewer/vite-plugin.ts +76 -1
- package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/viewer/webmcp/analytics.ts +165 -0
- package/src/viewer/webmcp/index.ts +3 -0
- package/src/viewer/webmcp/posthog-bridge.ts +39 -0
- package/src/viewer/webmcp/runtime-tools.ts +152 -0
- package/src/viewer/webmcp/scan-utils.ts +135 -0
- package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
- package/src/viewer/webmcp/viewer-state.ts +45 -0
- package/dist/chunk-TQOGBAOZ.js.map +0 -1
- package/dist/init-GID2DXB3.js +0 -498
- package/dist/init-GID2DXB3.js.map +0 -1
- package/dist/viewer-CNLZQUFO.js.map +0 -1
- package/src/viewer/components/StoryRenderer.tsx +0 -121
- /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
- /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
- /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
- /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
- /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
- /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
-
import type { Result } from 'axe-core';
|
|
12
11
|
import type {
|
|
13
12
|
A11yServiceConfig,
|
|
14
|
-
SerializedViolation,
|
|
15
13
|
ComponentScanState,
|
|
16
14
|
ScanStatus,
|
|
17
15
|
} from '../types/a11y.js';
|
|
@@ -22,6 +20,7 @@ import {
|
|
|
22
20
|
isComponentStale,
|
|
23
21
|
getA11ySummary,
|
|
24
22
|
} from './useA11yCache.js';
|
|
23
|
+
import { runAxeScan, type ScanResult } from '../webmcp/scan-utils.js';
|
|
25
24
|
|
|
26
25
|
// Default configuration
|
|
27
26
|
const DEFAULT_CONFIG: A11yServiceConfig = {
|
|
@@ -54,139 +53,6 @@ const serviceState: A11yServiceState = {
|
|
|
54
53
|
componentStates: new Map(),
|
|
55
54
|
};
|
|
56
55
|
|
|
57
|
-
// Cache the axe-core module
|
|
58
|
-
let axeModule: typeof import('axe-core') | null = null;
|
|
59
|
-
|
|
60
|
-
export interface ScanResult {
|
|
61
|
-
violations: SerializedViolation[];
|
|
62
|
-
passes: number;
|
|
63
|
-
incomplete: number;
|
|
64
|
-
counts: {
|
|
65
|
-
critical: number;
|
|
66
|
-
serious: number;
|
|
67
|
-
moderate: number;
|
|
68
|
-
minor: number;
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Convert axe-core Result to SerializedViolation
|
|
74
|
-
*/
|
|
75
|
-
function serializeViolation(result: Result): SerializedViolation {
|
|
76
|
-
return {
|
|
77
|
-
id: result.id,
|
|
78
|
-
impact: result.impact || null,
|
|
79
|
-
description: result.description,
|
|
80
|
-
help: result.help,
|
|
81
|
-
helpUrl: result.helpUrl,
|
|
82
|
-
tags: result.tags,
|
|
83
|
-
nodes: result.nodes.map(node => ({
|
|
84
|
-
html: node.html,
|
|
85
|
-
target: node.target as string[],
|
|
86
|
-
failureSummary: node.failureSummary,
|
|
87
|
-
any: node.any?.map(check => ({
|
|
88
|
-
id: check.id,
|
|
89
|
-
data: check.data,
|
|
90
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
91
|
-
html: rn.html,
|
|
92
|
-
target: rn.target as string[],
|
|
93
|
-
})),
|
|
94
|
-
impact: check.impact,
|
|
95
|
-
message: check.message,
|
|
96
|
-
})),
|
|
97
|
-
all: node.all?.map(check => ({
|
|
98
|
-
id: check.id,
|
|
99
|
-
data: check.data,
|
|
100
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
101
|
-
html: rn.html,
|
|
102
|
-
target: rn.target as string[],
|
|
103
|
-
})),
|
|
104
|
-
impact: check.impact,
|
|
105
|
-
message: check.message,
|
|
106
|
-
})),
|
|
107
|
-
none: node.none?.map(check => ({
|
|
108
|
-
id: check.id,
|
|
109
|
-
data: check.data,
|
|
110
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
111
|
-
html: rn.html,
|
|
112
|
-
target: rn.target as string[],
|
|
113
|
-
})),
|
|
114
|
-
impact: check.impact,
|
|
115
|
-
message: check.message,
|
|
116
|
-
})),
|
|
117
|
-
})),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Run axe-core scan on a target element
|
|
123
|
-
*/
|
|
124
|
-
async function runAxeScan(targetSelector: string): Promise<ScanResult | null> {
|
|
125
|
-
// Load axe-core if not cached
|
|
126
|
-
if (!axeModule) {
|
|
127
|
-
axeModule = await import('axe-core');
|
|
128
|
-
}
|
|
129
|
-
// Handle both ESM default export and CommonJS module
|
|
130
|
-
const axe = (axeModule as { default?: typeof import('axe-core') }).default || axeModule;
|
|
131
|
-
|
|
132
|
-
const target = document.querySelector(targetSelector);
|
|
133
|
-
if (!target) {
|
|
134
|
-
console.warn(`[A11y] Target element not found: ${targetSelector}`);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Configure axe-core
|
|
139
|
-
axe.configure({
|
|
140
|
-
rules: [
|
|
141
|
-
{ id: 'color-contrast', enabled: true },
|
|
142
|
-
{ id: 'image-alt', enabled: true },
|
|
143
|
-
{ id: 'button-name', enabled: true },
|
|
144
|
-
{ id: 'link-name', enabled: true },
|
|
145
|
-
{ id: 'label', enabled: true },
|
|
146
|
-
{ id: 'aria-valid-attr', enabled: true },
|
|
147
|
-
{ id: 'aria-valid-attr-value', enabled: true },
|
|
148
|
-
],
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// Run the scan
|
|
152
|
-
const results = await axe.run(target as HTMLElement, {
|
|
153
|
-
resultTypes: ['violations', 'passes', 'incomplete', 'inapplicable'],
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// Count violations by severity
|
|
157
|
-
const counts = {
|
|
158
|
-
critical: 0,
|
|
159
|
-
serious: 0,
|
|
160
|
-
moderate: 0,
|
|
161
|
-
minor: 0,
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
for (const violation of results.violations) {
|
|
165
|
-
switch (violation.impact) {
|
|
166
|
-
case 'critical':
|
|
167
|
-
counts.critical++;
|
|
168
|
-
break;
|
|
169
|
-
case 'serious':
|
|
170
|
-
counts.serious++;
|
|
171
|
-
break;
|
|
172
|
-
case 'moderate':
|
|
173
|
-
counts.moderate++;
|
|
174
|
-
break;
|
|
175
|
-
case 'minor':
|
|
176
|
-
default:
|
|
177
|
-
counts.minor++;
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
violations: results.violations.map(serializeViolation),
|
|
184
|
-
passes: results.passes.length,
|
|
185
|
-
incomplete: results.incomplete.length,
|
|
186
|
-
counts,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
56
|
/**
|
|
191
57
|
* Process the scan queue
|
|
192
58
|
*/
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { CompiledFragmentsFile } from "@fragments-sdk/context/types";
|
|
3
|
+
|
|
4
|
+
interface UseCompiledFragmentsResult {
|
|
5
|
+
data: CompiledFragmentsFile | null;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useCompiledFragments(): UseCompiledFragmentsResult {
|
|
11
|
+
const [data, setData] = useState<CompiledFragmentsFile | null>(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
|
|
18
|
+
fetch("/fragments/compiled.json")
|
|
19
|
+
.then((res) => {
|
|
20
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
})
|
|
23
|
+
.then((json) => {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
setData(json as CompiledFragmentsFile);
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.catch((err) => {
|
|
30
|
+
if (!cancelled) {
|
|
31
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
32
|
+
setLoading(false);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
};
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return { data, loading, error };
|
|
42
|
+
}
|
package/src/viewer/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Fragments</title>
|
|
7
|
-
<link rel="icon" type="image/
|
|
7
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
10
|
<link
|
|
Binary file
|
package/src/viewer/server.ts
CHANGED
|
@@ -32,6 +32,8 @@ const packagesRoot = resolve(cliPackageRoot, "..");
|
|
|
32
32
|
const localUiLibRoot = resolve(packagesRoot, "../libs/ui/src");
|
|
33
33
|
const localSharedLibRoot = resolve(packagesRoot, "../libs/shared/src");
|
|
34
34
|
const vendoredSharedLibRoot = resolve(viewerRoot, "vendor/shared/src");
|
|
35
|
+
const localWebMCPRoot = resolve(packagesRoot, "webmcp/src");
|
|
36
|
+
const localContextRoot = resolve(packagesRoot, "context/src");
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Resolve the @fragments/ui alias to the correct path.
|
|
@@ -57,6 +59,44 @@ function resolveUiLib(nodeModulesDir: string): string {
|
|
|
57
59
|
return localUiLibRoot;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolve @fragments-sdk/webmcp to monorepo source or installed package.
|
|
64
|
+
*/
|
|
65
|
+
function resolveWebMCPLib(nodeModulesDir: string): string {
|
|
66
|
+
const localIndex = join(localWebMCPRoot, "index.ts");
|
|
67
|
+
if (existsSync(localIndex)) {
|
|
68
|
+
return localWebMCPRoot;
|
|
69
|
+
}
|
|
70
|
+
const installedSrc = join(nodeModulesDir, "@fragments-sdk/webmcp/src/index.ts");
|
|
71
|
+
if (existsSync(installedSrc)) {
|
|
72
|
+
return resolve(dirname(installedSrc));
|
|
73
|
+
}
|
|
74
|
+
const installedDist = join(nodeModulesDir, "@fragments-sdk/webmcp");
|
|
75
|
+
if (existsSync(installedDist)) {
|
|
76
|
+
return installedDist;
|
|
77
|
+
}
|
|
78
|
+
return localWebMCPRoot;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve @fragments-sdk/context to monorepo source or installed package.
|
|
83
|
+
*/
|
|
84
|
+
function resolveContextLib(nodeModulesDir: string): string {
|
|
85
|
+
const localIndex = join(localContextRoot, "index.ts");
|
|
86
|
+
if (existsSync(localIndex)) {
|
|
87
|
+
return localContextRoot;
|
|
88
|
+
}
|
|
89
|
+
const installedSrc = join(nodeModulesDir, "@fragments-sdk/context/src/index.ts");
|
|
90
|
+
if (existsSync(installedSrc)) {
|
|
91
|
+
return resolve(dirname(installedSrc));
|
|
92
|
+
}
|
|
93
|
+
const installedDist = join(nodeModulesDir, "@fragments-sdk/context");
|
|
94
|
+
if (existsSync(installedDist)) {
|
|
95
|
+
return installedDist;
|
|
96
|
+
}
|
|
97
|
+
return localContextRoot;
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
/**
|
|
61
101
|
* Resolve the @fragments-sdk/shared alias to either monorepo source
|
|
62
102
|
* or vendored viewer fallback for npm installs.
|
|
@@ -211,6 +251,8 @@ export async function createDevServer(
|
|
|
211
251
|
const nodeModulesPath = findNodeModules(projectRoot);
|
|
212
252
|
const uiLibRoot = resolveUiLib(nodeModulesPath);
|
|
213
253
|
const sharedLibRoot = resolveSharedLib();
|
|
254
|
+
const webmcpLibRoot = resolveWebMCPLib(nodeModulesPath);
|
|
255
|
+
const contextLibRoot = resolveContextLib(nodeModulesPath);
|
|
214
256
|
console.log(`📁 Using node_modules: ${nodeModulesPath}`);
|
|
215
257
|
|
|
216
258
|
// Collect installed package roots so Vite can serve files from node_modules
|
|
@@ -230,6 +272,7 @@ export async function createDevServer(
|
|
|
230
272
|
const fragmentsConfig: InlineConfig = {
|
|
231
273
|
configFile: false, // Don't load config again
|
|
232
274
|
root: projectRoot, // Run from PROJECT root
|
|
275
|
+
publicDir: resolve(viewerRoot, "public"), // Serve static assets (favicon) from viewer
|
|
233
276
|
base: "/",
|
|
234
277
|
|
|
235
278
|
server: {
|
|
@@ -237,7 +280,7 @@ export async function createDevServer(
|
|
|
237
280
|
open: open ? "/fragments/" : false,
|
|
238
281
|
fs: {
|
|
239
282
|
// Allow serving files from viewer package, project, shared libs, and node_modules root
|
|
240
|
-
allow: [viewerRoot, uiLibRoot, sharedLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
|
|
283
|
+
allow: [viewerRoot, uiLibRoot, sharedLibRoot, webmcpLibRoot, contextLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
|
|
241
284
|
},
|
|
242
285
|
},
|
|
243
286
|
|
|
@@ -256,8 +299,13 @@ export async function createDevServer(
|
|
|
256
299
|
}),
|
|
257
300
|
],
|
|
258
301
|
|
|
259
|
-
// CSS configuration
|
|
260
|
-
|
|
302
|
+
// CSS configuration — preserve original hyphenated class names in CSS modules
|
|
303
|
+
// Vite 6 defaults to camelCaseOnly, but our components use styles['gap-sm'] etc.
|
|
304
|
+
css: {
|
|
305
|
+
modules: {
|
|
306
|
+
localsConvention: 'camelCase',
|
|
307
|
+
},
|
|
308
|
+
},
|
|
261
309
|
|
|
262
310
|
optimizeDeps: {
|
|
263
311
|
// Include common dependencies for faster startup
|
|
@@ -273,6 +321,14 @@ export async function createDevServer(
|
|
|
273
321
|
"@fragments-sdk/ui": uiLibRoot,
|
|
274
322
|
// Resolve @fragments-sdk/shared to monorepo source or vendored fallback
|
|
275
323
|
"@fragments-sdk/shared": sharedLibRoot,
|
|
324
|
+
// Resolve @fragments-sdk/webmcp subpaths to monorepo source or installed package
|
|
325
|
+
"@fragments-sdk/webmcp/react": join(webmcpLibRoot, "react/index.ts"),
|
|
326
|
+
"@fragments-sdk/webmcp/fragments": join(webmcpLibRoot, "fragments/index.ts"),
|
|
327
|
+
"@fragments-sdk/webmcp": webmcpLibRoot,
|
|
328
|
+
// Resolve @fragments-sdk/context subpaths to monorepo source or installed package
|
|
329
|
+
"@fragments-sdk/context/types": join(contextLibRoot, "types/index.ts"),
|
|
330
|
+
"@fragments-sdk/context/mcp-tools": join(contextLibRoot, "mcp-tools/index.ts"),
|
|
331
|
+
"@fragments-sdk/context": contextLibRoot,
|
|
276
332
|
// Resolve @fragments-sdk/cli/core to the CLI's own core source
|
|
277
333
|
"@fragments-sdk/cli/core": resolve(cliPackageRoot, "src/core/index.ts"),
|
|
278
334
|
// Ensure ALL react imports resolve to project's node_modules
|
|
@@ -85,7 +85,26 @@ export function DocsHeaderBar({
|
|
|
85
85
|
|
|
86
86
|
<NavigationMenu.Viewport />
|
|
87
87
|
|
|
88
|
+
<NavigationMenu.MobileBrand>{brand}</NavigationMenu.MobileBrand>
|
|
89
|
+
|
|
88
90
|
<NavigationMenu.MobileContent>
|
|
91
|
+
{/* Render all headerNav items in the mobile drawer */}
|
|
92
|
+
<NavigationMenu.MobileSection>
|
|
93
|
+
{headerNav.map((entry) =>
|
|
94
|
+
isDropdown(entry) ? (
|
|
95
|
+
entry.items.map((child) => (
|
|
96
|
+
<NavigationMenu.Link key={child.href} href={child.href} asChild>
|
|
97
|
+
{renderLink({ href: child.href, label: child.label })}
|
|
98
|
+
</NavigationMenu.Link>
|
|
99
|
+
))
|
|
100
|
+
) : (
|
|
101
|
+
<NavigationMenu.Link key={entry.href} href={entry.href} asChild>
|
|
102
|
+
{renderLink({ href: entry.href, label: entry.label })}
|
|
103
|
+
</NavigationMenu.Link>
|
|
104
|
+
)
|
|
105
|
+
)}
|
|
106
|
+
</NavigationMenu.MobileSection>
|
|
107
|
+
|
|
89
108
|
{mobileSections.map((section) => (
|
|
90
109
|
<NavigationMenu.MobileSection key={section.title} label={section.title}>
|
|
91
110
|
{section.items.map((item) => (
|
|
@@ -52,7 +52,7 @@ export function useDocsPageAside() {
|
|
|
52
52
|
export function DocsPageAsidePortal({ children, width = '320px' }: { children: React.ReactNode; width?: string }) {
|
|
53
53
|
const { setAsideVisible, setAsideWidth, asideContainer } = useDocsPageAside();
|
|
54
54
|
|
|
55
|
-
React.
|
|
55
|
+
React.useLayoutEffect(() => {
|
|
56
56
|
setAsideVisible(true);
|
|
57
57
|
setAsideWidth(width);
|
|
58
58
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { useEffect, useMemo,
|
|
3
|
+
import { Command, Dialog, Button, Text, Stack } from '@fragments-sdk/ui';
|
|
4
|
+
import { useEffect, useMemo, useState, useCallback } from 'react';
|
|
5
5
|
import type { SearchItem } from './types';
|
|
6
6
|
|
|
7
7
|
interface DocsSearchCommandProps {
|
|
@@ -14,121 +14,86 @@ interface DocsSearchCommandProps {
|
|
|
14
14
|
export function DocsSearchCommand({
|
|
15
15
|
searchItems,
|
|
16
16
|
onSelect,
|
|
17
|
-
placeholder = 'Search...',
|
|
18
|
-
maxResults =
|
|
17
|
+
placeholder = 'Search docs...',
|
|
18
|
+
maxResults = 9999,
|
|
19
19
|
}: DocsSearchCommandProps) {
|
|
20
|
-
const [
|
|
21
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
22
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
23
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
25
|
-
|
|
26
|
-
const results = useMemo(() => {
|
|
27
|
-
if (!query.trim()) return [];
|
|
28
|
-
const lowerQuery = query.toLowerCase();
|
|
29
|
-
return searchItems
|
|
30
|
-
.filter((item) => item.label.toLowerCase().includes(lowerQuery) || item.section.toLowerCase().includes(lowerQuery))
|
|
31
|
-
.slice(0, maxResults);
|
|
32
|
-
}, [maxResults, query, searchItems]);
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
setSelectedIndex(0);
|
|
36
|
-
}, [results]);
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
37
21
|
|
|
22
|
+
// Cmd+K / Ctrl+K to open
|
|
38
23
|
useEffect(() => {
|
|
39
|
-
const
|
|
24
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
40
25
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
41
26
|
event.preventDefault();
|
|
42
|
-
|
|
43
|
-
setIsOpen(true);
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
document.addEventListener('keydown', handleGlobalKeyDown);
|
|
48
|
-
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
53
|
-
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
54
|
-
setIsOpen(false);
|
|
27
|
+
setOpen((prev) => !prev);
|
|
55
28
|
}
|
|
56
29
|
};
|
|
57
30
|
|
|
58
|
-
document.addEventListener('
|
|
59
|
-
return () => document.removeEventListener('
|
|
31
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
32
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
60
33
|
}, []);
|
|
61
34
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!isOpen || results.length === 0) return;
|
|
70
|
-
|
|
71
|
-
switch (event.key) {
|
|
72
|
-
case 'ArrowDown':
|
|
73
|
-
event.preventDefault();
|
|
74
|
-
setSelectedIndex((prev) => (prev + 1) % results.length);
|
|
75
|
-
break;
|
|
76
|
-
case 'ArrowUp':
|
|
77
|
-
event.preventDefault();
|
|
78
|
-
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
|
79
|
-
break;
|
|
80
|
-
case 'Enter':
|
|
81
|
-
event.preventDefault();
|
|
82
|
-
if (results[selectedIndex]) {
|
|
83
|
-
handleSelect(results[selectedIndex]);
|
|
84
|
-
inputRef.current?.blur();
|
|
85
|
-
}
|
|
86
|
-
break;
|
|
87
|
-
case 'Escape':
|
|
88
|
-
setIsOpen(false);
|
|
89
|
-
inputRef.current?.blur();
|
|
90
|
-
break;
|
|
91
|
-
default:
|
|
92
|
-
break;
|
|
35
|
+
// Group items by section
|
|
36
|
+
const grouped = useMemo(() => {
|
|
37
|
+
const map = new Map<string, SearchItem[]>();
|
|
38
|
+
for (const item of searchItems) {
|
|
39
|
+
const section = item.section || 'Navigation';
|
|
40
|
+
if (!map.has(section)) map.set(section, []);
|
|
41
|
+
map.get(section)!.push(item);
|
|
93
42
|
}
|
|
94
|
-
|
|
43
|
+
return map;
|
|
44
|
+
}, [searchItems]);
|
|
45
|
+
|
|
46
|
+
const handleSelect = useCallback(
|
|
47
|
+
(item: SearchItem) => {
|
|
48
|
+
setOpen(false);
|
|
49
|
+
onSelect(item);
|
|
50
|
+
},
|
|
51
|
+
[onSelect],
|
|
52
|
+
);
|
|
95
53
|
|
|
96
54
|
return (
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
placeholder={placeholder}
|
|
101
|
-
aria-label="Search"
|
|
102
|
-
value={query}
|
|
103
|
-
onChange={(value) => {
|
|
104
|
-
setQuery(value);
|
|
105
|
-
setIsOpen(true);
|
|
106
|
-
}}
|
|
107
|
-
onFocus={() => setIsOpen(true)}
|
|
108
|
-
onKeyDown={handleKeyDown}
|
|
109
|
-
shortcut="⌘K"
|
|
55
|
+
<>
|
|
56
|
+
<Button
|
|
57
|
+
variant="secondary"
|
|
110
58
|
size="sm"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
59
|
+
onClick={() => setOpen(true)}
|
|
60
|
+
aria-label="Search documentation"
|
|
61
|
+
style={{ minWidth: 160, justifyContent: 'space-between' }}
|
|
62
|
+
>
|
|
63
|
+
<Text size="sm" color="secondary">Search...</Text>
|
|
64
|
+
<Text size="xs" color="secondary" font="mono">⌘K</Text>
|
|
65
|
+
</Button>
|
|
66
|
+
|
|
67
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
68
|
+
<Dialog.Content size="sm" style={{ padding: 0, overflow: 'hidden' }}>
|
|
69
|
+
<Command
|
|
70
|
+
filter={(value, search) => {
|
|
71
|
+
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
|
72
|
+
return 0;
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Command.Input placeholder={placeholder} />
|
|
76
|
+
<Command.List>
|
|
77
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
78
|
+
{[...grouped.entries()].map(([section, items]) => (
|
|
79
|
+
<Command.Group key={section} heading={section}>
|
|
80
|
+
{items.slice(0, maxResults).map((item) => (
|
|
81
|
+
<Command.Item
|
|
82
|
+
key={`${item.section}:${item.href}`}
|
|
83
|
+
value={`${item.label} ${item.section}`}
|
|
84
|
+
onItemSelect={() => handleSelect(item)}
|
|
85
|
+
>
|
|
86
|
+
<Stack direction="row" align="center" justify="between" style={{ width: '100%' }}>
|
|
87
|
+
<Text size="sm">{item.label}</Text>
|
|
88
|
+
</Stack>
|
|
89
|
+
</Command.Item>
|
|
90
|
+
))}
|
|
91
|
+
</Command.Group>
|
|
92
|
+
))}
|
|
93
|
+
</Command.List>
|
|
94
|
+
</Command>
|
|
95
|
+
</Dialog.Content>
|
|
96
|
+
</Dialog>
|
|
97
|
+
</>
|
|
133
98
|
);
|
|
134
99
|
}
|