@fragments-sdk/cli 0.7.10 → 0.7.11
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 +1 -1
- package/dist/{viewer-ZA7WK3EY.js → viewer-CNLZQUFO.js} +21 -4
- package/dist/viewer-CNLZQUFO.js.map +1 -0
- package/package.json +3 -2
- package/src/viewer/components/App.tsx +475 -119
- package/src/viewer/components/CodePanel.naming.test.tsx +0 -1
- package/src/viewer/components/CodePanel.tsx +0 -1
- package/src/viewer/components/Layout.tsx +16 -13
- package/src/viewer/components/PreviewArea.tsx +21 -11
- package/src/viewer/server.ts +27 -3
- package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +110 -0
- package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +89 -0
- package/src/viewer/vendor/shared/src/DocsPageShell.tsx +119 -0
- package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +134 -0
- package/src/viewer/vendor/shared/src/DocsSidebarNav.tsx +66 -0
- package/src/viewer/vendor/shared/src/docs-layout.scss +28 -0
- package/src/viewer/vendor/shared/src/docs-layout.scss.d.ts +2 -0
- package/src/viewer/vendor/shared/src/index.ts +26 -0
- package/src/viewer/vendor/shared/src/types.ts +41 -0
- package/dist/viewer-ZA7WK3EY.js.map +0 -1
- package/src/viewer/components/PreviewMenu.tsx +0 -247
|
@@ -43,7 +43,6 @@ describe('CodePanel authored code pipeline', () => {
|
|
|
43
43
|
const code = __buildFallbackSnippetForTest('Button', variant);
|
|
44
44
|
|
|
45
45
|
expect(code).toContain("import { Button } from '@/components/Button';");
|
|
46
|
-
expect(code).toContain('TODO: Add explicit `code` for variant "Primary"');
|
|
47
46
|
expect(code).toContain("<Button variant='primary' disabled={false}>Save Changes</Button>");
|
|
48
47
|
});
|
|
49
48
|
|
|
@@ -76,7 +76,6 @@ function buildFallbackSnippet(componentName: string, variant: FragmentVariant):
|
|
|
76
76
|
|
|
77
77
|
return `import { ${componentName} } from '@/components/${componentName}';
|
|
78
78
|
|
|
79
|
-
// TODO: Add explicit \`code\` for variant "${variant.name}" in this fragment file.
|
|
80
79
|
${usage}`;
|
|
81
80
|
}
|
|
82
81
|
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { DocsPageShell } from '@fragments-sdk/shared';
|
|
3
3
|
|
|
4
4
|
interface LayoutProps {
|
|
5
5
|
leftSidebar: ReactNode;
|
|
6
6
|
header: ReactNode;
|
|
7
7
|
children: ReactNode;
|
|
8
|
+
aside?: ReactNode;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function Layout({ leftSidebar, header, children }: LayoutProps) {
|
|
11
|
+
export function Layout({ leftSidebar, header, children, aside }: LayoutProps) {
|
|
11
12
|
return (
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
<DocsPageShell
|
|
14
|
+
header={header}
|
|
15
|
+
sidebar={leftSidebar}
|
|
16
|
+
sidebarWidth="260px"
|
|
17
|
+
sidebarCollapsible="icon"
|
|
18
|
+
sidebarAriaLabel="Preview sidebar"
|
|
19
|
+
mainAriaLabel="Preview content"
|
|
20
|
+
mainPadding="none"
|
|
21
|
+
aside={aside}
|
|
22
|
+
asideWidth="240px"
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</DocsPageShell>
|
|
23
26
|
);
|
|
24
27
|
}
|
|
@@ -397,11 +397,12 @@ export function PreviewArea({
|
|
|
397
397
|
if (useIframeIsolation && variant) {
|
|
398
398
|
// When no specific viewport width, fill the container
|
|
399
399
|
const isFullWidth = !viewportWidth;
|
|
400
|
+
const scaleFactor = zoom / 100;
|
|
400
401
|
|
|
401
402
|
return (
|
|
402
403
|
<div style={isFullWidth
|
|
403
|
-
? { height: '100%', display: 'flex', flexDirection: 'column' }
|
|
404
|
-
: { minHeight: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }
|
|
404
|
+
? { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' }
|
|
405
|
+
: { minHeight: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', overflow: 'auto' }
|
|
405
406
|
}>
|
|
406
407
|
<div
|
|
407
408
|
style={{
|
|
@@ -418,15 +419,24 @@ export function PreviewArea({
|
|
|
418
419
|
}),
|
|
419
420
|
}}
|
|
420
421
|
>
|
|
421
|
-
<
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
422
|
+
<div
|
|
423
|
+
style={{
|
|
424
|
+
transform: `scale(${scaleFactor})`,
|
|
425
|
+
transformOrigin: 'top left',
|
|
426
|
+
width: zoom !== 100 ? `${100 / scaleFactor}%` : '100%',
|
|
427
|
+
height: zoom !== 100 ? `${100 / scaleFactor}%` : '100%',
|
|
428
|
+
}}
|
|
429
|
+
>
|
|
430
|
+
<IsolatedPreviewFrame
|
|
431
|
+
fragmentPath={fragmentPath}
|
|
432
|
+
variantName={variant.name}
|
|
433
|
+
theme={previewTheme}
|
|
434
|
+
width="100%"
|
|
435
|
+
height="100%"
|
|
436
|
+
minHeight={viewportHeight || 200}
|
|
437
|
+
previewKey={previewKey}
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
430
440
|
</div>
|
|
431
441
|
</div>
|
|
432
442
|
);
|
package/src/viewer/server.ts
CHANGED
|
@@ -30,6 +30,8 @@ const cliPackageRoot = resolve(__dirname, "..");
|
|
|
30
30
|
const viewerRoot = resolve(cliPackageRoot, "src/viewer");
|
|
31
31
|
const packagesRoot = resolve(cliPackageRoot, "..");
|
|
32
32
|
const localUiLibRoot = resolve(packagesRoot, "../libs/ui/src");
|
|
33
|
+
const localSharedLibRoot = resolve(packagesRoot, "../libs/shared/src");
|
|
34
|
+
const vendoredSharedLibRoot = resolve(viewerRoot, "vendor/shared/src");
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Resolve the @fragments/ui alias to the correct path.
|
|
@@ -55,6 +57,24 @@ function resolveUiLib(nodeModulesDir: string): string {
|
|
|
55
57
|
return localUiLibRoot;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the @fragments-sdk/shared alias to either monorepo source
|
|
62
|
+
* or vendored viewer fallback for npm installs.
|
|
63
|
+
*/
|
|
64
|
+
function resolveSharedLib(): string {
|
|
65
|
+
const localIndex = join(localSharedLibRoot, "index.ts");
|
|
66
|
+
if (existsSync(localIndex)) {
|
|
67
|
+
return localSharedLibRoot;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const vendoredIndex = join(vendoredSharedLibRoot, "index.ts");
|
|
71
|
+
if (existsSync(vendoredIndex)) {
|
|
72
|
+
return vendoredSharedLibRoot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return localSharedLibRoot;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/**
|
|
59
79
|
* Vite plugin to handle CJS-only packages imported from within node_modules.
|
|
60
80
|
* Vite's optimizeDeps only redirects imports from source files to pre-bundled ESM,
|
|
@@ -189,6 +209,8 @@ export async function createDevServer(
|
|
|
189
209
|
|
|
190
210
|
// Find node_modules (handles monorepo setups)
|
|
191
211
|
const nodeModulesPath = findNodeModules(projectRoot);
|
|
212
|
+
const uiLibRoot = resolveUiLib(nodeModulesPath);
|
|
213
|
+
const sharedLibRoot = resolveSharedLib();
|
|
192
214
|
console.log(`📁 Using node_modules: ${nodeModulesPath}`);
|
|
193
215
|
|
|
194
216
|
// Collect installed package roots so Vite can serve files from node_modules
|
|
@@ -214,8 +236,8 @@ export async function createDevServer(
|
|
|
214
236
|
port,
|
|
215
237
|
open: open ? "/fragments/" : false,
|
|
216
238
|
fs: {
|
|
217
|
-
// Allow serving files from viewer package, project,
|
|
218
|
-
allow: [viewerRoot,
|
|
239
|
+
// Allow serving files from viewer package, project, shared libs, and node_modules root
|
|
240
|
+
allow: [viewerRoot, uiLibRoot, sharedLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
|
|
219
241
|
},
|
|
220
242
|
},
|
|
221
243
|
|
|
@@ -248,7 +270,9 @@ export async function createDevServer(
|
|
|
248
270
|
dedupe: ["react", "react-dom"],
|
|
249
271
|
alias: {
|
|
250
272
|
// Resolve @fragments-sdk/ui to local source or installed package
|
|
251
|
-
"@fragments-sdk/ui":
|
|
273
|
+
"@fragments-sdk/ui": uiLibRoot,
|
|
274
|
+
// Resolve @fragments-sdk/shared to monorepo source or vendored fallback
|
|
275
|
+
"@fragments-sdk/shared": sharedLibRoot,
|
|
252
276
|
// Resolve @fragments-sdk/cli/core to the CLI's own core source
|
|
253
277
|
"@fragments-sdk/cli/core": resolve(cliPackageRoot, "src/core/index.ts"),
|
|
254
278
|
// Ensure ALL react imports resolve to project's node_modules
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Header, NavigationMenu } from '@fragments-sdk/ui';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { DocsSearchCommand } from './DocsSearchCommand';
|
|
6
|
+
import type { DocsNavLinkRenderer, HeaderNavEntry, NavSection, SearchItem } from './types';
|
|
7
|
+
import { isDropdown } from './types';
|
|
8
|
+
|
|
9
|
+
interface DocsHeaderBarProps {
|
|
10
|
+
brand: ReactNode;
|
|
11
|
+
headerNav: HeaderNavEntry[];
|
|
12
|
+
mobileSections?: NavSection[];
|
|
13
|
+
currentPath: string;
|
|
14
|
+
searchItems: SearchItem[];
|
|
15
|
+
onSearchSelect: (item: SearchItem) => void;
|
|
16
|
+
renderLink?: DocsNavLinkRenderer;
|
|
17
|
+
isActive?: (href: string, currentPath: string) => boolean;
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
showSkipLink?: boolean;
|
|
20
|
+
navAriaLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultLinkRenderer: DocsNavLinkRenderer = ({ href, label, onClick }) => (
|
|
24
|
+
<a href={href} onClick={onClick}>
|
|
25
|
+
{label}
|
|
26
|
+
</a>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function defaultIsActive(href: string, currentPath: string): boolean {
|
|
30
|
+
return currentPath === href || currentPath.startsWith(`${href}/`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function DocsHeaderBar({
|
|
34
|
+
brand,
|
|
35
|
+
headerNav,
|
|
36
|
+
mobileSections = [],
|
|
37
|
+
currentPath,
|
|
38
|
+
searchItems,
|
|
39
|
+
onSearchSelect,
|
|
40
|
+
renderLink = defaultLinkRenderer,
|
|
41
|
+
isActive = defaultIsActive,
|
|
42
|
+
actions,
|
|
43
|
+
showSkipLink = true,
|
|
44
|
+
navAriaLabel = 'Primary navigation',
|
|
45
|
+
}: DocsHeaderBarProps) {
|
|
46
|
+
return (
|
|
47
|
+
<Header aria-label="Documentation header">
|
|
48
|
+
<Header.Brand>{brand}</Header.Brand>
|
|
49
|
+
{showSkipLink ? <Header.SkipLink /> : null}
|
|
50
|
+
|
|
51
|
+
<NavigationMenu aria-label={navAriaLabel}>
|
|
52
|
+
<NavigationMenu.List>
|
|
53
|
+
{headerNav.map((entry) =>
|
|
54
|
+
isDropdown(entry) ? (
|
|
55
|
+
<NavigationMenu.Item key={entry.label} value={entry.label}>
|
|
56
|
+
<NavigationMenu.Trigger>{entry.label}</NavigationMenu.Trigger>
|
|
57
|
+
<NavigationMenu.Content>
|
|
58
|
+
<div style={{ display: 'flex', flexDirection: 'column', padding: '4px', minWidth: '180px' }}>
|
|
59
|
+
{entry.items.map((child) => (
|
|
60
|
+
<NavigationMenu.Link
|
|
61
|
+
key={child.href}
|
|
62
|
+
href={child.href}
|
|
63
|
+
active={isActive(child.href, currentPath)}
|
|
64
|
+
asChild
|
|
65
|
+
>
|
|
66
|
+
{renderLink({ href: child.href, label: child.label })}
|
|
67
|
+
</NavigationMenu.Link>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</NavigationMenu.Content>
|
|
71
|
+
</NavigationMenu.Item>
|
|
72
|
+
) : (
|
|
73
|
+
<NavigationMenu.Item key={entry.href}>
|
|
74
|
+
<NavigationMenu.Link
|
|
75
|
+
href={entry.href}
|
|
76
|
+
active={isActive(entry.href, currentPath)}
|
|
77
|
+
asChild
|
|
78
|
+
>
|
|
79
|
+
{renderLink({ href: entry.href, label: entry.label })}
|
|
80
|
+
</NavigationMenu.Link>
|
|
81
|
+
</NavigationMenu.Item>
|
|
82
|
+
)
|
|
83
|
+
)}
|
|
84
|
+
</NavigationMenu.List>
|
|
85
|
+
|
|
86
|
+
<NavigationMenu.Viewport />
|
|
87
|
+
|
|
88
|
+
<NavigationMenu.MobileContent>
|
|
89
|
+
{mobileSections.map((section) => (
|
|
90
|
+
<NavigationMenu.MobileSection key={section.title} label={section.title}>
|
|
91
|
+
{section.items.map((item) => (
|
|
92
|
+
<NavigationMenu.Link key={item.href} href={item.href} asChild>
|
|
93
|
+
{renderLink({ href: item.href, label: item.label })}
|
|
94
|
+
</NavigationMenu.Link>
|
|
95
|
+
))}
|
|
96
|
+
</NavigationMenu.MobileSection>
|
|
97
|
+
))}
|
|
98
|
+
</NavigationMenu.MobileContent>
|
|
99
|
+
</NavigationMenu>
|
|
100
|
+
|
|
101
|
+
<Header.Spacer />
|
|
102
|
+
|
|
103
|
+
<Header.Search>
|
|
104
|
+
<DocsSearchCommand searchItems={searchItems} onSelect={onSearchSelect} />
|
|
105
|
+
</Header.Search>
|
|
106
|
+
|
|
107
|
+
<Header.Actions>{actions}</Header.Actions>
|
|
108
|
+
</Header>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
|
|
6
|
+
interface DocsPageAsideContextValue {
|
|
7
|
+
asideVisible: boolean;
|
|
8
|
+
setAsideVisible: (visible: boolean) => void;
|
|
9
|
+
asideWidth: string;
|
|
10
|
+
setAsideWidth: (width: string) => void;
|
|
11
|
+
asideContainer: HTMLDivElement | null;
|
|
12
|
+
setAsideContainer: (container: HTMLDivElement | null) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DocsPageAsideContext = React.createContext<DocsPageAsideContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function DocsPageAsideProvider({
|
|
18
|
+
children,
|
|
19
|
+
defaultWidth = '320px',
|
|
20
|
+
}: {
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
defaultWidth?: string;
|
|
23
|
+
}) {
|
|
24
|
+
const [asideVisible, setAsideVisible] = React.useState(false);
|
|
25
|
+
const [asideWidth, setAsideWidth] = React.useState(defaultWidth);
|
|
26
|
+
const [asideContainer, setAsideContainer] = React.useState<HTMLDivElement | null>(null);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<DocsPageAsideContext.Provider
|
|
30
|
+
value={{
|
|
31
|
+
asideVisible,
|
|
32
|
+
setAsideVisible,
|
|
33
|
+
asideWidth,
|
|
34
|
+
setAsideWidth,
|
|
35
|
+
asideContainer,
|
|
36
|
+
setAsideContainer,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</DocsPageAsideContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useDocsPageAside() {
|
|
45
|
+
const context = React.useContext(DocsPageAsideContext);
|
|
46
|
+
if (!context) {
|
|
47
|
+
throw new Error('useDocsPageAside must be used within DocsPageAsideProvider');
|
|
48
|
+
}
|
|
49
|
+
return context;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function DocsPageAsidePortal({ children, width = '320px' }: { children: React.ReactNode; width?: string }) {
|
|
53
|
+
const { setAsideVisible, setAsideWidth, asideContainer } = useDocsPageAside();
|
|
54
|
+
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
setAsideVisible(true);
|
|
57
|
+
setAsideWidth(width);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
setAsideVisible(false);
|
|
61
|
+
};
|
|
62
|
+
}, [setAsideVisible, setAsideWidth, width]);
|
|
63
|
+
|
|
64
|
+
if (!asideContainer) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return createPortal(children, asideContainer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface DocsPageAsideHostProps {
|
|
72
|
+
className?: string;
|
|
73
|
+
style?: React.CSSProperties;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function DocsPageAsideHost({ className, style }: DocsPageAsideHostProps) {
|
|
77
|
+
const { setAsideContainer } = useDocsPageAside();
|
|
78
|
+
|
|
79
|
+
const asideContainerCallback = React.useCallback(
|
|
80
|
+
(node: HTMLDivElement | null) => {
|
|
81
|
+
setAsideContainer(node);
|
|
82
|
+
},
|
|
83
|
+
[setAsideContainer]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div ref={asideContainerCallback} className={className} style={{ height: '100%', ...style }} />
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AppShell } from '@fragments-sdk/ui';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { DocsPageAsideHost, DocsPageAsideProvider, useDocsPageAside } from './DocsPageAsideHost';
|
|
6
|
+
|
|
7
|
+
type SidebarCollapsible = 'offcanvas' | 'icon' | 'none';
|
|
8
|
+
|
|
9
|
+
interface DocsPageShellProps {
|
|
10
|
+
header: ReactNode;
|
|
11
|
+
sidebar: ReactNode;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
sidebarWidth?: string;
|
|
14
|
+
sidebarCollapsible?: SidebarCollapsible;
|
|
15
|
+
sidebarAriaLabel?: string;
|
|
16
|
+
mainId?: string;
|
|
17
|
+
mainAriaLabel?: string;
|
|
18
|
+
mainPadding?: 'none' | 'sm' | 'md' | 'lg';
|
|
19
|
+
mainClassName?: string;
|
|
20
|
+
aside?: ReactNode;
|
|
21
|
+
asideWidth?: string;
|
|
22
|
+
useAsidePortal?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function DocsPageShellInner({
|
|
26
|
+
header,
|
|
27
|
+
sidebar,
|
|
28
|
+
children,
|
|
29
|
+
sidebarWidth = '260px',
|
|
30
|
+
sidebarCollapsible = 'offcanvas',
|
|
31
|
+
sidebarAriaLabel = 'Documentation sidebar',
|
|
32
|
+
mainId = 'main-content',
|
|
33
|
+
mainAriaLabel = 'Documentation content',
|
|
34
|
+
mainPadding = 'lg',
|
|
35
|
+
mainClassName,
|
|
36
|
+
aside,
|
|
37
|
+
asideWidth = '320px',
|
|
38
|
+
useAsidePortal = false,
|
|
39
|
+
}: DocsPageShellProps) {
|
|
40
|
+
return (
|
|
41
|
+
<AppShell>
|
|
42
|
+
<AppShell.Header>{header}</AppShell.Header>
|
|
43
|
+
|
|
44
|
+
<AppShell.Sidebar
|
|
45
|
+
width={sidebarWidth}
|
|
46
|
+
collapsible={sidebarCollapsible}
|
|
47
|
+
aria-label={sidebarAriaLabel}
|
|
48
|
+
>
|
|
49
|
+
{sidebar}
|
|
50
|
+
</AppShell.Sidebar>
|
|
51
|
+
|
|
52
|
+
<AppShell.Main
|
|
53
|
+
id={mainId}
|
|
54
|
+
aria-label={mainAriaLabel}
|
|
55
|
+
padding={mainPadding}
|
|
56
|
+
className={mainClassName}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</AppShell.Main>
|
|
60
|
+
|
|
61
|
+
{useAsidePortal ? <DocsPageAsideHost /> : null}
|
|
62
|
+
{!useAsidePortal && aside ? <AppShell.Aside width={asideWidth}>{aside}</AppShell.Aside> : null}
|
|
63
|
+
</AppShell>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function DocsPageShellPortalInner({
|
|
68
|
+
header,
|
|
69
|
+
sidebar,
|
|
70
|
+
children,
|
|
71
|
+
sidebarWidth = '260px',
|
|
72
|
+
sidebarCollapsible = 'offcanvas',
|
|
73
|
+
sidebarAriaLabel = 'Documentation sidebar',
|
|
74
|
+
mainId = 'main-content',
|
|
75
|
+
mainAriaLabel = 'Documentation content',
|
|
76
|
+
mainPadding = 'lg',
|
|
77
|
+
mainClassName,
|
|
78
|
+
}: DocsPageShellProps) {
|
|
79
|
+
const { asideVisible, asideWidth } = useDocsPageAside();
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<AppShell>
|
|
83
|
+
<AppShell.Header>{header}</AppShell.Header>
|
|
84
|
+
|
|
85
|
+
<AppShell.Sidebar
|
|
86
|
+
width={sidebarWidth}
|
|
87
|
+
collapsible={sidebarCollapsible}
|
|
88
|
+
aria-label={sidebarAriaLabel}
|
|
89
|
+
>
|
|
90
|
+
{sidebar}
|
|
91
|
+
</AppShell.Sidebar>
|
|
92
|
+
|
|
93
|
+
<AppShell.Main
|
|
94
|
+
id={mainId}
|
|
95
|
+
aria-label={mainAriaLabel}
|
|
96
|
+
padding={mainPadding}
|
|
97
|
+
className={mainClassName}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</AppShell.Main>
|
|
101
|
+
|
|
102
|
+
<AppShell.Aside width={asideWidth} visible={asideVisible} aria-label="On-page controls">
|
|
103
|
+
<DocsPageAsideHost />
|
|
104
|
+
</AppShell.Aside>
|
|
105
|
+
</AppShell>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function DocsPageShell(props: DocsPageShellProps) {
|
|
110
|
+
if (props.useAsidePortal) {
|
|
111
|
+
return (
|
|
112
|
+
<DocsPageAsideProvider defaultWidth={props.asideWidth}>
|
|
113
|
+
<DocsPageShellPortalInner {...props} />
|
|
114
|
+
</DocsPageAsideProvider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return <DocsPageShellInner {...props} />;
|
|
119
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Input, Listbox } from '@fragments-sdk/ui';
|
|
4
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
import type { SearchItem } from './types';
|
|
6
|
+
|
|
7
|
+
interface DocsSearchCommandProps {
|
|
8
|
+
searchItems: SearchItem[];
|
|
9
|
+
onSelect: (item: SearchItem) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
maxResults?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DocsSearchCommand({
|
|
15
|
+
searchItems,
|
|
16
|
+
onSelect,
|
|
17
|
+
placeholder = 'Search...',
|
|
18
|
+
maxResults = 8,
|
|
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]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
|
40
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
41
|
+
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);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
59
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
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;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
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"
|
|
110
|
+
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>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Sidebar } from '@fragments-sdk/ui';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import type { DocsNavLinkRenderer, NavSection } from './types';
|
|
6
|
+
|
|
7
|
+
interface DocsSidebarNavProps {
|
|
8
|
+
sections: NavSection[];
|
|
9
|
+
currentPath: string;
|
|
10
|
+
renderLink?: DocsNavLinkRenderer;
|
|
11
|
+
onNavigate?: () => void;
|
|
12
|
+
isActive?: (href: string, currentPath: string) => boolean;
|
|
13
|
+
footer?: ReactNode;
|
|
14
|
+
ariaLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultIsActive(href: string, currentPath: string): boolean {
|
|
18
|
+
return currentPath === href;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const defaultLinkRenderer: DocsNavLinkRenderer = ({ href, label, onClick }) => (
|
|
22
|
+
<a href={href} onClick={onClick}>
|
|
23
|
+
{label}
|
|
24
|
+
</a>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export function DocsSidebarNav({
|
|
28
|
+
sections,
|
|
29
|
+
currentPath,
|
|
30
|
+
renderLink = defaultLinkRenderer,
|
|
31
|
+
onNavigate,
|
|
32
|
+
isActive = defaultIsActive,
|
|
33
|
+
footer,
|
|
34
|
+
ariaLabel = 'Navigation',
|
|
35
|
+
}: DocsSidebarNavProps) {
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<Sidebar.Nav aria-label={ariaLabel}>
|
|
39
|
+
{sections.map((section) => (
|
|
40
|
+
<Sidebar.Section
|
|
41
|
+
key={section.title}
|
|
42
|
+
label={section.title}
|
|
43
|
+
collapsible={section.collapsible}
|
|
44
|
+
defaultOpen={section.defaultOpen}
|
|
45
|
+
>
|
|
46
|
+
{section.items.map((item) => (
|
|
47
|
+
<Sidebar.Item
|
|
48
|
+
key={item.href}
|
|
49
|
+
active={isActive(item.href, currentPath)}
|
|
50
|
+
asChild
|
|
51
|
+
>
|
|
52
|
+
{renderLink({
|
|
53
|
+
href: item.href,
|
|
54
|
+
label: item.label,
|
|
55
|
+
onClick: onNavigate,
|
|
56
|
+
})}
|
|
57
|
+
</Sidebar.Item>
|
|
58
|
+
))}
|
|
59
|
+
</Sidebar.Section>
|
|
60
|
+
))}
|
|
61
|
+
</Sidebar.Nav>
|
|
62
|
+
|
|
63
|
+
<Sidebar.Footer>{footer}</Sidebar.Footer>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|