@windforge/ui 0.1.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/dist/index.d.ts +1195 -0
- package/dist/index.js +3628 -0
- package/package.json +66 -0
- package/src/catalog.ts +654 -0
- package/src/components/accordion.tsx +91 -0
- package/src/components/alert.tsx +58 -0
- package/src/components/autocomplete.tsx +174 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +37 -0
- package/src/components/breadcrumb.tsx +62 -0
- package/src/components/button-group.tsx +23 -0
- package/src/components/button.tsx +53 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card.tsx +72 -0
- package/src/components/chart.tsx +130 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/chip.tsx +75 -0
- package/src/components/code-block.tsx +126 -0
- package/src/components/command.tsx +139 -0
- package/src/components/data-table.tsx +194 -0
- package/src/components/date-picker.tsx +77 -0
- package/src/components/dialog.tsx +57 -0
- package/src/components/dropdown-menu.tsx +186 -0
- package/src/components/form-field.tsx +97 -0
- package/src/components/input.tsx +29 -0
- package/src/components/label.tsx +18 -0
- package/src/components/layout.tsx +179 -0
- package/src/components/link.tsx +37 -0
- package/src/components/modal.tsx +67 -0
- package/src/components/multi-select.tsx +175 -0
- package/src/components/pagination.tsx +72 -0
- package/src/components/popover.tsx +25 -0
- package/src/components/progress.tsx +31 -0
- package/src/components/radio-group.tsx +34 -0
- package/src/components/select.tsx +134 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +80 -0
- package/src/components/skeleton.tsx +11 -0
- package/src/components/slider.tsx +28 -0
- package/src/components/stepper.tsx +69 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +121 -0
- package/src/components/tabs.tsx +90 -0
- package/src/components/text.tsx +109 -0
- package/src/components/textarea.tsx +27 -0
- package/src/components/toast.tsx +107 -0
- package/src/components/toggle-button.tsx +103 -0
- package/src/components/tooltip.tsx +26 -0
- package/src/icons/forge-icon.tsx +55 -0
- package/src/icons/icon-set.ts +60 -0
- package/src/icons/svg-icon.tsx +43 -0
- package/src/index.ts +80 -0
- package/src/layouts/app-bar.tsx +95 -0
- package/src/layouts/app-shell.tsx +80 -0
- package/src/layouts/side-nav.tsx +196 -0
- package/src/layouts/theme-provider.tsx +128 -0
- package/src/lib/recipes.ts +50 -0
- package/src/lib/types.ts +3 -0
- package/src/lib/use-media-query.ts +18 -0
- package/src/lib/utils.ts +10 -0
- package/tailwind-preset.cjs +77 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { Menu, Sun, Moon, MonitorSmartphone } from 'lucide-react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { Button } from '../components/button'
|
|
6
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../components/tooltip'
|
|
7
|
+
import { useTheme, type ColorMode } from './theme-provider'
|
|
8
|
+
import { useAppShell } from './app-shell'
|
|
9
|
+
|
|
10
|
+
// `variant` picks the surface treatment: `glass` is the translucent, blurred bar
|
|
11
|
+
// (content scrolls frostily behind it — the default); `solid` is a flat, opaque
|
|
12
|
+
// bar for a calmer, cleaner chrome. `color` overrides which surface token the bar
|
|
13
|
+
// sits on (e.g. `inset`) under either treatment. Class strings are spelled out in
|
|
14
|
+
// full so Tailwind's content scanner keeps them.
|
|
15
|
+
const appBarVariants = cva(
|
|
16
|
+
'sticky top-0 z-30 flex h-16 items-center gap-2 border-b border-border px-4',
|
|
17
|
+
{
|
|
18
|
+
variants: {
|
|
19
|
+
variant: { glass: 'backdrop-blur-md', solid: '' },
|
|
20
|
+
color: { surface: '', subtle: '', inset: '' },
|
|
21
|
+
},
|
|
22
|
+
compoundVariants: [
|
|
23
|
+
{ variant: 'glass', color: 'surface', class: 'bg-surface/80' },
|
|
24
|
+
{ variant: 'glass', color: 'subtle', class: 'bg-surface-subtle/80' },
|
|
25
|
+
{ variant: 'glass', color: 'inset', class: 'bg-surface-inset/80' },
|
|
26
|
+
{ variant: 'solid', color: 'surface', class: 'bg-surface' },
|
|
27
|
+
{ variant: 'solid', color: 'subtle', class: 'bg-surface-subtle' },
|
|
28
|
+
{ variant: 'solid', color: 'inset', class: 'bg-surface-inset' },
|
|
29
|
+
],
|
|
30
|
+
defaultVariants: { variant: 'glass', color: 'surface' },
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
export interface AppBarProps extends VariantProps<typeof appBarVariants> {
|
|
35
|
+
title?: string
|
|
36
|
+
logo?: React.ReactNode
|
|
37
|
+
actions?: React.ReactNode
|
|
38
|
+
/** Override the menu-button handler. Inside an AppShell it defaults to toggling the nav. */
|
|
39
|
+
onMenuClick?: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const modeIcon: Record<ColorMode, React.ReactNode> = {
|
|
43
|
+
light: <Sun className="h-5 w-5" />,
|
|
44
|
+
dark: <Moon className="h-5 w-5" />,
|
|
45
|
+
system: <MonitorSmartphone className="h-5 w-5" />,
|
|
46
|
+
}
|
|
47
|
+
const modeLabel: Record<ColorMode, string> = {
|
|
48
|
+
light: 'Light — switch to dark',
|
|
49
|
+
dark: 'Dark — switch to system',
|
|
50
|
+
system: 'System — switch to light',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ModeToggle — the light/dark/system switch, as a standalone control. Drop it
|
|
55
|
+
* into an AppBar's `actions` (or anywhere else); it reads and cycles the theme
|
|
56
|
+
* from the surrounding ThemeProvider.
|
|
57
|
+
*/
|
|
58
|
+
export function ModeToggle() {
|
|
59
|
+
const { mode, cycleMode } = useTheme()
|
|
60
|
+
return (
|
|
61
|
+
<TooltipProvider>
|
|
62
|
+
<Tooltip>
|
|
63
|
+
<TooltipTrigger asChild>
|
|
64
|
+
<Button variant="tertiary" size="icon" onClick={cycleMode} aria-label={modeLabel[mode]}>
|
|
65
|
+
{modeIcon[mode]}
|
|
66
|
+
</Button>
|
|
67
|
+
</TooltipTrigger>
|
|
68
|
+
<TooltipContent>{modeLabel[mode]}</TooltipContent>
|
|
69
|
+
</Tooltip>
|
|
70
|
+
</TooltipProvider>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function AppBar({
|
|
75
|
+
title, logo, actions, onMenuClick, variant, color,
|
|
76
|
+
}: AppBarProps) {
|
|
77
|
+
const appShell = useAppShell()
|
|
78
|
+
const handleMenu = onMenuClick ?? appShell?.toggleNav
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<header className={cn(appBarVariants({ variant, color }))}>
|
|
82
|
+
{handleMenu && (
|
|
83
|
+
<Button variant="tertiary" size="icon" onClick={handleMenu} aria-label="Toggle navigation">
|
|
84
|
+
<Menu className="h-5 w-5" />
|
|
85
|
+
</Button>
|
|
86
|
+
)}
|
|
87
|
+
{logo}
|
|
88
|
+
{title && <span className="text-sm font-semibold text-secondary">{title}</span>}
|
|
89
|
+
|
|
90
|
+
<div className="ml-auto flex items-center gap-1">
|
|
91
|
+
{actions}
|
|
92
|
+
</div>
|
|
93
|
+
</header>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Sheet, SheetContent } from '../components/sheet'
|
|
3
|
+
import { Box } from '../components/layout'
|
|
4
|
+
import { useMediaQuery } from '../lib/use-media-query'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
|
|
7
|
+
const NAV_WIDTH = 256
|
|
8
|
+
|
|
9
|
+
interface AppShellContextValue {
|
|
10
|
+
/** Toggle the sidebar — collapses it on desktop, opens/closes the drawer on mobile. */
|
|
11
|
+
toggleNav: () => void
|
|
12
|
+
/** Close the mobile drawer (no-op on desktop). */
|
|
13
|
+
closeNav: () => void
|
|
14
|
+
}
|
|
15
|
+
const AppShellContext = React.createContext<AppShellContextValue | null>(null)
|
|
16
|
+
|
|
17
|
+
/** Read the AppShell coordination handles. Returns null outside an AppShell. */
|
|
18
|
+
export const useAppShell = () => React.useContext(AppShellContext)
|
|
19
|
+
|
|
20
|
+
export interface AppShellProps {
|
|
21
|
+
/** The top bar slot — typically an `<AppBar>`. Rendered inside the shell so it
|
|
22
|
+
* can drive the nav via {@link useAppShell}. */
|
|
23
|
+
header?: React.ReactNode
|
|
24
|
+
/** The side navigation slot — typically a `<SideNav>`. */
|
|
25
|
+
sidebar?: React.ReactNode
|
|
26
|
+
children: React.ReactNode
|
|
27
|
+
fullBleed?: boolean
|
|
28
|
+
/** Whether the desktop sidebar starts expanded. Set `false` to open collapsed
|
|
29
|
+
* — e.g. a personal site where the nav is secondary. The menu button (and
|
|
30
|
+
* `useAppShell().toggleNav`) flips it. Defaults to `true`. */
|
|
31
|
+
defaultNavOpen?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* AppShell — the guaranteed-layout chrome: a fixed sidebar on desktop, a Sheet
|
|
36
|
+
* drawer on mobile, a sticky bar, and a scrollable main. `header` and `sidebar`
|
|
37
|
+
* are slots (their own components, `AppBar` and `SideNav`); AppShell only owns
|
|
38
|
+
* the responsive frame and exposes toggle/close handles via {@link useAppShell}.
|
|
39
|
+
*/
|
|
40
|
+
export function AppShell({ header, sidebar, children, fullBleed = false, defaultNavOpen = true }: AppShellProps) {
|
|
41
|
+
const isMobile = useMediaQuery('(max-width: 1023px)')
|
|
42
|
+
const [navOpen, setNavOpen] = React.useState(defaultNavOpen)
|
|
43
|
+
const [drawerOpen, setDrawerOpen] = React.useState(false)
|
|
44
|
+
|
|
45
|
+
const ctx = React.useMemo<AppShellContextValue>(
|
|
46
|
+
() => ({
|
|
47
|
+
toggleNav: () => (isMobile ? setDrawerOpen((o) => !o) : setNavOpen((o) => !o)),
|
|
48
|
+
closeNav: () => setDrawerOpen(false),
|
|
49
|
+
}),
|
|
50
|
+
[isMobile],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const desktopNavVisible = !isMobile && navOpen
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<AppShellContext.Provider value={ctx}>
|
|
57
|
+
<div className="flex min-h-screen bg-background text-primary">
|
|
58
|
+
{desktopNavVisible && sidebar && (
|
|
59
|
+
<aside className="fixed inset-y-0 left-0 z-20 shrink-0" style={{ width: NAV_WIDTH }}>
|
|
60
|
+
{sidebar}
|
|
61
|
+
</aside>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<Sheet open={isMobile && drawerOpen} onOpenChange={setDrawerOpen}>
|
|
65
|
+
<SheetContent side="left">
|
|
66
|
+
<Box className="-m-6 h-[calc(100%+3rem)] w-64">{sidebar}</Box>
|
|
67
|
+
</SheetContent>
|
|
68
|
+
</Sheet>
|
|
69
|
+
|
|
70
|
+
<div
|
|
71
|
+
className="flex min-w-0 flex-1 flex-col transition-[margin] duration-normal"
|
|
72
|
+
style={{ marginLeft: desktopNavVisible ? NAV_WIDTH : 0 }}
|
|
73
|
+
>
|
|
74
|
+
{header}
|
|
75
|
+
<main className={cn('flex-1 overflow-auto', !fullBleed && 'px-gutter py-lg md:px-xl')}>{children}</main>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</AppShellContext.Provider>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
|
3
|
+
import { ChevronDown } from 'lucide-react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { focusRingInset } from '../lib/recipes'
|
|
6
|
+
import { useAppShell } from './app-shell'
|
|
7
|
+
|
|
8
|
+
// ─── Nav row primitives (private to SideNav) ────────────────────────────────────
|
|
9
|
+
// Every row here is a clickable nav target — that's a menu, not a list — so these
|
|
10
|
+
// minimal helpers live with their only consumer instead of being a public `List`.
|
|
11
|
+
const NavList = ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
|
|
12
|
+
<ul className={cn('flex flex-col', className)} {...props} />
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const navButtonBase =
|
|
16
|
+
'group relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left text-sm font-medium ' +
|
|
17
|
+
'transition-colors disabled:opacity-40 disabled:pointer-events-none ' +
|
|
18
|
+
'[&_svg]:size-[18px] [&_svg]:shrink-0 focus-visible:ring-inset ' + focusRingInset
|
|
19
|
+
|
|
20
|
+
interface NavButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
21
|
+
active?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
|
|
25
|
+
({ className, active, ...props }, ref) => (
|
|
26
|
+
<button
|
|
27
|
+
ref={ref}
|
|
28
|
+
type="button"
|
|
29
|
+
className={cn(
|
|
30
|
+
navButtonBase,
|
|
31
|
+
active ? 'bg-surface-inset text-primary' : 'text-secondary hover:bg-surface-inset hover:text-primary',
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
NavButton.displayName = 'NavButton'
|
|
39
|
+
|
|
40
|
+
const NavSubheader = ({ className, ...props }: React.HTMLAttributes<HTMLLIElement>) => (
|
|
41
|
+
<li
|
|
42
|
+
className={cn(
|
|
43
|
+
'mt-md px-3 pb-1.5 pt-1 text-sm font-semibold uppercase tracking-wider text-tertiary first:mt-none',
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// ─── Nav config types (compatible with the MUI Windforge shape) ─────────────────
|
|
51
|
+
export interface NavLeafItem {
|
|
52
|
+
type?: 'item'
|
|
53
|
+
label: string
|
|
54
|
+
path?: string
|
|
55
|
+
icon?: React.ReactNode
|
|
56
|
+
badge?: React.ReactNode
|
|
57
|
+
onClick?: () => void
|
|
58
|
+
disabled?: boolean
|
|
59
|
+
}
|
|
60
|
+
export interface NavGroupItem {
|
|
61
|
+
type: 'group'
|
|
62
|
+
label: string
|
|
63
|
+
icon?: React.ReactNode
|
|
64
|
+
children: NavItem[]
|
|
65
|
+
defaultOpen?: boolean
|
|
66
|
+
}
|
|
67
|
+
export interface NavSectionItem {
|
|
68
|
+
type: 'section'
|
|
69
|
+
label: string
|
|
70
|
+
items: NavItem[]
|
|
71
|
+
}
|
|
72
|
+
export interface NavDividerItem {
|
|
73
|
+
type: 'divider'
|
|
74
|
+
}
|
|
75
|
+
export type NavItem = NavLeafItem | NavGroupItem | NavSectionItem | NavDividerItem
|
|
76
|
+
|
|
77
|
+
export interface SideNavProps {
|
|
78
|
+
items: NavItem[]
|
|
79
|
+
activePath?: string
|
|
80
|
+
onNavigate?: (path: string) => void
|
|
81
|
+
logo?: React.ReactNode
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Indent nested rows by one spacing step per level, starting from the row's own
|
|
85
|
+
// left padding (px-3). The per-level unit is a token; depth 0 keeps the base.
|
|
86
|
+
const indent = (depth: number) => ({
|
|
87
|
+
paddingLeft: depth > 0 ? `calc(0.75rem + ${depth} * var(--wf-spacing-md))` : undefined,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function NavLeaf({
|
|
91
|
+
item, active, depth, onNavigate,
|
|
92
|
+
}: { item: NavLeafItem; active: boolean; depth: number; onNavigate: (path: string) => void }) {
|
|
93
|
+
return (
|
|
94
|
+
<li>
|
|
95
|
+
<NavButton
|
|
96
|
+
active={active}
|
|
97
|
+
disabled={item.disabled}
|
|
98
|
+
aria-current={active ? 'page' : undefined}
|
|
99
|
+
style={indent(depth)}
|
|
100
|
+
onClick={() => {
|
|
101
|
+
item.onClick?.()
|
|
102
|
+
if (item.path) onNavigate(item.path)
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
{active && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-surface-inverse" />}
|
|
106
|
+
{item.icon}
|
|
107
|
+
<span className="truncate">{item.label}</span>
|
|
108
|
+
{item.badge && <span className="ml-auto">{item.badge}</span>}
|
|
109
|
+
</NavButton>
|
|
110
|
+
</li>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function NavGroup({
|
|
115
|
+
item, activePath, depth, onNavigate,
|
|
116
|
+
}: { item: NavGroupItem; activePath: string; depth: number; onNavigate: (path: string) => void }) {
|
|
117
|
+
const containsActive = React.useMemo(() => hasActivePath(item.children, activePath), [item.children, activePath])
|
|
118
|
+
const defaultValue = (item.defaultOpen ?? containsActive) ? 'group' : undefined
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<li>
|
|
122
|
+
<AccordionPrimitive.Root type="single" collapsible defaultValue={defaultValue}>
|
|
123
|
+
<AccordionPrimitive.Item value="group">
|
|
124
|
+
<AccordionPrimitive.Header className="flex">
|
|
125
|
+
<AccordionPrimitive.Trigger asChild>
|
|
126
|
+
<NavButton
|
|
127
|
+
style={indent(depth)}
|
|
128
|
+
className="data-[state=open]:[&_svg:last-of-type]:rotate-180"
|
|
129
|
+
>
|
|
130
|
+
{item.icon}
|
|
131
|
+
<span className="truncate">{item.label}</span>
|
|
132
|
+
<ChevronDown className="ml-auto !size-4 transition-transform duration-200" />
|
|
133
|
+
</NavButton>
|
|
134
|
+
</AccordionPrimitive.Trigger>
|
|
135
|
+
</AccordionPrimitive.Header>
|
|
136
|
+
<AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
137
|
+
<NavList>
|
|
138
|
+
{item.children.map((child, index) => (
|
|
139
|
+
<NavRenderer key={index} item={child} activePath={activePath} depth={depth + 1} onNavigate={onNavigate} />
|
|
140
|
+
))}
|
|
141
|
+
</NavList>
|
|
142
|
+
</AccordionPrimitive.Content>
|
|
143
|
+
</AccordionPrimitive.Item>
|
|
144
|
+
</AccordionPrimitive.Root>
|
|
145
|
+
</li>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function NavRenderer({
|
|
150
|
+
item, activePath, depth, onNavigate,
|
|
151
|
+
}: { item: NavItem; activePath: string; depth: number; onNavigate: (path: string) => void }) {
|
|
152
|
+
if (item.type === 'divider') return <li role="separator" className="my-sm h-px bg-border" />
|
|
153
|
+
if (item.type === 'section') {
|
|
154
|
+
return (
|
|
155
|
+
<>
|
|
156
|
+
<NavSubheader>{item.label}</NavSubheader>
|
|
157
|
+
{item.items.map((child, index) => (
|
|
158
|
+
<NavRenderer key={index} item={child} activePath={activePath} depth={depth} onNavigate={onNavigate} />
|
|
159
|
+
))}
|
|
160
|
+
</>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (item.type === 'group') {
|
|
164
|
+
return <NavGroup item={item} activePath={activePath} depth={depth} onNavigate={onNavigate} />
|
|
165
|
+
}
|
|
166
|
+
return <NavLeaf item={item} active={!!item.path && activePath === item.path} depth={depth} onNavigate={onNavigate} />
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function hasActivePath(items: NavItem[], activePath: string): boolean {
|
|
170
|
+
return items.some((item) => {
|
|
171
|
+
if (item.type === 'group') return hasActivePath(item.children, activePath)
|
|
172
|
+
if (item.type === 'section') return hasActivePath(item.items, activePath)
|
|
173
|
+
if (!item.type || item.type === 'item') return item.path === activePath
|
|
174
|
+
return false
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function SideNav({ items, activePath = '/', onNavigate = () => {}, logo }: SideNavProps) {
|
|
179
|
+
const appShell = useAppShell()
|
|
180
|
+
const handleNavigate = (path: string) => {
|
|
181
|
+
onNavigate(path)
|
|
182
|
+
appShell?.closeNav()
|
|
183
|
+
}
|
|
184
|
+
return (
|
|
185
|
+
<nav aria-label="Main navigation" className="flex h-full flex-col border-r border-border bg-surface-subtle">
|
|
186
|
+
{logo && (
|
|
187
|
+
<div className="flex h-16 shrink-0 items-center border-b border-border px-4">{logo}</div>
|
|
188
|
+
)}
|
|
189
|
+
<NavList className="flex-1 overflow-y-auto px-sm py-sm">
|
|
190
|
+
{items.map((item, index) => (
|
|
191
|
+
<NavRenderer key={index} item={item} activePath={activePath} depth={0} onNavigate={handleNavigate} />
|
|
192
|
+
))}
|
|
193
|
+
</NavList>
|
|
194
|
+
</nav>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
export type ColorMode = 'light' | 'dark' | 'system'
|
|
4
|
+
export type ResolvedMode = 'light' | 'dark'
|
|
5
|
+
|
|
6
|
+
const MODE_KEY = 'wf-mode'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Inline this in <head> BEFORE the app bundle to set the color mode on first
|
|
10
|
+
* paint and avoid a flash of the wrong theme (SSR/SPA alike). Mirrors the mode
|
|
11
|
+
* resolution below; reads the persisted `wf-mode`, falls back to system.
|
|
12
|
+
*
|
|
13
|
+
* <script dangerouslySetInnerHTML={{ __html: themeInitScript() }} />
|
|
14
|
+
*/
|
|
15
|
+
export function themeInitScript(storageKey: string = MODE_KEY): string {
|
|
16
|
+
return `(function(){try{var m=localStorage.getItem('${storageKey}')||'system';var d=m==='dark'||(m==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var e=document.documentElement;e.classList.toggle('dark',d);e.style.colorScheme=d?'dark':'light';}catch(e){}})()`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const canUseDOM = typeof window !== 'undefined'
|
|
20
|
+
const readStoredMode = (): ColorMode | null => {
|
|
21
|
+
try {
|
|
22
|
+
return canUseDOM ? (localStorage.getItem(MODE_KEY) as ColorMode | null) : null
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const prefersDark = () =>
|
|
29
|
+
canUseDOM && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
30
|
+
const resolveMode = (mode: ColorMode): ResolvedMode =>
|
|
31
|
+
mode === 'system' ? (prefersDark() ? 'dark' : 'light') : mode
|
|
32
|
+
const nextMode = (mode: ColorMode): ColorMode =>
|
|
33
|
+
mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
|
34
|
+
|
|
35
|
+
interface ThemeContextValue {
|
|
36
|
+
mode: ColorMode
|
|
37
|
+
resolvedMode: ResolvedMode
|
|
38
|
+
setMode: (mode: ColorMode) => void
|
|
39
|
+
cycleMode: () => void
|
|
40
|
+
}
|
|
41
|
+
const ThemeContext = React.createContext<ThemeContextValue | null>(null)
|
|
42
|
+
|
|
43
|
+
export interface ThemeProviderProps {
|
|
44
|
+
children: React.ReactNode
|
|
45
|
+
defaultMode?: ColorMode
|
|
46
|
+
/**
|
|
47
|
+
* Runtime token overrides — a `--wf-*` → value map applied as custom properties
|
|
48
|
+
* on the document root, so the whole app (including portaled overlays) re-skins
|
|
49
|
+
* with no rebuild. Pass a function to stay mode-aware:
|
|
50
|
+
* `tokens={(mode) => brandVars(ramp, mode)}`. For more than color, use
|
|
51
|
+
* `themeVars({ brand, fontSans, radius, … }, mode)`. For a site's fixed identity,
|
|
52
|
+
* prefer a static CSS `:root` override instead.
|
|
53
|
+
*/
|
|
54
|
+
tokens?: Record<string, string> | ((mode: ResolvedMode) => Record<string, string>)
|
|
55
|
+
persist?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ThemeProvider({ children, defaultMode = 'system', tokens, persist = true }: ThemeProviderProps) {
|
|
59
|
+
const [mode, setModeState] = React.useState<ColorMode>(
|
|
60
|
+
() => (persist && readStoredMode()) || defaultMode,
|
|
61
|
+
)
|
|
62
|
+
const [resolvedMode, setResolvedMode] = React.useState<ResolvedMode>(() => resolveMode(mode))
|
|
63
|
+
|
|
64
|
+
const applyMode = (mode: ColorMode) => {
|
|
65
|
+
const resolved = resolveMode(mode)
|
|
66
|
+
setResolvedMode(resolved)
|
|
67
|
+
if (!canUseDOM) return
|
|
68
|
+
const root = document.documentElement
|
|
69
|
+
|
|
70
|
+
const noTransitions = document.createElement('style')
|
|
71
|
+
noTransitions.textContent = '*,*::before,*::after{transition:none!important}'
|
|
72
|
+
document.head.appendChild(noTransitions)
|
|
73
|
+
|
|
74
|
+
root.classList.toggle('dark', resolved === 'dark')
|
|
75
|
+
root.style.colorScheme = resolved
|
|
76
|
+
|
|
77
|
+
void root.offsetHeight
|
|
78
|
+
window.setTimeout(() => noTransitions.remove(), 1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const setMode = (mode: ColorMode) => {
|
|
82
|
+
setModeState(mode)
|
|
83
|
+
if (persist) {
|
|
84
|
+
try {
|
|
85
|
+
localStorage.setItem(MODE_KEY, mode)
|
|
86
|
+
} catch {
|
|
87
|
+
/* storage unavailable (private mode / SSR) — mode still applies in memory */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
applyMode(mode)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (mode !== 'system') return
|
|
95
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
96
|
+
const onSystemChange = () => applyMode('system')
|
|
97
|
+
mediaQuery.addEventListener('change', onSystemChange)
|
|
98
|
+
return () => mediaQuery.removeEventListener('change', onSystemChange)
|
|
99
|
+
}, [mode])
|
|
100
|
+
|
|
101
|
+
const value: ThemeContextValue = { mode, resolvedMode, setMode, cycleMode: () => setMode(nextMode(mode)) }
|
|
102
|
+
const resolvedTokens = typeof tokens === 'function' ? tokens(resolvedMode) : tokens
|
|
103
|
+
// Stable signature so the effect only re-applies when the values actually change.
|
|
104
|
+
const tokenSig = resolvedTokens ? JSON.stringify(resolvedTokens) : ''
|
|
105
|
+
|
|
106
|
+
// Apply runtime token overrides to the document ROOT (not an in-tree wrapper) so
|
|
107
|
+
// they reach portaled overlays too — Dialog/DropdownMenu/Tooltip/Select/Toast/Sheet
|
|
108
|
+
// render into document.body, outside any React subtree. Custom properties on
|
|
109
|
+
// :root cascade everywhere. We track and remove exactly the keys we set.
|
|
110
|
+
React.useLayoutEffect(() => {
|
|
111
|
+
if (!canUseDOM || !tokenSig) return
|
|
112
|
+
const map = JSON.parse(tokenSig) as Record<string, string>
|
|
113
|
+
const root = document.documentElement
|
|
114
|
+
const keys = Object.keys(map)
|
|
115
|
+
for (const key of keys) root.style.setProperty(key, map[key])
|
|
116
|
+
return () => {
|
|
117
|
+
for (const key of keys) root.style.removeProperty(key)
|
|
118
|
+
}
|
|
119
|
+
}, [tokenSig])
|
|
120
|
+
|
|
121
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function useTheme() {
|
|
125
|
+
const ctx = React.useContext(ThemeContext)
|
|
126
|
+
if (!ctx) throw new Error('useTheme must be used within a <ThemeProvider>')
|
|
127
|
+
return ctx
|
|
128
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPES — named class-fragment bundles shared across components.
|
|
3
|
+
*
|
|
4
|
+
* These collapse the utility groups that repeat verbatim across many primitives
|
|
5
|
+
* (focus rings, floating surfaces, menu rows, overlays) into one named class
|
|
6
|
+
* each, so a component reads as `cn(floatingPanel, '…specifics…')` instead of a
|
|
7
|
+
* wall of utilities. Compose them with `cn()` — because `cn` runs tailwind-merge,
|
|
8
|
+
* a component can override any piece simply by adding the conflicting utility
|
|
9
|
+
* after the recipe (e.g. `cn(floatingPanel, 'rounded-xl shadow-lg')`).
|
|
10
|
+
*
|
|
11
|
+
* Every utility here resolves to a token (`var(--wf-*)`), so these stay
|
|
12
|
+
* theme- and accent-aware: the class is stable, the values move underneath it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Keyboard focus ring for standalone interactive controls (buttons, triggers, thumbs). */
|
|
16
|
+
export const focusRing =
|
|
17
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
|
|
18
|
+
'focus-visible:ring-offset-2 focus-visible:ring-offset-background'
|
|
19
|
+
|
|
20
|
+
/** Focus ring with no offset — for controls that sit flush inside a container. */
|
|
21
|
+
export const focusRingInset =
|
|
22
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
23
|
+
|
|
24
|
+
/** Focus treatment for text fields — recolors the border and adds a ring. */
|
|
25
|
+
export const focusRingField =
|
|
26
|
+
'focus-visible:outline-none focus-visible:border-focus focus-visible:ring-2 focus-visible:ring-ring'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Floating surface for popovers / menus / listboxes. Radius (`rounded-lg`) and
|
|
30
|
+
* shadow (`shadow-md`) are sensible defaults — override per component via `cn`.
|
|
31
|
+
* Padding, width, max-height and entrance animation are left to the caller.
|
|
32
|
+
*/
|
|
33
|
+
export const floatingPanel =
|
|
34
|
+
'z-50 rounded-lg border border-border bg-surface text-primary shadow-md outline-none'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Structural recipe for a selectable row inside a menu / listbox. Intentionally
|
|
38
|
+
* carries no focus colors or padding — those differ per surface (dropdown and
|
|
39
|
+
* select both use `focus:bg-surface-inset`; autocomplete highlights the same way).
|
|
40
|
+
*/
|
|
41
|
+
export const menuItem =
|
|
42
|
+
'relative flex cursor-pointer select-none items-center rounded-md text-sm outline-none ' +
|
|
43
|
+
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
|
44
|
+
|
|
45
|
+
/** Full-screen scrim behind modal surfaces (dialog, sheet). */
|
|
46
|
+
export const overlayBackdrop = 'fixed inset-0 z-50 bg-overlay animate-fade-in'
|
|
47
|
+
|
|
48
|
+
/** The corner dismiss (×) button shared by dialog and sheet. Pair with `focusRingInset`. */
|
|
49
|
+
export const dismissButton =
|
|
50
|
+
'absolute right-4 top-4 rounded-md p-1 text-secondary opacity-70 transition-opacity hover:opacity-100'
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
/** Subscribe to a media query; SSR-safe (returns false until mounted). */
|
|
4
|
+
export function useMediaQuery(query: string): boolean {
|
|
5
|
+
const subscribe = React.useCallback(
|
|
6
|
+
(onChange: () => void) => {
|
|
7
|
+
const mq = window.matchMedia(query)
|
|
8
|
+
mq.addEventListener('change', onChange)
|
|
9
|
+
return () => mq.removeEventListener('change', onChange)
|
|
10
|
+
},
|
|
11
|
+
[query],
|
|
12
|
+
)
|
|
13
|
+
return React.useSyncExternalStore(
|
|
14
|
+
subscribe,
|
|
15
|
+
() => window.matchMedia(query).matches,
|
|
16
|
+
() => false,
|
|
17
|
+
)
|
|
18
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `cn` — merge conditional class lists and resolve Tailwind conflicts.
|
|
6
|
+
* The one utility every component uses to compose token-driven classes.
|
|
7
|
+
*/
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs))
|
|
10
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @windforge/ui — the shippable Tailwind PRESET.
|
|
3
|
+
*
|
|
4
|
+
* This is the single thing a consumer needs to be guaranteed-on-system. It wires
|
|
5
|
+
* the generated token map into Tailwind's theme (colors, radius, shadow, spacing,
|
|
6
|
+
* type, icon sizes), supplies every keyframe/animation the components reference,
|
|
7
|
+
* and bundles the `tailwindcss-animate` plugin. Drop it into a project's
|
|
8
|
+
* `tailwind.config` and the components render correctly with zero hand-copied config:
|
|
9
|
+
*
|
|
10
|
+
* // tailwind.config.cjs
|
|
11
|
+
* module.exports = {
|
|
12
|
+
* presets: [require('@windforge/ui/tailwind')],
|
|
13
|
+
* content: [
|
|
14
|
+
* './src/**\/*.{ts,tsx}',
|
|
15
|
+
* './node_modules/@windforge/ui/src/**\/*.{ts,tsx}', // scan the lib's classes
|
|
16
|
+
* ],
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The token VALUES are CSS variables, so a brand/font/radius swap happens by
|
|
20
|
+
* overriding `--wf-*` (see @windforge/tokens) — never by editing this preset.
|
|
21
|
+
*/
|
|
22
|
+
const tokens = require('@windforge/tokens/tailwind')
|
|
23
|
+
|
|
24
|
+
/** @type {import('tailwindcss').Config} */
|
|
25
|
+
module.exports = {
|
|
26
|
+
darkMode: 'class',
|
|
27
|
+
theme: {
|
|
28
|
+
// Tokens fully REPLACE Tailwind's default palette/scales — the design system
|
|
29
|
+
// is the only source of truth. No raw Tailwind colors leak into components.
|
|
30
|
+
colors: {
|
|
31
|
+
transparent: 'transparent',
|
|
32
|
+
current: 'currentColor',
|
|
33
|
+
white: '#ffffff',
|
|
34
|
+
black: '#000000',
|
|
35
|
+
...tokens.colors,
|
|
36
|
+
},
|
|
37
|
+
borderRadius: tokens.borderRadius,
|
|
38
|
+
boxShadow: { none: 'none', ...tokens.boxShadow },
|
|
39
|
+
fontFamily: tokens.fontFamily,
|
|
40
|
+
fontSize: tokens.fontSize,
|
|
41
|
+
// Flat border-color utilities (border-strong, border-subtle, border-focus, …).
|
|
42
|
+
borderColor: tokens.borderColor,
|
|
43
|
+
extend: {
|
|
44
|
+
spacing: tokens.spacing,
|
|
45
|
+
size: tokens.size,
|
|
46
|
+
borderWidth: tokens.borderWidth,
|
|
47
|
+
ringColor: { DEFAULT: 'var(--wf-color-border-focus)' },
|
|
48
|
+
keyframes: {
|
|
49
|
+
'accordion-down': {
|
|
50
|
+
from: { height: '0' },
|
|
51
|
+
to: { height: 'var(--radix-accordion-content-height)' },
|
|
52
|
+
},
|
|
53
|
+
'accordion-up': {
|
|
54
|
+
from: { height: 'var(--radix-accordion-content-height)' },
|
|
55
|
+
to: { height: '0' },
|
|
56
|
+
},
|
|
57
|
+
'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } },
|
|
58
|
+
'scale-in': {
|
|
59
|
+
from: { opacity: '0', scale: '0.96' },
|
|
60
|
+
to: { opacity: '1', scale: '1' },
|
|
61
|
+
},
|
|
62
|
+
'progress-indeterminate': {
|
|
63
|
+
'0%': { transform: 'translateX(-100%)' },
|
|
64
|
+
'100%': { transform: 'translateX(250%)' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
animation: {
|
|
68
|
+
'accordion-down': 'accordion-down 200ms ease-out',
|
|
69
|
+
'accordion-up': 'accordion-up 200ms ease-out',
|
|
70
|
+
'fade-in': 'fade-in 160ms ease-out',
|
|
71
|
+
'scale-in': 'scale-in 160ms cubic-bezier(0.175,0.885,0.32,1.275)',
|
|
72
|
+
'progress-indeterminate': 'progress-indeterminate 1.4s ease-in-out infinite',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
plugins: [require('tailwindcss-animate')],
|
|
77
|
+
}
|