@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,209 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@shell/components/shell-ui/tabs"
|
|
5
|
+
import { PropsTable } from "@shell/components/props-table"
|
|
6
|
+
import { A11yInfo } from "@shell/components/a11y-info"
|
|
7
|
+
import { EmptyState, EmptyStateIcon, EmptyStateTitle, EmptyStateDescription } from "@shell/components/shell-ui/empty-state"
|
|
8
|
+
import { TestInfo } from "@shell/components/test-info"
|
|
9
|
+
import { useTranslations } from "@shell/lib/i18n"
|
|
10
|
+
import { branding } from "@shell/lib/branding"
|
|
11
|
+
import { FileText, Link as LinkIcon } from "lucide-react"
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INSTALL_TEMPLATE = "npx shadcn@latest add {siteUrl}/r/{name}.json"
|
|
14
|
+
|
|
15
|
+
function renderInstallCommand(name: string): string {
|
|
16
|
+
const template = process.env.NEXT_PUBLIC_SHELL_INSTALL_CMD ?? DEFAULT_INSTALL_TEMPLATE
|
|
17
|
+
return template
|
|
18
|
+
.replace(/\{name\}/g, name)
|
|
19
|
+
.replace(/\{siteUrl\}/g, branding.siteUrl.replace(/\/$/, ""))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ComponentTabsProps {
|
|
23
|
+
name: string
|
|
24
|
+
source: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TocEntry {
|
|
28
|
+
id: string
|
|
29
|
+
text: string
|
|
30
|
+
level: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function useTocFromContent(containerRef: React.RefObject<HTMLDivElement | null>, activeTab: string) {
|
|
34
|
+
const [entries, setEntries] = useState<TocEntry[]>([])
|
|
35
|
+
|
|
36
|
+
const scan = useCallback(() => {
|
|
37
|
+
const el = containerRef.current
|
|
38
|
+
if (!el) return
|
|
39
|
+
const headings = el.querySelectorAll<HTMLHeadingElement>("h3[id], h4[id]")
|
|
40
|
+
const items: TocEntry[] = []
|
|
41
|
+
headings.forEach((h) => {
|
|
42
|
+
items.push({
|
|
43
|
+
id: h.id,
|
|
44
|
+
text: h.textContent ?? "",
|
|
45
|
+
level: h.tagName === "H3" ? 3 : 4,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
setEntries(items)
|
|
49
|
+
}, [containerRef])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// Small delay to let tab content render
|
|
53
|
+
const t = setTimeout(scan, 50)
|
|
54
|
+
return () => clearTimeout(t)
|
|
55
|
+
}, [activeTab, scan])
|
|
56
|
+
|
|
57
|
+
return entries
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
|
|
61
|
+
return (
|
|
62
|
+
<h3 id={id} className="text-lg font-semibold group relative scroll-mt-20">
|
|
63
|
+
{children}
|
|
64
|
+
<a
|
|
65
|
+
href={`#${id}`}
|
|
66
|
+
onClick={(e) => {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
history.replaceState(null, "", `#${id}`)
|
|
69
|
+
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
70
|
+
}}
|
|
71
|
+
className="inline-flex items-center ml-2 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
|
72
|
+
aria-label={`Link to ${typeof children === "string" ? children : "section"}`}
|
|
73
|
+
>
|
|
74
|
+
<LinkIcon className="size-3.5" />
|
|
75
|
+
</a>
|
|
76
|
+
</h3>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tabs = [
|
|
81
|
+
{ value: "install", labelKey: "tabs.install" as const },
|
|
82
|
+
{ value: "docs", labelKey: "tabs.documentation" as const },
|
|
83
|
+
{ value: "guidelines", labelKey: "tabs.guidelines" as const },
|
|
84
|
+
{ value: "a11y", labelKey: "tabs.accessibility" as const },
|
|
85
|
+
{ value: "tests", labelKey: "tabs.tests" as const },
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
export function ComponentTabs({ name, source }: ComponentTabsProps) {
|
|
89
|
+
const [activeTab, setActiveTab] = useState("install")
|
|
90
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
91
|
+
const tocEntries = useTocFromContent(contentRef, activeTab)
|
|
92
|
+
const t = useTranslations()
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex justify-center gap-8">
|
|
96
|
+
<div ref={contentRef} className="flex-1 min-w-0 xl:max-w-225">
|
|
97
|
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
98
|
+
<div className="sticky top-14 md:top-14 z-10 bg-background border-b border-border pt-4 md:pt-8 -mx-1 px-1">
|
|
99
|
+
<TabsList variant="line" className="w-full justify-start *:shrink-0">
|
|
100
|
+
{tabs.map((tab) => (
|
|
101
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
102
|
+
{t(tab.labelKey)}
|
|
103
|
+
</TabsTrigger>
|
|
104
|
+
))}
|
|
105
|
+
</TabsList>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<TabsContent value="install" className="px-2 mt-6">
|
|
109
|
+
<div className="space-y-6">
|
|
110
|
+
<div>
|
|
111
|
+
<SectionHeading id="installation">
|
|
112
|
+
{t("component.installation")}
|
|
113
|
+
</SectionHeading>
|
|
114
|
+
<pre className="bg-muted rounded-lg p-4 overflow-x-auto text-sm">
|
|
115
|
+
<code>{renderInstallCommand(name)}</code>
|
|
116
|
+
</pre>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{source && (
|
|
120
|
+
<div>
|
|
121
|
+
<SectionHeading id="source">
|
|
122
|
+
{t("component.source")}
|
|
123
|
+
</SectionHeading>
|
|
124
|
+
<pre className="bg-muted rounded-lg p-4 overflow-x-auto text-sm">
|
|
125
|
+
<code>{source}</code>
|
|
126
|
+
</pre>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</TabsContent>
|
|
131
|
+
|
|
132
|
+
<TabsContent value="docs" className="px-2 mt-6">
|
|
133
|
+
<div className="space-y-6">
|
|
134
|
+
<SectionHeading id="props-behavior">
|
|
135
|
+
{t("component.propsBehavior")}
|
|
136
|
+
</SectionHeading>
|
|
137
|
+
<PropsTable name={name} />
|
|
138
|
+
</div>
|
|
139
|
+
</TabsContent>
|
|
140
|
+
|
|
141
|
+
<TabsContent value="guidelines" className="px-2 mt-6">
|
|
142
|
+
<div className="space-y-4">
|
|
143
|
+
<SectionHeading id="guidelines">
|
|
144
|
+
{t("tabs.guidelines")}
|
|
145
|
+
</SectionHeading>
|
|
146
|
+
<EmptyState>
|
|
147
|
+
<EmptyStateIcon><FileText /></EmptyStateIcon>
|
|
148
|
+
<EmptyStateTitle>{t("tabs.guidelines")}</EmptyStateTitle>
|
|
149
|
+
<EmptyStateDescription>{t("component.guidelinesPlaceholder")}</EmptyStateDescription>
|
|
150
|
+
</EmptyState>
|
|
151
|
+
</div>
|
|
152
|
+
</TabsContent>
|
|
153
|
+
|
|
154
|
+
<TabsContent value="a11y" className="px-2 mt-6">
|
|
155
|
+
<div className="space-y-4">
|
|
156
|
+
<SectionHeading id="accessibility">
|
|
157
|
+
{t("tabs.accessibility")}
|
|
158
|
+
</SectionHeading>
|
|
159
|
+
<A11yInfo name={name} />
|
|
160
|
+
</div>
|
|
161
|
+
</TabsContent>
|
|
162
|
+
|
|
163
|
+
<TabsContent value="tests" className="px-2 mt-6">
|
|
164
|
+
<div className="space-y-4">
|
|
165
|
+
<SectionHeading id="tests">
|
|
166
|
+
{t("tabs.tests")}
|
|
167
|
+
</SectionHeading>
|
|
168
|
+
<TestInfo name={name} />
|
|
169
|
+
</div>
|
|
170
|
+
</TabsContent>
|
|
171
|
+
</Tabs>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{/* TOC column — space always reserved at xl+ to avoid layout shift when
|
|
175
|
+
switching tabs; inner nav only renders when there are headings. */}
|
|
176
|
+
<div className="hidden xl:block w-44 shrink-0">
|
|
177
|
+
{tocEntries.length > 0 && (
|
|
178
|
+
<nav aria-label={t("toc.title")} className="sticky top-16 pt-20">
|
|
179
|
+
<p className="text-xs font-semibold text-foreground mb-2" aria-hidden="true">
|
|
180
|
+
{t("toc.title")}
|
|
181
|
+
</p>
|
|
182
|
+
<ul role="list" className="space-y-0.5">
|
|
183
|
+
{tocEntries.map((entry) => (
|
|
184
|
+
<li key={entry.id}>
|
|
185
|
+
<a
|
|
186
|
+
href={`#${entry.id}`}
|
|
187
|
+
onClick={(e) => {
|
|
188
|
+
e.preventDefault()
|
|
189
|
+
const el = document.getElementById(entry.id)
|
|
190
|
+
if (el) {
|
|
191
|
+
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
192
|
+
history.replaceState(null, "", `#${entry.id}`)
|
|
193
|
+
}
|
|
194
|
+
}}
|
|
195
|
+
className={`block text-xs px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/50 ${
|
|
196
|
+
entry.level === 4 ? "pl-4" : ""
|
|
197
|
+
}`}
|
|
198
|
+
>
|
|
199
|
+
{entry.text}
|
|
200
|
+
</a>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
</ul>
|
|
204
|
+
</nav>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { useTranslations } from "@shell/lib/i18n"
|
|
5
|
+
|
|
6
|
+
interface TocEntry {
|
|
7
|
+
id: string
|
|
8
|
+
text: string
|
|
9
|
+
level: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function DocsToc() {
|
|
13
|
+
const [entries, setEntries] = useState<TocEntry[]>([])
|
|
14
|
+
const [activeId, setActiveId] = useState<string>("")
|
|
15
|
+
const t = useTranslations()
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const article = document.querySelector("[data-docs-content]")
|
|
19
|
+
if (!article) return
|
|
20
|
+
|
|
21
|
+
const headings = article.querySelectorAll<HTMLHeadingElement>("h1, h2, h3")
|
|
22
|
+
const items: TocEntry[] = []
|
|
23
|
+
headings.forEach((h) => {
|
|
24
|
+
if (!h.id) {
|
|
25
|
+
h.id = h.textContent
|
|
26
|
+
?.toLowerCase()
|
|
27
|
+
.replace(/[^\w\s-]/g, "")
|
|
28
|
+
.replace(/\s+/g, "-") ?? ""
|
|
29
|
+
}
|
|
30
|
+
if (!h.id) return
|
|
31
|
+
const level = parseInt(h.tagName[1])
|
|
32
|
+
items.push({ id: h.id, text: h.textContent ?? "", level })
|
|
33
|
+
})
|
|
34
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing with external DOM headings
|
|
35
|
+
setEntries(items)
|
|
36
|
+
|
|
37
|
+
const observer = new IntersectionObserver(
|
|
38
|
+
(entries) => {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.isIntersecting) {
|
|
41
|
+
setActiveId(entry.target.id)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{ rootMargin: "0px 0px -80% 0px", threshold: 0.1 }
|
|
46
|
+
)
|
|
47
|
+
headings.forEach((h) => { if (h.id) observer.observe(h) })
|
|
48
|
+
return () => observer.disconnect()
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
if (entries.length === 0) return null
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<nav aria-label={t("toc.title")} className="w-44 hidden xl:block">
|
|
55
|
+
<p className="text-xs font-semibold text-foreground mb-2" aria-hidden="true">
|
|
56
|
+
{t("toc.title")}
|
|
57
|
+
</p>
|
|
58
|
+
<ul role="list" className="space-y-0.5">
|
|
59
|
+
{entries.map((entry) => (
|
|
60
|
+
<li key={entry.id}>
|
|
61
|
+
<a
|
|
62
|
+
href={`#${entry.id}`}
|
|
63
|
+
aria-current={activeId === entry.id ? "true" : undefined}
|
|
64
|
+
onClick={(e) => {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
const el = document.getElementById(entry.id)
|
|
67
|
+
if (el) {
|
|
68
|
+
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
69
|
+
// Update URL hash without scroll jump
|
|
70
|
+
history.replaceState(null, "", `#${entry.id}`)
|
|
71
|
+
}
|
|
72
|
+
}}
|
|
73
|
+
className={`block text-xs px-2 py-1 rounded-md transition-colors ${
|
|
74
|
+
activeId === entry.id
|
|
75
|
+
? "text-foreground font-medium bg-accent"
|
|
76
|
+
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
77
|
+
} ${entry.level === 3 ? "pl-4" : ""}`}
|
|
78
|
+
>
|
|
79
|
+
{entry.text}
|
|
80
|
+
</a>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
</nav>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Sidebar } from "@shell/components/sidebar"
|
|
4
|
+
import { useMobileSidebar } from "@shell/components/sidebar-provider"
|
|
5
|
+
import type { DocMeta } from "@shell/lib/docs"
|
|
6
|
+
import type { ComponentMeta } from "@shell/lib/components-nav"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client-side wrapper that mounts the mobile-only variant of the Sidebar from
|
|
10
|
+
* the root layout. This ensures the hamburger menu in the header works on
|
|
11
|
+
* **every** page (including the homepage), even though the desktop inline
|
|
12
|
+
* sidebar is still scoped to per-section layouts via `SidebarLayout`.
|
|
13
|
+
*
|
|
14
|
+
* The full Sidebar component reads its `open` / `onClose` from the
|
|
15
|
+
* `useMobileSidebar` hook, which is a client-side context — so this wrapper
|
|
16
|
+
* exists purely to bridge the server `RootLayout` to those hooks.
|
|
17
|
+
*/
|
|
18
|
+
export function GlobalMobileSidebar({
|
|
19
|
+
docs,
|
|
20
|
+
components,
|
|
21
|
+
}: {
|
|
22
|
+
docs: DocMeta[]
|
|
23
|
+
components: ComponentMeta[]
|
|
24
|
+
}) {
|
|
25
|
+
const { open, close } = useMobileSidebar()
|
|
26
|
+
return (
|
|
27
|
+
<Sidebar
|
|
28
|
+
docs={docs}
|
|
29
|
+
components={components}
|
|
30
|
+
open={open}
|
|
31
|
+
onClose={close}
|
|
32
|
+
display="mobile"
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from "next/image"
|
|
4
|
+
import Link from "next/link"
|
|
5
|
+
import {
|
|
6
|
+
Breadcrumb,
|
|
7
|
+
BreadcrumbItem,
|
|
8
|
+
BreadcrumbLink,
|
|
9
|
+
BreadcrumbList,
|
|
10
|
+
BreadcrumbSeparator,
|
|
11
|
+
} from "@shell/components/shell-ui/breadcrumb"
|
|
12
|
+
import { Badge } from "@shell/components/shell-ui/badge"
|
|
13
|
+
import { useTheme } from "next-themes"
|
|
14
|
+
import { useEffect, useState } from "react"
|
|
15
|
+
import { Github, Menu, Star } from "lucide-react"
|
|
16
|
+
import { Button } from "@shell/components/shell-ui/button"
|
|
17
|
+
import { LocaleToggle } from "@shell/components/locale-toggle"
|
|
18
|
+
import { ThemeToggle } from "@shell/components/theme-toggle"
|
|
19
|
+
import { SearchTrigger } from "@shell/components/search"
|
|
20
|
+
import { useMobileSidebar } from "@shell/components/sidebar-provider"
|
|
21
|
+
import { useNavData } from "@shell/components/nav-data-provider"
|
|
22
|
+
import { useActiveSection, type ActiveSection } from "@shell/hooks/use-active-section"
|
|
23
|
+
import { TranslatedText } from "@shell/components/translated-text"
|
|
24
|
+
import { GITHUB_URL, formatStarCount } from "@shell/lib/github"
|
|
25
|
+
import { branding } from "@shell/lib/branding"
|
|
26
|
+
|
|
27
|
+
function HeaderTab({
|
|
28
|
+
href,
|
|
29
|
+
active,
|
|
30
|
+
children,
|
|
31
|
+
}: {
|
|
32
|
+
href: string
|
|
33
|
+
active: boolean
|
|
34
|
+
children: React.ReactNode
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<Link
|
|
38
|
+
href={href}
|
|
39
|
+
className={`relative px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
40
|
+
active
|
|
41
|
+
? "text-foreground"
|
|
42
|
+
: "text-muted-foreground hover:text-foreground"
|
|
43
|
+
}`}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
{active && (
|
|
47
|
+
<span className="absolute -bottom-3.25 left-0 right-0 h-0.5 bg-primary" />
|
|
48
|
+
)}
|
|
49
|
+
</Link>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Header({ githubStars }: { githubStars?: number | null } = {}) {
|
|
54
|
+
const { resolvedTheme } = useTheme()
|
|
55
|
+
const [mounted, setMounted] = useState(false)
|
|
56
|
+
const { open: sidebarOpen, toggle, collapsed } = useMobileSidebar()
|
|
57
|
+
|
|
58
|
+
const navData = useNavData()
|
|
59
|
+
const activeSection: ActiveSection = useActiveSection(navData?.components ?? [])
|
|
60
|
+
const firstDocSlug = navData?.docs[0]?.slug
|
|
61
|
+
const firstComponentName = navData?.components.find((c) => c.kind === "component")?.name
|
|
62
|
+
const firstBlockName = navData?.components.find((c) => c.kind === "block")?.name
|
|
63
|
+
const hasBlocks = Boolean(firstBlockName)
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- standard hydration mount detection
|
|
66
|
+
useEffect(() => setMounted(true), [])
|
|
67
|
+
|
|
68
|
+
const faviconSrc = mounted
|
|
69
|
+
? resolvedTheme === "dark"
|
|
70
|
+
? branding.faviconLight
|
|
71
|
+
: branding.faviconDark
|
|
72
|
+
: branding.faviconDark
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<header className="h-14 border-b border-border sticky top-0 z-30 bg-background">
|
|
76
|
+
<div className="relative flex items-center justify-between h-full px-4 md:px-6">
|
|
77
|
+
{/* Left: hamburger + breadcrumb (fixed-width container so absolute-centered tabs don't shift) */}
|
|
78
|
+
<div className="flex items-center gap-2 md:w-80 md:shrink-0 min-w-0">
|
|
79
|
+
{/* Hamburger — always takes space on mobile for consistent brand position */}
|
|
80
|
+
<div
|
|
81
|
+
className={`md:overflow-hidden md:transition-all md:duration-300 md:ease-in-out motion-reduce:transition-none ${
|
|
82
|
+
collapsed ? "md:w-9 md:opacity-100" : "md:w-0 md:opacity-0"
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
<Button
|
|
86
|
+
variant={sidebarOpen ? "default" : "ghost"}
|
|
87
|
+
size="icon"
|
|
88
|
+
onClick={toggle}
|
|
89
|
+
aria-label="Toggle menu"
|
|
90
|
+
aria-expanded={sidebarOpen}
|
|
91
|
+
>
|
|
92
|
+
<Menu className="size-4" />
|
|
93
|
+
</Button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<Breadcrumb className="min-w-0">
|
|
97
|
+
<BreadcrumbList className="flex-nowrap gap-1.5 sm:gap-2 text-sm">
|
|
98
|
+
<BreadcrumbItem>
|
|
99
|
+
<BreadcrumbLink href="/" className="flex items-center gap-2 hover:no-underline">
|
|
100
|
+
<Image
|
|
101
|
+
src={faviconSrc}
|
|
102
|
+
alt={`${branding.logoAlt} logo`}
|
|
103
|
+
width={22}
|
|
104
|
+
height={22}
|
|
105
|
+
/>
|
|
106
|
+
</BreadcrumbLink>
|
|
107
|
+
</BreadcrumbItem>
|
|
108
|
+
<BreadcrumbSeparator />
|
|
109
|
+
<BreadcrumbItem>
|
|
110
|
+
<BreadcrumbLink href="/" className="font-medium hover:no-underline">
|
|
111
|
+
{branding.shortName}
|
|
112
|
+
</BreadcrumbLink>
|
|
113
|
+
</BreadcrumbItem>
|
|
114
|
+
<span id="header-breadcrumb" className="contents" />
|
|
115
|
+
</BreadcrumbList>
|
|
116
|
+
</Breadcrumb>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Center: section tabs — absolutely centered, independent of breadcrumb width */}
|
|
120
|
+
{navData && (
|
|
121
|
+
<nav
|
|
122
|
+
aria-label="Sections"
|
|
123
|
+
className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2 h-full"
|
|
124
|
+
>
|
|
125
|
+
{firstDocSlug && (
|
|
126
|
+
<HeaderTab href={`/docs/${firstDocSlug}`} active={activeSection === "docs"}>
|
|
127
|
+
<TranslatedText k="sidebar.documentation" />
|
|
128
|
+
</HeaderTab>
|
|
129
|
+
)}
|
|
130
|
+
{firstComponentName && (
|
|
131
|
+
<HeaderTab href={`/components/${firstComponentName}`} active={activeSection === "components"}>
|
|
132
|
+
<TranslatedText k="sidebar.components" />
|
|
133
|
+
</HeaderTab>
|
|
134
|
+
)}
|
|
135
|
+
{hasBlocks && firstBlockName && (
|
|
136
|
+
<HeaderTab href={`/components/${firstBlockName}`} active={activeSection === "blocks"}>
|
|
137
|
+
<TranslatedText k="sidebar.blocks" />
|
|
138
|
+
</HeaderTab>
|
|
139
|
+
)}
|
|
140
|
+
</nav>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<div className="flex items-center gap-1">
|
|
144
|
+
{/* GitHub button — rendered only when the registry config provides
|
|
145
|
+
a `github` object. Label defaults to "Github" but is overridable
|
|
146
|
+
(e.g. "Sponsor"). Star count fetches server-side with hourly
|
|
147
|
+
revalidation, unless the registry opted out via showStars=false. */}
|
|
148
|
+
{branding.github && (
|
|
149
|
+
<Button
|
|
150
|
+
asChild
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="sm"
|
|
153
|
+
className="hidden md:inline-flex h-8 px-2.5 gap-1.5 text-xs font-medium"
|
|
154
|
+
>
|
|
155
|
+
<a
|
|
156
|
+
href={GITHUB_URL}
|
|
157
|
+
target="_blank"
|
|
158
|
+
rel="noopener noreferrer"
|
|
159
|
+
aria-label={
|
|
160
|
+
typeof githubStars === "number"
|
|
161
|
+
? `${branding.github.label ?? "GitHub"}, ${githubStars} stars`
|
|
162
|
+
: branding.github.label ?? "GitHub repository"
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<Github className="size-3.5" />
|
|
166
|
+
<span>{branding.github.label ?? "Github"}</span>
|
|
167
|
+
{typeof githubStars === "number" && (
|
|
168
|
+
<Badge
|
|
169
|
+
variant="secondary"
|
|
170
|
+
className="gap-0.5 px-1.5 py-0 h-4 text-[10px] font-mono tabular-nums"
|
|
171
|
+
>
|
|
172
|
+
{formatStarCount(githubStars)}
|
|
173
|
+
<Star className="size-2.5 fill-current" />
|
|
174
|
+
</Badge>
|
|
175
|
+
)}
|
|
176
|
+
</a>
|
|
177
|
+
</Button>
|
|
178
|
+
)}
|
|
179
|
+
<SearchTrigger />
|
|
180
|
+
{/* Locale + theme switches always visible — small icons fit even on
|
|
181
|
+
mobile and avoid the indirection of a Settings modal. */}
|
|
182
|
+
<LocaleToggle />
|
|
183
|
+
<ThemeToggle />
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</header>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Link as LinkIcon } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
function slugify(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^\w\s-]/g, "")
|
|
9
|
+
.replace(/\s+/g, "-")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
|
13
|
+
children?: React.ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createHeading(level: 1 | 2 | 3 | 4 | 5 | 6) {
|
|
17
|
+
const Tag = `h${level}` as const
|
|
18
|
+
|
|
19
|
+
return function Heading({ children, id, ...props }: HeadingProps) {
|
|
20
|
+
const headingId = id || slugify(typeof children === "string" ? children : "")
|
|
21
|
+
if (!headingId) return <Tag {...props}>{children}</Tag>
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Tag id={headingId} className="group relative scroll-mt-20" {...props}>
|
|
25
|
+
{children}
|
|
26
|
+
<a
|
|
27
|
+
href={`#${headingId}`}
|
|
28
|
+
onClick={(e) => {
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
history.replaceState(null, "", `#${headingId}`)
|
|
31
|
+
document.getElementById(headingId)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
32
|
+
// Copy link to clipboard
|
|
33
|
+
navigator.clipboard?.writeText(window.location.href)
|
|
34
|
+
}}
|
|
35
|
+
className="inline-flex items-center ml-2 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
|
36
|
+
aria-label={`Link to ${typeof children === "string" ? children : "section"}`}
|
|
37
|
+
>
|
|
38
|
+
<LinkIcon className="size-4" />
|
|
39
|
+
</a>
|
|
40
|
+
</Tag>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const mdxHeadings = {
|
|
46
|
+
h1: createHeading(1),
|
|
47
|
+
h2: createHeading(2),
|
|
48
|
+
h3: createHeading(3),
|
|
49
|
+
h4: createHeading(4),
|
|
50
|
+
h5: createHeading(5),
|
|
51
|
+
h6: createHeading(6),
|
|
52
|
+
}
|