@neo4j-ndl/react 4.21.2 → 4.22.1
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/lib/cjs/_common/form-control/FormFieldLabel.js +52 -0
- package/lib/cjs/_common/form-control/FormFieldLabel.js.map +1 -0
- package/lib/cjs/_common/form-control/FormFieldMessage.js +72 -0
- package/lib/cjs/_common/form-control/FormFieldMessage.js.map +1 -0
- package/lib/cjs/_common/form-control/form-control-types.js +23 -0
- package/lib/cjs/_common/form-control/form-control-types.js.map +1 -0
- package/lib/cjs/_common/form-control/index.js +39 -0
- package/lib/cjs/_common/form-control/index.js.map +1 -0
- package/lib/cjs/_common/form-control/use-form-field.js +76 -0
- package/lib/cjs/_common/form-control/use-form-field.js.map +1 -0
- package/lib/cjs/_common/form-control/use-inline-field-label.js +76 -0
- package/lib/cjs/_common/form-control/use-inline-field-label.js.map +1 -0
- package/lib/cjs/checkbox/Checkbox.js +9 -21
- package/lib/cjs/checkbox/Checkbox.js.map +1 -1
- package/lib/cjs/combobox/Combobox.js +61 -45
- package/lib/cjs/combobox/Combobox.js.map +1 -1
- package/lib/cjs/combobox/combobox-types.js.map +1 -1
- package/lib/cjs/date-picker/DatePicker.js.map +1 -1
- package/lib/cjs/dropzone/stories/dropzone-full.story.js +5 -5
- package/lib/cjs/dropzone/stories/dropzone-full.story.js.map +1 -1
- package/lib/cjs/helpers/index.js +1 -0
- package/lib/cjs/helpers/index.js.map +1 -1
- package/lib/cjs/helpers/platform.js +59 -0
- package/lib/cjs/helpers/platform.js.map +1 -0
- package/lib/cjs/kbd/kbd-utils.js +6 -22
- package/lib/cjs/kbd/kbd-utils.js.map +1 -1
- package/lib/cjs/next/tree-view/TreeView.js +384 -66
- package/lib/cjs/next/tree-view/TreeView.js.map +1 -1
- package/lib/cjs/next/tree-view/selection.js +181 -0
- package/lib/cjs/next/tree-view/selection.js.map +1 -0
- package/lib/cjs/next/tree-view/stories/index.js +13 -1
- package/lib/cjs/next/tree-view/stories/index.js.map +1 -1
- package/lib/cjs/next/tree-view/stories/tree-view-large.story.js +57 -0
- package/lib/cjs/next/tree-view/stories/tree-view-large.story.js.map +1 -0
- package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js +1 -0
- package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
- package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js +16 -40
- package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
- package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js +38 -0
- package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
- package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
- package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
- package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js +125 -0
- package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
- package/lib/cjs/radio/Radio.js +12 -22
- package/lib/cjs/radio/Radio.js.map +1 -1
- package/lib/cjs/select/Select.js +14 -5
- package/lib/cjs/select/Select.js.map +1 -1
- package/lib/cjs/select/types.js.map +1 -1
- package/lib/cjs/switch/Switch.js +9 -21
- package/lib/cjs/switch/Switch.js.map +1 -1
- package/lib/cjs/text-area/TextArea.js +8 -21
- package/lib/cjs/text-area/TextArea.js.map +1 -1
- package/lib/cjs/text-input/TextInput.js +15 -38
- package/lib/cjs/text-input/TextInput.js.map +1 -1
- package/lib/cjs/text-input/stories/text-input-sizes.story.js +1 -1
- package/lib/cjs/text-input/stories/text-input-sizes.story.js.map +1 -1
- package/lib/cjs/time-picker/TimePicker.js +13 -7
- package/lib/cjs/time-picker/TimePicker.js.map +1 -1
- package/lib/cjs/timezone-picker/TimeZonePicker.js +13 -7
- package/lib/cjs/timezone-picker/TimeZonePicker.js.map +1 -1
- package/lib/cjs/toast/ToastControlled.js +1 -1
- package/lib/cjs/toast/ToastControlled.js.map +1 -1
- package/lib/esm/_common/form-control/FormFieldLabel.js +45 -0
- package/lib/esm/_common/form-control/FormFieldLabel.js.map +1 -0
- package/lib/esm/_common/form-control/FormFieldMessage.js +65 -0
- package/lib/esm/_common/form-control/FormFieldMessage.js.map +1 -0
- package/lib/esm/_common/form-control/form-control-types.js +22 -0
- package/lib/esm/_common/form-control/form-control-types.js.map +1 -0
- package/lib/esm/_common/form-control/index.js +32 -0
- package/lib/esm/_common/form-control/index.js.map +1 -0
- package/lib/esm/_common/form-control/use-form-field.js +72 -0
- package/lib/esm/_common/form-control/use-form-field.js.map +1 -0
- package/lib/esm/_common/form-control/use-inline-field-label.js +69 -0
- package/lib/esm/_common/form-control/use-inline-field-label.js.map +1 -0
- package/lib/esm/checkbox/Checkbox.js +10 -22
- package/lib/esm/checkbox/Checkbox.js.map +1 -1
- package/lib/esm/combobox/Combobox.js +62 -46
- package/lib/esm/combobox/Combobox.js.map +1 -1
- package/lib/esm/combobox/combobox-types.js.map +1 -1
- package/lib/esm/date-picker/DatePicker.js.map +1 -1
- package/lib/esm/dropzone/stories/dropzone-full.story.js +5 -5
- package/lib/esm/dropzone/stories/dropzone-full.story.js.map +1 -1
- package/lib/esm/helpers/index.js +1 -0
- package/lib/esm/helpers/index.js.map +1 -1
- package/lib/esm/helpers/platform.js +55 -0
- package/lib/esm/helpers/platform.js.map +1 -0
- package/lib/esm/kbd/kbd-utils.js +2 -18
- package/lib/esm/kbd/kbd-utils.js.map +1 -1
- package/lib/esm/next/tree-view/TreeView.js +385 -67
- package/lib/esm/next/tree-view/TreeView.js.map +1 -1
- package/lib/esm/next/tree-view/selection.js +175 -0
- package/lib/esm/next/tree-view/selection.js.map +1 -0
- package/lib/esm/next/tree-view/stories/index.js +9 -0
- package/lib/esm/next/tree-view/stories/index.js.map +1 -1
- package/lib/esm/next/tree-view/stories/tree-view-large.story.js +55 -0
- package/lib/esm/next/tree-view/stories/tree-view-large.story.js.map +1 -0
- package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js +1 -0
- package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
- package/lib/esm/next/tree-view/stories/tree-view-multi.story.js +16 -40
- package/lib/esm/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
- package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js +36 -0
- package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
- package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
- package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
- package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js +123 -0
- package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
- package/lib/esm/radio/Radio.js +12 -22
- package/lib/esm/radio/Radio.js.map +1 -1
- package/lib/esm/select/Select.js +14 -5
- package/lib/esm/select/Select.js.map +1 -1
- package/lib/esm/select/types.js.map +1 -1
- package/lib/esm/switch/Switch.js +10 -22
- package/lib/esm/switch/Switch.js.map +1 -1
- package/lib/esm/text-area/TextArea.js +9 -22
- package/lib/esm/text-area/TextArea.js.map +1 -1
- package/lib/esm/text-input/TextInput.js +15 -38
- package/lib/esm/text-input/TextInput.js.map +1 -1
- package/lib/esm/text-input/stories/text-input-sizes.story.js +1 -1
- package/lib/esm/text-input/stories/text-input-sizes.story.js.map +1 -1
- package/lib/esm/time-picker/TimePicker.js +13 -7
- package/lib/esm/time-picker/TimePicker.js.map +1 -1
- package/lib/esm/timezone-picker/TimeZonePicker.js +13 -7
- package/lib/esm/timezone-picker/TimeZonePicker.js.map +1 -1
- package/lib/esm/toast/ToastControlled.js +1 -1
- package/lib/esm/toast/ToastControlled.js.map +1 -1
- package/lib/types/_common/form-control/FormFieldLabel.d.ts +58 -0
- package/lib/types/_common/form-control/FormFieldLabel.d.ts.map +1 -0
- package/lib/types/_common/form-control/FormFieldMessage.d.ts +46 -0
- package/lib/types/_common/form-control/FormFieldMessage.d.ts.map +1 -0
- package/lib/types/_common/form-control/form-control-types.d.ts +92 -0
- package/lib/types/_common/form-control/form-control-types.d.ts.map +1 -0
- package/lib/types/_common/form-control/index.d.ts +33 -0
- package/lib/types/_common/form-control/index.d.ts.map +1 -0
- package/lib/types/_common/form-control/use-form-field.d.ts +53 -0
- package/lib/types/_common/form-control/use-form-field.d.ts.map +1 -0
- package/lib/types/_common/form-control/use-inline-field-label.d.ts +69 -0
- package/lib/types/_common/form-control/use-inline-field-label.d.ts.map +1 -0
- package/lib/types/checkbox/Checkbox.d.ts.map +1 -1
- package/lib/types/combobox/Combobox.d.ts.map +1 -1
- package/lib/types/combobox/combobox-types.d.ts +1 -1
- package/lib/types/combobox/combobox-types.d.ts.map +1 -1
- package/lib/types/helpers/index.d.ts +1 -0
- package/lib/types/helpers/index.d.ts.map +1 -1
- package/lib/types/helpers/platform.d.ts +29 -0
- package/lib/types/helpers/platform.d.ts.map +1 -0
- package/lib/types/kbd/kbd-utils.d.ts +2 -5
- package/lib/types/kbd/kbd-utils.d.ts.map +1 -1
- package/lib/types/next/tree-view/TreeView.d.ts +29 -5
- package/lib/types/next/tree-view/TreeView.d.ts.map +1 -1
- package/lib/types/next/tree-view/selection.d.ts +78 -0
- package/lib/types/next/tree-view/selection.d.ts.map +1 -0
- package/lib/types/next/tree-view/stories/index.d.ts +6 -0
- package/lib/types/next/tree-view/stories/index.d.ts.map +1 -1
- package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts +24 -0
- package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts.map +1 -0
- package/lib/types/next/tree-view/stories/tree-view-lazy.story.d.ts.map +1 -1
- package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts +10 -0
- package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts.map +1 -1
- package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts +29 -0
- package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts.map +1 -0
- package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts +5 -0
- package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts.map +1 -1
- package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts +24 -0
- package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts.map +1 -0
- package/lib/types/radio/Radio.d.ts.map +1 -1
- package/lib/types/select/Select.d.ts.map +1 -1
- package/lib/types/select/types.d.ts.map +1 -1
- package/lib/types/switch/Switch.d.ts.map +1 -1
- package/lib/types/text-area/TextArea.d.ts.map +1 -1
- package/lib/types/text-input/TextInput.d.ts.map +1 -1
- package/lib/types/text-input/stories/text-input-sizes.story.d.ts.map +1 -1
- package/lib/types/time-picker/TimePicker.d.ts +3 -1
- package/lib/types/time-picker/TimePicker.d.ts.map +1 -1
- package/lib/types/timezone-picker/TimeZonePicker.d.ts +4 -1
- package/lib/types/timezone-picker/TimeZonePicker.d.ts.map +1 -1
- package/package.json +2 -2
- package/skills/ndl-react/SKILL.md +1 -1
- package/skills/ndl-react/components/color-picker.md +32 -32
- package/skills/ndl-react/components/combobox.md +2 -2
- package/skills/ndl-react/components/dropzone.md +5 -5
- package/skills/ndl-react/components/next/tree-view.md +444 -104
- package/skills/ndl-react/components/text-input.md +6 -0
- package/skills/ndl-react/components/time-picker.md +1 -0
- package/skills/ndl-react/components/timezone-picker.md +1 -0
|
@@ -11,8 +11,23 @@ Import: `import { TreeView } from '@neo4j-ndl/react/next'`
|
|
|
11
11
|
| `ariaLabel` | `string \| null` | ✅ | | The aria-label for the tree. Required for accessibility, unless using ariaLabelledby. Pass null to omit. |
|
|
12
12
|
| `ariaLabelledby` | `string` | | | The aria-labelledby for the tree. Pass a string of space-separated IDs of elements that label the tree. |
|
|
13
13
|
| `children` | `ReactNode` | | | The children of the tree. Should be TreeView.Item components or TreeView.SkeletonItem components only. |
|
|
14
|
+
| `defaultSelectedIds` | `string[]` | | | Initially selected item ids for an uncontrolled root-managed selection. |
|
|
15
|
+
| `onSelectedIdsChange` | `((selectedIds: string[]) => void)` | | | Callback called with the complete next selection whenever the tree manages selection. Fires once per interaction. |
|
|
14
16
|
| `ref` | `Ref<HTMLDivElement>` | | | A ref to apply to the root element. |
|
|
15
|
-
| `
|
|
17
|
+
| `selectedIds` | `string[]` | | | Selected item ids. Makes the tree manage selection itself, instead of each item managing its own. |
|
|
18
|
+
| `selectionMode` | `'multiple-replace' \| 'multiple' \| 'single'` | | `single` | Selection mode for the tree. Defaults to "single". |
|
|
19
|
+
|
|
20
|
+
Selection can be managed per item, with `isSelected` and `onSelectedChange`
|
|
21
|
+
on each `TreeView.Item`, or by the tree itself, by passing `selectedIds` (or
|
|
22
|
+
`defaultSelectedIds`) together with `onSelectedIdsChange`. When the tree
|
|
23
|
+
manages selection, every item needs an `id`, and each item's own
|
|
24
|
+
`isSelected` and `onSelectedChange` are ignored.
|
|
25
|
+
|
|
26
|
+
The available selection modes are:
|
|
27
|
+
|
|
28
|
+
- `single` — one item at a time. An unmodified click selects the clicked item and never clears it.
|
|
29
|
+
- `multiple` — multi-select with checkboxes. An unmodified click toggles an item, and `Shift` repeats that across a range without clearing anything outside it.
|
|
30
|
+
- `multiple-replace` — multi-select without checkboxes. An unmodified click selects only that item, `Cmd`/`Ctrl` toggles it and moves the anchor, and `Shift` extends from that anchor while retaining its selection snapshot. `Shift` + arrow extends the range.
|
|
16
31
|
|
|
17
32
|
### TreeView.Item
|
|
18
33
|
|
|
@@ -23,14 +38,16 @@ Import: `import { TreeView } from '@neo4j-ndl/react/next'`
|
|
|
23
38
|
| `children` | `ReactNode` | | | Nested TreeView.Item or TreeView.SkeletonItem elements rendered as sub-items in a collapsible group. |
|
|
24
39
|
| `defaultExpanded` | `boolean` | | `false` | Default expansion for uncontrolled items |
|
|
25
40
|
| `hasChildren` | `boolean` | | | Whether the item has children. Decides if the item should render a chevron for expansion. Needed for lazy loading. |
|
|
41
|
+
| `id` | `string` | | | Identifies the item. Required when the tree manages selection via `selectedIds`. |
|
|
26
42
|
| `isDisabled` | `boolean` | | | Whether the item is disabled |
|
|
27
43
|
| `isExpanded` | `boolean` | | | Whether the item is expanded. Makes the item controlled. |
|
|
28
44
|
| `isIndeterminate` | `boolean` | | | Whether the item is in an indeterminate state. Only meaningful in multi-select mode for parent nodes. |
|
|
29
45
|
| `isLoading` | `boolean` | | | Whether the item is loading. Applies aria-busy="true" to the item. |
|
|
30
|
-
| `
|
|
46
|
+
| `isSelectable` | `boolean` | | | Whether the item can be selected. Defaults to true when the tree manages selection, and otherwise to whether `onSelectedChange` is set. |
|
|
47
|
+
| `isSelected` | `boolean` | | | Whether the item is selected. Ignored when the tree manages selection via `selectedIds`. |
|
|
31
48
|
| `leadingVisual` | `ReactNode` | | | Leading visual for the item. |
|
|
32
49
|
| `onExpandedChange` | `(isExpanded: boolean) => void` | | | Callback called when the item is expanded/collapsed. |
|
|
33
|
-
| `onSelectedChange` | `(isSelected: boolean) => void` | | | Callback called when the item is selected/deselected. |
|
|
50
|
+
| `onSelectedChange` | `(isSelected: boolean) => void` | | | Callback called when the item is selected/deselected. Receives the new selected state. Ignored when the tree manages selection via `selectedIds`. |
|
|
34
51
|
| `ref` | `Ref<HTMLDivElement>` | | | A ref to apply to the root element. |
|
|
35
52
|
| `title` | `ReactNode` | | | The label content displayed in the item row. |
|
|
36
53
|
| `tooltipContent` | `ReactNode` | | | Content rendered inside the tooltip. When provided, the item is wrapped in a Tooltip. |
|
|
@@ -58,21 +75,53 @@ Implements the keyboard interactions defined in the [WAI-ARIA TreeView pattern](
|
|
|
58
75
|
| `ArrowLeft` | Collapses an expanded parent item. If already collapsed (or a leaf), moves focus to the parent item |
|
|
59
76
|
| `Home` | Moves focus to the first tree item |
|
|
60
77
|
| `End` | Moves focus to the last visible tree item |
|
|
61
|
-
| `Enter` |
|
|
62
|
-
| `Space` |
|
|
78
|
+
| `Enter` | Selects the focused item, or toggles expansion if the item is not selectable |
|
|
79
|
+
| `Space` | Selects the focused item, or toggles expansion if the item is not selectable |
|
|
63
80
|
| `Shift + F10` | Opens the action menu on the focused item (when `actionMenu` is provided) |
|
|
81
|
+
| `Cmd/Ctrl + Enter`, `Cmd/Ctrl + Space` | `multiple-replace` only: toggles the focused item without clearing the rest |
|
|
82
|
+
| `Shift + Enter`, `Shift + Space` | Both multiple modes: extends the range from the anchor to the focused item |
|
|
83
|
+
| `Shift + ArrowUp`, `Shift + ArrowDown` | Both multiple modes: moves focus and extends the range to the newly focused item |
|
|
64
84
|
|
|
65
85
|
The tree uses a roving tabindex strategy: only the currently focused item has `tabIndex={0}`, all other items have `tabIndex={-1}`. When the tree root receives focus it delegates to the previously focused item, or the first item if none was focused.
|
|
66
86
|
|
|
87
|
+
The toggle modifier is `Cmd` on Apple platforms and `Ctrl` everywhere else, so `Ctrl` + click keeps its macOS meaning of opening the context menu.
|
|
88
|
+
|
|
89
|
+
### Selection in the multiple modes
|
|
90
|
+
|
|
91
|
+
Keyboard and pointer take the same modifiers and drive the same selection, so the two can be mixed freely. What each modifier means depends on the mode:
|
|
92
|
+
|
|
93
|
+
| Modifier | `multiple-replace` | `multiple` |
|
|
94
|
+
|----------|--------------------|------------|
|
|
95
|
+
| none | Selects only that item, clearing the rest | Toggles that item |
|
|
96
|
+
| `Cmd`/`Ctrl` | Toggles that item, leaving the rest alone | Nothing extra, since an unmodified click already toggles |
|
|
97
|
+
| `Shift` | Selects the range from the anchor and keeps the selection the anchor was placed in | Repeats the anchor's state across the range, leaving anything outside it alone |
|
|
98
|
+
| `Cmd`/`Ctrl` + `Shift` | Same as `Shift` alone | Same as `Shift` alone |
|
|
99
|
+
|
|
100
|
+
`Shift` + `ArrowUp`/`ArrowDown` moves focus and extends the range in one step, and `Shift` + `Enter`/`Space` extends it to the focused item.
|
|
101
|
+
|
|
102
|
+
Because `multiple` toggles, its ranges paint rather than replace: ticking an item and then `Shift`-clicking five rows down ticks all six, while unticking that item first unticks the same range. A keyboard range has no preceding click to repeat, so it selects outwards from the focused item.
|
|
103
|
+
|
|
104
|
+
Ranges run between an anchor and a target. Any unmodified or `Cmd`/`Ctrl` selection moves the anchor to that item; extending a range leaves it where it is, so a range can be grown and shrunk repeatedly from the same starting point. In `multiple-replace`, the anchor also remembers the selection immediately after it was placed. This means selecting several disconnected items with `Cmd`/`Ctrl`, then using `Shift`, keeps those earlier picks and starts the range at the most recently toggled item. Toggling that item off still moves the anchor there, and the later range selects outwards from it. If no anchor exists when a range starts, the currently focused item becomes one. If the anchor ends up inside a collapsed group, the range is measured from its nearest visible ancestor, so collapsing a group cannot silently shrink a range to a single item.
|
|
105
|
+
|
|
106
|
+
Ranges skip disabled items and never reach into collapsed groups. In `multiple-replace`, earlier picks from the anchor snapshot are retained only while visible; hidden selectable items are cleared, so collapsing a group never strands a selection the user can no longer see. `multiple` never clears outside the range, so nothing hidden is touched.
|
|
107
|
+
|
|
108
|
+
Note that in `multiple-replace` an unmodified `Enter`/`Space` selects exclusively rather than toggling, which departs from the WAI-ARIA multi-select tree pattern. It is deliberate: it keeps the keyboard and the pointer behaving identically, and `Cmd`/`Ctrl` + `Enter`/`Space` still covers additive selection. `multiple` toggles, as the pattern describes.
|
|
109
|
+
|
|
110
|
+
### Announcements
|
|
111
|
+
|
|
112
|
+
Range selections are announced in a polite live region rendered next to the tree, as `4 items selected`, counting the whole selection rather than the rows the range just moved. A range can move any number of rows at once, so nothing else reports the total. Selections made without `Shift` clear the region instead, since the row they moved already carries its own `aria-selected`/`aria-checked`, and a count left behind would stop describing the tree as soon as the next selection changed it. The message is English and not currently configurable.
|
|
113
|
+
|
|
67
114
|
## WAI-ARIA roles and attributes
|
|
68
115
|
|
|
69
116
|
The TreeView component follows the [WAI-ARIA TreeView pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
|
|
70
117
|
|
|
71
118
|
- The root container has `role="tree"` and is labeled via `aria-label` or `aria-labelledby`
|
|
72
|
-
- When `selectionMode` is `"multiple"`, the root sets `aria-multiselectable="true"`
|
|
119
|
+
- When `selectionMode` is `"multiple"` or `"multiple-replace"`, the root sets `aria-multiselectable="true"`
|
|
73
120
|
- Each item has `role="treeitem"` with `aria-level`, `aria-posinset`, and `aria-setsize` set automatically
|
|
74
121
|
- Parent items (items with children) set `aria-expanded` and `aria-owns` linking to their child `role="group"` container
|
|
75
|
-
- In single selection
|
|
122
|
+
- In single and `multiple-replace` selection modes, selected items set `aria-selected`. In multiple selection mode, selected items set `aria-checked`, with `aria-checked="mixed"` for indeterminate parent nodes
|
|
123
|
+
- Only selectable items expose `aria-selected`/`aria-checked`. An item is selectable when `isSelectable` says so, and otherwise when the tree manages selection or the item has an `onSelectedChange`. The rest are announced as non-selectable, which is what lets folders act as pure containers
|
|
124
|
+
- The live region carrying selection announcements has `role="status"` and `aria-live="polite"`. It sits beside the tree rather than inside it, since `role="tree"` only allows `treeitem` and `group` children
|
|
76
125
|
- Disabled items set `aria-disabled="true"`
|
|
77
126
|
- Loading items set `aria-busy="true"`
|
|
78
127
|
- When `actionMenu` is provided, the treeitem sets `aria-keyshortcuts="Shift+F10"` and the action button sets `aria-haspopup="menu"`, `aria-expanded`, and `aria-label="Actions"`
|
|
@@ -83,15 +132,19 @@ The TreeView component follows the [WAI-ARIA TreeView pattern](https://www.w3.or
|
|
|
83
132
|
|
|
84
133
|
- Always provide either `ariaLabel` or `ariaLabelledby` on the root so the tree has an accessible name
|
|
85
134
|
- `role="tree"` only allows children with roles `treeitem` or `group`. The built-in sub-components handle this automatically, but wrapping items in custom elements without a valid role will break the tree semantics for assistive technologies
|
|
86
|
-
- In single selection mode (`selectionMode="single"`, the default),
|
|
135
|
+
- In single selection mode (`selectionMode="single"`, the default), selecting always replaces and never deselects. When the tree manages selection through `selectedIds`, that is enforced for you. With per-item `isSelected`/`onSelectedChange` only the clicked item is told about the change, so keeping at most one item selected at a time stays the consumer's responsibility
|
|
87
136
|
- In multiple selection mode, manage `isIndeterminate` on parent nodes to communicate partial selection via `aria-checked="mixed"`. Screen reader users rely on this to understand that some but not all children are selected
|
|
137
|
+
- The multiple modes compute the whole selection for you. With per-item callbacks that means `onSelectedChange` fires on every item that changed, so one gesture can mean many callbacks — keep those state updates cheap. Passing `selectedIds` and `onSelectedIdsChange` on the root instead reports the next selection once per gesture, which is easier to reconcile when a range flips many items at once
|
|
138
|
+
- `isSelectable={false}` keeps an item out of selection entirely. Clicking it toggles its expansion instead, in every selection mode. When the tree manages selection through `selectedIds` every item is selectable by default, so folders that should stay pure containers have to opt out explicitly
|
|
139
|
+
- `multiple-replace` has no checkbox. Selection shows up only as `aria-selected` and the accent bar, so keep those two states distinguishable by more than colour
|
|
140
|
+
- Adjacent selected items merge into one rounded block. That is decorative only — each item still announces its own `aria-selected`
|
|
88
141
|
- When using `hasChildren` for lazy loading, set `isLoading` to apply `aria-busy="true"` and render `TreeView.SkeletonItem` inside the expanded item so users know content is being fetched
|
|
89
142
|
- The checkbox rendered in multiple selection mode has `tabIndex={-1}` — selection is driven from the treeitem via `Enter`/`Space`, not from the checkbox directly
|
|
90
143
|
- The action menu button has `tabIndex={-1}` and is only reachable via `Shift+F10`, keeping arrow-key navigation within the tree clean
|
|
91
144
|
|
|
92
145
|
### Related WCAG criteria
|
|
93
146
|
|
|
94
|
-
- [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) (A): All tree items are fully navigable and operable via keyboard
|
|
147
|
+
- [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) (A): All tree items are fully navigable and operable via keyboard, including range selection in both multiple modes via `Shift` + arrow or `Shift` + `Enter`/`Space`
|
|
95
148
|
- [2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) (A): Roving tabindex ensures a logical focus order; `ArrowLeft` returns focus to the parent item
|
|
96
149
|
- [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) (A): Tree hierarchy is expressed via `aria-level`, `aria-posinset`, `aria-setsize`, and `role="group"` nesting
|
|
97
150
|
- [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) (A): Roles (`tree`, `treeitem`, `group`), states (`aria-expanded`, `aria-selected`/`aria-checked`, `aria-disabled`, `aria-busy`), and accessible names (`aria-label`/`aria-labelledby`) are set automatically by the component
|
|
@@ -268,6 +321,87 @@ const Component = () => {
|
|
|
268
321
|
export default Component;
|
|
269
322
|
```
|
|
270
323
|
|
|
324
|
+
### Large
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';
|
|
328
|
+
|
|
329
|
+
import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
|
|
330
|
+
import { TreeView } from '@neo4j-ndl/react/next';
|
|
331
|
+
import { useState } from 'react';
|
|
332
|
+
|
|
333
|
+
type FileNode = {
|
|
334
|
+
children?: FileNode[];
|
|
335
|
+
id: string;
|
|
336
|
+
title: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const TOP_FOLDER_COUNT = 7;
|
|
340
|
+
const SUBFOLDER_COUNT = 9;
|
|
341
|
+
const FILE_COUNT = 10;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 7 top folders + 63 subfolders + 630 files = 700 items, all expanded so every
|
|
345
|
+
* row is mounted and visible. Exercises the worst-case performance paths:
|
|
346
|
+
* arrow-key navigation, Shift ranges, and the multiple-replace edge styling.
|
|
347
|
+
*/
|
|
348
|
+
const files: FileNode[] = Array.from(
|
|
349
|
+
{ length: TOP_FOLDER_COUNT },
|
|
350
|
+
(_unused, folderIndex) => ({
|
|
351
|
+
children: Array.from({ length: SUBFOLDER_COUNT }, (_unused, subIndex) => ({
|
|
352
|
+
children: Array.from({ length: FILE_COUNT }, (_unused, fileIndex) => ({
|
|
353
|
+
id: `file-${folderIndex}-${subIndex}-${fileIndex}`,
|
|
354
|
+
title: `file-${folderIndex}-${subIndex}-${fileIndex}.txt`,
|
|
355
|
+
})),
|
|
356
|
+
id: `subfolder-${folderIndex}-${subIndex}`,
|
|
357
|
+
title: `Subfolder ${folderIndex}.${subIndex}`,
|
|
358
|
+
})),
|
|
359
|
+
id: `folder-${folderIndex}`,
|
|
360
|
+
title: `Folder ${folderIndex}`,
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const Component = () => {
|
|
365
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
366
|
+
|
|
367
|
+
const renderNode = (node: FileNode) => (
|
|
368
|
+
<TreeView.Item
|
|
369
|
+
key={node.id}
|
|
370
|
+
id={node.id}
|
|
371
|
+
title={node.title}
|
|
372
|
+
defaultExpanded
|
|
373
|
+
leadingVisual={
|
|
374
|
+
node.children === undefined ? (
|
|
375
|
+
<DocumentIconOutline />
|
|
376
|
+
) : (
|
|
377
|
+
<FolderIconSolid />
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
>
|
|
381
|
+
{node.children?.map(renderNode)}
|
|
382
|
+
</TreeView.Item>
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<>
|
|
387
|
+
<p style={{ marginBottom: '8px' }}>
|
|
388
|
+
700 items, {selectedIds.length} selected
|
|
389
|
+
</p>
|
|
390
|
+
<TreeView
|
|
391
|
+
ariaLabel="Large file explorer"
|
|
392
|
+
selectionMode="multiple-replace"
|
|
393
|
+
selectedIds={selectedIds}
|
|
394
|
+
onSelectedIdsChange={setSelectedIds}
|
|
395
|
+
>
|
|
396
|
+
{files.map(renderNode)}
|
|
397
|
+
</TreeView>
|
|
398
|
+
</>
|
|
399
|
+
);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export default Component;
|
|
403
|
+
```
|
|
404
|
+
|
|
271
405
|
### Lazy
|
|
272
406
|
|
|
273
407
|
```tsx
|
|
@@ -450,7 +584,12 @@ function renderNode(node: TreeNode, ctx: RenderContext) {
|
|
|
450
584
|
{hasChildren && state.isLoading && !state.hasLoaded && (
|
|
451
585
|
<TreeView.SkeletonItem rows={2} />
|
|
452
586
|
)}
|
|
587
|
+
{/* Rendering children only while expanded keeps collapsed subtrees out
|
|
588
|
+
of the DOM, so large trees only pay for the rows the user can see.
|
|
589
|
+
All item state lives outside the tree (keyed by node id), so nothing
|
|
590
|
+
is lost when a subtree unmounts on collapse. */}
|
|
453
591
|
{hasChildren &&
|
|
592
|
+
state.isExpanded &&
|
|
454
593
|
state.hasLoaded &&
|
|
455
594
|
node.children!.map((child) => renderNode(child, ctx))}
|
|
456
595
|
</TreeView.Item>
|
|
@@ -489,75 +628,37 @@ import {
|
|
|
489
628
|
import { TreeView } from '@neo4j-ndl/react/next';
|
|
490
629
|
import { useCallback, useState } from 'react';
|
|
491
630
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
): { isChecked: boolean; isIndeterminate: boolean } {
|
|
503
|
-
const enabledKeys = childKeys.filter((key) => !DISABLED_KEYS.has(key));
|
|
504
|
-
if (enabledKeys.length === 0) {
|
|
505
|
-
return { isChecked: false, isIndeterminate: false };
|
|
506
|
-
}
|
|
507
|
-
const checkedCount = enabledKeys.filter((key) => checked[key]).length;
|
|
508
|
-
if (checkedCount === 0) {
|
|
509
|
-
return { isChecked: false, isIndeterminate: false };
|
|
510
|
-
}
|
|
511
|
-
if (checkedCount === enabledKeys.length) {
|
|
512
|
-
return { isChecked: true, isIndeterminate: false };
|
|
513
|
-
}
|
|
514
|
-
return { isChecked: false, isIndeterminate: true };
|
|
515
|
-
}
|
|
516
|
-
|
|
631
|
+
/**
|
|
632
|
+
* Every row owns its own checkbox: `onSelectedChange` reports the state the row
|
|
633
|
+
* should end up in, and the story stores exactly that. Folders are ordinary
|
|
634
|
+
* selectable rows here, unrelated to what sits inside them.
|
|
635
|
+
*
|
|
636
|
+
* Deriving a folder's state from its children instead needs the tree to own the
|
|
637
|
+
* selection, since a Shift range moves several rows at once and a cascade has
|
|
638
|
+
* to be applied to the whole set in one pass. See the root-managed selection
|
|
639
|
+
* story for that.
|
|
640
|
+
*/
|
|
517
641
|
const Component = () => {
|
|
518
642
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
519
|
-
const [
|
|
643
|
+
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
|
520
644
|
|
|
521
|
-
const
|
|
645
|
+
const toggleExpanded = (key: string) =>
|
|
522
646
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
523
647
|
|
|
524
|
-
const
|
|
525
|
-
|
|
648
|
+
const setRowSelected = useCallback((key: string, isSelected: boolean) => {
|
|
649
|
+
setSelected((prev) => ({ ...prev, [key]: isSelected }));
|
|
526
650
|
}, []);
|
|
527
651
|
|
|
528
|
-
const toggleParent = useCallback(
|
|
529
|
-
(childKeys: readonly string[]) => {
|
|
530
|
-
const enabledKeys = childKeys.filter((key) => !DISABLED_KEYS.has(key));
|
|
531
|
-
const isAllChecked = enabledKeys.every((key) => checked[key]);
|
|
532
|
-
setChecked((prev) => {
|
|
533
|
-
const next = { ...prev };
|
|
534
|
-
for (const key of enabledKeys) {
|
|
535
|
-
next[key] = !isAllChecked;
|
|
536
|
-
}
|
|
537
|
-
return next;
|
|
538
|
-
});
|
|
539
|
-
},
|
|
540
|
-
[checked],
|
|
541
|
-
);
|
|
542
|
-
|
|
543
|
-
const docState = getParentChecked(checked, [
|
|
544
|
-
...DOC_CHILDREN,
|
|
545
|
-
...WORK_CHILDREN,
|
|
546
|
-
]);
|
|
547
|
-
const workState = getParentChecked(checked, WORK_CHILDREN);
|
|
548
|
-
const imageState = getParentChecked(checked, IMAGE_CHILDREN);
|
|
549
|
-
|
|
550
652
|
return (
|
|
551
653
|
<TreeView ariaLabel="File explorer" selectionMode="multiple">
|
|
552
654
|
<TreeView.Item
|
|
553
655
|
title="Documents"
|
|
554
656
|
hasChildren
|
|
555
657
|
isExpanded={expanded['documents']}
|
|
556
|
-
onExpandedChange={() =>
|
|
557
|
-
isSelected={
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
toggleParent([...DOC_CHILDREN, ...WORK_CHILDREN])
|
|
658
|
+
onExpandedChange={() => toggleExpanded('documents')}
|
|
659
|
+
isSelected={selected['documents'] ?? false}
|
|
660
|
+
onSelectedChange={(isSelected) =>
|
|
661
|
+
setRowSelected('documents', isSelected)
|
|
561
662
|
}
|
|
562
663
|
leadingVisual={<FolderIconSolid />}
|
|
563
664
|
>
|
|
@@ -565,29 +666,35 @@ const Component = () => {
|
|
|
565
666
|
title="Work"
|
|
566
667
|
hasChildren
|
|
567
668
|
isExpanded={expanded['work']}
|
|
568
|
-
onExpandedChange={() =>
|
|
569
|
-
isSelected={
|
|
570
|
-
|
|
571
|
-
onSelectedChange={() => toggleParent(WORK_CHILDREN)}
|
|
669
|
+
onExpandedChange={() => toggleExpanded('work')}
|
|
670
|
+
isSelected={selected['work'] ?? false}
|
|
671
|
+
onSelectedChange={(isSelected) => setRowSelected('work', isSelected)}
|
|
572
672
|
leadingVisual={<FolderIconSolid />}
|
|
573
673
|
>
|
|
574
674
|
<TreeView.Item
|
|
575
675
|
title="Report.pdf"
|
|
576
|
-
isSelected={
|
|
577
|
-
onSelectedChange={() =>
|
|
676
|
+
isSelected={selected['report'] ?? false}
|
|
677
|
+
onSelectedChange={(isSelected) =>
|
|
678
|
+
setRowSelected('report', isSelected)
|
|
679
|
+
}
|
|
578
680
|
leadingVisual={<DocumentIconOutline />}
|
|
579
681
|
/>
|
|
580
682
|
<TreeView.Item
|
|
581
683
|
title="Presentation.pptx"
|
|
582
684
|
isDisabled
|
|
583
|
-
|
|
685
|
+
isSelected={selected['presentation'] ?? false}
|
|
686
|
+
onSelectedChange={(isSelected) =>
|
|
687
|
+
setRowSelected('presentation', isSelected)
|
|
688
|
+
}
|
|
584
689
|
leadingVisual={<DocumentIconOutline />}
|
|
585
690
|
/>
|
|
586
691
|
</TreeView.Item>
|
|
587
692
|
<TreeView.Item
|
|
588
693
|
title="Resume.pdf"
|
|
589
|
-
isSelected={
|
|
590
|
-
onSelectedChange={() =>
|
|
694
|
+
isSelected={selected['resume'] ?? false}
|
|
695
|
+
onSelectedChange={(isSelected) =>
|
|
696
|
+
setRowSelected('resume', isSelected)
|
|
697
|
+
}
|
|
591
698
|
leadingVisual={<DocumentIconOutline />}
|
|
592
699
|
/>
|
|
593
700
|
</TreeView.Item>
|
|
@@ -595,22 +702,107 @@ const Component = () => {
|
|
|
595
702
|
title="Images"
|
|
596
703
|
hasChildren
|
|
597
704
|
isExpanded={expanded['images']}
|
|
598
|
-
onExpandedChange={() =>
|
|
599
|
-
isSelected={
|
|
600
|
-
|
|
601
|
-
onSelectedChange={() => toggleParent(IMAGE_CHILDREN)}
|
|
705
|
+
onExpandedChange={() => toggleExpanded('images')}
|
|
706
|
+
isSelected={selected['images'] ?? false}
|
|
707
|
+
onSelectedChange={(isSelected) => setRowSelected('images', isSelected)}
|
|
602
708
|
leadingVisual={<FolderIconSolid />}
|
|
603
709
|
>
|
|
604
710
|
<TreeView.Item
|
|
605
711
|
title="Photo.jpg"
|
|
606
|
-
isSelected={
|
|
607
|
-
onSelectedChange={() =>
|
|
712
|
+
isSelected={selected['photo'] ?? false}
|
|
713
|
+
onSelectedChange={(isSelected) => setRowSelected('photo', isSelected)}
|
|
608
714
|
leadingVisual={<PhotoIconOutline />}
|
|
609
715
|
/>
|
|
610
716
|
<TreeView.Item
|
|
611
717
|
title="Screenshot.png"
|
|
612
|
-
isSelected={
|
|
613
|
-
onSelectedChange={() =>
|
|
718
|
+
isSelected={selected['screenshot'] ?? false}
|
|
719
|
+
onSelectedChange={(isSelected) =>
|
|
720
|
+
setRowSelected('screenshot', isSelected)
|
|
721
|
+
}
|
|
722
|
+
leadingVisual={<PhotoIconOutline />}
|
|
723
|
+
/>
|
|
724
|
+
</TreeView.Item>
|
|
725
|
+
</TreeView>
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
export default Component;
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Multiple Replace
|
|
733
|
+
|
|
734
|
+
```tsx
|
|
735
|
+
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';
|
|
736
|
+
|
|
737
|
+
import {
|
|
738
|
+
DocumentIconOutline,
|
|
739
|
+
FolderIconSolid,
|
|
740
|
+
PhotoIconOutline,
|
|
741
|
+
} from '@neo4j-ndl/react/icons';
|
|
742
|
+
import { TreeView } from '@neo4j-ndl/react/next';
|
|
743
|
+
import { useState } from 'react';
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Letting the tree own the selection keeps the items declarative: they carry an
|
|
747
|
+
* `id` and nothing else, and a Shift range that moves several rows at once
|
|
748
|
+
* arrives as a single `onSelectedIdsChange`.
|
|
749
|
+
*/
|
|
750
|
+
const Component = () => {
|
|
751
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
752
|
+
|
|
753
|
+
return (
|
|
754
|
+
<TreeView
|
|
755
|
+
ariaLabel="File explorer"
|
|
756
|
+
selectionMode="multiple-replace"
|
|
757
|
+
selectedIds={selectedIds}
|
|
758
|
+
onSelectedIdsChange={setSelectedIds}
|
|
759
|
+
>
|
|
760
|
+
<TreeView.Item
|
|
761
|
+
id="documents"
|
|
762
|
+
title="Documents"
|
|
763
|
+
hasChildren
|
|
764
|
+
defaultExpanded
|
|
765
|
+
leadingVisual={<FolderIconSolid />}
|
|
766
|
+
>
|
|
767
|
+
<TreeView.Item
|
|
768
|
+
id="work"
|
|
769
|
+
title="Work"
|
|
770
|
+
hasChildren
|
|
771
|
+
defaultExpanded
|
|
772
|
+
leadingVisual={<FolderIconSolid />}
|
|
773
|
+
>
|
|
774
|
+
<TreeView.Item
|
|
775
|
+
id="report"
|
|
776
|
+
title="Report.pdf"
|
|
777
|
+
isDisabled
|
|
778
|
+
leadingVisual={<DocumentIconOutline />}
|
|
779
|
+
/>
|
|
780
|
+
<TreeView.Item
|
|
781
|
+
id="presentation"
|
|
782
|
+
title="Presentation.pptx"
|
|
783
|
+
leadingVisual={<DocumentIconOutline />}
|
|
784
|
+
/>
|
|
785
|
+
</TreeView.Item>
|
|
786
|
+
<TreeView.Item
|
|
787
|
+
id="resume"
|
|
788
|
+
title="Resume.pdf"
|
|
789
|
+
leadingVisual={<DocumentIconOutline />}
|
|
790
|
+
/>
|
|
791
|
+
</TreeView.Item>
|
|
792
|
+
<TreeView.Item
|
|
793
|
+
id="images"
|
|
794
|
+
title="Images"
|
|
795
|
+
hasChildren
|
|
796
|
+
leadingVisual={<FolderIconSolid />}
|
|
797
|
+
>
|
|
798
|
+
<TreeView.Item
|
|
799
|
+
id="photo"
|
|
800
|
+
title="Photo.jpg"
|
|
801
|
+
leadingVisual={<PhotoIconOutline />}
|
|
802
|
+
/>
|
|
803
|
+
<TreeView.Item
|
|
804
|
+
id="screenshot"
|
|
805
|
+
title="Screenshot.png"
|
|
614
806
|
leadingVisual={<PhotoIconOutline />}
|
|
615
807
|
/>
|
|
616
808
|
</TreeView.Item>
|
|
@@ -630,66 +822,66 @@ import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
|
|
|
630
822
|
import { TreeView } from '@neo4j-ndl/react/next';
|
|
631
823
|
import { useState } from 'react';
|
|
632
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Folders here are pure containers: `isSelectable={false}` makes them expand on
|
|
827
|
+
* click without ever becoming part of the selection, and screen readers
|
|
828
|
+
* announce them as non-selectable rather than as unselected rows.
|
|
829
|
+
*/
|
|
633
830
|
const Component = () => {
|
|
634
|
-
const [
|
|
635
|
-
const [selected, setSelected] = useState<string | null>(null);
|
|
636
|
-
|
|
637
|
-
const toggle = (key: string) =>
|
|
638
|
-
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
831
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
639
832
|
|
|
640
833
|
return (
|
|
641
|
-
<TreeView
|
|
834
|
+
<TreeView
|
|
835
|
+
ariaLabel="File explorer"
|
|
836
|
+
selectedIds={selectedIds}
|
|
837
|
+
onSelectedIdsChange={setSelectedIds}
|
|
838
|
+
>
|
|
642
839
|
<TreeView.Item
|
|
840
|
+
id="documents"
|
|
643
841
|
title="Documents"
|
|
644
842
|
hasChildren
|
|
645
|
-
|
|
646
|
-
onExpandedChange={() => toggle('documents')}
|
|
843
|
+
isSelectable={false}
|
|
647
844
|
leadingVisual={<FolderIconSolid />}
|
|
648
845
|
>
|
|
649
846
|
<TreeView.Item
|
|
847
|
+
id="work"
|
|
650
848
|
title="Work"
|
|
651
849
|
hasChildren
|
|
652
|
-
|
|
653
|
-
onExpandedChange={() => toggle('work')}
|
|
850
|
+
isSelectable={false}
|
|
654
851
|
leadingVisual={<FolderIconSolid />}
|
|
655
852
|
>
|
|
656
853
|
<TreeView.Item
|
|
854
|
+
id="report"
|
|
657
855
|
title="Report.pdf"
|
|
658
|
-
isSelected={selected === 'report'}
|
|
659
|
-
onSelectedChange={() => setSelected('report')}
|
|
660
856
|
leadingVisual={<DocumentIconOutline />}
|
|
661
857
|
/>
|
|
662
858
|
<TreeView.Item
|
|
859
|
+
id="presentation"
|
|
663
860
|
title="Presentation.pptx"
|
|
664
|
-
isSelected={selected === 'presentation'}
|
|
665
|
-
onSelectedChange={() => setSelected('presentation')}
|
|
666
861
|
leadingVisual={<DocumentIconOutline />}
|
|
667
862
|
/>
|
|
668
863
|
</TreeView.Item>
|
|
669
864
|
<TreeView.Item
|
|
865
|
+
id="resume"
|
|
670
866
|
title="Resume.pdf"
|
|
671
|
-
isSelected={selected === 'resume'}
|
|
672
|
-
onSelectedChange={() => setSelected('resume')}
|
|
673
867
|
leadingVisual={<DocumentIconOutline />}
|
|
674
868
|
/>
|
|
675
869
|
</TreeView.Item>
|
|
676
870
|
<TreeView.Item
|
|
871
|
+
id="images"
|
|
677
872
|
title="Images"
|
|
678
873
|
hasChildren
|
|
679
|
-
|
|
680
|
-
onExpandedChange={() => toggle('images')}
|
|
874
|
+
isSelectable={false}
|
|
681
875
|
leadingVisual={<FolderIconSolid />}
|
|
682
876
|
>
|
|
683
877
|
<TreeView.Item
|
|
878
|
+
id="photo"
|
|
684
879
|
title="Photo.jpg"
|
|
685
|
-
isSelected={selected === 'photo'}
|
|
686
|
-
onSelectedChange={() => setSelected('photo')}
|
|
687
880
|
leadingVisual={<DocumentIconOutline />}
|
|
688
881
|
/>
|
|
689
882
|
<TreeView.Item
|
|
883
|
+
id="screenshot"
|
|
690
884
|
title="Screenshot.png"
|
|
691
|
-
isSelected={selected === 'screenshot'}
|
|
692
|
-
onSelectedChange={() => setSelected('screenshot')}
|
|
693
885
|
leadingVisual={<DocumentIconOutline />}
|
|
694
886
|
/>
|
|
695
887
|
</TreeView.Item>
|
|
@@ -700,6 +892,154 @@ const Component = () => {
|
|
|
700
892
|
export default Component;
|
|
701
893
|
```
|
|
702
894
|
|
|
895
|
+
### Root Selection
|
|
896
|
+
|
|
897
|
+
```tsx
|
|
898
|
+
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';
|
|
899
|
+
|
|
900
|
+
import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
|
|
901
|
+
import { TreeView } from '@neo4j-ndl/react/next';
|
|
902
|
+
import { useCallback, useState } from 'react';
|
|
903
|
+
|
|
904
|
+
type FileNode = {
|
|
905
|
+
children?: FileNode[];
|
|
906
|
+
id: string;
|
|
907
|
+
title: string;
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const files: FileNode[] = [
|
|
911
|
+
{
|
|
912
|
+
children: [
|
|
913
|
+
{
|
|
914
|
+
children: [
|
|
915
|
+
{ id: 'report', title: 'Report.pdf' },
|
|
916
|
+
{ id: 'presentation', title: 'Presentation.pptx' },
|
|
917
|
+
],
|
|
918
|
+
id: 'work',
|
|
919
|
+
title: 'Work',
|
|
920
|
+
},
|
|
921
|
+
{ id: 'resume', title: 'Resume.pdf' },
|
|
922
|
+
],
|
|
923
|
+
id: 'documents',
|
|
924
|
+
title: 'Documents',
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
children: [
|
|
928
|
+
{ id: 'photo', title: 'Photo.jpg' },
|
|
929
|
+
{ id: 'screenshot', title: 'Screenshot.png' },
|
|
930
|
+
],
|
|
931
|
+
id: 'images',
|
|
932
|
+
title: 'Images',
|
|
933
|
+
},
|
|
934
|
+
];
|
|
935
|
+
|
|
936
|
+
const walk = (nodes: FileNode[], visit: (node: FileNode) => void) => {
|
|
937
|
+
nodes.forEach((node) => {
|
|
938
|
+
visit(node);
|
|
939
|
+
walk(node.children ?? [], visit);
|
|
940
|
+
});
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const getDescendantIds = (node: FileNode): string[] =>
|
|
944
|
+
(node.children ?? []).flatMap((child) => [
|
|
945
|
+
child.id,
|
|
946
|
+
...getDescendantIds(child),
|
|
947
|
+
]);
|
|
948
|
+
|
|
949
|
+
const getLeafIds = (node: FileNode): string[] =>
|
|
950
|
+
node.children === undefined ? [node.id] : node.children.flatMap(getLeafIds);
|
|
951
|
+
|
|
952
|
+
/** Folders follow their contents: selected only when every leaf below them is. */
|
|
953
|
+
const syncFolders = (nodes: FileNode[], selected: Set<string>) => {
|
|
954
|
+
nodes.forEach((node) => {
|
|
955
|
+
if (node.children === undefined) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
syncFolders(node.children, selected);
|
|
959
|
+
if (getLeafIds(node).every((id) => selected.has(id))) {
|
|
960
|
+
selected.add(node.id);
|
|
961
|
+
} else {
|
|
962
|
+
selected.delete(node.id);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
const Component = () => {
|
|
968
|
+
const [selectedIds, setSelectedIds] = useState<string[]>(['resume']);
|
|
969
|
+
const selected = new Set(selectedIds);
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The tree reports the whole selection at once, so cascading folders onto
|
|
973
|
+
* their contents takes a single pass even when a Shift range flipped many
|
|
974
|
+
* rows.
|
|
975
|
+
*/
|
|
976
|
+
const handleSelectedIdsChange = useCallback((nextIds: string[]) => {
|
|
977
|
+
setSelectedIds((previousIds) => {
|
|
978
|
+
const next = new Set(nextIds);
|
|
979
|
+
const previous = new Set(previousIds);
|
|
980
|
+
walk(files, (node) => {
|
|
981
|
+
const isSelected = next.has(node.id);
|
|
982
|
+
if (
|
|
983
|
+
node.children === undefined ||
|
|
984
|
+
isSelected === previous.has(node.id)
|
|
985
|
+
) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
getDescendantIds(node).forEach((id) => {
|
|
989
|
+
if (isSelected) {
|
|
990
|
+
next.add(id);
|
|
991
|
+
} else {
|
|
992
|
+
next.delete(id);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
syncFolders(files, next);
|
|
997
|
+
return [...next];
|
|
998
|
+
});
|
|
999
|
+
}, []);
|
|
1000
|
+
|
|
1001
|
+
const renderNode = (node: FileNode) => {
|
|
1002
|
+
const leafIds = getLeafIds(node);
|
|
1003
|
+
const selectedLeafCount = leafIds.filter((id) => selected.has(id)).length;
|
|
1004
|
+
return (
|
|
1005
|
+
<TreeView.Item
|
|
1006
|
+
key={node.id}
|
|
1007
|
+
id={node.id}
|
|
1008
|
+
title={node.title}
|
|
1009
|
+
defaultExpanded
|
|
1010
|
+
isIndeterminate={
|
|
1011
|
+
node.children !== undefined &&
|
|
1012
|
+
selectedLeafCount > 0 &&
|
|
1013
|
+
selectedLeafCount < leafIds.length
|
|
1014
|
+
}
|
|
1015
|
+
leadingVisual={
|
|
1016
|
+
node.children === undefined ? (
|
|
1017
|
+
<DocumentIconOutline />
|
|
1018
|
+
) : (
|
|
1019
|
+
<FolderIconSolid />
|
|
1020
|
+
)
|
|
1021
|
+
}
|
|
1022
|
+
>
|
|
1023
|
+
{node.children?.map(renderNode)}
|
|
1024
|
+
</TreeView.Item>
|
|
1025
|
+
);
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
return (
|
|
1029
|
+
<TreeView
|
|
1030
|
+
ariaLabel="File explorer"
|
|
1031
|
+
selectionMode="multiple"
|
|
1032
|
+
selectedIds={selectedIds}
|
|
1033
|
+
onSelectedIdsChange={handleSelectedIdsChange}
|
|
1034
|
+
>
|
|
1035
|
+
{files.map(renderNode)}
|
|
1036
|
+
</TreeView>
|
|
1037
|
+
);
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
export default Component;
|
|
1041
|
+
```
|
|
1042
|
+
|
|
703
1043
|
### Single
|
|
704
1044
|
|
|
705
1045
|
```tsx
|