@duro-app/ui 3.5.0 → 3.7.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-Cgr5VDEZ.js} +430 -417
- package/dist/Table-Cgr5VDEZ.js.map +1 -0
- package/dist/components/Input/Input.d.ts +6 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.stories.d.ts +1 -0
- package/dist/components/Input/Input.stories.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.stories.d.ts +1 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts +6 -1
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.stories.d.ts +1 -0
- package/dist/components/Textarea/Textarea.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 +2742 -2439
- package/dist/index.js.map +1 -1
- package/dist/shared/devWarnOnce.d.ts +3 -0
- package/dist/shared/devWarnOnce.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Input/Input.stories.tsx +16 -0
- package/src/components/Input/Input.tsx +11 -0
- package/src/components/Menu/Menu.meta.ts +8 -5
- package/src/components/Menu/Menu.stories.tsx +27 -1
- package/src/components/Menu/Menu.tsx +17 -0
- package/src/components/Table/Table.tsx +2 -13
- package/src/components/Textarea/Textarea.stories.tsx +16 -0
- package/src/components/Textarea/Textarea.tsx +11 -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/src/shared/devWarnOnce.ts +15 -0
- package/dist/Table-P-UHkEqJ.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"devWarnOnce.d.ts","sourceRoot":"","sources":["../../src/shared/devWarnOnce.ts"],"names":[],"mappings":"AAOA,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAM9E"}
|
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-Cgr5VDEZ.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.7.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.7.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -39,6 +39,22 @@ export const Default: Story = {
|
|
|
39
39
|
},
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export const KeyboardEvents: Story = {
|
|
43
|
+
args: {placeholder: 'Search commands', onFocus: fn(), onKeyDown: fn(), onKeyUp: fn()},
|
|
44
|
+
play: async ({args, canvas, userEvent}) => {
|
|
45
|
+
const input = canvas.getByPlaceholderText('Search commands')
|
|
46
|
+
await userEvent.click(input)
|
|
47
|
+
await expect(args.onFocus).toHaveBeenCalledTimes(1)
|
|
48
|
+
|
|
49
|
+
await userEvent.keyboard('{ArrowDown}{Enter}{Escape}')
|
|
50
|
+
const keys = (args.onKeyDown as ReturnType<typeof fn>).mock.calls.map(
|
|
51
|
+
([e]) => (e as React.KeyboardEvent).key,
|
|
52
|
+
)
|
|
53
|
+
await expect(keys).toEqual(['ArrowDown', 'Enter', 'Escape'])
|
|
54
|
+
await expect(args.onKeyUp).toHaveBeenCalledTimes(3)
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
export const Error: Story = {
|
|
43
59
|
args: {variant: 'error', placeholder: 'Invalid input'},
|
|
44
60
|
play: async ({canvas}) => {
|
|
@@ -51,6 +51,11 @@ interface InputProps {
|
|
|
51
51
|
'aria-describedby'?: string
|
|
52
52
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
53
53
|
onBlur?: () => void
|
|
54
|
+
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void
|
|
55
|
+
/** Keyboard handlers — e.g. Enter to commit, Escape to cancel, arrow-key
|
|
56
|
+
* navigation of a list driven by this field. */
|
|
57
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
|
58
|
+
onKeyUp?: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
|
54
59
|
ref?: React.Ref<HTMLInputElement>
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -80,6 +85,9 @@ export function Input({
|
|
|
80
85
|
'aria-describedby': ariaDescribedby,
|
|
81
86
|
onChange,
|
|
82
87
|
onBlur,
|
|
88
|
+
onFocus,
|
|
89
|
+
onKeyDown,
|
|
90
|
+
onKeyUp,
|
|
83
91
|
ref,
|
|
84
92
|
}: InputProps) {
|
|
85
93
|
const ctx = useFieldContext()
|
|
@@ -129,6 +137,9 @@ export function Input({
|
|
|
129
137
|
aria-invalid={ctx?.invalid || variant === 'error' || undefined}
|
|
130
138
|
onChange={effectiveOnChange as StrictInputProps['onChange']}
|
|
131
139
|
onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
|
|
140
|
+
onFocus={onFocus as StrictInputProps['onFocus']}
|
|
141
|
+
onKeyDown={onKeyDown as StrictInputProps['onKeyDown']}
|
|
142
|
+
onKeyUp={onKeyUp as StrictInputProps['onKeyUp']}
|
|
132
143
|
ref={effectiveRef as React.Ref<HTMLInputElement>}
|
|
133
144
|
style={[
|
|
134
145
|
styles.base,
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it).',
|
|
5
|
+
'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Context menu or "more actions" dropdown',
|
|
8
8
|
'Navigation links in a dropdown',
|
|
@@ -19,12 +19,15 @@ export const meta: ComponentMeta = {
|
|
|
19
19
|
kind: 'contrast',
|
|
20
20
|
relationship: 'Select picks values; Menu triggers actions',
|
|
21
21
|
},
|
|
22
|
-
{
|
|
22
|
+
{
|
|
23
|
+
component: 'Button',
|
|
24
|
+
kind: 'contrast',
|
|
25
|
+
relationship:
|
|
26
|
+
'Button runs one action; Menu opens a list of them. Menu.Trigger is already a button — never put a Button inside it',
|
|
27
|
+
},
|
|
23
28
|
],
|
|
24
29
|
example: `<Menu.Root>
|
|
25
|
-
<Menu.Trigger>
|
|
26
|
-
<Button variant="secondary">Actions</Button>
|
|
27
|
-
</Menu.Trigger>
|
|
30
|
+
<Menu.Trigger>Actions</Menu.Trigger>
|
|
28
31
|
<Menu.Popup>
|
|
29
32
|
<Menu.Item onClick={() => console.log('edit')}>Edit</Menu.Item>
|
|
30
33
|
<Menu.Item onClick={() => console.log('duplicate')}>Duplicate</Menu.Item>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, fn} from 'storybook/test'
|
|
2
|
+
import {expect, fn, spyOn} from 'storybook/test'
|
|
3
3
|
import {Menu} from './Menu'
|
|
4
|
+
import {Button} from '../Button/Button'
|
|
4
5
|
|
|
5
6
|
const meta: Meta = {
|
|
6
7
|
title: 'Components/Menu',
|
|
@@ -144,3 +145,28 @@ export const WithLinks: Story = {
|
|
|
144
145
|
await expect(items[0]).toHaveAttribute('href', '#admin')
|
|
145
146
|
},
|
|
146
147
|
}
|
|
148
|
+
|
|
149
|
+
// Misuse on purpose, so it runs as a test but stays out of the sidebar:
|
|
150
|
+
// Menu.Trigger is the button, and a Button inside it earns a dev warning.
|
|
151
|
+
export const NestedControlWarns: Story = {
|
|
152
|
+
tags: ['!dev', '!autodocs'],
|
|
153
|
+
beforeEach: () => {
|
|
154
|
+
const warn = spyOn(console, 'warn').mockImplementation(() => {})
|
|
155
|
+
return () => warn.mockRestore()
|
|
156
|
+
},
|
|
157
|
+
render: () => (
|
|
158
|
+
<Menu.Root>
|
|
159
|
+
<Menu.Trigger>
|
|
160
|
+
<Button variant="secondary">Nested</Button>
|
|
161
|
+
</Menu.Trigger>
|
|
162
|
+
<Menu.Popup>
|
|
163
|
+
<Menu.Item>Edit</Menu.Item>
|
|
164
|
+
</Menu.Popup>
|
|
165
|
+
</Menu.Root>
|
|
166
|
+
),
|
|
167
|
+
play: async () => {
|
|
168
|
+
await expect(console.warn).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringContaining('Menu.Trigger is itself the button'),
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
|
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {MenuContext, useMenu} from './MenuContext'
|
|
5
5
|
import {useMenuRoot} from './useMenuRoot'
|
|
6
|
+
import {devWarnOnce} from '../../shared/devWarnOnce'
|
|
6
7
|
|
|
7
8
|
// --- Root ---
|
|
8
9
|
interface RootProps {
|
|
@@ -22,6 +23,11 @@ function Root({children}: RootProps) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
// --- Trigger ---
|
|
26
|
+
// Trigger IS the button: aria-haspopup / aria-expanded / aria-controls have to
|
|
27
|
+
// sit on the focusable element, so it cannot be a wrapper like Dialog.Trigger.
|
|
28
|
+
// Its children are the label (text, an Icon) — never a Button or other control.
|
|
29
|
+
const NESTED_CONTROL = 'button, a[href], input, select, textarea, [role="button"]'
|
|
30
|
+
|
|
25
31
|
function Trigger({children}: {children: ReactNode}) {
|
|
26
32
|
const {open, toggle, menuId, triggerRef} = useMenu()
|
|
27
33
|
const localRef = useRef<HTMLButtonElement>(null)
|
|
@@ -31,6 +37,17 @@ function Trigger({children}: {children: ReactNode}) {
|
|
|
31
37
|
triggerRef.current = localRef.current
|
|
32
38
|
})
|
|
33
39
|
|
|
40
|
+
// TypeScript can't stop a <Button> child; the DOM can tell.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (localRef.current?.querySelector(NESTED_CONTROL)) {
|
|
43
|
+
devWarnOnce(
|
|
44
|
+
'Menu',
|
|
45
|
+
'trigger-nested-control',
|
|
46
|
+
'Menu.Trigger is itself the button — give it the label (text, an Icon), not a Button, link or other control. A control inside a <button> is invalid HTML: it breaks hydration and hands assistive tech a button inside a button.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
34
51
|
return (
|
|
35
52
|
<html.button
|
|
36
53
|
ref={localRef}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from 'react'
|
|
13
13
|
import {html} from 'react-strict-dom'
|
|
14
14
|
import {styles} from './styles.css'
|
|
15
|
+
import {devWarnOnce} from '../../shared/devWarnOnce'
|
|
15
16
|
|
|
16
17
|
// --- Types ---
|
|
17
18
|
|
|
@@ -43,19 +44,6 @@ function useTable() {
|
|
|
43
44
|
|
|
44
45
|
const HeaderContext = createContext(false)
|
|
45
46
|
|
|
46
|
-
// --- Dev-only warn registry ---
|
|
47
|
-
//
|
|
48
|
-
// One-shot per warning code per process: long-running dev sessions
|
|
49
|
-
// shouldn't drown the console on every re-render of a misconfigured table.
|
|
50
|
-
const _IS_PROD = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
|
|
51
|
-
const _devWarned = new Set<string>()
|
|
52
|
-
function devWarnOnce(code: string, message: string) {
|
|
53
|
-
if (_IS_PROD || _devWarned.has(code)) return
|
|
54
|
-
_devWarned.add(code)
|
|
55
|
-
|
|
56
|
-
console.warn(`[duro-app/ui Table] ${message}`)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
47
|
// --- Container — owns the @container query target ---
|
|
60
48
|
//
|
|
61
49
|
// @deprecated Root sets up its own container query and accepts `sortChip`
|
|
@@ -148,6 +136,7 @@ function extractColumnMeta(
|
|
|
148
136
|
// Children are JSX with no string content — stack mode will render
|
|
149
137
|
// an unlabeled cell. Tell the developer once.
|
|
150
138
|
devWarnOnce(
|
|
139
|
+
'Table',
|
|
151
140
|
'headerCell-missing-label',
|
|
152
141
|
'Table.HeaderCell with JSX children must set `label` for stack-mode rendering.',
|
|
153
142
|
)
|
|
@@ -36,6 +36,22 @@ export const Default: Story = {
|
|
|
36
36
|
},
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export const KeyboardEvents: Story = {
|
|
40
|
+
args: {placeholder: 'Edit note', onFocus: fn(), onKeyDown: fn(), onKeyUp: fn()},
|
|
41
|
+
play: async ({args, canvas, userEvent}) => {
|
|
42
|
+
const textarea = canvas.getByPlaceholderText('Edit note')
|
|
43
|
+
await userEvent.click(textarea)
|
|
44
|
+
await expect(args.onFocus).toHaveBeenCalledTimes(1)
|
|
45
|
+
|
|
46
|
+
await userEvent.keyboard('{ArrowDown}{Enter}{Escape}')
|
|
47
|
+
const keys = (args.onKeyDown as ReturnType<typeof fn>).mock.calls.map(
|
|
48
|
+
([e]) => (e as React.KeyboardEvent).key,
|
|
49
|
+
)
|
|
50
|
+
await expect(keys).toEqual(['ArrowDown', 'Enter', 'Escape'])
|
|
51
|
+
await expect(args.onKeyUp).toHaveBeenCalledTimes(3)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
export const Error: Story = {
|
|
40
56
|
args: {variant: 'error', placeholder: 'Invalid content'},
|
|
41
57
|
play: async ({canvas}) => {
|
|
@@ -18,6 +18,11 @@ interface TextareaProps {
|
|
|
18
18
|
autoFocus?: boolean
|
|
19
19
|
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
|
20
20
|
onBlur?: () => void
|
|
21
|
+
onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void
|
|
22
|
+
/** Keyboard handlers — e.g. Enter to commit, Escape to cancel, arrow-key
|
|
23
|
+
* navigation of a list driven by this field. */
|
|
24
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
25
|
+
onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
21
26
|
ref?: React.Ref<HTMLTextAreaElement>
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -33,6 +38,9 @@ export function Textarea({
|
|
|
33
38
|
autoFocus,
|
|
34
39
|
onChange,
|
|
35
40
|
onBlur,
|
|
41
|
+
onFocus,
|
|
42
|
+
onKeyDown,
|
|
43
|
+
onKeyUp,
|
|
36
44
|
ref,
|
|
37
45
|
}: TextareaProps) {
|
|
38
46
|
const ctx = useFieldContext()
|
|
@@ -65,6 +73,9 @@ export function Textarea({
|
|
|
65
73
|
aria-invalid={ctx?.invalid || variant === 'error' || undefined}
|
|
66
74
|
onChange={effectiveOnChange as StrictTextareaProps['onChange']}
|
|
67
75
|
onBlur={effectiveOnBlur as StrictTextareaProps['onBlur']}
|
|
76
|
+
onFocus={onFocus as StrictTextareaProps['onFocus']}
|
|
77
|
+
onKeyDown={onKeyDown as StrictTextareaProps['onKeyDown']}
|
|
78
|
+
onKeyUp={onKeyUp as StrictTextareaProps['onKeyUp']}
|
|
68
79
|
ref={effectiveRef as React.Ref<HTMLTextAreaElement>}
|
|
69
80
|
style={[styles.base, styles[effectiveVariant]]}
|
|
70
81
|
{...extraProps}
|
|
@@ -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
|
+
}
|