@fragments-sdk/cli 0.7.14 → 0.7.15

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 (46) 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-GID2DXB3.js → init-V42FFMUJ.js} +3 -3
  12. package/dist/mcp-bin.js +2 -2
  13. package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
  14. package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
  15. package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
  16. package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
  17. package/dist/{viewer-CNLZQUFO.js → viewer-7I4WGVU3.js} +60 -12
  18. package/dist/viewer-7I4WGVU3.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/core/__tests__/preview-runtime.test.tsx +111 -0
  21. package/src/core/index.ts +13 -0
  22. package/src/core/preview-runtime.tsx +144 -0
  23. package/src/viewer/components/App.tsx +3 -3
  24. package/src/viewer/components/FragmentRenderer.tsx +61 -0
  25. package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
  26. package/src/viewer/components/PreviewFrameHost.tsx +27 -60
  27. package/src/viewer/components/SkeletonLoader.tsx +114 -125
  28. package/src/viewer/components/VariantMatrix.tsx +3 -3
  29. package/src/viewer/entry.tsx +26 -2
  30. package/src/viewer/index.html +1 -1
  31. package/src/viewer/public/favicon.ico +0 -0
  32. package/src/viewer/server.ts +1 -0
  33. package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
  34. package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
  35. package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
  36. package/src/viewer/vite-plugin.ts +58 -1
  37. package/dist/chunk-TQOGBAOZ.js.map +0 -1
  38. package/dist/viewer-CNLZQUFO.js.map +0 -1
  39. package/src/viewer/components/StoryRenderer.tsx +0 -121
  40. /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
  41. /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
  42. /package/dist/{init-GID2DXB3.js.map → init-V42FFMUJ.js.map} +0 -0
  43. /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
  44. /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
  45. /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
  46. /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
@@ -11,149 +11,138 @@ import { Skeleton, Loading } from '@fragments-sdk/ui';
11
11
  */
12
12
  export function AppSkeleton() {
13
13
  return (
14
- <div style={{ display: 'flex', height: '100vh', backgroundColor: 'var(--bg-primary)' }}>
15
- {/* Sidebar skeleton */}
16
- <div style={{
17
- width: '240px',
18
- borderRight: '1px solid var(--border)',
19
- backgroundColor: 'var(--bg-secondary)',
20
- display: 'flex',
21
- flexDirection: 'column',
22
- }}>
23
- {/* Header */}
24
- <div style={{
14
+ <div
15
+ style={{
16
+ display: 'grid',
17
+ minHeight: '100vh',
18
+ minHeight: '100dvh',
19
+ gridTemplateRows: '56px 1fr',
20
+ gridTemplateColumns: '260px 1fr 240px',
21
+ gridTemplateAreas: '"header header header" "sidebar main aside"',
22
+ backgroundColor: 'var(--bg-primary)',
23
+ }}
24
+ >
25
+ <div
26
+ style={{
27
+ gridArea: 'header',
25
28
  display: 'flex',
26
29
  alignItems: 'center',
27
30
  justifyContent: 'space-between',
28
- padding: '8px 16px',
31
+ gap: '16px',
32
+ padding: '0 16px',
29
33
  borderBottom: '1px solid var(--border)',
30
- }}>
31
- <Skeleton variant="text" style={{ width: '80px' }} />
32
- <Skeleton variant="circular" style={{ width: '24px', height: '24px' }} />
33
- </div>
34
-
35
- {/* Search */}
36
- <div style={{ padding: '12px' }}>
37
- <Skeleton variant="rectangular" style={{ width: '100%', height: '32px', borderRadius: '6px' }} />
38
- </div>
39
-
40
- {/* Component list */}
41
- <div style={{ flex: 1, padding: '0 8px', overflow: 'hidden' }}>
42
- {/* Category 1 */}
43
- <div style={{ marginBottom: '16px' }}>
44
- <Skeleton variant="text" style={{ width: '64px', margin: '0 8px 8px' }} />
45
- <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
46
- <Skeleton variant="rectangular" style={{ width: '100%', height: '28px', borderRadius: '6px' }} />
47
- <Skeleton variant="rectangular" style={{ width: '100%', height: '28px', borderRadius: '6px' }} />
48
- <Skeleton variant="rectangular" style={{ width: '75%', height: '28px', borderRadius: '6px' }} />
49
- </div>
50
- </div>
51
-
52
- {/* Category 2 */}
53
- <div style={{ marginBottom: '16px' }}>
54
- <Skeleton variant="text" style={{ width: '80px', margin: '0 8px 8px' }} />
55
- <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
56
- <Skeleton variant="rectangular" style={{ width: '100%', height: '28px', borderRadius: '6px' }} />
57
- <Skeleton variant="rectangular" style={{ width: '83%', height: '28px', borderRadius: '6px' }} />
58
- <Skeleton variant="rectangular" style={{ width: '100%', height: '28px', borderRadius: '6px' }} />
59
- <Skeleton variant="rectangular" style={{ width: '66%', height: '28px', borderRadius: '6px' }} />
60
- </div>
61
- </div>
62
-
63
- {/* Category 3 */}
64
- <div>
65
- <Skeleton variant="text" style={{ width: '96px', margin: '0 8px 8px' }} />
66
- <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
67
- <Skeleton variant="rectangular" style={{ width: '80%', height: '28px', borderRadius: '6px' }} />
68
- <Skeleton variant="rectangular" style={{ width: '100%', height: '28px', borderRadius: '6px' }} />
69
- </div>
70
- </div>
34
+ backgroundColor: 'var(--bg-primary)',
35
+ }}
36
+ >
37
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
38
+ <Skeleton.Circle size={20} />
39
+ <Skeleton variant="text" width={96} />
40
+ <Skeleton variant="text" width={64} />
71
41
  </div>
72
-
73
- {/* Footer */}
74
- <div style={{ padding: '12px 16px', borderTop: '1px solid var(--border-subtle)' }}>
75
- <Skeleton variant="text" style={{ width: '96px' }} />
42
+ <Skeleton variant="rect" width={240} height={32} radius="md" />
43
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
44
+ <Skeleton variant="rect" width={64} height={28} radius="md" />
45
+ <Skeleton.Circle size={20} />
46
+ <Skeleton.Circle size={20} />
47
+ <Skeleton.Circle size={20} />
76
48
  </div>
77
49
  </div>
78
50
 
79
- {/* Main content skeleton */}
80
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
81
- {/* Toolbar */}
82
- <div style={{
51
+ <aside
52
+ style={{
53
+ gridArea: 'sidebar',
54
+ borderRight: '1px solid var(--border)',
55
+ backgroundColor: 'var(--bg-primary)',
83
56
  display: 'flex',
84
- alignItems: 'center',
85
- justifyContent: 'space-between',
86
- padding: '8px 16px',
87
- borderBottom: '1px solid var(--border)',
88
- backgroundColor: 'var(--bg-secondary)',
89
- }}>
90
- <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
91
- <Skeleton variant="text" style={{ width: '96px', height: '20px' }} />
92
- <Skeleton variant="text" style={{ width: '64px', height: '16px' }} />
93
- </div>
94
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
95
- <Skeleton variant="rectangular" style={{ width: '64px', height: '28px', borderRadius: '4px' }} />
96
- <Skeleton variant="rectangular" style={{ width: '96px', height: '28px', borderRadius: '4px' }} />
97
- <Skeleton variant="rectangular" style={{ width: '80px', height: '28px', borderRadius: '4px' }} />
98
- <Skeleton variant="circular" style={{ width: '24px', height: '24px' }} />
99
- <Skeleton variant="circular" style={{ width: '24px', height: '24px' }} />
100
- </div>
57
+ flexDirection: 'column',
58
+ minHeight: 0,
59
+ }}
60
+ >
61
+ <div style={{ padding: '12px 16px' }}>
62
+ <Skeleton variant="rect" height={32} radius="md" />
101
63
  </div>
102
-
103
- {/* Variant tabs */}
104
- <div style={{
105
- padding: '8px 16px',
106
- borderBottom: '1px solid var(--border)',
107
- backgroundColor: 'var(--bg-primary)',
108
- }}>
109
- <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
110
- <Skeleton variant="rectangular" style={{ width: '64px', height: '28px', borderRadius: '6px' }} />
111
- <Skeleton variant="rectangular" style={{ width: '80px', height: '28px', borderRadius: '6px' }} />
112
- <Skeleton variant="rectangular" style={{ width: '56px', height: '28px', borderRadius: '6px' }} />
113
- <Skeleton variant="rectangular" style={{ width: '72px', height: '28px', borderRadius: '6px' }} />
114
- <Skeleton variant="rectangular" style={{ width: '48px', height: '28px', borderRadius: '6px' }} />
115
- </div>
64
+ <div style={{ padding: '0 12px', display: 'flex', flexDirection: 'column', gap: '12px', overflow: 'hidden' }}>
65
+ <Skeleton variant="text" width={68} />
66
+ <Skeleton variant="rect" height={30} radius="md" />
67
+ <Skeleton variant="rect" height={30} radius="md" />
68
+ <Skeleton variant="rect" height={30} radius="md" />
69
+ <Skeleton variant="text" width={84} />
70
+ <Skeleton variant="rect" height={30} radius="md" />
71
+ <Skeleton variant="rect" height={30} radius="md" />
72
+ <Skeleton variant="rect" width="78%" height={30} radius="md" />
116
73
  </div>
117
-
118
- {/* Preview area */}
119
- <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px' }}>
120
- <Skeleton variant="rectangular" style={{ width: '256px', height: '128px', borderRadius: '8px' }} />
74
+ <div style={{ marginTop: 'auto', padding: '12px 16px', borderTop: '1px solid var(--border-subtle)' }}>
75
+ <Skeleton variant="text" width={92} />
121
76
  </div>
77
+ </aside>
122
78
 
123
- {/* Bottom panel */}
124
- <div style={{
125
- height: '256px',
126
- borderTop: '1px solid var(--border)',
127
- backgroundColor: 'var(--bg-secondary)',
128
- }}>
129
- <div style={{
130
- display: 'flex',
131
- alignItems: 'center',
132
- gap: '4px',
133
- padding: '0 16px',
134
- height: '40px',
135
- borderBottom: '1px solid var(--border-subtle)',
136
- }}>
137
- <Skeleton variant="rectangular" style={{ width: '48px', height: '24px', borderRadius: '4px' }} />
138
- <Skeleton variant="rectangular" style={{ width: '40px', height: '24px', borderRadius: '4px' }} />
139
- <Skeleton variant="rectangular" style={{ width: '48px', height: '24px', borderRadius: '4px' }} />
140
- </div>
141
- <div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
142
- <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
143
- <Skeleton variant="text" style={{ width: '80px' }} />
144
- <Skeleton variant="rectangular" style={{ width: '128px', height: '32px', borderRadius: '4px' }} />
79
+ <main
80
+ style={{
81
+ gridArea: 'main',
82
+ display: 'flex',
83
+ flexDirection: 'column',
84
+ minWidth: 0,
85
+ minHeight: 0,
86
+ backgroundColor: 'var(--bg-primary)',
87
+ }}
88
+ >
89
+ <div style={{ flex: 1, padding: '20px', overflow: 'hidden' }}>
90
+ <div
91
+ style={{
92
+ border: '1px solid var(--border)',
93
+ borderRadius: '10px',
94
+ overflow: 'hidden',
95
+ marginBottom: '16px',
96
+ }}
97
+ >
98
+ <div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border-subtle)', backgroundColor: 'var(--bg-secondary)', display: 'flex', gap: '10px' }}>
99
+ <Skeleton variant="text" width={84} />
100
+ <Skeleton variant="text" width={120} />
101
+ </div>
102
+ <div style={{ padding: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
103
+ <Skeleton variant="rect" width={280} height={120} radius="lg" />
145
104
  </div>
146
- <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
147
- <Skeleton variant="text" style={{ width: '64px' }} />
148
- <Skeleton variant="rectangular" style={{ width: '96px', height: '32px', borderRadius: '4px' }} />
105
+ </div>
106
+
107
+ <div
108
+ style={{
109
+ border: '1px solid var(--border)',
110
+ borderRadius: '10px',
111
+ overflow: 'hidden',
112
+ }}
113
+ >
114
+ <div style={{ height: '40px', display: 'flex', alignItems: 'center', gap: '6px', padding: '0 14px', borderBottom: '1px solid var(--border-subtle)', backgroundColor: 'var(--bg-secondary)' }}>
115
+ <Skeleton variant="rect" width={52} height={22} radius="md" />
116
+ <Skeleton variant="rect" width={88} height={22} radius="md" />
117
+ <Skeleton variant="rect" width={58} height={22} radius="md" />
149
118
  </div>
150
- <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
151
- <Skeleton variant="text" style={{ width: '96px' }} />
152
- <Skeleton variant="rectangular" style={{ width: '80px', height: '32px', borderRadius: '4px' }} />
119
+ <div style={{ padding: '16px' }}>
120
+ <Skeleton.Text lines={4} lastLineWidth={72} />
153
121
  </div>
154
122
  </div>
155
123
  </div>
156
- </div>
124
+ </main>
125
+
126
+ <aside
127
+ style={{
128
+ gridArea: 'aside',
129
+ borderLeft: '1px solid var(--border)',
130
+ backgroundColor: 'var(--bg-primary)',
131
+ padding: '16px 14px',
132
+ }}
133
+ >
134
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
135
+ <Skeleton variant="text" width={92} />
136
+ <Skeleton variant="text" width={64} />
137
+ <Skeleton variant="text" width={48} />
138
+ <Skeleton variant="text" width={76} />
139
+ <Skeleton variant="rect" height={1} />
140
+ <Skeleton variant="text" width={86} />
141
+ <Skeleton variant="rect" width="100%" height={26} radius="md" />
142
+ <Skeleton variant="rect" width="84%" height={26} radius="md" />
143
+ <Skeleton variant="rect" width="70%" height={26} radius="md" />
144
+ </div>
145
+ </aside>
157
146
  </div>
158
147
  );
159
148
  }
@@ -14,7 +14,7 @@ import { useState, useMemo, useRef, useCallback } from "react";
14
14
  import { useVirtualizer } from "@tanstack/react-virtual";
15
15
  import type { FragmentVariant } from "../../core/index.js";
16
16
  import { ErrorBoundary } from "./ErrorBoundary.js";
17
- import { StoryRenderer, LoaderIndicator } from "./StoryRenderer.js";
17
+ import { FragmentRenderer, LoaderIndicator } from "./FragmentRenderer.js";
18
18
  import { IsolatedPreviewFrame } from "./IsolatedPreviewFrame.js";
19
19
  import { ChevronDownIcon } from "./Icons.js";
20
20
 
@@ -389,7 +389,7 @@ function VariantCard({
389
389
  </div>
390
390
  }
391
391
  >
392
- <StoryRenderer variant={variant}>
392
+ <FragmentRenderer variant={variant}>
393
393
  {(content, isLoading, error) => {
394
394
  if (isLoading) {
395
395
  return (
@@ -407,7 +407,7 @@ function VariantCard({
407
407
  }
408
408
  return content;
409
409
  }}
410
- </StoryRenderer>
410
+ </FragmentRenderer>
411
411
  </ErrorBoundary>
412
412
  </div>
413
413
  )}
@@ -149,6 +149,8 @@ type FragmentItem = {
149
149
  let fragments: FragmentItem[] = window.__FRAGMENTS__ ?? [];
150
150
  let loadError: string | null = window.__FRAGMENTS_ERROR__ ?? null;
151
151
  let appRoot: Root | null = null;
152
+ const SKELETON_DELAY_MS = 120;
153
+ const MIN_SKELETON_DISPLAY_MS = 220;
152
154
 
153
155
  // Filter helper
154
156
  function filterValidFragments(items: FragmentItem[]): FragmentItem[] {
@@ -257,11 +259,33 @@ if (rootElement) {
257
259
 
258
260
  // Show skeleton immediately if no fragments yet
259
261
  if (fragments.length === 0 && !loadError) {
260
- renderApp(true);
262
+ const loadStartedAt = Date.now();
263
+ let skeletonShown = false;
264
+
265
+ const skeletonTimer = setTimeout(() => {
266
+ skeletonShown = true;
267
+ renderApp(true);
268
+ }, SKELETON_DELAY_MS);
261
269
 
262
270
  // Load fragments asynchronously and re-render
263
271
  loadFragmentsFromVirtualModule().then(() => {
264
- renderApp(false);
272
+ clearTimeout(skeletonTimer);
273
+
274
+ if (!skeletonShown) {
275
+ renderApp(false);
276
+ return;
277
+ }
278
+
279
+ const elapsed = Date.now() - loadStartedAt;
280
+ const remaining = Math.max(MIN_SKELETON_DISPLAY_MS - elapsed, 0);
281
+ if (remaining === 0) {
282
+ renderApp(false);
283
+ return;
284
+ }
285
+
286
+ setTimeout(() => {
287
+ renderApp(false);
288
+ }, remaining);
265
289
  });
266
290
  } else {
267
291
  // We have fragments from window, render immediately
@@ -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
@@ -230,6 +230,7 @@ export async function createDevServer(
230
230
  const fragmentsConfig: InlineConfig = {
231
231
  configFile: false, // Don't load config again
232
232
  root: projectRoot, // Run from PROJECT root
233
+ publicDir: resolve(viewerRoot, "public"), // Serve static assets (favicon) from viewer
233
234
  base: "/",
234
235
 
235
236
  server: {
@@ -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
  }