@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.
Files changed (67) hide show
  1. package/dist/bin.js +7 -7
  2. package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
  3. package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
  4. package/dist/chunk-WLXFE6XW.js.map +1 -0
  5. package/dist/core/index.d.ts +44 -3
  6. package/dist/core/index.js +11 -3
  7. package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
  8. package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/init-DIZ6UNBL.js +806 -0
  12. package/dist/init-DIZ6UNBL.js.map +1 -0
  13. package/dist/mcp-bin.js +2 -2
  14. package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
  15. package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
  16. package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
  17. package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
  18. package/dist/{viewer-CNLZQUFO.js → viewer-QKIAPTPG.js} +126 -15
  19. package/dist/viewer-QKIAPTPG.js.map +1 -0
  20. package/package.json +3 -2
  21. package/src/commands/init-framework.ts +414 -0
  22. package/src/commands/init.ts +41 -1
  23. package/src/core/__tests__/preview-runtime.test.tsx +111 -0
  24. package/src/core/index.ts +13 -0
  25. package/src/core/preview-runtime.tsx +144 -0
  26. package/src/viewer/components/App.tsx +8 -3
  27. package/src/viewer/components/FragmentRenderer.tsx +61 -0
  28. package/src/viewer/components/HealthDashboard.tsx +1 -1
  29. package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
  30. package/src/viewer/components/PreviewFrameHost.tsx +27 -60
  31. package/src/viewer/components/PropsTable.tsx +2 -2
  32. package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
  33. package/src/viewer/components/SkeletonLoader.tsx +114 -125
  34. package/src/viewer/components/VariantMatrix.tsx +3 -3
  35. package/src/viewer/components/ViewerStateSync.tsx +52 -0
  36. package/src/viewer/components/WebMCPDevTools.tsx +509 -0
  37. package/src/viewer/components/WebMCPIntegration.tsx +47 -0
  38. package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
  39. package/src/viewer/entry.tsx +32 -5
  40. package/src/viewer/hooks/useA11yService.ts +1 -135
  41. package/src/viewer/hooks/useCompiledFragments.ts +42 -0
  42. package/src/viewer/index.html +1 -1
  43. package/src/viewer/public/favicon.ico +0 -0
  44. package/src/viewer/server.ts +59 -3
  45. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
  46. package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
  47. package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
  48. package/src/viewer/vite-plugin.ts +76 -1
  49. package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
  50. package/src/viewer/webmcp/analytics.ts +165 -0
  51. package/src/viewer/webmcp/index.ts +3 -0
  52. package/src/viewer/webmcp/posthog-bridge.ts +39 -0
  53. package/src/viewer/webmcp/runtime-tools.ts +152 -0
  54. package/src/viewer/webmcp/scan-utils.ts +135 -0
  55. package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
  56. package/src/viewer/webmcp/viewer-state.ts +45 -0
  57. package/dist/chunk-TQOGBAOZ.js.map +0 -1
  58. package/dist/init-GID2DXB3.js +0 -498
  59. package/dist/init-GID2DXB3.js.map +0 -1
  60. package/dist/viewer-CNLZQUFO.js.map +0 -1
  61. package/src/viewer/components/StoryRenderer.tsx +0 -121
  62. /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
  63. /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
  64. /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
  65. /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
  66. /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
  67. /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
+ }
@@ -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/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='2' y='6' width='12' height='12' rx='2' fill='%234f46e5'/%3E%3Crect x='18' y='6' width='12' height='5' rx='1.5' fill='%236366f1'/%3E%3Crect x='18' y='14' width='8' height='4' rx='1' fill='%23a5b4fc'/%3E%3Crect x='2' y='22' width='28' height='4' rx='1' fill='%23e0e7ff'/%3E%3C/svg%3E" />
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
@@ -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
- css: {},
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.useEffect(() => {
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 { Input, Listbox } from '@fragments-sdk/ui';
4
- import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
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 = 8,
17
+ placeholder = 'Search docs...',
18
+ maxResults = 9999,
19
19
  }: DocsSearchCommandProps) {
20
- const [query, setQuery] = useState('');
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 handleGlobalKeyDown = (event: KeyboardEvent) => {
24
+ const handleKeyDown = (event: KeyboardEvent) => {
40
25
  if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
41
26
  event.preventDefault();
42
- inputRef.current?.focus();
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('mousedown', handleClickOutside);
59
- return () => document.removeEventListener('mousedown', handleClickOutside);
31
+ document.addEventListener('keydown', handleKeyDown);
32
+ return () => document.removeEventListener('keydown', handleKeyDown);
60
33
  }, []);
61
34
 
62
- const handleSelect = (item: SearchItem) => {
63
- onSelect(item);
64
- setIsOpen(false);
65
- setQuery('');
66
- };
67
-
68
- const handleKeyDown = (event: ReactKeyboardEvent) => {
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
- <div ref={containerRef} className="shared-docs-search-container">
98
- <Input
99
- ref={inputRef}
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
- {isOpen && results.length > 0 && (
113
- <Listbox aria-label="Search results" className="shared-docs-search-results">
114
- {results.map((item, index) => (
115
- <Listbox.Item
116
- key={`${item.section}:${item.href}`}
117
- selected={index === selectedIndex}
118
- onClick={() => handleSelect(item)}
119
- onMouseEnter={() => setSelectedIndex(index)}
120
- >
121
- <span className="shared-docs-search-result-label">{item.label}</span>
122
- <span className="shared-docs-search-result-section">{item.section}</span>
123
- </Listbox.Item>
124
- ))}
125
- </Listbox>
126
- )}
127
- {isOpen && query.trim() && results.length === 0 && (
128
- <Listbox aria-label="Search results" className="shared-docs-search-results">
129
- <Listbox.Empty>No results found</Listbox.Empty>
130
- </Listbox>
131
- )}
132
- </div>
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
  }