@sntlr/registry-shell 1.0.0
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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/adapter/custom.d.ts +47 -0
- package/dist/adapter/custom.js +53 -0
- package/dist/adapter/custom.js.map +1 -0
- package/dist/adapter/default.d.ts +40 -0
- package/dist/adapter/default.js +202 -0
- package/dist/adapter/default.js.map +1 -0
- package/dist/cli/build.d.ts +1 -0
- package/dist/cli/build.js +31 -0
- package/dist/cli/build.js.map +1 -0
- package/dist/cli/dev.d.ts +1 -0
- package/dist/cli/dev.js +26 -0
- package/dist/cli/dev.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.js +49 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +70 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/shared.d.ts +33 -0
- package/dist/cli/shared.js +278 -0
- package/dist/cli/shared.js.map +1 -0
- package/dist/cli/start.d.ts +1 -0
- package/dist/cli/start.js +24 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/config-loader.d.ts +49 -0
- package/dist/config-loader.js +140 -0
- package/dist/define-config.d.ts +188 -0
- package/dist/define-config.js +21 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/package.json +124 -0
- package/src/adapter/custom.ts +90 -0
- package/src/adapter/default.ts +241 -0
- package/src/cli/build.ts +38 -0
- package/src/cli/dev.ts +38 -0
- package/src/cli/index.ts +52 -0
- package/src/cli/init.ts +76 -0
- package/src/cli/shared.ts +306 -0
- package/src/cli/start.ts +28 -0
- package/src/config-loader.ts +190 -0
- package/src/define-config.ts +206 -0
- package/src/index.ts +17 -0
- package/src/next-app/app/[...asset]/route.ts +81 -0
- package/src/next-app/app/_user-global.css +6 -0
- package/src/next-app/app/_user-sources.css +9 -0
- package/src/next-app/app/a11y/[name]/route.ts +19 -0
- package/src/next-app/app/api/search-index/route.ts +19 -0
- package/src/next-app/app/components/[name]/page.tsx +61 -0
- package/src/next-app/app/components/layout.tsx +18 -0
- package/src/next-app/app/docs/[slug]/page.tsx +53 -0
- package/src/next-app/app/docs/layout.tsx +18 -0
- package/src/next-app/app/globals.css +329 -0
- package/src/next-app/app/layout.tsx +102 -0
- package/src/next-app/app/page.tsx +9 -0
- package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
- package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
- package/src/next-app/app/props/[name]/route.ts +19 -0
- package/src/next-app/app/r/[name]/route.ts +14 -0
- package/src/next-app/app/tests/[name]/route.ts +19 -0
- package/src/next-app/components/a11y-info.tsx +287 -0
- package/src/next-app/components/a11y-provider.tsx +39 -0
- package/src/next-app/components/component-breadcrumb.tsx +55 -0
- package/src/next-app/components/component-icon.tsx +140 -0
- package/src/next-app/components/component-preview.tsx +13 -0
- package/src/next-app/components/component-tabs.tsx +209 -0
- package/src/next-app/components/docs-toc.tsx +86 -0
- package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
- package/src/next-app/components/header.tsx +188 -0
- package/src/next-app/components/heading-anchor.tsx +52 -0
- package/src/next-app/components/homepage-demo.tsx +180 -0
- package/src/next-app/components/locale-toggle.tsx +35 -0
- package/src/next-app/components/localized-mdx-client.tsx +14 -0
- package/src/next-app/components/localized-mdx.tsx +27 -0
- package/src/next-app/components/mobile-sidebar.tsx +22 -0
- package/src/next-app/components/nav-data-provider.tsx +37 -0
- package/src/next-app/components/navigation-progress.tsx +62 -0
- package/src/next-app/components/preview-canvas.tsx +368 -0
- package/src/next-app/components/preview-controls.tsx +94 -0
- package/src/next-app/components/preview-layout.tsx +218 -0
- package/src/next-app/components/props-table.tsx +134 -0
- package/src/next-app/components/resizable-preview.tsx +101 -0
- package/src/next-app/components/search.tsx +177 -0
- package/src/next-app/components/settings-modal.tsx +98 -0
- package/src/next-app/components/shell-ui/accordion.tsx +70 -0
- package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
- package/src/next-app/components/shell-ui/badge.tsx +55 -0
- package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
- package/src/next-app/components/shell-ui/button.tsx +64 -0
- package/src/next-app/components/shell-ui/card.tsx +127 -0
- package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
- package/src/next-app/components/shell-ui/dialog.tsx +171 -0
- package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
- package/src/next-app/components/shell-ui/input.tsx +27 -0
- package/src/next-app/components/shell-ui/kbd.tsx +30 -0
- package/src/next-app/components/shell-ui/label.tsx +25 -0
- package/src/next-app/components/shell-ui/select.tsx +204 -0
- package/src/next-app/components/shell-ui/separator.tsx +32 -0
- package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
- package/src/next-app/components/shell-ui/table.tsx +124 -0
- package/src/next-app/components/shell-ui/tabs.tsx +102 -0
- package/src/next-app/components/shell-ui/toggle.tsx +56 -0
- package/src/next-app/components/sidebar-layout.tsx +37 -0
- package/src/next-app/components/sidebar-provider.tsx +75 -0
- package/src/next-app/components/sidebar.tsx +222 -0
- package/src/next-app/components/snapshot-preview.tsx +28 -0
- package/src/next-app/components/test-info.tsx +155 -0
- package/src/next-app/components/theme-provider.tsx +16 -0
- package/src/next-app/components/theme-toggle.tsx +21 -0
- package/src/next-app/components/translated-text.tsx +8 -0
- package/src/next-app/fallback/homepage.tsx +112 -0
- package/src/next-app/fallback/previews.ts +17 -0
- package/src/next-app/hooks/use-active-section.ts +23 -0
- package/src/next-app/hooks/use-controls.ts +72 -0
- package/src/next-app/hooks/use-mobile.ts +19 -0
- package/src/next-app/lib/branding.ts +52 -0
- package/src/next-app/lib/components-nav.ts +8 -0
- package/src/next-app/lib/docs.ts +16 -0
- package/src/next-app/lib/github.ts +38 -0
- package/src/next-app/lib/i18n.tsx +630 -0
- package/src/next-app/lib/locales.ts +17 -0
- package/src/next-app/lib/preview-loader.ts +7 -0
- package/src/next-app/lib/registry-adapter.ts +199 -0
- package/src/next-app/lib/utils.ts +6 -0
- package/src/next-app/next-env.d.ts +6 -0
- package/src/next-app/next.config.ts +101 -0
- package/src/next-app/postcss.config.mjs +7 -0
- package/src/next-app/public/favicon.ico +0 -0
- package/src/next-app/public/favicon_dark.svg +3 -0
- package/src/next-app/public/favicon_light.svg +3 -0
- package/src/next-app/registry.config.ts +50 -0
- package/src/next-app/tsconfig.json +29 -0
- package/src/next-app/user-aliases.d.ts +17 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
import { usePathname } from "next/navigation"
|
|
5
|
+
import { useEffect, useRef } from "react"
|
|
6
|
+
import { useIsMobile } from "@shell/hooks/use-mobile"
|
|
7
|
+
import { BookOpen, Component, Blocks } from "lucide-react"
|
|
8
|
+
import type { DocMeta } from "@shell/lib/docs"
|
|
9
|
+
import type { ComponentMeta } from "@shell/lib/components-nav"
|
|
10
|
+
import { useTranslations, useLocale } from "@shell/lib/i18n"
|
|
11
|
+
import { Backdrop } from "@shell/components/shell-ui/backdrop"
|
|
12
|
+
|
|
13
|
+
import type { ActiveSection } from "@shell/hooks/use-active-section"
|
|
14
|
+
|
|
15
|
+
interface SidebarProps {
|
|
16
|
+
docs: DocMeta[]
|
|
17
|
+
components: ComponentMeta[]
|
|
18
|
+
open?: boolean
|
|
19
|
+
onClose?: () => void
|
|
20
|
+
collapsed?: boolean
|
|
21
|
+
/** When set, desktop views show only this section. Mobile always shows all three. */
|
|
22
|
+
activeSection?: ActiveSection
|
|
23
|
+
/**
|
|
24
|
+
* Which viewport variants of the sidebar to render.
|
|
25
|
+
* - `"all"` (default): mobile floating card + desktop inline + desktop floating (current behavior).
|
|
26
|
+
* - `"mobile"`: only the mobile floating card + backdrop. Used by the root layout so the
|
|
27
|
+
* hamburger menu works on every page (including the homepage), without injecting a
|
|
28
|
+
* desktop sidebar on pages that don't have one.
|
|
29
|
+
* - `"desktop"`: only the desktop inline + desktop floating card. Used by `SidebarLayout`
|
|
30
|
+
* so the per-section docs/components pages still get their desktop nav, while the
|
|
31
|
+
* root layout owns the mobile variant.
|
|
32
|
+
*/
|
|
33
|
+
display?: "all" | "mobile" | "desktop"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Sidebar({ docs, components, open, onClose, collapsed, activeSection, display = "all" }: SidebarProps) {
|
|
37
|
+
const pathname = usePathname()
|
|
38
|
+
const t = useTranslations()
|
|
39
|
+
const { locale } = useLocale()
|
|
40
|
+
|
|
41
|
+
const isMobile = useIsMobile()
|
|
42
|
+
const uiComponents = components.filter((c) => c.kind === "component")
|
|
43
|
+
const blocks = components.filter((c) => c.kind === "block")
|
|
44
|
+
|
|
45
|
+
// Close floating nav on mobile navigation only
|
|
46
|
+
const isMobileRef = useRef(isMobile)
|
|
47
|
+
isMobileRef.current = isMobile
|
|
48
|
+
const mountedRef = useRef(false)
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!mountedRef.current) { mountedRef.current = true; return }
|
|
51
|
+
if (isMobileRef.current) onClose?.()
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pathname change
|
|
53
|
+
}, [pathname])
|
|
54
|
+
|
|
55
|
+
const docsSection = (
|
|
56
|
+
<SidebarSection icon={BookOpen} title={t("sidebar.documentation")}>
|
|
57
|
+
{docs.map((doc) => (
|
|
58
|
+
<SidebarLink
|
|
59
|
+
key={doc.slug}
|
|
60
|
+
href={`/docs/${doc.slug}`}
|
|
61
|
+
active={pathname === `/docs/${doc.slug}`}
|
|
62
|
+
>
|
|
63
|
+
{doc.titles?.[locale] ?? doc.title}
|
|
64
|
+
</SidebarLink>
|
|
65
|
+
))}
|
|
66
|
+
</SidebarSection>
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const componentsSection = (
|
|
70
|
+
<SidebarSection icon={Component} title={t("sidebar.components")}>
|
|
71
|
+
{uiComponents.map((comp) => (
|
|
72
|
+
<SidebarLink
|
|
73
|
+
key={comp.name}
|
|
74
|
+
href={`/components/${comp.name}`}
|
|
75
|
+
active={pathname === `/components/${comp.name}`}
|
|
76
|
+
>
|
|
77
|
+
{comp.label}
|
|
78
|
+
</SidebarLink>
|
|
79
|
+
))}
|
|
80
|
+
</SidebarSection>
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const blocksSection = blocks.length > 0 ? (
|
|
84
|
+
<SidebarSection icon={Blocks} title={t("sidebar.blocks")}>
|
|
85
|
+
{blocks.map((comp) => (
|
|
86
|
+
<SidebarLink
|
|
87
|
+
key={comp.name}
|
|
88
|
+
href={`/components/${comp.name}`}
|
|
89
|
+
active={pathname === `/components/${comp.name}`}
|
|
90
|
+
>
|
|
91
|
+
{comp.label}
|
|
92
|
+
</SidebarLink>
|
|
93
|
+
))}
|
|
94
|
+
</SidebarSection>
|
|
95
|
+
) : null
|
|
96
|
+
|
|
97
|
+
// Mobile: always show all three sections (no topbar tabs on mobile)
|
|
98
|
+
const mobileNavContent = (
|
|
99
|
+
<nav aria-label="Main navigation" className="p-4 space-y-4">
|
|
100
|
+
{docsSection}
|
|
101
|
+
{componentsSection}
|
|
102
|
+
{blocksSection}
|
|
103
|
+
</nav>
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Desktop: show only the active section (topbar tabs handle section switching)
|
|
107
|
+
const desktopNavContent = (
|
|
108
|
+
<nav aria-label="Main navigation" className="p-4 space-y-4">
|
|
109
|
+
{activeSection === "docs" && docsSection}
|
|
110
|
+
{activeSection === "components" && componentsSection}
|
|
111
|
+
{activeSection === "blocks" && blocksSection}
|
|
112
|
+
{/* Fallback: when we can't determine a section, show all */}
|
|
113
|
+
{activeSection === null && (
|
|
114
|
+
<>
|
|
115
|
+
{docsSection}
|
|
116
|
+
{componentsSection}
|
|
117
|
+
{blocksSection}
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</nav>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const showMobile = display === "all" || display === "mobile"
|
|
124
|
+
const showDesktop = display === "all" || display === "desktop"
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
{/* Backdrop — mobile nav only (no backdrop in desktop fullscreen) */}
|
|
129
|
+
{showMobile && open && !collapsed && (
|
|
130
|
+
<Backdrop
|
|
131
|
+
belowHeader
|
|
132
|
+
className="md:hidden"
|
|
133
|
+
onClick={onClose}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{/* Mobile floating card — mobile only */}
|
|
138
|
+
{showMobile && (
|
|
139
|
+
<aside
|
|
140
|
+
className={`
|
|
141
|
+
md:hidden fixed top-18 left-4 z-50 w-64 rounded-lg border border-border bg-background shadow-xl overflow-y-auto overflow-x-hidden transition-all duration-300 ease-in-out motion-reduce:transition-none
|
|
142
|
+
${open ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"}
|
|
143
|
+
`}
|
|
144
|
+
style={{ maxHeight: "calc(100vh - 6rem)" }}
|
|
145
|
+
>
|
|
146
|
+
{mobileNavContent}
|
|
147
|
+
</aside>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Desktop floating card — only meaningful on component pages where the
|
|
151
|
+
user can enter fullscreen preview mode. On other sections the
|
|
152
|
+
fullscreen state is moot and the floating card just looks out of
|
|
153
|
+
place; skip rendering it entirely. */}
|
|
154
|
+
{showDesktop &&
|
|
155
|
+
(activeSection === "components" || activeSection === "blocks") && (
|
|
156
|
+
<aside
|
|
157
|
+
className={`
|
|
158
|
+
hidden md:block fixed top-18 left-4 z-50 w-64 rounded-lg border border-border bg-background shadow-xl overflow-y-auto overflow-x-hidden transition-all duration-300 ease-in-out motion-reduce:transition-none
|
|
159
|
+
${open && collapsed ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"}
|
|
160
|
+
`}
|
|
161
|
+
style={{ maxHeight: "calc(100vh - 6rem)" }}
|
|
162
|
+
>
|
|
163
|
+
{desktopNavContent}
|
|
164
|
+
</aside>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Desktop inline sidebar — only when not collapsed */}
|
|
168
|
+
{showDesktop && !collapsed && (
|
|
169
|
+
<aside
|
|
170
|
+
className="hidden md:block w-64 border-r border-border bg-background h-[calc(100vh-3.5rem)] overflow-y-auto overflow-x-hidden sticky top-14 shrink-0"
|
|
171
|
+
>
|
|
172
|
+
{desktopNavContent}
|
|
173
|
+
</aside>
|
|
174
|
+
)}
|
|
175
|
+
</>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function SidebarSection({
|
|
180
|
+
icon: Icon,
|
|
181
|
+
title,
|
|
182
|
+
children,
|
|
183
|
+
}: {
|
|
184
|
+
icon: React.ComponentType<{ className?: string }>
|
|
185
|
+
title: string
|
|
186
|
+
children: React.ReactNode
|
|
187
|
+
}) {
|
|
188
|
+
return (
|
|
189
|
+
<div>
|
|
190
|
+
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mb-2">
|
|
191
|
+
<Icon className="size-4" />
|
|
192
|
+
<span className="flex-1 text-left">{title}</span>
|
|
193
|
+
</div>
|
|
194
|
+
<ul className="space-y-1">{children}</ul>
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function SidebarLink({
|
|
200
|
+
href,
|
|
201
|
+
active,
|
|
202
|
+
children,
|
|
203
|
+
}: {
|
|
204
|
+
href: string
|
|
205
|
+
active: boolean
|
|
206
|
+
children: React.ReactNode
|
|
207
|
+
}) {
|
|
208
|
+
return (
|
|
209
|
+
<li>
|
|
210
|
+
<Link
|
|
211
|
+
href={href}
|
|
212
|
+
className={`block text-sm px-2 py-1.5 rounded-md transition-colors ${
|
|
213
|
+
active
|
|
214
|
+
? "bg-accent text-accent-foreground font-medium"
|
|
215
|
+
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
216
|
+
}`}
|
|
217
|
+
>
|
|
218
|
+
{children}
|
|
219
|
+
</Link>
|
|
220
|
+
</li>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { SnapshotModeProvider } from "@shell/components/preview-layout"
|
|
4
|
+
import { previewLoader } from "@shell/lib/preview-loader"
|
|
5
|
+
|
|
6
|
+
const { Preview } = previewLoader
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders a component preview without the PreviewCanvas chrome.
|
|
10
|
+
* The preview components still use PreviewLayout internally, but this page
|
|
11
|
+
* provides a clean, minimal container for Playwright screenshots.
|
|
12
|
+
*
|
|
13
|
+
* Usage: /preview-snapshot/{component-name}
|
|
14
|
+
*/
|
|
15
|
+
export function SnapshotPreview({ name }: { name: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<SnapshotModeProvider>
|
|
18
|
+
<div data-snapshot-target className="inline-block">
|
|
19
|
+
<Preview
|
|
20
|
+
name={name}
|
|
21
|
+
fallback={
|
|
22
|
+
<p className="text-sm text-muted-foreground">No preview for {name}</p>
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
</SnapshotModeProvider>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { CheckCircle2, XCircle, FlaskConical, Eye, Keyboard, Gauge, Shield } from "lucide-react"
|
|
5
|
+
import { Badge } from "@shell/components/shell-ui/badge"
|
|
6
|
+
import {
|
|
7
|
+
Accordion,
|
|
8
|
+
AccordionContent,
|
|
9
|
+
AccordionItem,
|
|
10
|
+
AccordionTrigger,
|
|
11
|
+
} from "@shell/components/shell-ui/accordion"
|
|
12
|
+
import {
|
|
13
|
+
EmptyState,
|
|
14
|
+
EmptyStateIcon,
|
|
15
|
+
EmptyStateDescription,
|
|
16
|
+
} from "@shell/components/shell-ui/empty-state"
|
|
17
|
+
import { useTranslations } from "@shell/lib/i18n"
|
|
18
|
+
|
|
19
|
+
interface TestFile {
|
|
20
|
+
file: string
|
|
21
|
+
type: string
|
|
22
|
+
tests: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TestReport {
|
|
26
|
+
component: string
|
|
27
|
+
hasUnitTests: boolean
|
|
28
|
+
hasInteractionTests: boolean
|
|
29
|
+
hasVisualTests: boolean
|
|
30
|
+
hasA11yTests: boolean
|
|
31
|
+
hasPerformanceTests: boolean
|
|
32
|
+
hasProps: boolean
|
|
33
|
+
hasA11yDocs: boolean
|
|
34
|
+
hasPreview: boolean
|
|
35
|
+
testFiles: TestFile[]
|
|
36
|
+
totalTests: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const typeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
40
|
+
unit: FlaskConical,
|
|
41
|
+
interaction: Keyboard,
|
|
42
|
+
visual: Eye,
|
|
43
|
+
a11y: Shield,
|
|
44
|
+
performance: Gauge,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function CheckItem({ passed, label }: { passed: boolean; label: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex items-center gap-2 text-sm">
|
|
50
|
+
{passed ? (
|
|
51
|
+
<CheckCircle2 className="size-4 text-green-500 shrink-0" />
|
|
52
|
+
) : (
|
|
53
|
+
<XCircle className="size-4 text-muted-foreground/40 shrink-0" />
|
|
54
|
+
)}
|
|
55
|
+
<span className={passed ? "text-foreground" : "text-muted-foreground"}>
|
|
56
|
+
{label}
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function TestInfo({ name }: { name: string }) {
|
|
63
|
+
const [report, setReport] = useState<TestReport | null>(null)
|
|
64
|
+
const [error, setError] = useState(false)
|
|
65
|
+
const t = useTranslations()
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
fetch(`/tests/${name}.json`)
|
|
69
|
+
.then((r) => {
|
|
70
|
+
if (!r.ok) throw new Error()
|
|
71
|
+
return r.json()
|
|
72
|
+
})
|
|
73
|
+
.then(setReport)
|
|
74
|
+
.catch(() => setError(true))
|
|
75
|
+
}, [name])
|
|
76
|
+
|
|
77
|
+
if (error) {
|
|
78
|
+
return (
|
|
79
|
+
<EmptyState>
|
|
80
|
+
<EmptyStateIcon><FlaskConical /></EmptyStateIcon>
|
|
81
|
+
<EmptyStateDescription>{t("component.testsPlaceholder")}</EmptyStateDescription>
|
|
82
|
+
</EmptyState>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!report) {
|
|
87
|
+
return <p className="text-sm text-muted-foreground" role="status">{t("a11y.loading")}</p>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasAnyTests = report.totalTests > 0
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-6">
|
|
94
|
+
{/* Health overview */}
|
|
95
|
+
<div className="space-y-3">
|
|
96
|
+
<h4 id="test-health" className="text-base font-semibold">{t("tests.health")}</h4>
|
|
97
|
+
<div className="grid grid-cols-2 gap-2">
|
|
98
|
+
<CheckItem passed={report.hasProps} label={t("tests.propsDocs")} />
|
|
99
|
+
<CheckItem passed={report.hasA11yDocs} label={t("tests.a11yDocs")} />
|
|
100
|
+
<CheckItem passed={report.hasPreview} label={t("tests.preview")} />
|
|
101
|
+
<CheckItem passed={report.hasUnitTests} label={t("tests.unit")} />
|
|
102
|
+
<CheckItem passed={report.hasInteractionTests} label={t("tests.interaction")} />
|
|
103
|
+
<CheckItem passed={report.hasVisualTests} label={t("tests.visual")} />
|
|
104
|
+
<CheckItem passed={report.hasA11yTests} label={t("tests.accessibility")} />
|
|
105
|
+
<CheckItem passed={report.hasPerformanceTests} label={t("tests.performance")} />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{hasAnyTests && (
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
<h4 id="test-coverage" className="text-base font-semibold">
|
|
112
|
+
{t("tests.tests")}
|
|
113
|
+
<Badge variant="secondary" className="ml-2">{report.totalTests}</Badge>
|
|
114
|
+
</h4>
|
|
115
|
+
|
|
116
|
+
<Accordion type="multiple" defaultValue={report.testFiles.map((f) => f.type)}>
|
|
117
|
+
{report.testFiles.map((tf) => {
|
|
118
|
+
const Icon = typeIcons[tf.type] ?? FlaskConical
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<AccordionItem key={tf.file} value={tf.type}>
|
|
122
|
+
<AccordionTrigger>
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<Icon className="size-4 text-muted-foreground" />
|
|
125
|
+
<span>{t(`tests.${tf.type}` as string)}</span>
|
|
126
|
+
<Badge variant="outline" className="text-[10px]">{tf.tests.length}</Badge>
|
|
127
|
+
</div>
|
|
128
|
+
</AccordionTrigger>
|
|
129
|
+
<AccordionContent>
|
|
130
|
+
<p className="text-xs text-muted-foreground font-mono mb-2">{tf.file}</p>
|
|
131
|
+
<ul className="space-y-1">
|
|
132
|
+
{tf.tests.map((test, i) => (
|
|
133
|
+
<li key={i} className="text-sm text-muted-foreground flex items-center gap-2">
|
|
134
|
+
<CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
|
135
|
+
{test}
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
</AccordionContent>
|
|
140
|
+
</AccordionItem>
|
|
141
|
+
)
|
|
142
|
+
})}
|
|
143
|
+
</Accordion>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{!hasAnyTests && (
|
|
148
|
+
<EmptyState>
|
|
149
|
+
<EmptyStateIcon><FlaskConical /></EmptyStateIcon>
|
|
150
|
+
<EmptyStateDescription>{t("component.testsPlaceholder")}</EmptyStateDescription>
|
|
151
|
+
</EmptyState>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
|
4
|
+
|
|
5
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<NextThemesProvider
|
|
8
|
+
attribute="class"
|
|
9
|
+
defaultTheme="system"
|
|
10
|
+
enableSystem
|
|
11
|
+
disableTransitionOnChange
|
|
12
|
+
>
|
|
13
|
+
{children}
|
|
14
|
+
</NextThemesProvider>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useTheme } from "next-themes"
|
|
4
|
+
import { Moon, Sun } from "lucide-react"
|
|
5
|
+
import { Button } from "@shell/components/shell-ui/button"
|
|
6
|
+
|
|
7
|
+
export function ThemeToggle() {
|
|
8
|
+
const { setTheme, resolvedTheme } = useTheme()
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Button
|
|
12
|
+
variant="ghost"
|
|
13
|
+
size="icon"
|
|
14
|
+
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
|
15
|
+
aria-label="Toggle theme"
|
|
16
|
+
>
|
|
17
|
+
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
|
18
|
+
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
|
19
|
+
</Button>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback homepage rendered at `/` when the user's config has no custom
|
|
3
|
+
* `homePage`. Two states:
|
|
4
|
+
* - Registry has content → generic index listing components/blocks/docs.
|
|
5
|
+
* - Registry empty / absent → terse "no registry wired" placeholder
|
|
6
|
+
* pointing at the shell's documentation site.
|
|
7
|
+
*/
|
|
8
|
+
import Link from "next/link"
|
|
9
|
+
import { getAllComponents } from "@shell/lib/components-nav"
|
|
10
|
+
import { getAllDocs } from "@shell/lib/docs"
|
|
11
|
+
import type { HomePageProps } from "@shell/lib/registry-adapter"
|
|
12
|
+
|
|
13
|
+
export default function FallbackHomePage({ firstDocSlug }: HomePageProps) {
|
|
14
|
+
const items = getAllComponents()
|
|
15
|
+
const docs = getAllDocs()
|
|
16
|
+
|
|
17
|
+
if (items.length === 0 && docs.length === 0) return <NoRegistryPlaceholder />
|
|
18
|
+
|
|
19
|
+
const components = items.filter((c) => c.kind === "component")
|
|
20
|
+
const blocks = items.filter((c) => c.kind === "block")
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<main
|
|
24
|
+
id="main-content"
|
|
25
|
+
tabIndex={-1}
|
|
26
|
+
className="outline-none max-w-5xl mx-auto px-4 md:px-8 py-12"
|
|
27
|
+
>
|
|
28
|
+
<section className="mb-12">
|
|
29
|
+
<h1 className="text-3xl font-bold">Registry</h1>
|
|
30
|
+
<p className="mt-2 text-muted-foreground">
|
|
31
|
+
{components.length} component{components.length === 1 ? "" : "s"},{" "}
|
|
32
|
+
{blocks.length} block{blocks.length === 1 ? "" : "s"}, {docs.length} doc
|
|
33
|
+
{docs.length === 1 ? "" : "s"}
|
|
34
|
+
</p>
|
|
35
|
+
{firstDocSlug && (
|
|
36
|
+
<Link
|
|
37
|
+
href={`/docs/${firstDocSlug}`}
|
|
38
|
+
className="mt-4 inline-block text-sm underline underline-offset-4"
|
|
39
|
+
>
|
|
40
|
+
Browse documentation →
|
|
41
|
+
</Link>
|
|
42
|
+
)}
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
{components.length > 0 && (
|
|
46
|
+
<section className="mb-12">
|
|
47
|
+
<h2 className="text-xl font-semibold mb-4">Components</h2>
|
|
48
|
+
<ul className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
|
|
49
|
+
{components.map((c) => (
|
|
50
|
+
<li key={c.name}>
|
|
51
|
+
<Link className="hover:underline" href={`/components/${c.name}`}>
|
|
52
|
+
{c.label}
|
|
53
|
+
</Link>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
</section>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{blocks.length > 0 && (
|
|
61
|
+
<section>
|
|
62
|
+
<h2 className="text-xl font-semibold mb-4">Blocks</h2>
|
|
63
|
+
<ul className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
|
|
64
|
+
{blocks.map((b) => (
|
|
65
|
+
<li key={b.name}>
|
|
66
|
+
<Link className="hover:underline" href={`/components/${b.name}`}>
|
|
67
|
+
{b.label}
|
|
68
|
+
</Link>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</section>
|
|
73
|
+
)}
|
|
74
|
+
</main>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shown when the shell boots with no config AND no content. Zero external
|
|
80
|
+
* dependencies (no fetches, no dynamic content) — if a consumer ever sees
|
|
81
|
+
* this, they're either running `registry-shell dev` in a blank directory
|
|
82
|
+
* or they're mid-setup.
|
|
83
|
+
*/
|
|
84
|
+
function NoRegistryPlaceholder() {
|
|
85
|
+
return (
|
|
86
|
+
<main
|
|
87
|
+
id="main-content"
|
|
88
|
+
tabIndex={-1}
|
|
89
|
+
className="outline-none max-w-xl mx-auto px-6 py-24 text-center"
|
|
90
|
+
>
|
|
91
|
+
<h1 className="text-2xl font-semibold">No registry wired</h1>
|
|
92
|
+
<p className="mt-3 text-sm text-muted-foreground">
|
|
93
|
+
Add a{" "}
|
|
94
|
+
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
|
|
95
|
+
registry-shell.config.ts
|
|
96
|
+
</code>{" "}
|
|
97
|
+
to this project and restart the dev server.
|
|
98
|
+
</p>
|
|
99
|
+
<p className="mt-8 text-xs text-muted-foreground">
|
|
100
|
+
Setup guide:{" "}
|
|
101
|
+
<a
|
|
102
|
+
href="https://github.com/scintillar-com/registry-shell"
|
|
103
|
+
className="underline underline-offset-4 hover:text-foreground"
|
|
104
|
+
target="_blank"
|
|
105
|
+
rel="noopener noreferrer"
|
|
106
|
+
>
|
|
107
|
+
scintillar-com/registry-shell
|
|
108
|
+
</a>
|
|
109
|
+
</p>
|
|
110
|
+
</main>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback previewLoader used when the user's registry has no
|
|
3
|
+
* `components/previews/index.ts`. Renders the caller's `fallback` prop (or
|
|
4
|
+
* nothing) for every name.
|
|
5
|
+
*/
|
|
6
|
+
import { createElement, Fragment, type ReactNode } from "react"
|
|
7
|
+
import type { PreviewLoader } from "@shell/lib/registry-adapter"
|
|
8
|
+
|
|
9
|
+
function EmptyPreview({ fallback = null }: { name: string; fallback?: ReactNode }) {
|
|
10
|
+
return createElement(Fragment, null, fallback)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const previewLoader: PreviewLoader = {
|
|
14
|
+
Preview: EmptyPreview,
|
|
15
|
+
load: () => null,
|
|
16
|
+
names: () => [],
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation"
|
|
4
|
+
import type { ComponentMeta } from "@shell/lib/components-nav"
|
|
5
|
+
|
|
6
|
+
export type ActiveSection = "docs" | "components" | "blocks" | null
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the current navigation section based on the URL and component list.
|
|
10
|
+
* - `/docs/*` → "docs"
|
|
11
|
+
* - `/components/{name}` → "components" or "blocks" depending on the item's kind
|
|
12
|
+
* - anything else → null
|
|
13
|
+
*/
|
|
14
|
+
export function useActiveSection(components: ComponentMeta[]): ActiveSection {
|
|
15
|
+
const pathname = usePathname()
|
|
16
|
+
if (pathname.startsWith("/docs/")) return "docs"
|
|
17
|
+
if (pathname.startsWith("/components/")) {
|
|
18
|
+
const name = pathname.split("/")[2]
|
|
19
|
+
const item = components.find((c) => c.name === name)
|
|
20
|
+
return item?.kind === "block" ? "blocks" : "components"
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
interface SelectControl {
|
|
6
|
+
type: "select"
|
|
7
|
+
options: string[]
|
|
8
|
+
default: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface BooleanControl {
|
|
12
|
+
type: "boolean"
|
|
13
|
+
default: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TextControl {
|
|
17
|
+
type: "text"
|
|
18
|
+
default: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface NumberControl {
|
|
22
|
+
type: "number"
|
|
23
|
+
default: number
|
|
24
|
+
min?: number
|
|
25
|
+
max?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ControlDef = SelectControl | BooleanControl | TextControl | NumberControl
|
|
29
|
+
|
|
30
|
+
export type ControlDefs = Record<string, ControlDef>
|
|
31
|
+
|
|
32
|
+
type ControlValues<T extends ControlDefs> = {
|
|
33
|
+
[K in keyof T]: T[K] extends SelectControl
|
|
34
|
+
? string
|
|
35
|
+
: T[K] extends BooleanControl
|
|
36
|
+
? boolean
|
|
37
|
+
: T[K] extends TextControl
|
|
38
|
+
? string
|
|
39
|
+
: T[K] extends NumberControl
|
|
40
|
+
? number
|
|
41
|
+
: never
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ControlEntry {
|
|
45
|
+
name: string
|
|
46
|
+
def: ControlDef
|
|
47
|
+
value: unknown
|
|
48
|
+
onChange: (value: unknown) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useControls<T extends ControlDefs>(defs: T) {
|
|
52
|
+
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
|
53
|
+
const initial: Record<string, unknown> = {}
|
|
54
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
55
|
+
initial[key] = def.default
|
|
56
|
+
}
|
|
57
|
+
return initial
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const entries: ControlEntry[] = Object.entries(defs).map(([name, def]) => ({
|
|
61
|
+
name,
|
|
62
|
+
def,
|
|
63
|
+
value: values[name],
|
|
64
|
+
onChange: (value: unknown) =>
|
|
65
|
+
setValues((prev) => ({ ...prev, [name]: value })),
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
values: values as ControlValues<T>,
|
|
70
|
+
entries,
|
|
71
|
+
}
|
|
72
|
+
}
|