@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.
Files changed (50) hide show
  1. package/dist/{Table-P-UHkEqJ.js → Table-Cgr5VDEZ.js} +430 -417
  2. package/dist/Table-Cgr5VDEZ.js.map +1 -0
  3. package/dist/components/Input/Input.d.ts +6 -1
  4. package/dist/components/Input/Input.d.ts.map +1 -1
  5. package/dist/components/Input/Input.stories.d.ts +1 -0
  6. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  7. package/dist/components/Menu/Menu.d.ts.map +1 -1
  8. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  9. package/dist/components/Menu/Menu.stories.d.ts +1 -0
  10. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  11. package/dist/components/Table/Table.d.ts.map +1 -1
  12. package/dist/components/Textarea/Textarea.d.ts +6 -1
  13. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  14. package/dist/components/Textarea/Textarea.stories.d.ts +1 -0
  15. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  16. package/dist/components/Tree/Tree.d.ts +35 -0
  17. package/dist/components/Tree/Tree.d.ts.map +1 -0
  18. package/dist/components/Tree/Tree.meta.d.ts +3 -0
  19. package/dist/components/Tree/Tree.meta.d.ts.map +1 -0
  20. package/dist/components/Tree/Tree.stories.d.ts +9 -0
  21. package/dist/components/Tree/Tree.stories.d.ts.map +1 -0
  22. package/dist/components/Tree/TreeContext.d.ts +18 -0
  23. package/dist/components/Tree/TreeContext.d.ts.map +1 -0
  24. package/dist/components/Tree/styles.css.d.ts +84 -0
  25. package/dist/components/Tree/styles.css.d.ts.map +1 -0
  26. package/dist/index.css +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2742 -2439
  30. package/dist/index.js.map +1 -1
  31. package/dist/shared/devWarnOnce.d.ts +3 -0
  32. package/dist/shared/devWarnOnce.d.ts.map +1 -0
  33. package/dist/table.js +1 -1
  34. package/package.json +2 -2
  35. package/src/components/Input/Input.stories.tsx +16 -0
  36. package/src/components/Input/Input.tsx +11 -0
  37. package/src/components/Menu/Menu.meta.ts +8 -5
  38. package/src/components/Menu/Menu.stories.tsx +27 -1
  39. package/src/components/Menu/Menu.tsx +17 -0
  40. package/src/components/Table/Table.tsx +2 -13
  41. package/src/components/Textarea/Textarea.stories.tsx +16 -0
  42. package/src/components/Textarea/Textarea.tsx +11 -0
  43. package/src/components/Tree/Tree.meta.ts +41 -0
  44. package/src/components/Tree/Tree.stories.tsx +151 -0
  45. package/src/components/Tree/Tree.tsx +304 -0
  46. package/src/components/Tree/TreeContext.ts +26 -0
  47. package/src/components/Tree/styles.css.ts +96 -0
  48. package/src/index.ts +1 -0
  49. package/src/shared/devWarnOnce.ts +15 -0
  50. package/dist/Table-P-UHkEqJ.js.map +0 -1
@@ -0,0 +1,3 @@
1
+ /** Warn once, outside production, as `[duro-app/ui <scope>] <message>`. */
2
+ export declare function devWarnOnce(scope: string, code: string, message: string): void;
3
+ //# sourceMappingURL=devWarnOnce.d.ts.map
@@ -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-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-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.5.0",
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.5.0"
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
- {component: 'Button', kind: 'composition', relationship: 'Often used as Menu.Trigger content'},
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
+ }