@duro-app/ui 3.5.0 → 3.6.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/table.js CHANGED
@@ -1,4 +1,4 @@
1
- import { c as H, m as p, d as b, M as F, s as xt, R as rt, H as kt, a as pt, B as bt, e as ft, C as mt, b as ht, f as yt } from "./Table-P-UHkEqJ.js";
1
+ import { c as H, m as p, d as b, M as F, s as xt, R as rt, H as kt, a as pt, B as bt, e as ft, C as mt, b as ht, f as yt } from "./Table-B4F8XyqK.js";
2
2
  import { jsx as u, jsxs as B } from "react/jsx-runtime";
3
3
  import { flexRender as E, useReactTable as it, getPaginationRowModel as St, getFilteredRowModel as Tt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
4
4
  import { useState as G, useCallback as Ct, useRef as K, useEffect as Z, useLayoutEffect as wt } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@tanstack/react-virtual": "^3.14.6",
67
- "@duro-app/tokens": "^3.5.0"
67
+ "@duro-app/tokens": "^3.6.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@babel/preset-typescript": "^7.28.0",
@@ -0,0 +1,41 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. Compound component — Root is required.',
6
+ whenToUse: [
7
+ 'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
8
+ 'Picking one node of a hierarchy to show its details beside the tree',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Site or app navigation — use SideNav (a rail advertises destinations; a tree browses data)',
12
+ 'A flat list of choices — use List or RadioGroup',
13
+ 'Choosing several nodes at once — not supported (single selection)',
14
+ ],
15
+ anatomy: {
16
+ required: ['Root', 'Item'],
17
+ },
18
+ relatedTo: [
19
+ {
20
+ component: 'SideNav',
21
+ kind: 'contrast',
22
+ relationship:
23
+ 'SideNav is navigation between pages; Tree browses arbitrary-depth data. Never nest SideNav to fake a tree',
24
+ },
25
+ {
26
+ component: 'split-pane',
27
+ kind: 'composition',
28
+ relationship:
29
+ 'A Tree in the list column of the split-pane recipe, the selection’s details beside it',
30
+ },
31
+ ],
32
+ example: `<Tree.Root aria-label="Blocs" defaultExpanded={['energy']} onValueChange={setSelected}>
33
+ <Tree.Item value="energy" label="Core business — Energy">
34
+ <Tree.Item value="generation" label="Generation" meta="4 apps" />
35
+ <Tree.Item value="network" label="Network operations">
36
+ <Tree.Item value="scada" label="SCADA supervision" />
37
+ </Tree.Item>
38
+ </Tree.Item>
39
+ <Tree.Item value="finance" label="Finance & steering" />
40
+ </Tree.Root>`,
41
+ }
@@ -0,0 +1,151 @@
1
+ import {useState} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import type {Meta, StoryObj} from '@storybook/react'
4
+ import {expect} from 'storybook/test'
5
+ import {Tree} from './Tree'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/Tree',
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ function Landscape(props: {
15
+ defaultExpanded?: string[]
16
+ onValueChange?: (v: string) => void
17
+ value?: string | null
18
+ }) {
19
+ return (
20
+ <Tree.Root
21
+ aria-label="Blocs"
22
+ defaultExpanded={props.defaultExpanded}
23
+ onValueChange={props.onValueChange}
24
+ value={props.value}
25
+ >
26
+ <Tree.Item value="energy" label="Core business — Energy" meta="3 zones">
27
+ <Tree.Item value="generation" label="Generation" meta="4 apps" />
28
+ <Tree.Item value="network" label="Network operations">
29
+ <Tree.Item value="scada" label="SCADA supervision" />
30
+ <Tree.Item value="outage" label="Outage management" />
31
+ </Tree.Item>
32
+ <Tree.Item value="metering" label="Metering & data" />
33
+ </Tree.Item>
34
+ <Tree.Item value="market" label="Market & field">
35
+ <Tree.Item value="trading" label="Trading" />
36
+ </Tree.Item>
37
+ <Tree.Item value="finance" label="Finance & steering" />
38
+ </Tree.Root>
39
+ )
40
+ }
41
+
42
+ export const Default: Story = {
43
+ render: () => <Landscape defaultExpanded={['energy']} />,
44
+ play: async ({canvas, userEvent}) => {
45
+ const tree = canvas.getByRole('tree', {name: 'Blocs'})
46
+ await expect(tree).toBeInTheDocument()
47
+
48
+ const energy = canvas.getByRole('treeitem', {name: /Core business/})
49
+ await expect(energy).toHaveAttribute('aria-expanded', 'true')
50
+ await expect(energy).toHaveAttribute('aria-level', '1')
51
+ const generation = canvas.getByRole('treeitem', {name: /Generation/})
52
+ await expect(generation).toHaveAttribute('aria-level', '2')
53
+ // a leaf has no expanded state at all
54
+ await expect(generation).not.toHaveAttribute('aria-expanded')
55
+ // a closed branch renders no children
56
+ await expect(canvas.queryByRole('treeitem', {name: /SCADA/})).toBeNull()
57
+
58
+ // one tab stop: the first item
59
+ await expect(energy).toHaveAttribute('tabindex', '0')
60
+ await expect(generation).toHaveAttribute('tabindex', '-1')
61
+
62
+ // a click selects the row
63
+ await userEvent.click(canvas.getByText('Generation'))
64
+ await expect(generation).toHaveAttribute('aria-selected', 'true')
65
+ await expect(energy).toHaveAttribute('aria-selected', 'false')
66
+ },
67
+ }
68
+
69
+ export const KeyboardNavigation: Story = {
70
+ render: () => <Landscape />,
71
+ play: async ({canvas, userEvent}) => {
72
+ const item = (name: RegExp) => canvas.getByRole('treeitem', {name})
73
+ await userEvent.tab()
74
+ await expect(item(/Core business/)).toHaveFocus()
75
+
76
+ // → opens the branch, → again goes to its first child
77
+ await userEvent.keyboard('{ArrowRight}')
78
+ await expect(item(/Core business/)).toHaveAttribute('aria-expanded', 'true')
79
+ await expect(item(/Core business/)).toHaveFocus()
80
+ await userEvent.keyboard('{ArrowRight}')
81
+ await expect(item(/Generation/)).toHaveFocus()
82
+
83
+ // ↓ walks the visible items; ↓ onto a closed branch, → opens it
84
+ await userEvent.keyboard('{ArrowDown}')
85
+ await expect(item(/Network operations/)).toHaveFocus()
86
+ await userEvent.keyboard('{ArrowRight}{ArrowDown}')
87
+ await expect(item(/SCADA/)).toHaveFocus()
88
+ await expect(item(/SCADA/)).toHaveAttribute('aria-level', '3')
89
+
90
+ // ← on a leaf goes to the parent; ← on an open branch closes it
91
+ await userEvent.keyboard('{ArrowLeft}')
92
+ await expect(item(/Network operations/)).toHaveFocus()
93
+ await userEvent.keyboard('{ArrowLeft}')
94
+ await expect(item(/Network operations/)).toHaveAttribute('aria-expanded', 'false')
95
+ await expect(canvas.queryByRole('treeitem', {name: /SCADA/})).toBeNull()
96
+
97
+ // Home / End
98
+ await userEvent.keyboard('{End}')
99
+ await expect(item(/Finance/)).toHaveFocus()
100
+ await userEvent.keyboard('{Home}')
101
+ await expect(item(/Core business/)).toHaveFocus()
102
+
103
+ // Enter selects; the tab stop follows focus
104
+ await userEvent.keyboard('{ArrowDown}{Enter}')
105
+ await expect(item(/Generation/)).toHaveAttribute('aria-selected', 'true')
106
+ await expect(item(/Generation/)).toHaveAttribute('tabindex', '0')
107
+ await expect(item(/Core business/)).toHaveAttribute('tabindex', '-1')
108
+ },
109
+ }
110
+
111
+ export const Typeahead: Story = {
112
+ render: () => <Landscape defaultExpanded={['energy']} />,
113
+ play: async ({canvas, userEvent}) => {
114
+ const item = (name: RegExp) => canvas.getByRole('treeitem', {name})
115
+ await userEvent.tab()
116
+ // a letter jumps to the next visible item starting with it
117
+ await userEvent.keyboard('m')
118
+ await expect(item(/Metering/)).toHaveFocus()
119
+ // the same letter again cycles to the next match
120
+ await userEvent.keyboard('m')
121
+ await expect(item(/Market/)).toHaveFocus()
122
+ // a pause starts a new search; a prefix typed in one go narrows
123
+ await new Promise((r) => setTimeout(r, 600))
124
+ await userEvent.keyboard('fi')
125
+ await expect(item(/Finance/)).toHaveFocus()
126
+ },
127
+ }
128
+
129
+ function ControlledTree() {
130
+ const [value, setValue] = useState<string | null>('trading')
131
+ return (
132
+ <>
133
+ <Landscape defaultExpanded={['market']} value={value} onValueChange={setValue} />
134
+ <html.span role="status" aria-label="Selected">
135
+ {value}
136
+ </html.span>
137
+ </>
138
+ )
139
+ }
140
+
141
+ export const Controlled: Story = {
142
+ render: () => <ControlledTree />,
143
+ play: async ({canvas, userEvent}) => {
144
+ const trading = canvas.getByRole('treeitem', {name: /Trading/})
145
+ await expect(trading).toHaveAttribute('aria-selected', 'true')
146
+ // the selection holds the tab stop
147
+ await expect(trading).toHaveAttribute('tabindex', '0')
148
+ await userEvent.click(canvas.getByText('Finance & steering'))
149
+ await expect(canvas.getByLabelText('Selected')).toHaveTextContent('finance')
150
+ },
151
+ }
@@ -0,0 +1,304 @@
1
+ import {
2
+ Children,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useId,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react'
12
+ import {html} from 'react-strict-dom'
13
+ import {styles} from './styles.css'
14
+ import {TreeContext, TreeLevelContext, useTree} from './TreeContext'
15
+
16
+ /* Tree — a hierarchy of items (the WAI-ARIA tree pattern): single selection,
17
+ * expandable branches, one tab stop (roving tabindex), and the keyboard of a
18
+ * file explorer — ↑/↓ move between visible items, → opens a branch then goes
19
+ * to its first child, ← closes it then goes to the parent, Home/End, Enter
20
+ * or Space select, and typing jumps to the next item whose label starts with
21
+ * the typed letters. */
22
+
23
+ // --- Root ---
24
+
25
+ interface RootProps {
26
+ children: ReactNode
27
+ /** accessible name of the tree (or give `aria-labelledby`) */
28
+ 'aria-label'?: string
29
+ 'aria-labelledby'?: string
30
+ /** the selected item (controlled) */
31
+ value?: string | null
32
+ defaultValue?: string | null
33
+ onValueChange?: (value: string) => void
34
+ /** the open branches (controlled) */
35
+ expanded?: ReadonlyArray<string>
36
+ defaultExpanded?: ReadonlyArray<string>
37
+ onExpandedChange?: (expanded: string[]) => void
38
+ }
39
+
40
+ const TYPEAHEAD_RESET_MS = 500
41
+
42
+ function Root({
43
+ children,
44
+ 'aria-label': ariaLabel,
45
+ 'aria-labelledby': ariaLabelledBy,
46
+ value,
47
+ defaultValue = null,
48
+ onValueChange,
49
+ expanded,
50
+ defaultExpanded = [],
51
+ onExpandedChange,
52
+ }: RootProps) {
53
+ const [selectedInner, setSelectedInner] = useState<string | null>(defaultValue)
54
+ const selectedValue = value !== undefined ? value : selectedInner
55
+ const [expandedInner, setExpandedInner] = useState<ReadonlyArray<string>>(defaultExpanded)
56
+ const open = expanded ?? expandedInner
57
+ const [focusValue, setFocusValue] = useState<string | null>(selectedValue)
58
+ // the ring shows on the focused ROW (the item wraps its children, so a
59
+ // ring on it would circle the whole branch), only for keyboard focus —
60
+ // the tree's own :focus-visible
61
+ const [keyboardFocus, setKeyboardFocus] = useState(false)
62
+ const rootRef = useRef<HTMLUListElement>(null)
63
+
64
+ const onSelect = useCallback(
65
+ (v: string) => {
66
+ if (value === undefined) setSelectedInner(v)
67
+ onValueChange?.(v)
68
+ },
69
+ [value, onValueChange],
70
+ )
71
+ const setExpanded = useCallback(
72
+ (v: string, isOpen: boolean) => {
73
+ const next = isOpen ? [...new Set([...open, v])] : open.filter((x) => x !== v)
74
+ if (next.length === open.length && next.every((x, i) => x === open[i])) return
75
+ if (expanded === undefined) setExpandedInner(next)
76
+ onExpandedChange?.(next)
77
+ },
78
+ [open, expanded, onExpandedChange],
79
+ )
80
+ const isExpanded = useCallback((v: string) => open.includes(v), [open])
81
+ const toggle = useCallback((v: string) => setExpanded(v, !open.includes(v)), [open, setExpanded])
82
+
83
+ // Exactly one item is tabbable: the focus holder, else the selection, else
84
+ // the first item — re-checked when an item appears, closes or goes away.
85
+ useLayoutEffect(() => {
86
+ const root = rootRef.current
87
+ if (!root) return
88
+ const tabbable = root.querySelector('[role="treeitem"][tabindex="0"]')
89
+ if (tabbable) return
90
+ const first = root.querySelector<HTMLElement>('[role="treeitem"]')
91
+ const next = first?.dataset.treeValue ?? null
92
+ if (next !== focusValue) setFocusValue(next)
93
+ }, [focusValue, open, children])
94
+
95
+ // Keyboard: DOM order over the rendered items IS the visible order (a
96
+ // closed branch renders no children), so navigation reads it directly.
97
+ const typeahead = useRef({text: '', at: 0})
98
+ useEffect(() => {
99
+ const root = rootRef.current
100
+ if (!root) return
101
+ const rootEl = root
102
+ const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
103
+ const focusItem = (el: HTMLElement | null | undefined) => {
104
+ if (!el) return
105
+ el.focus()
106
+ }
107
+ function onKeyDown(e: KeyboardEvent) {
108
+ const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
109
+ if (!current || !rootEl.contains(current)) return
110
+ const list = items()
111
+ const i = list.indexOf(current)
112
+ const val = current.dataset.treeValue ?? ''
113
+ const isOpen = current.getAttribute('aria-expanded')
114
+ switch (e.key) {
115
+ case 'ArrowDown':
116
+ e.preventDefault()
117
+ focusItem(list[i + 1])
118
+ return
119
+ case 'ArrowUp':
120
+ e.preventDefault()
121
+ focusItem(list[i - 1])
122
+ return
123
+ case 'ArrowRight':
124
+ e.preventDefault()
125
+ if (isOpen === 'false') setExpanded(val, true)
126
+ else if (isOpen === 'true') focusItem(list[i + 1])
127
+ return
128
+ case 'ArrowLeft': {
129
+ e.preventDefault()
130
+ if (isOpen === 'true') {
131
+ setExpanded(val, false)
132
+ return
133
+ }
134
+ const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
135
+ if (parent && rootEl.contains(parent)) focusItem(parent)
136
+ return
137
+ }
138
+ case 'Home':
139
+ e.preventDefault()
140
+ focusItem(list[0])
141
+ return
142
+ case 'End':
143
+ e.preventDefault()
144
+ focusItem(list[list.length - 1])
145
+ return
146
+ case 'Enter':
147
+ case ' ':
148
+ e.preventDefault()
149
+ onSelect(val)
150
+ return
151
+ }
152
+ // typeahead: a printable character, no modifier
153
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
154
+ const now = Date.now()
155
+ const t = typeahead.current
156
+ t.text = now - t.at > TYPEAHEAD_RESET_MS ? e.key : t.text + e.key
157
+ t.at = now
158
+ // the same letter typed again cycles through the items it starts;
159
+ // a real prefix ("fi") narrows, and may match the current item
160
+ const typed = t.text.toLocaleLowerCase()
161
+ const repeat = [...typed].every((c) => c === typed[0])
162
+ const needle = repeat ? typed[0] : typed
163
+ const start = repeat ? i + 1 : i
164
+ for (let k = 0; k < list.length; k++) {
165
+ const el = list[(start + k) % list.length]
166
+ if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
167
+ focusItem(el)
168
+ break
169
+ }
170
+ }
171
+ }
172
+ }
173
+ // the roving tab stop follows focus, however it moved (keys or pointer)
174
+ function onFocusIn(e: FocusEvent) {
175
+ const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
176
+ const v = item?.dataset.treeValue
177
+ if (v) setFocusValue(v)
178
+ setKeyboardFocus(item?.matches(':focus-visible') ?? false)
179
+ }
180
+ function onFocusOut(e: FocusEvent) {
181
+ if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
182
+ }
183
+ const onPointerDown = () => setKeyboardFocus(false)
184
+ const onKey = () => setKeyboardFocus(true)
185
+ root.addEventListener('keydown', onKeyDown)
186
+ root.addEventListener('keydown', onKey)
187
+ root.addEventListener('focusin', onFocusIn)
188
+ root.addEventListener('focusout', onFocusOut)
189
+ root.addEventListener('pointerdown', onPointerDown)
190
+ return () => {
191
+ root.removeEventListener('keydown', onKeyDown)
192
+ root.removeEventListener('keydown', onKey)
193
+ root.removeEventListener('focusin', onFocusIn)
194
+ root.removeEventListener('focusout', onFocusOut)
195
+ root.removeEventListener('pointerdown', onPointerDown)
196
+ }
197
+ }, [onSelect, setExpanded])
198
+
199
+ return (
200
+ <TreeContext.Provider
201
+ value={{
202
+ selectedValue,
203
+ onSelect,
204
+ isExpanded,
205
+ toggle,
206
+ setExpanded,
207
+ focusValue,
208
+ setFocusValue,
209
+ keyboardFocus,
210
+ }}
211
+ >
212
+ <TreeLevelContext.Provider value={1}>
213
+ <html.ul
214
+ ref={rootRef}
215
+ role="tree"
216
+ aria-label={ariaLabel}
217
+ aria-labelledby={ariaLabelledBy}
218
+ style={styles.root}
219
+ >
220
+ {children}
221
+ </html.ul>
222
+ </TreeLevelContext.Provider>
223
+ </TreeContext.Provider>
224
+ )
225
+ }
226
+
227
+ // --- Item ---
228
+
229
+ interface ItemProps {
230
+ /** unique within the tree */
231
+ value: string
232
+ /** what the row shows */
233
+ label: ReactNode
234
+ /** plain text for typeahead when `label` isn't a string */
235
+ textValue?: string
236
+ /** nested items: the branch's children (none = a leaf) */
237
+ children?: ReactNode
238
+ /** trailing content on the row (a count, a status) */
239
+ meta?: ReactNode
240
+ }
241
+
242
+ function Item({value, label, textValue, children, meta}: ItemProps) {
243
+ const {selectedValue, onSelect, isExpanded, toggle, focusValue, keyboardFocus} = useTree()
244
+ const level = useContext(TreeLevelContext)
245
+ const hasChildren = Children.count(children) > 0
246
+ const open = hasChildren && isExpanded(value)
247
+ const selected = selectedValue === value
248
+ const text = textValue ?? (typeof label === 'string' ? label : '')
249
+ // named by its own label only: a branch's content includes its children,
250
+ // and a name computed from content would read the whole subtree
251
+ const labelId = useId()
252
+
253
+ return (
254
+ <html.li
255
+ role="treeitem"
256
+ aria-labelledby={labelId}
257
+ aria-level={level}
258
+ aria-expanded={hasChildren ? open : undefined}
259
+ aria-selected={selected}
260
+ tabIndex={focusValue === value ? 0 : -1}
261
+ data-tree-value={value}
262
+ data-tree-text={text}
263
+ style={styles.item}
264
+ >
265
+ <html.div
266
+ onClick={() => onSelect(value)}
267
+ style={[
268
+ styles.row,
269
+ styles.indent(level),
270
+ selected && styles.rowSelected,
271
+ keyboardFocus && focusValue === value && styles.rowFocused,
272
+ ]}
273
+ >
274
+ <html.span
275
+ aria-hidden
276
+ onClick={(e: {stopPropagation: () => void}) => {
277
+ if (!hasChildren) return
278
+ e.stopPropagation()
279
+ toggle(value)
280
+ }}
281
+ style={[styles.chevron, open && styles.chevronOpen, !hasChildren && styles.chevronLeaf]}
282
+ >
283
+ ▸
284
+ </html.span>
285
+ <html.span id={labelId} style={styles.label}>
286
+ {label}
287
+ </html.span>
288
+ {meta != null ? <html.span style={styles.meta}>{meta}</html.span> : null}
289
+ </html.div>
290
+ {open ? (
291
+ <TreeLevelContext.Provider value={level + 1}>
292
+ <html.ul role="group" style={styles.group}>
293
+ {children}
294
+ </html.ul>
295
+ </TreeLevelContext.Provider>
296
+ ) : null}
297
+ </html.li>
298
+ )
299
+ }
300
+
301
+ export const Tree = {
302
+ Root,
303
+ Item,
304
+ }
@@ -0,0 +1,26 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export interface TreeContextValue {
4
+ /** the selected item's value (single selection), or null */
5
+ selectedValue: string | null
6
+ onSelect: (value: string) => void
7
+ isExpanded: (value: string) => boolean
8
+ toggle: (value: string) => void
9
+ setExpanded: (value: string, expanded: boolean) => void
10
+ /** the item holding the tree's single tab stop (roving tabindex) */
11
+ focusValue: string | null
12
+ setFocusValue: (value: string) => void
13
+ /** the focus holder is focused from the keyboard (draws its ring) */
14
+ keyboardFocus: boolean
15
+ }
16
+
17
+ export const TreeContext = createContext<TreeContextValue | null>(null)
18
+
19
+ export function useTree() {
20
+ const ctx = useContext(TreeContext)
21
+ if (!ctx) throw new Error('Tree compound components must be used within Tree.Root')
22
+ return ctx
23
+ }
24
+
25
+ /** The nesting depth of the items rendered here (1 = top level). */
26
+ export const TreeLevelContext = createContext(1)
@@ -0,0 +1,96 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
+
7
+ export const styles = css.create({
8
+ root: {
9
+ display: 'flex',
10
+ flexDirection: 'column',
11
+ margin: 0,
12
+ padding: 0,
13
+ listStyleType: 'none',
14
+ },
15
+ // The `role="group"` holding a branch's children: purely structural.
16
+ group: {
17
+ display: 'flex',
18
+ flexDirection: 'column',
19
+ margin: 0,
20
+ padding: 0,
21
+ listStyleType: 'none',
22
+ },
23
+ // The treeitem carries focus; its row draws the ring (the item also wraps
24
+ // its children, so a ring on it would circle the whole branch).
25
+ item: {
26
+ display: 'flex',
27
+ flexDirection: 'column',
28
+ outlineWidth: 0,
29
+ outlineStyle: 'none',
30
+ },
31
+ row: {
32
+ display: 'flex',
33
+ alignItems: 'center',
34
+ gap: spacing.xs,
35
+ paddingTop: '5px',
36
+ paddingBottom: '5px',
37
+ paddingRight: spacing.sm,
38
+ fontFamily: typography.fontFamily,
39
+ fontSize: typography.fontSizeSm,
40
+ color: colors.text,
41
+ borderRadius: radii.sm,
42
+ cursor: 'pointer',
43
+ backgroundColor: {
44
+ default: 'transparent',
45
+ ':hover': colors.bgCardHover,
46
+ },
47
+ transitionProperty: 'background-color',
48
+ transitionDuration: duration.fast,
49
+ transitionTimingFunction: easing.standard,
50
+ },
51
+ // one indent step per level below the top
52
+ indent: (level: number) => ({
53
+ paddingLeft: `calc(${spacing.sm} + ${Math.max(0, level - 1)} * ${spacing.lg})`,
54
+ }),
55
+ rowFocused: {
56
+ outlineWidth: 2,
57
+ outlineStyle: 'solid',
58
+ outlineColor: colors.accent,
59
+ outlineOffset: -2,
60
+ },
61
+ rowSelected: {
62
+ color: colors.accent,
63
+ fontWeight: typography.fontWeightMedium,
64
+ backgroundColor: colors.bgCardHover,
65
+ },
66
+ chevron: {
67
+ display: 'inline-flex',
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ width: spacing.md,
71
+ flexShrink: 0,
72
+ color: colors.textMuted,
73
+ transitionProperty: 'transform',
74
+ transitionDuration: duration.fast,
75
+ transitionTimingFunction: easing.standard,
76
+ },
77
+ chevronOpen: {
78
+ transform: 'rotate(90deg)',
79
+ },
80
+ // a leaf keeps the chevron's width (labels align) but shows nothing
81
+ chevronLeaf: {
82
+ visibility: 'hidden',
83
+ },
84
+ label: {
85
+ flexGrow: 1,
86
+ minWidth: 0,
87
+ overflow: 'hidden',
88
+ textOverflow: 'ellipsis',
89
+ whiteSpace: 'nowrap',
90
+ },
91
+ meta: {
92
+ flexShrink: 0,
93
+ fontSize: typography.fontSizeXs,
94
+ color: colors.textMuted,
95
+ },
96
+ })
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ export {TagGroup} from './components/TagGroup/TagGroup'
74
74
  // from there gives the same object with those attached.
75
75
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
76
76
  export {Tabs} from './components/Tabs/Tabs'
77
+ export {Tree} from './components/Tree/Tree'
77
78
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
78
79
  export {
79
80
  ThemeProvider,