@glyphui/ui 0.2.0 → 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.
- package/dist/chart.d.ts +202 -0
- package/dist/chart.js +457 -0
- package/dist/data-table.js +3 -2
- package/dist/{field-CCecIzdq.js → field-CWZ_FdkD.js} +2 -1
- package/dist/{field-context-B6WOcK5m.js → field-context-anGPYXbW.js} +2 -56
- package/dist/form.js +3 -2
- package/dist/index.d.ts +2285 -241
- package/dist/index.js +2819 -266
- package/dist/{states-Rp8wZ_jH.js → states-DR-OoDT-.js} +2 -1
- package/dist/tv-DBAGlgXJ.js +58 -0
- package/package.json +17 -2
- package/src/components/accordion/accordion.styles.ts +4 -4
- package/src/components/calendar/calendar.styles.ts +50 -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/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/modal/index.ts +2 -0
- package/src/components/modal/modal.styles.ts +18 -0
- package/src/components/modal/modal.tsx +86 -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 +198 -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/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/sheet/sheet.styles.ts +7 -7
- 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/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/patterns/chart/chart-parts.tsx +171 -0
- package/src/patterns/chart/chart.styles.ts +35 -0
- package/src/patterns/chart/chart.tsx +369 -0
- package/src/patterns/chart/index.ts +13 -0
- 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,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,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
|
+
}
|
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'
|