@duro-app/ui 3.4.2 → 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-P-UHkEqJ.js → Table-B4F8XyqK.js} +222 -216
- package/dist/{Table-P-UHkEqJ.js.map → Table-B4F8XyqK.js.map} +1 -1
- package/dist/components/Icon/Icon.d.ts +1 -1
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
- package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
- package/dist/components/Tree/Tree.d.ts +35 -0
- package/dist/components/Tree/Tree.d.ts.map +1 -0
- package/dist/components/Tree/Tree.meta.d.ts +3 -0
- package/dist/components/Tree/Tree.meta.d.ts.map +1 -0
- package/dist/components/Tree/Tree.stories.d.ts +9 -0
- package/dist/components/Tree/Tree.stories.d.ts.map +1 -0
- package/dist/components/Tree/TreeContext.d.ts +18 -0
- package/dist/components/Tree/TreeContext.d.ts.map +1 -0
- package/dist/components/Tree/styles.css.d.ts +84 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2525 -2215
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Icon/Icon.tsx +20 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +21 -0
- package/src/components/Tree/Tree.meta.ts +41 -0
- package/src/components/Tree/Tree.stories.tsx +151 -0
- package/src/components/Tree/Tree.tsx +304 -0
- package/src/components/Tree/TreeContext.ts +26 -0
- package/src/components/Tree/styles.css.ts +96 -0
- package/src/index.ts +1 -0
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-
|
|
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.
|
|
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.
|
|
67
|
+
"@duro-app/tokens": "^3.6.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -37,6 +37,9 @@ export type IconName =
|
|
|
37
37
|
| 'mail'
|
|
38
38
|
| 'file-text'
|
|
39
39
|
| 'plug'
|
|
40
|
+
// Input / action glyphs
|
|
41
|
+
| 'search'
|
|
42
|
+
| 'mic'
|
|
40
43
|
// Color-mode glyphs
|
|
41
44
|
| 'sun'
|
|
42
45
|
| 'moon'
|
|
@@ -264,6 +267,23 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
|
|
|
264
267
|
<path d="M9 10.76V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6.76a2 2 0 0 0 .55 1.38l1.68 1.78A1 1 0 0 1 16.5 16h-9a1 1 0 0 1-.73-1.68l1.68-1.78A2 2 0 0 0 9 10.76z" />
|
|
265
268
|
</>
|
|
266
269
|
),
|
|
270
|
+
// ---- input / action ----
|
|
271
|
+
// Magnifying glass — search fields, find actions.
|
|
272
|
+
search: (
|
|
273
|
+
<>
|
|
274
|
+
<circle cx="11" cy="11" r="8" />
|
|
275
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
276
|
+
</>
|
|
277
|
+
),
|
|
278
|
+
// Microphone — voice input, dictation.
|
|
279
|
+
mic: (
|
|
280
|
+
<>
|
|
281
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
|
282
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
283
|
+
<line x1="12" y1="19" x2="12" y2="23" />
|
|
284
|
+
<line x1="8" y1="23" x2="16" y2="23" />
|
|
285
|
+
</>
|
|
286
|
+
),
|
|
267
287
|
// ---- color mode ----
|
|
268
288
|
sun: (
|
|
269
289
|
<>
|
|
@@ -3,6 +3,7 @@ import {expect, fn} from 'storybook/test'
|
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {InputGroup} from './InputGroup'
|
|
5
5
|
import {Input} from '../Input/Input'
|
|
6
|
+
import {Icon} from '../Icon/Icon'
|
|
6
7
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
7
8
|
|
|
8
9
|
const meta: Meta = {
|
|
@@ -64,6 +65,26 @@ export const BothSides: Story = {
|
|
|
64
65
|
},
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
export const SearchField: Story = {
|
|
69
|
+
render: () => (
|
|
70
|
+
<html.div style={layoutStyles.container}>
|
|
71
|
+
<InputGroup.Root>
|
|
72
|
+
<InputGroup.Addon position="start">
|
|
73
|
+
<Icon name="search" size="sm" />
|
|
74
|
+
</InputGroup.Addon>
|
|
75
|
+
<Input type="search" name="q" aria-label="Search" placeholder="Search" />
|
|
76
|
+
<InputGroup.Addon>
|
|
77
|
+
<Icon name="mic" size="sm" />
|
|
78
|
+
</InputGroup.Addon>
|
|
79
|
+
</InputGroup.Root>
|
|
80
|
+
</html.div>
|
|
81
|
+
),
|
|
82
|
+
play: async ({canvas}) => {
|
|
83
|
+
const input = canvas.getByRole('searchbox', {name: 'Search'})
|
|
84
|
+
await expect(input).toHaveAttribute('name', 'q')
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
export const ClickableAddon: Story = {
|
|
68
89
|
render: () => (
|
|
69
90
|
<html.div style={layoutStyles.container}>
|
|
@@ -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)
|