@voltro/ui-shadcn 0.66.0 → 0.67.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/CHANGELOG.md +52 -0
- package/THIRD-PARTY-NOTICES.md +6 -6
- package/dist/brand.d.ts +5 -1
- package/dist/brand.js +3 -177
- package/dist/index.d.ts +80 -5
- package/dist/index.js +1316 -882
- package/dist/signal.css +223 -0
- package/dist/voltroLogo-CSnSpQhz.js +207 -0
- package/package.json +7 -6
- package/src/brand/voltroLogo.tsx +13 -4
- package/src/compositions/appShell.tsx +7 -9
- package/src/compositions/codeCompare.tsx +2 -3
- package/src/compositions/docsLayout.tsx +56 -35
- package/src/compositions/profileMenu.tsx +12 -9
- package/src/compositions/signalControls.tsx +43 -0
- package/src/compositions/signalField.tsx +75 -0
- package/src/compositions/signalHeader.tsx +96 -0
- package/src/compositions/signalMotion.ts +68 -0
- package/src/compositions/themeToggle.tsx +6 -3
- package/src/cookies.ts +3 -0
- package/src/index.ts +5 -1
- package/src/primitives/codeBlock.tsx +4 -4
- package/src/primitives/highlightedCode.tsx +2 -0
- package/src/primitives/searchModal.tsx +16 -2
- package/src/signal.css +223 -0
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// (callouts, code blocks, steps) are siblings of this file and
|
|
17
17
|
// composable in any combination.
|
|
18
18
|
|
|
19
|
-
import { useEffect, useState, type ComponentType, type ReactNode } from 'react'
|
|
19
|
+
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
20
20
|
import { cn } from '../cn'
|
|
21
21
|
import type { ShellLinkProps } from './docShell'
|
|
22
22
|
import { ChevronLeftIcon, ChevronRightIcon } from '../primitives/docIcons'
|
|
@@ -121,6 +121,12 @@ interface DocsLayoutProps {
|
|
|
121
121
|
readonly brand: ReactNode
|
|
122
122
|
readonly nav: ReadonlyArray<DocsNavGroup>
|
|
123
123
|
readonly children: ReactNode
|
|
124
|
+
/** Compose the top bar while retaining the layout's mobile navigation control. */
|
|
125
|
+
readonly header?: (controls: { readonly menuButton: ReactNode }) => ReactNode
|
|
126
|
+
/** Full-width introduction above the reading columns. */
|
|
127
|
+
readonly intro?: ReactNode
|
|
128
|
+
/** Show the desktop section rail. Mobile navigation remains available. */
|
|
129
|
+
readonly sidebar?: boolean
|
|
124
130
|
/** Current pathname — used to highlight the active nav entry +
|
|
125
131
|
* decide which group opens by default. */
|
|
126
132
|
readonly currentPath?: string
|
|
@@ -200,7 +206,7 @@ const NavLeaf = ({
|
|
|
200
206
|
}): ReactNode => {
|
|
201
207
|
const active = isActiveHref(entry.href, currentPath)
|
|
202
208
|
return (
|
|
203
|
-
<li>
|
|
209
|
+
<li data-docs-active={active}>
|
|
204
210
|
<Link to={entry.href} prefetch className={cn(NAV_ROW, 'block', navRowColor(active))}>
|
|
205
211
|
<span className="inline-flex items-center gap-2">
|
|
206
212
|
{entry.label}
|
|
@@ -454,13 +460,32 @@ const Breadcrumbs = ({
|
|
|
454
460
|
export const DocsLayout = ({
|
|
455
461
|
brand, nav, children, currentPath, topNav, topRight, search,
|
|
456
462
|
page, LinkComponent = PlainLink, className, labels, footer, sidebarHeader,
|
|
463
|
+
header, intro, sidebar = true,
|
|
457
464
|
}: DocsLayoutProps): ReactNode => {
|
|
458
465
|
const Link = LinkComponent
|
|
459
466
|
const t = { ...DEFAULT_DOCS_LABELS, ...labels }
|
|
460
|
-
// Mobile drawer state — only the inline state, no portal/escape
|
|
461
|
-
// handling here; the search-trigger button can call setMobileOpen
|
|
462
|
-
// via a parent island when full cmd-k lands.
|
|
463
467
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
468
|
+
const drawer = useRef<HTMLDialogElement>(null)
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const element = drawer.current
|
|
471
|
+
if (!element) return
|
|
472
|
+
if (mobileOpen && !element.open) element.showModal()
|
|
473
|
+
if (!mobileOpen && element.open) element.close()
|
|
474
|
+
if (!mobileOpen) return
|
|
475
|
+
const previousOverflow = document.body.style.overflow
|
|
476
|
+
document.body.style.overflow = 'hidden'
|
|
477
|
+
return () => { document.body.style.overflow = previousOverflow }
|
|
478
|
+
}, [mobileOpen])
|
|
479
|
+
useEffect(() => { setMobileOpen(false) }, [currentPath])
|
|
480
|
+
const menuButton = (
|
|
481
|
+
<button type="button" onClick={() => setMobileOpen(true)}
|
|
482
|
+
className="docs-menu-toggle md:hidden inline-flex items-center justify-center w-11 h-11 rounded-md hover:bg-card transition-colors"
|
|
483
|
+
aria-label={t.toggleMenu} aria-expanded={mobileOpen} aria-haspopup="dialog">
|
|
484
|
+
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
485
|
+
<path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
486
|
+
</svg>
|
|
487
|
+
</button>
|
|
488
|
+
)
|
|
464
489
|
|
|
465
490
|
return (
|
|
466
491
|
<div className={cn('min-h-screen bg-background text-foreground', className)}>
|
|
@@ -468,19 +493,9 @@ export const DocsLayout = ({
|
|
|
468
493
|
* sticky / max-width / h-16 / glass-on-scroll fade. The
|
|
469
494
|
* children below define the docs-specific row: mobile menu,
|
|
470
495
|
* brand, optional topNav, search, topRight slots. */}
|
|
471
|
-
<PageHeader>
|
|
496
|
+
{header ? header({ menuButton }) : <PageHeader>
|
|
472
497
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
|
473
|
-
|
|
474
|
-
type="button"
|
|
475
|
-
onClick={() => setMobileOpen(!mobileOpen)}
|
|
476
|
-
className="md:hidden inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-card transition-colors"
|
|
477
|
-
aria-label={t.toggleMenu}
|
|
478
|
-
aria-expanded={mobileOpen}
|
|
479
|
-
>
|
|
480
|
-
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
481
|
-
<path d="M3 5 H15 M3 9 H15 M3 13 H15" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
482
|
-
</svg>
|
|
483
|
-
</button>
|
|
498
|
+
{menuButton}
|
|
484
499
|
<div className="flex items-center gap-2 font-semibold">{brand}</div>
|
|
485
500
|
{topNav ? <div className="hidden md:flex items-center gap-1 ml-4">{topNav}</div> : null}
|
|
486
501
|
</div>
|
|
@@ -488,36 +503,43 @@ export const DocsLayout = ({
|
|
|
488
503
|
{search ? <div className="hidden md:block">{search}</div> : null}
|
|
489
504
|
{topRight ? <div className="flex items-center gap-1">{topRight}</div> : null}
|
|
490
505
|
</div>
|
|
491
|
-
</PageHeader>
|
|
506
|
+
</PageHeader>}
|
|
492
507
|
|
|
493
|
-
|
|
508
|
+
{intro}
|
|
509
|
+
|
|
510
|
+
<div className="docs-columns mx-auto max-w-7xl flex">
|
|
494
511
|
{/* Sidebar (desktop) */}
|
|
495
|
-
<aside className="hidden md:block w-64 shrink-0 border-r border-border">
|
|
512
|
+
{sidebar ? <aside data-docs-sidebar className="hidden md:block w-64 shrink-0 border-r border-border">
|
|
496
513
|
<div className="sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 py-8">
|
|
497
514
|
{sidebarHeader ? <div className="mb-6">{sidebarHeader}</div> : null}
|
|
498
515
|
{nav.map((group) => (
|
|
499
516
|
<SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
500
517
|
))}
|
|
501
518
|
</div>
|
|
502
|
-
</aside>
|
|
519
|
+
</aside> : null}
|
|
503
520
|
|
|
504
521
|
{/* Sidebar (mobile drawer) */}
|
|
505
|
-
{
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
522
|
+
<dialog ref={drawer} aria-label={t.menu}
|
|
523
|
+
onCancel={() => setMobileOpen(false)} onClose={() => setMobileOpen(false)}
|
|
524
|
+
onKeyDown={event => {
|
|
525
|
+
if (event.key !== 'Tab') return
|
|
526
|
+
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], select:not([disabled]), [tabindex="0"]')]
|
|
527
|
+
const first = controls[0]
|
|
528
|
+
const last = controls.at(-1)
|
|
529
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
|
530
|
+
if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
|
531
|
+
}}
|
|
532
|
+
onClick={event => { if (event.target === event.currentTarget) setMobileOpen(false) }}
|
|
533
|
+
className="docs-drawer m-0 ml-auto h-dvh max-h-dvh w-80 max-w-[90vw] border-0 border-l border-border bg-card text-foreground p-0 backdrop:bg-black/50">
|
|
534
|
+
{mobileOpen ? <div className="min-h-full px-6 py-6" onClick={event => {
|
|
535
|
+
if (event.target instanceof Element && event.target.closest('a[href]')) setMobileOpen(false)
|
|
536
|
+
}}>
|
|
515
537
|
<div className="mb-4 flex items-center justify-between">
|
|
516
538
|
<span className="font-semibold">{t.menu}</span>
|
|
517
539
|
<button
|
|
518
540
|
type="button"
|
|
519
541
|
onClick={() => setMobileOpen(false)}
|
|
520
|
-
className="inline-flex items-center justify-center w-
|
|
542
|
+
className="inline-flex items-center justify-center w-11 h-11 rounded-md hover:bg-background transition-colors"
|
|
521
543
|
aria-label={t.close}
|
|
522
544
|
>
|
|
523
545
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -529,9 +551,8 @@ export const DocsLayout = ({
|
|
|
529
551
|
{nav.map((group) => (
|
|
530
552
|
<SidebarGroup key={group.section} group={group} {...(currentPath ? { currentPath } : {})} Link={Link} />
|
|
531
553
|
))}
|
|
532
|
-
</
|
|
533
|
-
|
|
534
|
-
) : null}
|
|
554
|
+
</div> : null}
|
|
555
|
+
</dialog>
|
|
535
556
|
|
|
536
557
|
{/* Content + TOC */}
|
|
537
558
|
<div className="flex-1 min-w-0 flex">
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from '../primitives/dropdownMenu'
|
|
33
33
|
import { ToggleGroup, ToggleGroupItem } from '../primitives/toggleGroup'
|
|
34
34
|
import {
|
|
35
|
-
THEME_COOKIE, LOCALE_COOKIE, getCookie, setCookie,
|
|
35
|
+
THEME_COOKIE, THEME_CHANGE_EVENT, LOCALE_COOKIE, getCookie, setCookie, applyTheme,
|
|
36
36
|
type ThemePreference,
|
|
37
37
|
} from '../cookies'
|
|
38
38
|
|
|
@@ -43,10 +43,8 @@ const readStoredTheme = (): ThemePreference => {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const persistTheme = (choice: ThemePreference): void => {
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
if (choice === 'system') deleteCookie(THEME_COOKIE)
|
|
49
|
-
else setCookie(THEME_COOKIE, choice)
|
|
46
|
+
// Persist System explicitly so every appearance control reads the same choice.
|
|
47
|
+
setCookie(THEME_COOKIE, choice)
|
|
50
48
|
applyTheme(choice)
|
|
51
49
|
}
|
|
52
50
|
|
|
@@ -66,6 +64,8 @@ export interface ProfileMenuLanguage {
|
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
export interface ProfileMenuProps {
|
|
67
|
+
/** Custom accessible trigger; Radix supplies menu state and focus handling. */
|
|
68
|
+
readonly trigger?: ReactNode
|
|
69
69
|
readonly identity?: {
|
|
70
70
|
readonly name: string
|
|
71
71
|
readonly sublabel?: string
|
|
@@ -146,7 +146,7 @@ const navigateTo = (href: string, external?: boolean): void => {
|
|
|
146
146
|
|
|
147
147
|
export const ProfileMenu = ({
|
|
148
148
|
identity, links, languages, currentLanguage, onLanguageChange,
|
|
149
|
-
logoutUrl, onLogout, signInUrl, labels,
|
|
149
|
+
logoutUrl, onLogout, signInUrl, labels, trigger,
|
|
150
150
|
}: ProfileMenuProps): ReactNode => {
|
|
151
151
|
const [theme, setTheme] = useState<ThemePreference | undefined>(undefined)
|
|
152
152
|
// Hydrate language from cookie on mount; the prop overrides if set.
|
|
@@ -154,8 +154,11 @@ export const ProfileMenu = ({
|
|
|
154
154
|
const locale = currentLanguage ?? cookieLocale
|
|
155
155
|
|
|
156
156
|
useEffect(() => {
|
|
157
|
-
setTheme(readStoredTheme())
|
|
157
|
+
const refreshTheme = (): void => setTheme(readStoredTheme())
|
|
158
|
+
refreshTheme()
|
|
158
159
|
setCookieLocale(getCookie(LOCALE_COOKIE) ?? undefined)
|
|
160
|
+
window.addEventListener(THEME_CHANGE_EVENT, refreshTheme)
|
|
161
|
+
return () => window.removeEventListener(THEME_CHANGE_EVENT, refreshTheme)
|
|
159
162
|
}, [])
|
|
160
163
|
|
|
161
164
|
const onThemeChange = useCallback((next: string): void => {
|
|
@@ -203,7 +206,7 @@ export const ProfileMenu = ({
|
|
|
203
206
|
return (
|
|
204
207
|
<DropdownMenu>
|
|
205
208
|
<DropdownMenuTrigger asChild>
|
|
206
|
-
<Button
|
|
209
|
+
{trigger ?? <Button
|
|
207
210
|
type="button"
|
|
208
211
|
variant="outline"
|
|
209
212
|
size="icon"
|
|
@@ -211,7 +214,7 @@ export const ProfileMenu = ({
|
|
|
211
214
|
className="rounded-full size-8 text-muted-foreground hover:text-foreground"
|
|
212
215
|
>
|
|
213
216
|
<AvatarGlyph {...(initial ? { initial } : {})} />
|
|
214
|
-
</Button>
|
|
217
|
+
</Button>}
|
|
215
218
|
</DropdownMenuTrigger>
|
|
216
219
|
|
|
217
220
|
<DropdownMenuContent align="end" className="w-64">
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
2
|
+
import { ProfileMenu, type ProfileMenuProps } from './profileMenu'
|
|
3
|
+
import { SearchIcon } from '../primitives/docIcons'
|
|
4
|
+
import { cn } from '../cn'
|
|
5
|
+
|
|
6
|
+
export interface SignalPreferencesProps extends Omit<ProfileMenuProps, 'trigger' | 'labels' | 'languages' | 'currentLanguage'> {
|
|
7
|
+
readonly locale: 'en' | 'de'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One appearance/language entry point for public and operating Signal surfaces. */
|
|
11
|
+
export function SignalPreferences({ locale, ...props }: SignalPreferencesProps): ReactNode {
|
|
12
|
+
const de = locale === 'de'
|
|
13
|
+
const label = de ? 'Darstellung und Sprache' : 'Appearance and language'
|
|
14
|
+
return <ProfileMenu {...props} currentLanguage={locale}
|
|
15
|
+
languages={[{ code: 'en', label: 'EN' }, { code: 'de', label: 'DE' }]}
|
|
16
|
+
labels={{ openMenu: label, theme: de ? 'Design' : 'Theme', language: de ? 'Sprache' : 'Language',
|
|
17
|
+
signIn: de ? 'Anmelden' : 'Sign in', signOut: de ? 'Abmelden' : 'Sign out',
|
|
18
|
+
themes: { system: 'System', light: de ? 'Hell' : 'Light', dark: de ? 'Dunkel' : 'Dark' } }}
|
|
19
|
+
trigger={<button type="button" className="signal-control signal-preferences" aria-label={label}>
|
|
20
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17M12 3.5c4.5 4.5 4.5 12.5 0 17-4.5-4.5-4.5-12.5 0-17Z" /></svg>
|
|
21
|
+
<span>{props.identity?.name.trim().charAt(0).toUpperCase() ?? locale.toUpperCase()}</span>
|
|
22
|
+
</button>} />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SignalSearchProps {
|
|
26
|
+
readonly label: string
|
|
27
|
+
readonly onOpen: () => void
|
|
28
|
+
readonly className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Search is a command trigger, sized to the navigation rail with a compact mobile state. */
|
|
32
|
+
export function SignalSearch({ label, onOpen, className }: SignalSearchProps): ReactNode {
|
|
33
|
+
return <button type="button" className={cn('signal-control signal-search', className)} onClick={onOpen} aria-label={label}>
|
|
34
|
+
<SearchIcon /><span>{label}</span><kbd aria-hidden="true">⌘ K</kbd>
|
|
35
|
+
</button>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Secondary header action; the filled Signal CTA remains the page's primary action. */
|
|
39
|
+
export function SignalHeaderAction({ children, className, ...props }: ComponentProps<'a'>): ReactNode {
|
|
40
|
+
return <a {...props} className={cn('signal-control signal-header-action', className)}><span>{children}</span>
|
|
41
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" /></svg>
|
|
42
|
+
</a>
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../cn'
|
|
3
|
+
import { createSignalMotion } from './signalMotion'
|
|
4
|
+
|
|
5
|
+
export interface SignalFieldProps {
|
|
6
|
+
/** Enable the ambient wave. Supply a visible pause control when enabling it. */
|
|
7
|
+
readonly animated?: boolean
|
|
8
|
+
readonly paused?: boolean
|
|
9
|
+
readonly className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ribbons = [
|
|
13
|
+
"M1080-160C870 90 1570 60 1530 400S1100 670 1320 990",
|
|
14
|
+
"M1055-160C845 98 1545 72 1505 408S1075 678 1295 998",
|
|
15
|
+
"M1030-160C820 106 1520 84 1480 416S1050 686 1270 1006",
|
|
16
|
+
"M1005-160C795 114 1495 96 1455 424S1025 694 1245 1014",
|
|
17
|
+
"M980-160C770 122 1470 108 1430 432S1000 702 1220 1022",
|
|
18
|
+
"M955-160C745 130 1445 120 1405 440S975 710 1195 1030",
|
|
19
|
+
"M930-160C720 138 1420 132 1380 448S950 718 1170 1038",
|
|
20
|
+
"M905-160C695 146 1395 144 1355 456S925 726 1145 1046",
|
|
21
|
+
"M880-160C670 154 1370 156 1330 464S900 734 1120 1054",
|
|
22
|
+
"M855-160C645 162 1345 168 1305 472S875 742 1095 1062",
|
|
23
|
+
"M830-160C620 170 1320 180 1280 480S850 750 1070 1070",
|
|
24
|
+
"M805-160C595 178 1295 192 1255 488S825 758 1045 1078"
|
|
25
|
+
]
|
|
26
|
+
const returns = [
|
|
27
|
+
"M-150 600C170 550-70 930 360 960S490 1100 780 1200",
|
|
28
|
+
"M-150 621C165 571-75 951 355 981S485 1121 775 1221",
|
|
29
|
+
"M-150 642C160 592-80 972 350 1002S480 1142 770 1242",
|
|
30
|
+
"M-150 663C155 613-85 993 345 1023S475 1163 765 1263",
|
|
31
|
+
"M-150 684C150 634-90 1014 340 1044S470 1184 760 1284"
|
|
32
|
+
]
|
|
33
|
+
const traces = [ribbons[1], ribbons[4], ribbons[7], ribbons[10], returns[2]] as const
|
|
34
|
+
|
|
35
|
+
/** Decorative Voltro contour field. Import `@voltro/ui-shadcn/signal.css`.
|
|
36
|
+
* Static at SSR, with reduced-motion and visibility-aware animation on hydration. */
|
|
37
|
+
export function SignalField({ animated = false, paused = false, className }: SignalFieldProps): ReactNode {
|
|
38
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
39
|
+
const control = useRef<ReturnType<typeof createSignalMotion> | null>(null)
|
|
40
|
+
const id = useId()
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const element = ref.current
|
|
43
|
+
if (!element) return
|
|
44
|
+
const motion = createSignalMotion(element)
|
|
45
|
+
control.current = motion
|
|
46
|
+
return () => { motion.dispose(); control.current = null }
|
|
47
|
+
}, [])
|
|
48
|
+
useEffect(() => { control.current?.setEnabled(animated && !paused) }, [animated, paused])
|
|
49
|
+
return (
|
|
50
|
+
<div ref={ref} className={cn('signal-atmosphere', className)} aria-hidden="true" data-motion="paused">
|
|
51
|
+
<div className="field-light" />
|
|
52
|
+
<svg className="field-contours" viewBox="0 0 1600 1120" fill="none">
|
|
53
|
+
<defs>
|
|
54
|
+
<linearGradient id={`${id}-ink`} x1="750" y1="60" x2="1390" y2="1040" gradientUnits="userSpaceOnUse">
|
|
55
|
+
<stop stopColor="#A874FF" stopOpacity=".05" /><stop offset=".34" stopColor="#A874FF" stopOpacity=".36" />
|
|
56
|
+
<stop offset=".7" stopColor="#7C3AED" stopOpacity=".12" /><stop offset="1" stopColor="#A874FF" stopOpacity="0" />
|
|
57
|
+
</linearGradient>
|
|
58
|
+
<linearGradient id={`${id}-edge`} x1="0" y1="670" x2="740" y2="1120" gradientUnits="userSpaceOnUse">
|
|
59
|
+
<stop stopColor="#A874FF" stopOpacity="0" /><stop offset=".5" stopColor="#A874FF" stopOpacity=".22" />
|
|
60
|
+
<stop offset="1" stopColor="#A874FF" stopOpacity="0" />
|
|
61
|
+
</linearGradient>
|
|
62
|
+
</defs>
|
|
63
|
+
<g className="field-ribbons" stroke={`url(#${id}-ink)`} strokeWidth=".8">
|
|
64
|
+
{ribbons.map((d, i) => <path key={d} d={d} data-wave={`main-${i}`} />)}
|
|
65
|
+
</g>
|
|
66
|
+
<g className="field-return" stroke={`url(#${id}-edge)`} strokeWidth=".7">
|
|
67
|
+
{returns.map((d, i) => <path key={d} d={d} data-wave={`return-${i}`} />)}
|
|
68
|
+
</g>
|
|
69
|
+
{traces.map((d, i) => <path key={d} d={d} className={`field-ambient-trace trace-${i}`}
|
|
70
|
+
data-wave={['main-1', 'main-4', 'main-7', 'main-10', 'return-2'][i]}
|
|
71
|
+
stroke="#B995FF" strokeWidth="1" pathLength="100" />)}
|
|
72
|
+
</svg>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../cn'
|
|
3
|
+
|
|
4
|
+
export interface SignalNavigationLink {
|
|
5
|
+
readonly label: string
|
|
6
|
+
readonly href: string
|
|
7
|
+
readonly description?: string
|
|
8
|
+
readonly current?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SignalHeaderProps {
|
|
12
|
+
readonly brand: ReactNode
|
|
13
|
+
readonly links: readonly SignalNavigationLink[]
|
|
14
|
+
readonly navigationLabel: string
|
|
15
|
+
readonly menuLabel: string
|
|
16
|
+
readonly menuTitle: ReactNode
|
|
17
|
+
readonly menuLinks: readonly SignalNavigationLink[]
|
|
18
|
+
readonly utilities?: ReactNode
|
|
19
|
+
/** Context navigation, such as the Docs section drawer, stays beside the rail. */
|
|
20
|
+
readonly navigationAction?: ReactNode
|
|
21
|
+
readonly className?: string
|
|
22
|
+
/** Keep navigation available while reading; the surface appears after scrolling. */
|
|
23
|
+
readonly sticky?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Compact brand navigation with a keyboard-accessible disclosure and moving marker. */
|
|
27
|
+
export function SignalHeader({ brand, links, navigationLabel, menuLabel, menuTitle, menuLinks, utilities, navigationAction, className, sticky = false }: SignalHeaderProps): ReactNode {
|
|
28
|
+
const [open, setOpen] = useState(false)
|
|
29
|
+
const [scrolled, setScrolled] = useState(false)
|
|
30
|
+
const ref = useRef<HTMLElement>(null)
|
|
31
|
+
const trigger = useRef<HTMLButtonElement>(null)
|
|
32
|
+
const marker = useRef<HTMLSpanElement>(null)
|
|
33
|
+
const id = useId()
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!sticky) return
|
|
36
|
+
const update = (): void => setScrolled(window.scrollY > 24)
|
|
37
|
+
update()
|
|
38
|
+
window.addEventListener('scroll', update, { passive: true })
|
|
39
|
+
return () => window.removeEventListener('scroll', update)
|
|
40
|
+
}, [sticky])
|
|
41
|
+
const move = (target: HTMLElement | null): void => {
|
|
42
|
+
if (!target || !marker.current) return
|
|
43
|
+
marker.current.style.transform = `translateX(${target.offsetLeft - 5}px) scaleX(${target.offsetWidth / 100})`
|
|
44
|
+
}
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const header = ref.current
|
|
47
|
+
if (!header) return
|
|
48
|
+
const reset = (): void => move(trigger.current)
|
|
49
|
+
const resize = new ResizeObserver(reset)
|
|
50
|
+
resize.observe(header)
|
|
51
|
+
reset()
|
|
52
|
+
return () => resize.disconnect()
|
|
53
|
+
}, [])
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return
|
|
56
|
+
const close = (event: PointerEvent): void => {
|
|
57
|
+
if (event.target instanceof Node && !ref.current?.contains(event.target)) setOpen(false)
|
|
58
|
+
}
|
|
59
|
+
document.addEventListener('pointerdown', close)
|
|
60
|
+
return () => document.removeEventListener('pointerdown', close)
|
|
61
|
+
}, [open])
|
|
62
|
+
return (
|
|
63
|
+
<header ref={ref} className={cn('signal-masthead', sticky && 'signal-masthead-sticky', className)} data-scrolled={sticky && scrolled}
|
|
64
|
+
onKeyDown={event => { if (event.key === 'Escape' && open) { setOpen(false); trigger.current?.focus() } }}
|
|
65
|
+
onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) }}>
|
|
66
|
+
<div className="signal-header signal-shell">
|
|
67
|
+
<div className="signal-brand">{brand}</div>
|
|
68
|
+
<div className="signal-navigation-area">
|
|
69
|
+
<nav className="signal-navigation" aria-label={navigationLabel} onPointerLeave={() => move(trigger.current)}>
|
|
70
|
+
<span ref={marker} className="signal-nav-marker" aria-hidden="true" />
|
|
71
|
+
<button ref={trigger} className="signal-nav-item" type="button" aria-expanded={open} aria-controls={id}
|
|
72
|
+
onClick={() => setOpen(!open)} onPointerEnter={event => move(event.currentTarget)} onFocus={event => move(event.currentTarget)}>
|
|
73
|
+
<span className="signal-nav-dot" aria-hidden="true" />{menuLabel}
|
|
74
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4" /></svg>
|
|
75
|
+
</button>
|
|
76
|
+
{links.map(link => <a key={link.href} href={link.href} className="signal-nav-item" aria-current={link.current ? 'page' : undefined}
|
|
77
|
+
onPointerEnter={event => move(event.currentTarget)} onFocus={event => move(event.currentTarget)}>
|
|
78
|
+
{link.label}<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12" /></svg>
|
|
79
|
+
</a>)}
|
|
80
|
+
{navigationAction}
|
|
81
|
+
</nav>
|
|
82
|
+
{utilities && <div className="signal-header-utilities">{utilities}</div>}
|
|
83
|
+
</div>
|
|
84
|
+
<nav id={id} className="signal-menu" aria-label={menuLabel} hidden={!open}>
|
|
85
|
+
<div className="signal-menu-intro">{menuTitle}</div>
|
|
86
|
+
<div className="signal-menu-links">
|
|
87
|
+
{menuLinks.map(link => <a key={link.href} href={link.href} onClick={() => setOpen(false)}>
|
|
88
|
+
<span>{link.label}{link.description && <small>{link.description}</small>}</span>
|
|
89
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12" /></svg>
|
|
90
|
+
</a>)}
|
|
91
|
+
</div>
|
|
92
|
+
</nav>
|
|
93
|
+
</div>
|
|
94
|
+
</header>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Own all browser resources for one decorative field, including SPA teardown. */
|
|
2
|
+
export function createSignalMotion(element: HTMLElement): {
|
|
3
|
+
setEnabled: (enabled: boolean) => void
|
|
4
|
+
dispose: () => void
|
|
5
|
+
} {
|
|
6
|
+
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
7
|
+
const animations: Animation[] = []
|
|
8
|
+
let enabled = false
|
|
9
|
+
let visible = false
|
|
10
|
+
let disposed = false
|
|
11
|
+
const supportsPath = typeof CSS !== 'undefined' && CSS.supports('d', 'path("M 0 0 L 1 1")')
|
|
12
|
+
|
|
13
|
+
if (supportsPath) {
|
|
14
|
+
for (const path of element.querySelectorAll<SVGPathElement>('path[data-wave]')) {
|
|
15
|
+
const coordinates = path.getAttribute('d')?.match(/-?\d+(?:\.\d+)?/g)?.map(Number)
|
|
16
|
+
if (!coordinates || coordinates.length !== 12) continue
|
|
17
|
+
const n = coordinates
|
|
18
|
+
const [group, index = '0'] = (path.dataset.wave ?? '').split('-')
|
|
19
|
+
// Neighboring curves share a wave with a small phase gap so the field
|
|
20
|
+
// visibly bends without collapsing its parallel contour spacing.
|
|
21
|
+
const main = group === 'main'
|
|
22
|
+
const deltas = main
|
|
23
|
+
? [0, 0, -60, 34, 86, 54, -44, 70, -70, -54, 0, 0]
|
|
24
|
+
: [0, 0, 42, -35, -56, 45, 54, -51, -48, 32, 0, 0]
|
|
25
|
+
const shape = (direction: number): string => {
|
|
26
|
+
const values = n.map((value, i) => value + (deltas[i] ?? 0) * direction)
|
|
27
|
+
return `path("M ${values[0]} ${values[1]} C ${values[2]} ${values[3]} ${values[4]} ${values[5]} ${values[6]} ${values[7]} S ${values[8]} ${values[9]} ${values[10]} ${values[11]}")`
|
|
28
|
+
}
|
|
29
|
+
const outward = 'cubic-bezier(.333,.524,.667,1)'
|
|
30
|
+
const inward = 'cubic-bezier(.333,0,.667,.476)'
|
|
31
|
+
const animation = path.animate([
|
|
32
|
+
{ d: shape(0), easing: outward }, { d: shape(1), easing: inward },
|
|
33
|
+
{ d: shape(0), easing: outward }, { d: shape(-1), easing: inward }, { d: shape(0) },
|
|
34
|
+
], { duration: main ? 12000 : 15000, delay: -Number(index) * (main ? 320 : 450), iterations: Infinity })
|
|
35
|
+
animation.pause()
|
|
36
|
+
animations.push(animation)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sync = (): void => {
|
|
41
|
+
if (disposed) return
|
|
42
|
+
const running = enabled && visible && !document.hidden && !media.matches
|
|
43
|
+
element.dataset.motion = running ? 'running' : 'paused'
|
|
44
|
+
for (const animation of animations) {
|
|
45
|
+
if (media.matches) animation.cancel()
|
|
46
|
+
else if (running && animation.playState !== 'running') animation.play()
|
|
47
|
+
else if (!running && animation.playState === 'running') animation.pause()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
51
|
+
visible = entry?.isIntersecting ?? false
|
|
52
|
+
sync()
|
|
53
|
+
}, { threshold: .01 })
|
|
54
|
+
observer.observe(element)
|
|
55
|
+
document.addEventListener('visibilitychange', sync)
|
|
56
|
+
media.addEventListener('change', sync)
|
|
57
|
+
return {
|
|
58
|
+
setEnabled(value) { enabled = value; sync() },
|
|
59
|
+
dispose() {
|
|
60
|
+
disposed = true
|
|
61
|
+
observer.disconnect()
|
|
62
|
+
document.removeEventListener('visibilitychange', sync)
|
|
63
|
+
media.removeEventListener('change', sync)
|
|
64
|
+
animations.forEach(animation => animation.cancel())
|
|
65
|
+
element.dataset.motion = 'paused'
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
|
|
10
10
|
import { Button } from '../primitives/button'
|
|
11
11
|
import { cn } from '../cn'
|
|
12
|
-
import { THEME_COOKIE, setCookie } from '../cookies'
|
|
12
|
+
import { THEME_COOKIE, THEME_CHANGE_EVENT, setCookie, applyTheme } from '../cookies'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Inline script to drop into <head>. Reads the `voltro:theme` cookie
|
|
@@ -56,13 +56,16 @@ export const ThemeToggle = ({
|
|
|
56
56
|
const [isDark, setIsDark] = useState<boolean | undefined>(undefined)
|
|
57
57
|
|
|
58
58
|
useEffect(() => {
|
|
59
|
-
setIsDark(document.documentElement.classList.contains('dark'))
|
|
59
|
+
const refreshTheme = (): void => setIsDark(document.documentElement.classList.contains('dark'))
|
|
60
|
+
refreshTheme()
|
|
61
|
+
window.addEventListener(THEME_CHANGE_EVENT, refreshTheme)
|
|
62
|
+
return () => window.removeEventListener(THEME_CHANGE_EVENT, refreshTheme)
|
|
60
63
|
}, [])
|
|
61
64
|
|
|
62
65
|
const toggle = (): void => {
|
|
63
66
|
const next = !document.documentElement.classList.contains('dark')
|
|
64
|
-
document.documentElement.classList.toggle('dark', next)
|
|
65
67
|
setCookie(THEME_COOKIE, next ? 'dark' : 'light')
|
|
68
|
+
applyTheme(next ? 'dark' : 'light')
|
|
66
69
|
setIsDark(next)
|
|
67
70
|
}
|
|
68
71
|
|
package/src/cookies.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// omit it in the browser (falls back to `document.cookie`).
|
|
12
12
|
|
|
13
13
|
export const THEME_COOKIE = 'voltro:theme'
|
|
14
|
+
/** Fired after applyTheme so preference controls in the same document stay in sync. */
|
|
15
|
+
export const THEME_CHANGE_EVENT = 'voltro:theme-change'
|
|
14
16
|
export const LOCALE_COOKIE = 'voltro:locale'
|
|
15
17
|
|
|
16
18
|
export type ThemePreference = 'system' | 'light' | 'dark'
|
|
@@ -108,4 +110,5 @@ export const applyTheme = (theme: ThemePreference): void => {
|
|
|
108
110
|
typeof window !== 'undefined' &&
|
|
109
111
|
window.matchMedia?.('(prefers-color-scheme: dark)').matches === true)
|
|
110
112
|
document.documentElement.classList.toggle('dark', dark)
|
|
113
|
+
if (typeof window !== 'undefined') window.dispatchEvent(new Event(THEME_CHANGE_EVENT))
|
|
111
114
|
}
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { shadcnWidgets } from './widgets'
|
|
|
33
33
|
|
|
34
34
|
// ---- User-preference cookies (theme + language) ----
|
|
35
35
|
export {
|
|
36
|
-
THEME_COOKIE, LOCALE_COOKIE,
|
|
36
|
+
THEME_COOKIE, THEME_CHANGE_EVENT, LOCALE_COOKIE,
|
|
37
37
|
getCookie, setCookie, deleteCookie,
|
|
38
38
|
parsePreferenceCookies, applyTheme,
|
|
39
39
|
} from './cookies'
|
|
@@ -158,3 +158,7 @@ export {
|
|
|
158
158
|
} from './primitives/docIcons'
|
|
159
159
|
export { SearchModal, SearchTrigger } from './primitives/searchModal'
|
|
160
160
|
export type { SearchModalProps, SearchModalLabels } from './primitives/searchModal'
|
|
161
|
+
|
|
162
|
+
export { SignalField, type SignalFieldProps } from './compositions/signalField'
|
|
163
|
+
export { SignalPreferences, SignalSearch, SignalHeaderAction, type SignalPreferencesProps, type SignalSearchProps } from './compositions/signalControls'
|
|
164
|
+
export { SignalHeader, type SignalHeaderProps, type SignalNavigationLink } from './compositions/signalHeader'
|
|
@@ -77,14 +77,14 @@ export const CodeBlock = ({
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
|
-
<figure className={cn('not-prose my-5 rounded-lg overflow-hidden border border-border bg-
|
|
80
|
+
<figure className={cn('not-prose my-5 rounded-lg overflow-hidden border border-border bg-card text-card-foreground', className)}>
|
|
81
81
|
{(filename || language) ? (
|
|
82
|
-
<header className="flex items-center justify-between border-b border-border/60 px-4 py-2 bg-
|
|
82
|
+
<header className="flex items-center justify-between border-b border-border/60 px-4 py-2 bg-muted">
|
|
83
83
|
{filename ? (
|
|
84
84
|
<span className="text-xs text-muted-foreground font-mono">{filename}</span>
|
|
85
85
|
) : <span />}
|
|
86
86
|
{language ? (
|
|
87
|
-
<span className="text-[0.65rem] uppercase tracking-wider text-muted-foreground
|
|
87
|
+
<span className="text-[0.65rem] uppercase tracking-wider text-muted-foreground font-semibold">
|
|
88
88
|
{language}
|
|
89
89
|
</span>
|
|
90
90
|
) : null}
|
|
@@ -95,7 +95,7 @@ export const CodeBlock = ({
|
|
|
95
95
|
type="button"
|
|
96
96
|
onClick={doCopy}
|
|
97
97
|
aria-label={copied ? copiedAriaLabel : copyAriaLabel}
|
|
98
|
-
className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md border border-border/60 bg-background
|
|
98
|
+
className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
99
99
|
>
|
|
100
100
|
{copied ? (
|
|
101
101
|
<>
|
|
@@ -112,6 +112,8 @@ export const HighlightedCode = ({
|
|
|
112
112
|
const out = hl.codeToHtml(code, {
|
|
113
113
|
lang,
|
|
114
114
|
themes: { light: 'github-light', dark: 'github-dark-dimmed' },
|
|
115
|
+
// GitHub Light's parameter orange falls below 4.5:1 on white.
|
|
116
|
+
colorReplacements: { 'github-light': { '#e36209': '#a44100' } },
|
|
115
117
|
defaultColor: false,
|
|
116
118
|
})
|
|
117
119
|
setHtml(out)
|