@glyphui/ui 0.2.0 → 0.4.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.
Files changed (116) hide show
  1. package/README.md +65 -12
  2. package/dist/chart.d.ts +202 -0
  3. package/dist/chart.js +466 -0
  4. package/dist/color-mode-script-Bf7LYkOj.js +13 -0
  5. package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
  6. package/dist/data-table.js +3 -2
  7. package/dist/{field-CCecIzdq.js → field-CVbywksE.js} +21 -5
  8. package/dist/{field-context-B6WOcK5m.js → field-context-7I-cWvvS.js} +3 -57
  9. package/dist/form.d.ts +11 -4
  10. package/dist/form.js +34 -8
  11. package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
  12. package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
  13. package/dist/index.d.ts +2334 -283
  14. package/dist/index.js +2952 -326
  15. package/dist/react-router.d.ts +18 -0
  16. package/dist/react-router.js +44 -0
  17. package/dist/{states-Rp8wZ_jH.js → states-IvhjBgna.js} +2 -1
  18. package/dist/tv-DLbT66dC.js +70 -0
  19. package/dist/vite.d.ts +27 -0
  20. package/dist/vite.js +29 -0
  21. package/fonts.css +10 -0
  22. package/package.json +47 -5
  23. package/src/components/accordion/accordion.styles.ts +4 -4
  24. package/src/components/badge/badge.styles.ts +11 -16
  25. package/src/components/banner/banner.styles.ts +4 -4
  26. package/src/components/button/button.styles.ts +3 -1
  27. package/src/components/calendar/calendar.styles.ts +51 -0
  28. package/src/components/calendar/calendar.tsx +84 -0
  29. package/src/components/calendar/index.ts +3 -0
  30. package/src/components/carousel/carousel.styles.ts +43 -0
  31. package/src/components/carousel/carousel.tsx +278 -0
  32. package/src/components/carousel/index.ts +13 -0
  33. package/src/components/collapsible/collapsible.tsx +66 -0
  34. package/src/components/collapsible/index.ts +6 -0
  35. package/src/components/context-menu/context-menu.styles.ts +19 -0
  36. package/src/components/context-menu/context-menu.tsx +226 -0
  37. package/src/components/context-menu/index.ts +19 -0
  38. package/src/components/copy-button/copy-button.tsx +117 -0
  39. package/src/components/copy-button/index.ts +1 -0
  40. package/src/components/date-picker/date-picker.styles.ts +42 -0
  41. package/src/components/date-picker/date-picker.tsx +322 -0
  42. package/src/components/date-picker/index.ts +8 -0
  43. package/src/components/dropdown-menu/dropdown-menu.styles.ts +2 -2
  44. package/src/components/eyebrow/eyebrow.styles.ts +1 -1
  45. package/src/components/field/field-context.ts +2 -0
  46. package/src/components/field/field.styles.ts +4 -2
  47. package/src/components/field/field.tsx +25 -4
  48. package/src/components/file-upload/file-upload.styles.ts +43 -0
  49. package/src/components/file-upload/file-upload.tsx +343 -0
  50. package/src/components/file-upload/index.ts +10 -0
  51. package/src/components/hover-card/hover-card.tsx +41 -0
  52. package/src/components/hover-card/index.ts +1 -0
  53. package/src/components/input-otp/index.ts +10 -0
  54. package/src/components/input-otp/input-otp.styles.ts +38 -0
  55. package/src/components/input-otp/input-otp.tsx +116 -0
  56. package/src/components/link/link.styles.ts +1 -1
  57. package/src/components/modal/index.ts +2 -0
  58. package/src/components/modal/modal.styles.ts +20 -0
  59. package/src/components/modal/modal.tsx +111 -0
  60. package/src/components/navigation-menu/index.ts +14 -0
  61. package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
  62. package/src/components/navigation-menu/navigation-menu.tsx +208 -0
  63. package/src/components/number-input/index.ts +2 -0
  64. package/src/components/number-input/number-input.styles.ts +41 -0
  65. package/src/components/number-input/number-input.tsx +211 -0
  66. package/src/components/password-input/index.ts +7 -0
  67. package/src/components/password-input/password-input.styles.ts +30 -0
  68. package/src/components/password-input/password-input.tsx +127 -0
  69. package/src/components/prose/prose.tsx +1 -1
  70. package/src/components/resizable/index.ts +9 -0
  71. package/src/components/resizable/resizable.styles.ts +28 -0
  72. package/src/components/resizable/resizable.tsx +69 -0
  73. package/src/components/scroll-area/scroll-area.tsx +23 -1
  74. package/src/components/segmented-control/segmented-control.tsx +17 -2
  75. package/src/components/sheet/sheet.styles.ts +7 -7
  76. package/src/components/slider/slider.tsx +9 -2
  77. package/src/components/stepper/index.ts +2 -0
  78. package/src/components/stepper/stepper.styles.ts +74 -0
  79. package/src/components/stepper/stepper.tsx +138 -0
  80. package/src/components/tag-input/index.ts +2 -0
  81. package/src/components/tag-input/tag-input.styles.ts +39 -0
  82. package/src/components/tag-input/tag-input.tsx +198 -0
  83. package/src/components/text/text.styles.ts +4 -4
  84. package/src/components/timeline/index.ts +8 -0
  85. package/src/components/timeline/timeline.styles.ts +56 -0
  86. package/src/components/timeline/timeline.tsx +91 -0
  87. package/src/components/toggle/index.ts +9 -0
  88. package/src/components/toggle/toggle.styles.ts +39 -0
  89. package/src/components/toggle/toggle.tsx +61 -0
  90. package/src/components/tree-view/index.ts +2 -0
  91. package/src/components/tree-view/tree-view.styles.ts +42 -0
  92. package/src/components/tree-view/tree-view.tsx +274 -0
  93. package/src/components/visually-hidden/index.ts +1 -0
  94. package/src/components/visually-hidden/visually-hidden.tsx +10 -0
  95. package/src/index.ts +20 -0
  96. package/src/integrations/react-router.tsx +39 -0
  97. package/src/integrations/vite.test-d.ts +6 -0
  98. package/src/integrations/vite.test.ts +28 -0
  99. package/src/integrations/vite.ts +33 -0
  100. package/src/internal/dev.ts +12 -0
  101. package/src/patterns/app-shell/app-shell.styles.ts +1 -1
  102. package/src/patterns/app-shell/navbar.tsx +34 -16
  103. package/src/patterns/app-shell/sidebar.tsx +7 -2
  104. package/src/patterns/chart/chart-parts.tsx +179 -0
  105. package/src/patterns/chart/chart.styles.ts +35 -0
  106. package/src/patterns/chart/chart.tsx +386 -0
  107. package/src/patterns/chart/index.ts +13 -0
  108. package/src/patterns/form/form.tsx +48 -6
  109. package/src/patterns/form/index.ts +1 -0
  110. package/src/provider/color-mode-script.ts +28 -0
  111. package/src/provider/color-mode.tsx +14 -20
  112. package/src/provider/glyph-context.ts +38 -0
  113. package/src/provider/glyph-provider.tsx +16 -4
  114. package/src/provider/index.ts +4 -0
  115. package/src/provider/theme-scope.tsx +7 -3
  116. package/styles.css +88 -0
@@ -0,0 +1,274 @@
1
+ import {
2
+ type ComponentProps,
3
+ type KeyboardEvent,
4
+ type MouseEvent,
5
+ type ReactNode,
6
+ useCallback,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import { useComponentConfig } from '../../hooks/use-component-config'
13
+ import { useControllableState } from '../../hooks/use-controllable-state'
14
+ import { type TreeViewVariants, treeViewStyles } from './tree-view.styles'
15
+
16
+ export interface TreeNode {
17
+ id: string
18
+ label: ReactNode
19
+ icon?: ReactNode
20
+ children?: readonly TreeNode[]
21
+ disabled?: boolean
22
+ /** Text used for type-to-find when `label` is not a string. */
23
+ textValue?: string
24
+ }
25
+
26
+ export interface TreeViewProps
27
+ extends Omit<ComponentProps<'div'>, 'onSelect' | 'children' | 'defaultValue'>,
28
+ TreeViewVariants {
29
+ nodes: readonly TreeNode[]
30
+ /** Ids of expanded nodes (controlled). */
31
+ expanded?: readonly string[]
32
+ defaultExpanded?: readonly string[]
33
+ onExpandedChange?: (expanded: string[]) => void
34
+ /** Id of the selected node (controlled). `null` for none. */
35
+ selected?: string | null
36
+ defaultSelected?: string | null
37
+ onSelect?: (id: string, node: TreeNode) => void
38
+ /** Required: describes the tree, e.g. "Project files". */
39
+ 'aria-label': string
40
+ }
41
+
42
+ interface VisibleNode {
43
+ node: TreeNode
44
+ level: number
45
+ parentId: string | null
46
+ }
47
+
48
+ function flattenVisible(nodes: readonly TreeNode[], expanded: ReadonlySet<string>): VisibleNode[] {
49
+ const out: VisibleNode[] = []
50
+ const walk = (list: readonly TreeNode[], level: number, parentId: string | null) => {
51
+ for (const node of list) {
52
+ out.push({ node, level, parentId })
53
+ if (node.children?.length && expanded.has(node.id)) walk(node.children, level + 1, node.id)
54
+ }
55
+ }
56
+ walk(nodes, 1, null)
57
+ return out
58
+ }
59
+
60
+ function textOf(node: TreeNode): string {
61
+ return (node.textValue ?? (typeof node.label === 'string' ? node.label : '')).toLowerCase()
62
+ }
63
+
64
+ const NO_IDS: readonly string[] = []
65
+
66
+ /**
67
+ * An accessible tree for file browsers and nested navigation. Arrow keys move and
68
+ * expand/collapse, Home/End jump, typing a letter finds the next match, Enter or Space selects.
69
+ */
70
+ export function TreeView(inProps: TreeViewProps) {
71
+ const [props, slots] = useComponentConfig('TreeView', inProps)
72
+ const {
73
+ nodes,
74
+ expanded: expandedProp,
75
+ defaultExpanded = NO_IDS,
76
+ onExpandedChange,
77
+ selected: selectedProp,
78
+ defaultSelected = null,
79
+ onSelect,
80
+ size,
81
+ className,
82
+ ...rest
83
+ } = props
84
+
85
+ const [expandedList, setExpandedList] = useControllableState<readonly string[]>({
86
+ value: expandedProp,
87
+ defaultValue: defaultExpanded,
88
+ onChange: onExpandedChange as ((value: readonly string[]) => void) | undefined,
89
+ })
90
+ const [selected, setSelected] = useControllableState<string | null>({
91
+ value: selectedProp,
92
+ defaultValue: defaultSelected,
93
+ })
94
+ const expanded = useMemo(() => new Set(expandedList), [expandedList])
95
+ const visible = useMemo(() => flattenVisible(nodes, expanded), [nodes, expanded])
96
+ const [focusedId, setFocusedId] = useState<string | null>(null)
97
+ const refs = useRef(new Map<string, HTMLDivElement>())
98
+ const baseId = useId()
99
+
100
+ // The single tabbable item: the focused one, else the selected one if visible, else the first.
101
+ const tabbableId =
102
+ (focusedId && visible.some((v) => v.node.id === focusedId) && focusedId) ||
103
+ (selected && visible.some((v) => v.node.id === selected) && selected) ||
104
+ visible[0]?.node.id
105
+
106
+ const focus = useCallback((id: string | undefined) => {
107
+ if (!id) return
108
+ setFocusedId(id)
109
+ refs.current.get(id)?.focus()
110
+ }, [])
111
+
112
+ const toggle = useCallback(
113
+ (id: string, open?: boolean) => {
114
+ const isOpen = expanded.has(id)
115
+ const next = open ?? !isOpen
116
+ if (next === isOpen) return
117
+ setExpandedList(next ? [...expandedList, id] : expandedList.filter((x) => x !== id))
118
+ },
119
+ [expanded, expandedList, setExpandedList],
120
+ )
121
+
122
+ const select = useCallback(
123
+ (node: TreeNode) => {
124
+ if (node.disabled) return
125
+ setSelected(node.id)
126
+ onSelect?.(node.id, node)
127
+ },
128
+ [onSelect, setSelected],
129
+ )
130
+
131
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
132
+ const index = visible.findIndex((v) => v.node.id === tabbableId)
133
+ const current = visible[index]
134
+ if (!current) return
135
+ const { node } = current
136
+ const hasChildren = Boolean(node.children?.length)
137
+ const isOpen = expanded.has(node.id)
138
+ let handled = true
139
+
140
+ switch (event.key) {
141
+ case 'ArrowDown':
142
+ focus(visible[index + 1]?.node.id)
143
+ break
144
+ case 'ArrowUp':
145
+ focus(visible[index - 1]?.node.id)
146
+ break
147
+ case 'ArrowRight':
148
+ if (hasChildren && !isOpen) toggle(node.id, true)
149
+ else if (hasChildren) focus(node.children?.[0]?.id)
150
+ break
151
+ case 'ArrowLeft':
152
+ if (hasChildren && isOpen) toggle(node.id, false)
153
+ else if (current.parentId) focus(current.parentId)
154
+ break
155
+ case 'Home':
156
+ focus(visible[0]?.node.id)
157
+ break
158
+ case 'End':
159
+ focus(visible.at(-1)?.node.id)
160
+ break
161
+ case 'Enter':
162
+ case ' ':
163
+ select(node)
164
+ break
165
+ default:
166
+ handled = false
167
+ if (event.key.length === 1 && /\S/.test(event.key) && !event.metaKey && !event.ctrlKey) {
168
+ const char = event.key.toLowerCase()
169
+ const ordered = [...visible.slice(index + 1), ...visible.slice(0, index + 1)]
170
+ const match = ordered.find((v) => textOf(v.node).startsWith(char))
171
+ if (match) {
172
+ focus(match.node.id)
173
+ handled = true
174
+ }
175
+ }
176
+ }
177
+ if (handled) event.preventDefault()
178
+ }
179
+
180
+ const s = treeViewStyles({ size })
181
+
182
+ /** Clicks bubble from nested items; only handle the item whose own row was clicked. */
183
+ const onItemClick = (event: MouseEvent<HTMLDivElement>, node: TreeNode, hasChildren: boolean) => {
184
+ const row = (event.target as HTMLElement).closest('[data-slot=tree-row]')
185
+ if (row?.parentElement !== event.currentTarget) return
186
+ focus(node.id)
187
+ if (hasChildren) toggle(node.id)
188
+ select(node)
189
+ }
190
+
191
+ const renderNodes = (list: readonly TreeNode[], level: number): ReactNode =>
192
+ list.map((node, i) => {
193
+ const hasChildren = Boolean(node.children?.length)
194
+ const isOpen = hasChildren && expanded.has(node.id)
195
+ return (
196
+ // Keyboard interaction is handled once on the tree (roving focus), not per item.
197
+ // biome-ignore lint/a11y/useKeyWithClickEvents: see above
198
+ <div
199
+ key={node.id}
200
+ ref={(el) => {
201
+ if (el) refs.current.set(node.id, el)
202
+ else refs.current.delete(node.id)
203
+ }}
204
+ role="treeitem"
205
+ data-slot="tree-item"
206
+ // Name the item by its label only, not by the nested items it contains.
207
+ aria-labelledby={`${baseId}-${node.id}`}
208
+ aria-level={level}
209
+ aria-setsize={list.length}
210
+ aria-posinset={i + 1}
211
+ aria-expanded={hasChildren ? isOpen : undefined}
212
+ aria-selected={selected === node.id}
213
+ aria-disabled={node.disabled || undefined}
214
+ tabIndex={node.id === tabbableId ? 0 : -1}
215
+ onFocus={(event) => {
216
+ if (event.target === event.currentTarget) setFocusedId(node.id)
217
+ }}
218
+ onClick={(event) => onItemClick(event, node, hasChildren)}
219
+ className={s.item({ class: slots.item })}
220
+ >
221
+ <div
222
+ data-slot="tree-row"
223
+ data-state={isOpen ? 'open' : 'closed'}
224
+ className={s.row({ class: slots.row })}
225
+ style={{ paddingInlineStart: `calc(${level - 1} * 1rem + 0.25rem)` }}
226
+ >
227
+ {hasChildren ? (
228
+ <svg
229
+ data-slot="tree-chevron"
230
+ viewBox="0 0 24 24"
231
+ fill="none"
232
+ stroke="currentColor"
233
+ strokeWidth="2"
234
+ strokeLinecap="round"
235
+ strokeLinejoin="round"
236
+ aria-hidden="true"
237
+ className={s.chevron({ class: slots.chevron })}
238
+ >
239
+ <path d="m9 18 6-6-6-6" />
240
+ </svg>
241
+ ) : (
242
+ <span className={s.spacer({ class: slots.spacer })} />
243
+ )}
244
+ {node.icon && (
245
+ <span className={s.icon({ class: slots.icon })} aria-hidden="true">
246
+ {node.icon}
247
+ </span>
248
+ )}
249
+ <span id={`${baseId}-${node.id}`} className={s.label({ class: slots.label })}>
250
+ {node.label}
251
+ </span>
252
+ </div>
253
+ {isOpen && (
254
+ // biome-ignore lint/a11y/useSemanticElements: role="group" is the WAI-ARIA tree pattern for child items
255
+ <div role="group" className={s.group({ class: slots.group })}>
256
+ {renderNodes(node.children ?? [], level + 1)}
257
+ </div>
258
+ )}
259
+ </div>
260
+ )
261
+ })
262
+
263
+ return (
264
+ <div
265
+ role="tree"
266
+ data-slot="tree-view"
267
+ onKeyDown={onKeyDown}
268
+ className={s.root({ class: [slots.root, className] })}
269
+ {...rest}
270
+ >
271
+ {renderNodes(nodes, 1)}
272
+ </div>
273
+ )
274
+ }
@@ -0,0 +1 @@
1
+ export { VisuallyHidden } from './visually-hidden'
@@ -0,0 +1,10 @@
1
+ import { VisuallyHidden as VisuallyHiddenPrimitive } from 'radix-ui'
2
+ import type { ComponentProps } from 'react'
3
+
4
+ /**
5
+ * Hides content visually while keeping it available to screen readers, e.g. a label for an
6
+ * icon-only control or extra context in a table cell.
7
+ */
8
+ export function VisuallyHidden(props: ComponentProps<typeof VisuallyHiddenPrimitive.Root>) {
9
+ return <VisuallyHiddenPrimitive.Root data-slot="visually-hidden" {...props} />
10
+ }
package/src/index.ts CHANGED
@@ -26,28 +26,42 @@ export * from './components/banner'
26
26
  export * from './components/blockquote'
27
27
  export * from './components/breadcrumb'
28
28
  export * from './components/button'
29
+ export * from './components/calendar'
29
30
  export * from './components/card'
31
+ export * from './components/carousel'
30
32
  export * from './components/checkbox'
31
33
  export * from './components/code'
34
+ export * from './components/collapsible'
32
35
  export * from './components/combobox'
33
36
  export * from './components/command-menu'
34
37
  export * from './components/container'
38
+ export * from './components/context-menu'
39
+ export * from './components/copy-button'
40
+ export * from './components/date-picker'
35
41
  export * from './components/description-list'
36
42
  export * from './components/dialog'
37
43
  export * from './components/dropdown-menu'
38
44
  export * from './components/eyebrow'
39
45
  export * from './components/field'
46
+ export * from './components/file-upload'
40
47
  export * from './components/grid'
41
48
  export * from './components/heading'
49
+ export * from './components/hover-card'
42
50
  export * from './components/input'
51
+ export * from './components/input-otp'
43
52
  export * from './components/kbd'
44
53
  export * from './components/label'
45
54
  export * from './components/link'
55
+ export * from './components/modal'
56
+ export * from './components/navigation-menu'
57
+ export * from './components/number-input'
46
58
  export * from './components/pagination'
59
+ export * from './components/password-input'
47
60
  export * from './components/popover'
48
61
  export * from './components/progress'
49
62
  export * from './components/prose'
50
63
  export * from './components/radio-group'
64
+ export * from './components/resizable'
51
65
  export * from './components/scroll-area'
52
66
  export * from './components/section'
53
67
  export * from './components/segmented-control'
@@ -59,13 +73,19 @@ export * from './components/slider'
59
73
  export * from './components/spinner'
60
74
  export * from './components/stack'
61
75
  export * from './components/stat-card'
76
+ export * from './components/stepper'
62
77
  export * from './components/switch'
63
78
  export * from './components/table'
64
79
  export * from './components/tabs'
80
+ export * from './components/tag-input'
65
81
  export * from './components/text'
66
82
  export * from './components/textarea'
83
+ export * from './components/timeline'
67
84
  export * from './components/toast'
85
+ export * from './components/toggle'
68
86
  export * from './components/tooltip'
87
+ export * from './components/tree-view'
88
+ export * from './components/visually-hidden'
69
89
  export * from './hooks'
70
90
  // Patterns
71
91
  export * from './patterns/app-shell'
@@ -0,0 +1,39 @@
1
+ import { Link, useLocation } from 'react-router'
2
+ import type { GlyphRouter, IsActiveOptions, LinkComponentProps } from '../provider/glyph-context'
3
+
4
+ const isOutsideApp = (href: string) =>
5
+ href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(href)
6
+
7
+ /**
8
+ * React Router's `Link`, taking Glyph's `href` prop. Hash links, external URLs and `mailto:`
9
+ * stay plain anchors, so in-page anchors scroll and external links leave the app.
10
+ */
11
+ export function RouterLink({ href, ...props }: LinkComponentProps) {
12
+ if (isOutsideApp(href)) return <a href={href} {...props} />
13
+ return <Link to={href} {...props} />
14
+ }
15
+
16
+ const trimSlash = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path)
17
+
18
+ /** Whether an in-app `href` matches a pathname, with React Router's `NavLink` semantics. */
19
+ export function matchPath(href: string, pathname: string, { end = false }: IsActiveOptions = {}) {
20
+ // Hash links, external URLs and protocol links never mark an item active.
21
+ if (!href || isOutsideApp(href)) return false
22
+ const target = trimSlash(new URL(href, 'http://glyph.local').pathname)
23
+ const current = trimSlash(pathname)
24
+ if (end || target === '/') return current === target
25
+ return current === target || current.startsWith(`${target}/`)
26
+ }
27
+
28
+ /** Marks a link active when the current location matches it, like React Router's `NavLink`. */
29
+ export function useRouterIsActive(href: string, options?: IsActiveOptions): boolean {
30
+ const { pathname } = useLocation()
31
+ return matchPath(href, pathname, options)
32
+ }
33
+
34
+ /**
35
+ * Connects Glyph's navigation to React Router (v7 or later):
36
+ * `<GlyphProvider router={reactRouter}>`. Links render client-side, and Sidebar, Navbar
37
+ * and NavigationMenu items mark the current page automatically. Must render inside a router.
38
+ */
39
+ export const reactRouter: GlyphRouter = { Link: RouterLink, useIsActive: useRouterIsActive }
@@ -0,0 +1,6 @@
1
+ // Type-level contract: the plugin plugs into Vite without casts. Checked by `tsc`.
2
+ import type { Plugin } from 'vite'
3
+ import { glyphColorMode } from './vite'
4
+
5
+ const plugin: Plugin = glyphColorMode({ defaultMode: 'dark' })
6
+ void plugin
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getColorModeScript } from '../provider/color-mode-script'
3
+ import { glyphColorMode } from './vite'
4
+
5
+ describe('glyphColorMode', () => {
6
+ it('prepends the color-mode script to the head before other transforms', () => {
7
+ const plugin = glyphColorMode()
8
+ expect(plugin.name).toBe('glyph:color-mode')
9
+ expect(plugin.transformIndexHtml.order).toBe('pre')
10
+ expect(plugin.transformIndexHtml.handler()).toEqual([
11
+ { tag: 'script', children: getColorModeScript(), injectTo: 'head-prepend' },
12
+ ])
13
+ })
14
+
15
+ it('uses the same options as GlyphProvider', () => {
16
+ const [tag] = glyphColorMode({
17
+ defaultMode: 'dark',
18
+ storageKey: 'acme-mode',
19
+ }).transformIndexHtml.handler()
20
+ expect(tag?.children).toContain('"acme-mode"')
21
+ expect(tag?.children).toContain('d="dark"')
22
+ })
23
+
24
+ it('skips localStorage when persistence is off', () => {
25
+ const [tag] = glyphColorMode({ storageKey: null }).transformIndexHtml.handler()
26
+ expect(tag?.children).not.toContain('localStorage')
27
+ })
28
+ })
@@ -0,0 +1,33 @@
1
+ import { type ColorModeOptions, getColorModeScript } from '../provider/color-mode-script'
2
+
3
+ /** The subset of Vite's `Plugin` this plugin uses, so `vite` stays an optional peer. */
4
+ export interface GlyphVitePlugin {
5
+ name: string
6
+ transformIndexHtml: {
7
+ order: 'pre'
8
+ handler: () => { tag: 'script'; children: string; injectTo: 'head-prepend' }[]
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Injects Glyph's color-mode script into `index.html` so dark-mode visitors never see a
14
+ * light flash. Pass the same `defaultMode` and `storageKey` as `GlyphProvider`.
15
+ *
16
+ * ```ts
17
+ * // vite.config.ts
18
+ * import { glyphColorMode } from '@glyphui/ui/vite'
19
+ * export default defineConfig({ plugins: [react(), tailwindcss(), glyphColorMode()] })
20
+ * ```
21
+ */
22
+ export function glyphColorMode(options: ColorModeOptions = {}): GlyphVitePlugin {
23
+ const script = getColorModeScript(options)
24
+ return {
25
+ name: 'glyph:color-mode',
26
+ transformIndexHtml: {
27
+ order: 'pre',
28
+ handler: () => [{ tag: 'script', children: script, injectTo: 'head-prepend' }],
29
+ },
30
+ }
31
+ }
32
+
33
+ export type { ColorModeOptions }
@@ -0,0 +1,12 @@
1
+ // Bundlers replace process.env.NODE_ENV; the typeof guard keeps unbundled browsers safe.
2
+ declare const process: { env: { NODE_ENV?: string } } | undefined
3
+
4
+ const warned = new Set<string>()
5
+
6
+ /** Logs a console warning once per key, in development only. */
7
+ export function warnOnce(key: string, message: string): void {
8
+ if (typeof process === 'undefined' || process.env.NODE_ENV === 'production') return
9
+ if (warned.has(key)) return
10
+ warned.add(key)
11
+ console.warn(`[glyph] ${message}`)
12
+ }
@@ -55,7 +55,7 @@ export const sidebarStyles = tv({
55
55
  'group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:px-0',
56
56
  ],
57
57
  itemIcon: 'flex size-4 shrink-0 items-center justify-center [&>svg]:size-4',
58
- itemLabel: 'min-w-0 flex-1 truncate text-left group-data-[collapsed=true]/sidebar:hidden',
58
+ itemLabel: 'min-w-0 flex-1 truncate text-left group-data-[collapsed=true]/sidebar:sr-only',
59
59
  itemBadge:
60
60
  'ml-auto font-mono text-caption text-faint-foreground tabular-nums group-data-[collapsed=true]/sidebar:hidden',
61
61
  footer: [
@@ -5,13 +5,42 @@ import { Container, type ContainerProps } from '../../components/container'
5
5
  import { useComponentConfig } from '../../hooks/use-component-config'
6
6
  import { useScrolled } from '../../hooks/use-scrolled'
7
7
  import { MenuIcon, XIcon } from '../../internal/icons'
8
- import { useLinkComponent } from '../../provider/glyph-context'
8
+ import { useIsActive, useLinkComponent } from '../../provider/glyph-context'
9
9
  import { navbarStyles } from './app-shell.styles'
10
10
 
11
11
  export interface NavLink {
12
12
  label: ReactNode
13
13
  href: string
14
+ /** Marks the current page. Computed from the provider's `router` when not set. */
14
15
  active?: boolean
16
+ /** With automatic matching, only the exact path is active (not its children). */
17
+ end?: boolean
18
+ }
19
+
20
+ function NavbarLink({
21
+ link,
22
+ className,
23
+ onClick,
24
+ }: {
25
+ link: NavLink
26
+ className: string
27
+ onClick?: () => void
28
+ }) {
29
+ const Link = useLinkComponent()
30
+ const active = useIsActive(link.href, {
31
+ explicit: link.active,
32
+ ...(link.end !== undefined && { end: link.end }),
33
+ })
34
+ return (
35
+ <Link
36
+ href={link.href}
37
+ aria-current={active ? 'page' : undefined}
38
+ className={className}
39
+ {...(onClick && { onClick })}
40
+ >
41
+ {link.label}
42
+ </Link>
43
+ )
15
44
  }
16
45
 
17
46
  export interface NavbarProps extends Omit<ComponentProps<'header'>, 'children'> {
@@ -44,7 +73,6 @@ export function Navbar(inProps: NavbarProps) {
44
73
  } = props
45
74
  const [open, setOpen] = useState(false)
46
75
  const scrolled = useScrolled(8, blurOnScroll)
47
- const Link = useLinkComponent()
48
76
  const s = navbarStyles({ sticky, blur: blurOnScroll })
49
77
 
50
78
  return (
@@ -59,14 +87,7 @@ export function Navbar(inProps: NavbarProps) {
59
87
  {links.length > 0 && (
60
88
  <nav aria-label="Main" className={s.links({ class: slots.links })}>
61
89
  {links.map((link) => (
62
- <Link
63
- key={link.href}
64
- href={link.href}
65
- aria-current={link.active ? 'page' : undefined}
66
- className={s.link({ class: slots.link })}
67
- >
68
- {link.label}
69
- </Link>
90
+ <NavbarLink key={link.href} link={link} className={s.link({ class: slots.link })} />
70
91
  ))}
71
92
  </nav>
72
93
  )}
@@ -96,15 +117,12 @@ export function Navbar(inProps: NavbarProps) {
96
117
  </div>
97
118
  <nav aria-label="Main" className={s.panelLinks({ class: slots.panelLinks })}>
98
119
  {links.map((link) => (
99
- <Link
120
+ <NavbarLink
100
121
  key={link.href}
101
- href={link.href}
102
- aria-current={link.active ? 'page' : undefined}
122
+ link={link}
103
123
  className={s.panelLink({ class: slots.panelLink })}
104
124
  onClick={() => setOpen(false)}
105
- >
106
- {link.label}
107
- </Link>
125
+ />
108
126
  ))}
109
127
  </nav>
110
128
  {actions && (
@@ -4,7 +4,7 @@ import { Tooltip } from '../../components/tooltip'
4
4
  import { useComponentConfig } from '../../hooks/use-component-config'
5
5
  import { useMediaQuery } from '../../hooks/use-media-query'
6
6
  import { PanelLeftIcon } from '../../internal/icons'
7
- import { useLinkComponent } from '../../provider/glyph-context'
7
+ import { useIsActive, useLinkComponent } from '../../provider/glyph-context'
8
8
  import { sidebarStyles } from './app-shell.styles'
9
9
  import { useAppShell } from './app-shell-context'
10
10
 
@@ -75,7 +75,10 @@ export interface SidebarItemProps {
75
75
  icon?: ReactNode
76
76
  /** Count or short status shown at the end. */
77
77
  badge?: ReactNode
78
+ /** Marks the current page. Computed from the provider's `router` when not set. */
78
79
  active?: boolean
80
+ /** With automatic matching, only the exact path is active (not its children). */
81
+ end?: boolean
79
82
  onClick?: (event: MouseEvent<HTMLElement>) => void
80
83
  className?: string
81
84
  children: ReactNode
@@ -85,11 +88,13 @@ export function SidebarItem({
85
88
  href,
86
89
  icon,
87
90
  badge,
88
- active,
91
+ active: activeProp,
92
+ end,
89
93
  onClick,
90
94
  className,
91
95
  children,
92
96
  }: SidebarItemProps) {
97
+ const active = useIsActive(href, { explicit: activeProp, ...(end !== undefined && { end }) })
93
98
  const { collapsed, inDrawer, setMobileOpen } = useAppShell()
94
99
  const Link = useLinkComponent()
95
100
  const slots = useSlots()