@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.
- package/README.md +65 -12
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +466 -0
- package/dist/color-mode-script-Bf7LYkOj.js +13 -0
- package/dist/color-mode-script-KdwItKN2.d.ts +16 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-CCecIzdq.js → field-CVbywksE.js} +21 -5
- package/dist/{field-context-B6WOcK5m.js → field-context-7I-cWvvS.js} +3 -57
- package/dist/form.d.ts +11 -4
- package/dist/form.js +34 -8
- package/dist/glyph-context-DxGJrdvr.d.ts +50 -0
- package/dist/{index-BWK4czIG.d.ts → index-Bi7rZ6ba.d.ts} +11 -0
- package/dist/index.d.ts +2334 -283
- package/dist/index.js +2952 -326
- package/dist/react-router.d.ts +18 -0
- package/dist/react-router.js +44 -0
- package/dist/{states-Rp8wZ_jH.js → states-IvhjBgna.js} +2 -1
- package/dist/tv-DLbT66dC.js +70 -0
- package/dist/vite.d.ts +27 -0
- package/dist/vite.js +29 -0
- package/fonts.css +10 -0
- package/package.json +47 -5
- package/src/components/accordion/accordion.styles.ts +4 -4
- package/src/components/badge/badge.styles.ts +11 -16
- package/src/components/banner/banner.styles.ts +4 -4
- package/src/components/button/button.styles.ts +3 -1
- package/src/components/calendar/calendar.styles.ts +51 -0
- package/src/components/calendar/calendar.tsx +84 -0
- package/src/components/calendar/index.ts +3 -0
- package/src/components/carousel/carousel.styles.ts +43 -0
- package/src/components/carousel/carousel.tsx +278 -0
- package/src/components/carousel/index.ts +13 -0
- package/src/components/collapsible/collapsible.tsx +66 -0
- package/src/components/collapsible/index.ts +6 -0
- package/src/components/context-menu/context-menu.styles.ts +19 -0
- package/src/components/context-menu/context-menu.tsx +226 -0
- package/src/components/context-menu/index.ts +19 -0
- package/src/components/copy-button/copy-button.tsx +117 -0
- package/src/components/copy-button/index.ts +1 -0
- package/src/components/date-picker/date-picker.styles.ts +42 -0
- package/src/components/date-picker/date-picker.tsx +322 -0
- package/src/components/date-picker/index.ts +8 -0
- package/src/components/dropdown-menu/dropdown-menu.styles.ts +2 -2
- package/src/components/eyebrow/eyebrow.styles.ts +1 -1
- package/src/components/field/field-context.ts +2 -0
- package/src/components/field/field.styles.ts +4 -2
- package/src/components/field/field.tsx +25 -4
- package/src/components/file-upload/file-upload.styles.ts +43 -0
- package/src/components/file-upload/file-upload.tsx +343 -0
- package/src/components/file-upload/index.ts +10 -0
- package/src/components/hover-card/hover-card.tsx +41 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/input-otp/index.ts +10 -0
- package/src/components/input-otp/input-otp.styles.ts +38 -0
- package/src/components/input-otp/input-otp.tsx +116 -0
- package/src/components/link/link.styles.ts +1 -1
- package/src/components/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +20 -0
- package/src/components/modal/modal.tsx +111 -0
- package/src/components/navigation-menu/index.ts +14 -0
- package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
- package/src/components/navigation-menu/navigation-menu.tsx +208 -0
- package/src/components/number-input/index.ts +2 -0
- package/src/components/number-input/number-input.styles.ts +41 -0
- package/src/components/number-input/number-input.tsx +211 -0
- package/src/components/password-input/index.ts +7 -0
- package/src/components/password-input/password-input.styles.ts +30 -0
- package/src/components/password-input/password-input.tsx +127 -0
- package/src/components/prose/prose.tsx +1 -1
- package/src/components/resizable/index.ts +9 -0
- package/src/components/resizable/resizable.styles.ts +28 -0
- package/src/components/resizable/resizable.tsx +69 -0
- package/src/components/scroll-area/scroll-area.tsx +23 -1
- package/src/components/segmented-control/segmented-control.tsx +17 -2
- package/src/components/sheet/sheet.styles.ts +7 -7
- package/src/components/slider/slider.tsx +9 -2
- package/src/components/stepper/index.ts +2 -0
- package/src/components/stepper/stepper.styles.ts +74 -0
- package/src/components/stepper/stepper.tsx +138 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.styles.ts +39 -0
- package/src/components/tag-input/tag-input.tsx +198 -0
- package/src/components/text/text.styles.ts +4 -4
- package/src/components/timeline/index.ts +8 -0
- package/src/components/timeline/timeline.styles.ts +56 -0
- package/src/components/timeline/timeline.tsx +91 -0
- package/src/components/toggle/index.ts +9 -0
- package/src/components/toggle/toggle.styles.ts +39 -0
- package/src/components/toggle/toggle.tsx +61 -0
- package/src/components/tree-view/index.ts +2 -0
- package/src/components/tree-view/tree-view.styles.ts +42 -0
- package/src/components/tree-view/tree-view.tsx +274 -0
- package/src/components/visually-hidden/index.ts +1 -0
- package/src/components/visually-hidden/visually-hidden.tsx +10 -0
- package/src/index.ts +20 -0
- package/src/integrations/react-router.tsx +39 -0
- package/src/integrations/vite.test-d.ts +6 -0
- package/src/integrations/vite.test.ts +28 -0
- package/src/integrations/vite.ts +33 -0
- package/src/internal/dev.ts +12 -0
- package/src/patterns/app-shell/app-shell.styles.ts +1 -1
- package/src/patterns/app-shell/navbar.tsx +34 -16
- package/src/patterns/app-shell/sidebar.tsx +7 -2
- package/src/patterns/chart/chart-parts.tsx +179 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +386 -0
- package/src/patterns/chart/index.ts +13 -0
- package/src/patterns/form/form.tsx +48 -6
- package/src/patterns/form/index.ts +1 -0
- package/src/provider/color-mode-script.ts +28 -0
- package/src/provider/color-mode.tsx +14 -20
- package/src/provider/glyph-context.ts +38 -0
- package/src/provider/glyph-provider.tsx +16 -4
- package/src/provider/index.ts +4 -0
- package/src/provider/theme-scope.tsx +7 -3
- 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,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:
|
|
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
|
-
<
|
|
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
|
-
<
|
|
120
|
+
<NavbarLink
|
|
100
121
|
key={link.href}
|
|
101
|
-
|
|
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()
|