@glyphui/ui 0.1.1 → 0.3.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 (149) hide show
  1. package/README.md +1 -1
  2. package/dist/chart.d.ts +202 -0
  3. package/dist/chart.js +457 -0
  4. package/dist/data-table.js +3 -2
  5. package/dist/{field-DXZD0l8w.js → field-CWZ_FdkD.js} +2 -1
  6. package/dist/{field-context-B8cYheXz.js → field-context-anGPYXbW.js} +89 -143
  7. package/dist/form.js +3 -2
  8. package/dist/index.d.ts +4960 -266
  9. package/dist/index.js +5336 -146
  10. package/dist/{states-BxPjCUxz.js → states-DR-OoDT-.js} +2 -1
  11. package/dist/tv-DBAGlgXJ.js +58 -0
  12. package/package.json +19 -2
  13. package/src/components/accordion/accordion.styles.ts +41 -0
  14. package/src/components/accordion/accordion.tsx +86 -0
  15. package/src/components/accordion/index.ts +8 -0
  16. package/src/components/alert/alert-icons.tsx +66 -0
  17. package/src/components/alert/alert.styles.ts +57 -0
  18. package/src/components/alert/alert.tsx +86 -0
  19. package/src/components/alert/index.ts +2 -0
  20. package/src/components/alert-dialog/alert-dialog.styles.ts +24 -0
  21. package/src/components/alert-dialog/alert-dialog.tsx +118 -0
  22. package/src/components/alert-dialog/confirm.tsx +111 -0
  23. package/src/components/alert-dialog/index.ts +15 -0
  24. package/src/components/aspect-ratio/aspect-ratio.tsx +40 -0
  25. package/src/components/aspect-ratio/index.ts +1 -0
  26. package/src/components/badge/badge.styles.ts +1 -1
  27. package/src/components/banner/banner.styles.ts +40 -0
  28. package/src/components/banner/banner.tsx +101 -0
  29. package/src/components/banner/index.ts +2 -0
  30. package/src/components/blockquote/blockquote.tsx +59 -0
  31. package/src/components/blockquote/index.ts +1 -0
  32. package/src/components/breadcrumb/breadcrumb.styles.ts +20 -0
  33. package/src/components/breadcrumb/breadcrumb.tsx +135 -0
  34. package/src/components/breadcrumb/index.ts +12 -0
  35. package/src/components/button/button.tsx +11 -8
  36. package/src/components/calendar/calendar.styles.ts +50 -0
  37. package/src/components/calendar/calendar.tsx +84 -0
  38. package/src/components/calendar/index.ts +3 -0
  39. package/src/components/carousel/carousel.styles.ts +43 -0
  40. package/src/components/carousel/carousel.tsx +278 -0
  41. package/src/components/carousel/index.ts +13 -0
  42. package/src/components/code/code.styles.ts +33 -0
  43. package/src/components/code/code.tsx +96 -0
  44. package/src/components/code/index.ts +2 -0
  45. package/src/components/collapsible/collapsible.tsx +66 -0
  46. package/src/components/collapsible/index.ts +6 -0
  47. package/src/components/combobox/combobox.styles.ts +50 -0
  48. package/src/components/combobox/combobox.tsx +203 -0
  49. package/src/components/combobox/index.ts +2 -0
  50. package/src/components/command-menu/command-menu.styles.ts +41 -0
  51. package/src/components/command-menu/command-menu.tsx +217 -0
  52. package/src/components/command-menu/index.ts +14 -0
  53. package/src/components/context-menu/context-menu.styles.ts +19 -0
  54. package/src/components/context-menu/context-menu.tsx +226 -0
  55. package/src/components/context-menu/index.ts +19 -0
  56. package/src/components/copy-button/copy-button.tsx +117 -0
  57. package/src/components/copy-button/index.ts +1 -0
  58. package/src/components/date-picker/date-picker.styles.ts +42 -0
  59. package/src/components/date-picker/date-picker.tsx +322 -0
  60. package/src/components/date-picker/index.ts +8 -0
  61. package/src/components/description-list/description-list.styles.ts +31 -0
  62. package/src/components/description-list/description-list.tsx +56 -0
  63. package/src/components/description-list/index.ts +7 -0
  64. package/src/components/dialog/close-icon.tsx +22 -0
  65. package/src/components/dialog/dialog.styles.ts +41 -0
  66. package/src/components/dialog/dialog.tsx +111 -0
  67. package/src/components/dialog/index.ts +13 -0
  68. package/src/components/dropdown-menu/dropdown-menu.styles.ts +45 -0
  69. package/src/components/dropdown-menu/dropdown-menu.tsx +230 -0
  70. package/src/components/dropdown-menu/index.ts +19 -0
  71. package/src/components/file-upload/file-upload.styles.ts +43 -0
  72. package/src/components/file-upload/file-upload.tsx +343 -0
  73. package/src/components/file-upload/index.ts +10 -0
  74. package/src/components/grid/grid.tsx +144 -0
  75. package/src/components/grid/index.ts +1 -0
  76. package/src/components/hover-card/hover-card.tsx +41 -0
  77. package/src/components/hover-card/index.ts +1 -0
  78. package/src/components/input-otp/index.ts +10 -0
  79. package/src/components/input-otp/input-otp.styles.ts +38 -0
  80. package/src/components/input-otp/input-otp.tsx +116 -0
  81. package/src/components/link/index.ts +2 -0
  82. package/src/components/link/link.styles.ts +39 -0
  83. package/src/components/link/link.tsx +62 -0
  84. package/src/components/modal/index.ts +2 -0
  85. package/src/components/modal/modal.styles.ts +18 -0
  86. package/src/components/modal/modal.tsx +86 -0
  87. package/src/components/navigation-menu/index.ts +14 -0
  88. package/src/components/navigation-menu/navigation-menu.styles.ts +48 -0
  89. package/src/components/navigation-menu/navigation-menu.tsx +198 -0
  90. package/src/components/number-input/index.ts +2 -0
  91. package/src/components/number-input/number-input.styles.ts +41 -0
  92. package/src/components/number-input/number-input.tsx +211 -0
  93. package/src/components/pagination/index.ts +3 -0
  94. package/src/components/pagination/pagination-range.ts +35 -0
  95. package/src/components/pagination/pagination.styles.ts +41 -0
  96. package/src/components/pagination/pagination.tsx +130 -0
  97. package/src/components/password-input/index.ts +7 -0
  98. package/src/components/password-input/password-input.styles.ts +30 -0
  99. package/src/components/password-input/password-input.tsx +127 -0
  100. package/src/components/popover/index.ts +9 -0
  101. package/src/components/popover/popover.tsx +66 -0
  102. package/src/components/progress/index.ts +7 -0
  103. package/src/components/progress/progress.styles.ts +68 -0
  104. package/src/components/progress/progress.tsx +158 -0
  105. package/src/components/prose/index.ts +1 -0
  106. package/src/components/prose/prose.tsx +82 -0
  107. package/src/components/radio-group/index.ts +7 -0
  108. package/src/components/radio-group/radio-group.styles.ts +64 -0
  109. package/src/components/radio-group/radio-group.tsx +128 -0
  110. package/src/components/resizable/index.ts +9 -0
  111. package/src/components/resizable/resizable.styles.ts +28 -0
  112. package/src/components/resizable/resizable.tsx +69 -0
  113. package/src/components/scroll-area/index.ts +1 -0
  114. package/src/components/scroll-area/scroll-area.tsx +68 -0
  115. package/src/components/sheet/index.ts +13 -0
  116. package/src/components/sheet/sheet.styles.ts +60 -0
  117. package/src/components/sheet/sheet.tsx +108 -0
  118. package/src/components/slider/index.ts +2 -0
  119. package/src/components/slider/slider.styles.ts +32 -0
  120. package/src/components/slider/slider.tsx +131 -0
  121. package/src/components/stat-card/index.ts +2 -0
  122. package/src/components/stat-card/stat-card.styles.ts +24 -0
  123. package/src/components/stat-card/stat-card.tsx +108 -0
  124. package/src/components/stepper/index.ts +2 -0
  125. package/src/components/stepper/stepper.styles.ts +74 -0
  126. package/src/components/stepper/stepper.tsx +138 -0
  127. package/src/components/tag-input/index.ts +2 -0
  128. package/src/components/tag-input/tag-input.styles.ts +39 -0
  129. package/src/components/tag-input/tag-input.tsx +198 -0
  130. package/src/components/timeline/index.ts +8 -0
  131. package/src/components/timeline/timeline.styles.ts +56 -0
  132. package/src/components/timeline/timeline.tsx +91 -0
  133. package/src/components/toast/index.ts +1 -0
  134. package/src/components/toast/toast.tsx +91 -0
  135. package/src/components/toggle/index.ts +9 -0
  136. package/src/components/toggle/toggle.styles.ts +39 -0
  137. package/src/components/toggle/toggle.tsx +61 -0
  138. package/src/components/tree-view/index.ts +2 -0
  139. package/src/components/tree-view/tree-view.styles.ts +42 -0
  140. package/src/components/tree-view/tree-view.tsx +274 -0
  141. package/src/components/visually-hidden/index.ts +1 -0
  142. package/src/components/visually-hidden/visually-hidden.tsx +10 -0
  143. package/src/index.ts +45 -0
  144. package/src/patterns/chart/chart-parts.tsx +171 -0
  145. package/src/patterns/chart/chart.styles.ts +35 -0
  146. package/src/patterns/chart/chart.tsx +369 -0
  147. package/src/patterns/chart/index.ts +13 -0
  148. package/src/provider/glyph-provider.tsx +16 -2
  149. package/styles.css +16 -0
@@ -0,0 +1,91 @@
1
+ import { type ComponentProps, createContext, type ReactNode, useContext } from 'react'
2
+ import { type SlotClasses, useComponentConfig } from '../../hooks/use-component-config'
3
+ import { type TimelineVariants, timelineStyles } from './timeline.styles'
4
+
5
+ const TimelineContext = createContext<SlotClasses<'Timeline'>>({})
6
+
7
+ export interface TimelineItemData extends TimelineVariants {
8
+ /** Stable key. Falls back to the index. */
9
+ id?: string
10
+ title: ReactNode
11
+ /** Display text for when it happened, e.g. "2h ago" or "Oct 12". */
12
+ time?: ReactNode
13
+ /** Machine-readable date for the `<time>` element, e.g. "2026-10-12T09:41". */
14
+ dateTime?: string
15
+ description?: ReactNode
16
+ /** Replaces the dot in the marker. */
17
+ icon?: ReactNode
18
+ }
19
+
20
+ export interface TimelineProps extends Omit<ComponentProps<'ol'>, 'children'> {
21
+ /** Data-driven items. Use `children` with TimelineItem for custom content. */
22
+ items?: readonly TimelineItemData[]
23
+ children?: ReactNode
24
+ }
25
+
26
+ /** A vertical list of events in order, for activity feeds, changelogs and order tracking. */
27
+ export function Timeline(inProps: TimelineProps) {
28
+ const [props, slots] = useComponentConfig('Timeline', inProps)
29
+ const { items, className, children, ...rest } = props
30
+ const s = timelineStyles()
31
+ return (
32
+ <TimelineContext.Provider value={slots}>
33
+ <ol data-slot="timeline" className={s.root({ class: [slots.root, className] })} {...rest}>
34
+ {items
35
+ ? items.map(({ id, description, ...item }, index) => (
36
+ <TimelineItem key={id ?? index} {...item}>
37
+ {description}
38
+ </TimelineItem>
39
+ ))
40
+ : children}
41
+ </ol>
42
+ </TimelineContext.Provider>
43
+ )
44
+ }
45
+
46
+ export interface TimelineItemProps
47
+ extends Omit<ComponentProps<'li'>, 'title'>,
48
+ Omit<TimelineItemData, 'id' | 'description'> {
49
+ /** Description below the title. */
50
+ children?: ReactNode
51
+ }
52
+
53
+ export function TimelineItem({
54
+ title,
55
+ time,
56
+ dateTime,
57
+ icon,
58
+ tone,
59
+ className,
60
+ children,
61
+ ...props
62
+ }: TimelineItemProps) {
63
+ const slots = useContext(TimelineContext)
64
+ const s = timelineStyles({ tone })
65
+ return (
66
+ <li
67
+ data-slot="timeline-item"
68
+ data-tone={tone ?? 'neutral'}
69
+ className={s.item({ class: [slots.item, className] })}
70
+ {...props}
71
+ >
72
+ <div className={s.rail({ class: slots.rail })} aria-hidden="true">
73
+ <span className={s.marker({ class: slots.marker })}>
74
+ {icon ?? <span className={s.dot({ class: slots.dot })} />}
75
+ </span>
76
+ <span className={s.connector({ class: slots.connector })} />
77
+ </div>
78
+ <div className={s.content({ class: slots.content })}>
79
+ <div className={s.header({ class: slots.header })}>
80
+ <p className={s.title({ class: slots.title })}>{title}</p>
81
+ {time && (
82
+ <time dateTime={dateTime} className={s.time({ class: slots.time })}>
83
+ {time}
84
+ </time>
85
+ )}
86
+ </div>
87
+ {children && <div className={s.description({ class: slots.description })}>{children}</div>}
88
+ </div>
89
+ </li>
90
+ )
91
+ }
@@ -0,0 +1 @@
1
+ export { Toaster, type ToasterProps, toast, toastStyles } from './toast'
@@ -0,0 +1,91 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { Toaster as Sonner, toast } from 'sonner'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import type { StylesEntry } from '../../utils/registry'
5
+ import { focusRing, themedBorder, transition } from '../../utils/styles'
6
+ import { tv } from '../../utils/tv'
7
+
8
+ export const toastStyles = tv({
9
+ slots: {
10
+ toast: [
11
+ 'group flex w-(--width) items-start gap-3 rounded-lg border-border bg-raised p-4 text-small text-foreground shadow-lg',
12
+ themedBorder,
13
+ ],
14
+ content: 'flex min-w-0 flex-1 flex-col gap-0.5',
15
+ title: 'font-medium text-foreground',
16
+ description: 'text-muted-foreground',
17
+ icon: 'mt-0.5 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4',
18
+ actionButton: [
19
+ 'inline-flex h-7 shrink-0 cursor-pointer items-center rounded-sm bg-primary px-2.5 text-caption font-medium text-primary-foreground hover:bg-primary/88',
20
+ transition,
21
+ focusRing,
22
+ ],
23
+ cancelButton: [
24
+ 'inline-flex h-7 shrink-0 cursor-pointer items-center rounded-sm bg-muted px-2.5 text-caption font-medium text-muted-foreground hover:text-foreground',
25
+ transition,
26
+ focusRing,
27
+ ],
28
+ closeButton: [
29
+ 'absolute -top-2 -left-2 flex size-5 cursor-pointer items-center justify-center rounded-pill border-border bg-raised text-muted-foreground shadow-xs hover:text-foreground [&>svg]:size-3',
30
+ themedBorder,
31
+ focusRing,
32
+ ],
33
+ success: '[&_[data-icon]]:text-success',
34
+ error: '[&_[data-icon]]:text-danger',
35
+ warning: '[&_[data-icon]]:text-warning',
36
+ info: '[&_[data-icon]]:text-info',
37
+ loading: '[&_[data-icon]]:text-muted-foreground',
38
+ },
39
+ })
40
+
41
+ declare module '@glyphui/tokens' {
42
+ interface GlyphComponentRegistry {
43
+ Toast: StylesEntry<typeof toastStyles>
44
+ }
45
+ }
46
+
47
+ export type ToasterProps = ComponentProps<typeof Sonner>
48
+
49
+ /**
50
+ * Renders toasts sent with `toast()`. Mount once near the root. Styled entirely by the
51
+ * theme's tokens, so it follows the theme and color mode automatically.
52
+ *
53
+ * ```tsx
54
+ * toast.success('Saved')
55
+ * toast.promise(save(), { loading: 'Saving…', success: 'Saved', error: 'Couldn’t save' })
56
+ * ```
57
+ */
58
+ export function Toaster(inProps: ToasterProps) {
59
+ const [props, slots] = useComponentConfig('Toast', inProps)
60
+ const { toastOptions, position = 'bottom-right', ...rest } = props
61
+ const s = toastStyles()
62
+ const classNames = toastOptions?.classNames ?? {}
63
+ return (
64
+ <Sonner
65
+ position={position}
66
+ gap={10}
67
+ {...rest}
68
+ toastOptions={{
69
+ ...toastOptions,
70
+ unstyled: true,
71
+ classNames: {
72
+ toast: s.toast({ class: [slots.toast, classNames.toast] }),
73
+ content: s.content({ class: [slots.content, classNames.content] }),
74
+ title: s.title({ class: [slots.title, classNames.title] }),
75
+ description: s.description({ class: [slots.description, classNames.description] }),
76
+ icon: s.icon({ class: [slots.icon, classNames.icon] }),
77
+ actionButton: s.actionButton({ class: [slots.actionButton, classNames.actionButton] }),
78
+ cancelButton: s.cancelButton({ class: [slots.cancelButton, classNames.cancelButton] }),
79
+ closeButton: s.closeButton({ class: [slots.closeButton, classNames.closeButton] }),
80
+ success: s.success({ class: [slots.success, classNames.success] }),
81
+ error: s.error({ class: [slots.error, classNames.error] }),
82
+ warning: s.warning({ class: [slots.warning, classNames.warning] }),
83
+ info: s.info({ class: [slots.info, classNames.info] }),
84
+ loading: s.loading({ class: [slots.loading, classNames.loading] }),
85
+ },
86
+ }}
87
+ />
88
+ )
89
+ }
90
+
91
+ export { toast }
@@ -0,0 +1,9 @@
1
+ export {
2
+ Toggle,
3
+ ToggleGroup,
4
+ ToggleGroupItem,
5
+ type ToggleGroupItemProps,
6
+ type ToggleGroupProps,
7
+ type ToggleProps,
8
+ } from './toggle'
9
+ export { type ToggleVariants, toggleStyles } from './toggle.styles'
@@ -0,0 +1,39 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { focusRing, themedBorder, transition } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const toggleStyles = tv({
6
+ slots: {
7
+ base: [
8
+ 'inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-muted-foreground',
9
+ transition,
10
+ focusRing,
11
+ 'hover:bg-muted hover:text-foreground',
12
+ 'data-[state=on]:bg-muted data-[state=on]:text-foreground',
13
+ 'disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
14
+ ],
15
+ group: 'inline-flex items-center gap-1',
16
+ },
17
+ variants: {
18
+ variant: {
19
+ default: { base: 'bg-transparent' },
20
+ outline: {
21
+ base: ['border-border bg-transparent shadow-xs hover:border-border-strong', themedBorder],
22
+ },
23
+ },
24
+ size: {
25
+ sm: { base: 'h-(--glyph-control-sm) min-w-(--glyph-control-sm) px-2 text-caption' },
26
+ md: { base: 'h-(--glyph-control-md) min-w-(--glyph-control-md) px-2.5 text-small' },
27
+ lg: { base: 'h-(--glyph-control-lg) min-w-(--glyph-control-lg) px-3 text-small' },
28
+ },
29
+ },
30
+ defaultVariants: { variant: 'default', size: 'md' },
31
+ })
32
+
33
+ export type ToggleVariants = VariantProps<typeof toggleStyles>
34
+
35
+ declare module '@glyphui/tokens' {
36
+ interface GlyphComponentRegistry {
37
+ Toggle: StylesEntry<typeof toggleStyles>
38
+ }
39
+ }
@@ -0,0 +1,61 @@
1
+ import { ToggleGroup as ToggleGroupPrimitive, Toggle as TogglePrimitive } from 'radix-ui'
2
+ import { type ComponentProps, createContext, useContext } from 'react'
3
+ import { useComponentConfig } from '../../hooks/use-component-config'
4
+ import { type ToggleVariants, toggleStyles } from './toggle.styles'
5
+
6
+ export interface ToggleProps extends ComponentProps<typeof TogglePrimitive.Root>, ToggleVariants {}
7
+
8
+ /**
9
+ * A two-state button, e.g. Bold in a toolbar. Use Switch for settings that take effect
10
+ * immediately, and Checkbox inside forms.
11
+ */
12
+ export function Toggle(inProps: ToggleProps) {
13
+ const [props, slots] = useComponentConfig('Toggle', inProps)
14
+ const { variant, size, className, ...rest } = props
15
+ return (
16
+ <TogglePrimitive.Root
17
+ data-slot="toggle"
18
+ className={toggleStyles({ variant, size }).base({ class: [slots.base, className] })}
19
+ {...rest}
20
+ />
21
+ )
22
+ }
23
+
24
+ const ToggleGroupContext = createContext<ToggleVariants>({})
25
+
26
+ export type ToggleGroupProps = ComponentProps<typeof ToggleGroupPrimitive.Root> & ToggleVariants
27
+
28
+ /**
29
+ * A set of toggles. `type="single"` behaves like a radio group (text alignment),
30
+ * `type="multiple"` like checkboxes (bold, italic, underline). Arrow keys move between items.
31
+ */
32
+ export function ToggleGroup(inProps: ToggleGroupProps) {
33
+ const [props, slots] = useComponentConfig('Toggle', inProps)
34
+ const { variant, size, className, ...rest } = props
35
+ return (
36
+ <ToggleGroupContext.Provider value={{ variant, size }}>
37
+ <ToggleGroupPrimitive.Root
38
+ data-slot="toggle-group"
39
+ className={toggleStyles().group({ class: [slots.group, className] })}
40
+ {...rest}
41
+ />
42
+ </ToggleGroupContext.Provider>
43
+ )
44
+ }
45
+
46
+ export interface ToggleGroupItemProps
47
+ extends ComponentProps<typeof ToggleGroupPrimitive.Item>,
48
+ ToggleVariants {}
49
+
50
+ export function ToggleGroupItem({ variant, size, className, ...props }: ToggleGroupItemProps) {
51
+ const group = useContext(ToggleGroupContext)
52
+ const [, slots] = useComponentConfig('Toggle', {})
53
+ const s = toggleStyles({ variant: variant ?? group.variant, size: size ?? group.size })
54
+ return (
55
+ <ToggleGroupPrimitive.Item
56
+ data-slot="toggle-group-item"
57
+ className={s.base({ class: [slots.base, className] })}
58
+ {...props}
59
+ />
60
+ )
61
+ }
@@ -0,0 +1,2 @@
1
+ export { type TreeNode, TreeView, type TreeViewProps } from './tree-view'
2
+ export { type TreeViewVariants, treeViewStyles } from './tree-view.styles'
@@ -0,0 +1,42 @@
1
+ import type { StylesEntry } from '../../utils/registry'
2
+ import { transition } from '../../utils/styles'
3
+ import { tv, type VariantProps } from '../../utils/tv'
4
+
5
+ export const treeViewStyles = tv({
6
+ slots: {
7
+ root: 'grid gap-px text-small',
8
+ // Focus and selection are drawn on the item's own row only, never on nested rows.
9
+ item: [
10
+ 'outline-none',
11
+ 'focus-visible:[&>[data-slot=tree-row]]:ring-2 focus-visible:[&>[data-slot=tree-row]]:ring-ring',
12
+ 'aria-selected:[&>[data-slot=tree-row]]:bg-muted aria-selected:[&>[data-slot=tree-row]]:text-foreground aria-selected:[&>[data-slot=tree-row]]:font-medium',
13
+ 'aria-disabled:[&>[data-slot=tree-row]]:pointer-events-none aria-disabled:[&>[data-slot=tree-row]]:opacity-50',
14
+ ],
15
+ row: [
16
+ 'group/row flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-sm pr-2 text-muted-foreground',
17
+ transition,
18
+ 'hover:bg-muted hover:text-foreground',
19
+ ],
20
+ chevron:
21
+ 'size-4 shrink-0 text-faint-foreground transition-transform duration-(--glyph-duration-fast) ease-standard group-data-[state=open]/row:rotate-90',
22
+ spacer: 'size-4 shrink-0',
23
+ icon: 'flex size-4 shrink-0 items-center justify-center text-faint-foreground [&>svg]:size-4',
24
+ label: 'min-w-0 flex-1 truncate',
25
+ group: 'grid gap-px',
26
+ },
27
+ variants: {
28
+ size: {
29
+ sm: { row: 'h-7 text-caption' },
30
+ md: {},
31
+ },
32
+ },
33
+ defaultVariants: { size: 'md' },
34
+ })
35
+
36
+ export type TreeViewVariants = VariantProps<typeof treeViewStyles>
37
+
38
+ declare module '@glyphui/tokens' {
39
+ interface GlyphComponentRegistry {
40
+ TreeView: StylesEntry<typeof treeViewStyles>
41
+ }
42
+ }
@@ -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
+ }