@neo4j-ndl/react 4.21.2 → 4.22.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/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/toast/ToastControlled.js +1 -1
- package/lib/cjs/toast/ToastControlled.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/toast/ToastControlled.js +1 -1
- package/lib/esm/toast/ToastControlled.js.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/package.json +2 -2
- package/skills/ndl-react/SKILL.md +1 -1
- package/skills/ndl-react/components/next/tree-view.md +358 -104
|
@@ -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.
|
|
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
|
|
@@ -489,75 +542,37 @@ import {
|
|
|
489
542
|
import { TreeView } from '@neo4j-ndl/react/next';
|
|
490
543
|
import { useCallback, useState } from 'react';
|
|
491
544
|
|
|
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
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Every row owns its own checkbox: `onSelectedChange` reports the state the row
|
|
547
|
+
* should end up in, and the story stores exactly that. Folders are ordinary
|
|
548
|
+
* selectable rows here, unrelated to what sits inside them.
|
|
549
|
+
*
|
|
550
|
+
* Deriving a folder's state from its children instead needs the tree to own the
|
|
551
|
+
* selection, since a Shift range moves several rows at once and a cascade has
|
|
552
|
+
* to be applied to the whole set in one pass. See the root-managed selection
|
|
553
|
+
* story for that.
|
|
554
|
+
*/
|
|
517
555
|
const Component = () => {
|
|
518
556
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
519
|
-
const [
|
|
557
|
+
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
|
520
558
|
|
|
521
|
-
const
|
|
559
|
+
const toggleExpanded = (key: string) =>
|
|
522
560
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
523
561
|
|
|
524
|
-
const
|
|
525
|
-
|
|
562
|
+
const setRowSelected = useCallback((key: string, isSelected: boolean) => {
|
|
563
|
+
setSelected((prev) => ({ ...prev, [key]: isSelected }));
|
|
526
564
|
}, []);
|
|
527
565
|
|
|
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
566
|
return (
|
|
551
567
|
<TreeView ariaLabel="File explorer" selectionMode="multiple">
|
|
552
568
|
<TreeView.Item
|
|
553
569
|
title="Documents"
|
|
554
570
|
hasChildren
|
|
555
571
|
isExpanded={expanded['documents']}
|
|
556
|
-
onExpandedChange={() =>
|
|
557
|
-
isSelected={
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
toggleParent([...DOC_CHILDREN, ...WORK_CHILDREN])
|
|
572
|
+
onExpandedChange={() => toggleExpanded('documents')}
|
|
573
|
+
isSelected={selected['documents'] ?? false}
|
|
574
|
+
onSelectedChange={(isSelected) =>
|
|
575
|
+
setRowSelected('documents', isSelected)
|
|
561
576
|
}
|
|
562
577
|
leadingVisual={<FolderIconSolid />}
|
|
563
578
|
>
|
|
@@ -565,29 +580,35 @@ const Component = () => {
|
|
|
565
580
|
title="Work"
|
|
566
581
|
hasChildren
|
|
567
582
|
isExpanded={expanded['work']}
|
|
568
|
-
onExpandedChange={() =>
|
|
569
|
-
isSelected={
|
|
570
|
-
|
|
571
|
-
onSelectedChange={() => toggleParent(WORK_CHILDREN)}
|
|
583
|
+
onExpandedChange={() => toggleExpanded('work')}
|
|
584
|
+
isSelected={selected['work'] ?? false}
|
|
585
|
+
onSelectedChange={(isSelected) => setRowSelected('work', isSelected)}
|
|
572
586
|
leadingVisual={<FolderIconSolid />}
|
|
573
587
|
>
|
|
574
588
|
<TreeView.Item
|
|
575
589
|
title="Report.pdf"
|
|
576
|
-
isSelected={
|
|
577
|
-
onSelectedChange={() =>
|
|
590
|
+
isSelected={selected['report'] ?? false}
|
|
591
|
+
onSelectedChange={(isSelected) =>
|
|
592
|
+
setRowSelected('report', isSelected)
|
|
593
|
+
}
|
|
578
594
|
leadingVisual={<DocumentIconOutline />}
|
|
579
595
|
/>
|
|
580
596
|
<TreeView.Item
|
|
581
597
|
title="Presentation.pptx"
|
|
582
598
|
isDisabled
|
|
583
|
-
|
|
599
|
+
isSelected={selected['presentation'] ?? false}
|
|
600
|
+
onSelectedChange={(isSelected) =>
|
|
601
|
+
setRowSelected('presentation', isSelected)
|
|
602
|
+
}
|
|
584
603
|
leadingVisual={<DocumentIconOutline />}
|
|
585
604
|
/>
|
|
586
605
|
</TreeView.Item>
|
|
587
606
|
<TreeView.Item
|
|
588
607
|
title="Resume.pdf"
|
|
589
|
-
isSelected={
|
|
590
|
-
onSelectedChange={() =>
|
|
608
|
+
isSelected={selected['resume'] ?? false}
|
|
609
|
+
onSelectedChange={(isSelected) =>
|
|
610
|
+
setRowSelected('resume', isSelected)
|
|
611
|
+
}
|
|
591
612
|
leadingVisual={<DocumentIconOutline />}
|
|
592
613
|
/>
|
|
593
614
|
</TreeView.Item>
|
|
@@ -595,22 +616,107 @@ const Component = () => {
|
|
|
595
616
|
title="Images"
|
|
596
617
|
hasChildren
|
|
597
618
|
isExpanded={expanded['images']}
|
|
598
|
-
onExpandedChange={() =>
|
|
599
|
-
isSelected={
|
|
600
|
-
|
|
601
|
-
|
|
619
|
+
onExpandedChange={() => toggleExpanded('images')}
|
|
620
|
+
isSelected={selected['images'] ?? false}
|
|
621
|
+
onSelectedChange={(isSelected) => setRowSelected('images', isSelected)}
|
|
622
|
+
leadingVisual={<FolderIconSolid />}
|
|
623
|
+
>
|
|
624
|
+
<TreeView.Item
|
|
625
|
+
title="Photo.jpg"
|
|
626
|
+
isSelected={selected['photo'] ?? false}
|
|
627
|
+
onSelectedChange={(isSelected) => setRowSelected('photo', isSelected)}
|
|
628
|
+
leadingVisual={<PhotoIconOutline />}
|
|
629
|
+
/>
|
|
630
|
+
<TreeView.Item
|
|
631
|
+
title="Screenshot.png"
|
|
632
|
+
isSelected={selected['screenshot'] ?? false}
|
|
633
|
+
onSelectedChange={(isSelected) =>
|
|
634
|
+
setRowSelected('screenshot', isSelected)
|
|
635
|
+
}
|
|
636
|
+
leadingVisual={<PhotoIconOutline />}
|
|
637
|
+
/>
|
|
638
|
+
</TreeView.Item>
|
|
639
|
+
</TreeView>
|
|
640
|
+
);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
export default Component;
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### Multiple Replace
|
|
647
|
+
|
|
648
|
+
```tsx
|
|
649
|
+
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';
|
|
650
|
+
|
|
651
|
+
import {
|
|
652
|
+
DocumentIconOutline,
|
|
653
|
+
FolderIconSolid,
|
|
654
|
+
PhotoIconOutline,
|
|
655
|
+
} from '@neo4j-ndl/react/icons';
|
|
656
|
+
import { TreeView } from '@neo4j-ndl/react/next';
|
|
657
|
+
import { useState } from 'react';
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Letting the tree own the selection keeps the items declarative: they carry an
|
|
661
|
+
* `id` and nothing else, and a Shift range that moves several rows at once
|
|
662
|
+
* arrives as a single `onSelectedIdsChange`.
|
|
663
|
+
*/
|
|
664
|
+
const Component = () => {
|
|
665
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<TreeView
|
|
669
|
+
ariaLabel="File explorer"
|
|
670
|
+
selectionMode="multiple-replace"
|
|
671
|
+
selectedIds={selectedIds}
|
|
672
|
+
onSelectedIdsChange={setSelectedIds}
|
|
673
|
+
>
|
|
674
|
+
<TreeView.Item
|
|
675
|
+
id="documents"
|
|
676
|
+
title="Documents"
|
|
677
|
+
hasChildren
|
|
678
|
+
defaultExpanded
|
|
679
|
+
leadingVisual={<FolderIconSolid />}
|
|
680
|
+
>
|
|
681
|
+
<TreeView.Item
|
|
682
|
+
id="work"
|
|
683
|
+
title="Work"
|
|
684
|
+
hasChildren
|
|
685
|
+
defaultExpanded
|
|
686
|
+
leadingVisual={<FolderIconSolid />}
|
|
687
|
+
>
|
|
688
|
+
<TreeView.Item
|
|
689
|
+
id="report"
|
|
690
|
+
title="Report.pdf"
|
|
691
|
+
isDisabled
|
|
692
|
+
leadingVisual={<DocumentIconOutline />}
|
|
693
|
+
/>
|
|
694
|
+
<TreeView.Item
|
|
695
|
+
id="presentation"
|
|
696
|
+
title="Presentation.pptx"
|
|
697
|
+
leadingVisual={<DocumentIconOutline />}
|
|
698
|
+
/>
|
|
699
|
+
</TreeView.Item>
|
|
700
|
+
<TreeView.Item
|
|
701
|
+
id="resume"
|
|
702
|
+
title="Resume.pdf"
|
|
703
|
+
leadingVisual={<DocumentIconOutline />}
|
|
704
|
+
/>
|
|
705
|
+
</TreeView.Item>
|
|
706
|
+
<TreeView.Item
|
|
707
|
+
id="images"
|
|
708
|
+
title="Images"
|
|
709
|
+
hasChildren
|
|
602
710
|
leadingVisual={<FolderIconSolid />}
|
|
603
711
|
>
|
|
604
712
|
<TreeView.Item
|
|
713
|
+
id="photo"
|
|
605
714
|
title="Photo.jpg"
|
|
606
|
-
isSelected={!!checked['photo']}
|
|
607
|
-
onSelectedChange={() => toggleCheck('photo')}
|
|
608
715
|
leadingVisual={<PhotoIconOutline />}
|
|
609
716
|
/>
|
|
610
717
|
<TreeView.Item
|
|
718
|
+
id="screenshot"
|
|
611
719
|
title="Screenshot.png"
|
|
612
|
-
isSelected={!!checked['screenshot']}
|
|
613
|
-
onSelectedChange={() => toggleCheck('screenshot')}
|
|
614
720
|
leadingVisual={<PhotoIconOutline />}
|
|
615
721
|
/>
|
|
616
722
|
</TreeView.Item>
|
|
@@ -630,66 +736,66 @@ import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
|
|
|
630
736
|
import { TreeView } from '@neo4j-ndl/react/next';
|
|
631
737
|
import { useState } from 'react';
|
|
632
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Folders here are pure containers: `isSelectable={false}` makes them expand on
|
|
741
|
+
* click without ever becoming part of the selection, and screen readers
|
|
742
|
+
* announce them as non-selectable rather than as unselected rows.
|
|
743
|
+
*/
|
|
633
744
|
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] }));
|
|
745
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
639
746
|
|
|
640
747
|
return (
|
|
641
|
-
<TreeView
|
|
748
|
+
<TreeView
|
|
749
|
+
ariaLabel="File explorer"
|
|
750
|
+
selectedIds={selectedIds}
|
|
751
|
+
onSelectedIdsChange={setSelectedIds}
|
|
752
|
+
>
|
|
642
753
|
<TreeView.Item
|
|
754
|
+
id="documents"
|
|
643
755
|
title="Documents"
|
|
644
756
|
hasChildren
|
|
645
|
-
|
|
646
|
-
onExpandedChange={() => toggle('documents')}
|
|
757
|
+
isSelectable={false}
|
|
647
758
|
leadingVisual={<FolderIconSolid />}
|
|
648
759
|
>
|
|
649
760
|
<TreeView.Item
|
|
761
|
+
id="work"
|
|
650
762
|
title="Work"
|
|
651
763
|
hasChildren
|
|
652
|
-
|
|
653
|
-
onExpandedChange={() => toggle('work')}
|
|
764
|
+
isSelectable={false}
|
|
654
765
|
leadingVisual={<FolderIconSolid />}
|
|
655
766
|
>
|
|
656
767
|
<TreeView.Item
|
|
768
|
+
id="report"
|
|
657
769
|
title="Report.pdf"
|
|
658
|
-
isSelected={selected === 'report'}
|
|
659
|
-
onSelectedChange={() => setSelected('report')}
|
|
660
770
|
leadingVisual={<DocumentIconOutline />}
|
|
661
771
|
/>
|
|
662
772
|
<TreeView.Item
|
|
773
|
+
id="presentation"
|
|
663
774
|
title="Presentation.pptx"
|
|
664
|
-
isSelected={selected === 'presentation'}
|
|
665
|
-
onSelectedChange={() => setSelected('presentation')}
|
|
666
775
|
leadingVisual={<DocumentIconOutline />}
|
|
667
776
|
/>
|
|
668
777
|
</TreeView.Item>
|
|
669
778
|
<TreeView.Item
|
|
779
|
+
id="resume"
|
|
670
780
|
title="Resume.pdf"
|
|
671
|
-
isSelected={selected === 'resume'}
|
|
672
|
-
onSelectedChange={() => setSelected('resume')}
|
|
673
781
|
leadingVisual={<DocumentIconOutline />}
|
|
674
782
|
/>
|
|
675
783
|
</TreeView.Item>
|
|
676
784
|
<TreeView.Item
|
|
785
|
+
id="images"
|
|
677
786
|
title="Images"
|
|
678
787
|
hasChildren
|
|
679
|
-
|
|
680
|
-
onExpandedChange={() => toggle('images')}
|
|
788
|
+
isSelectable={false}
|
|
681
789
|
leadingVisual={<FolderIconSolid />}
|
|
682
790
|
>
|
|
683
791
|
<TreeView.Item
|
|
792
|
+
id="photo"
|
|
684
793
|
title="Photo.jpg"
|
|
685
|
-
isSelected={selected === 'photo'}
|
|
686
|
-
onSelectedChange={() => setSelected('photo')}
|
|
687
794
|
leadingVisual={<DocumentIconOutline />}
|
|
688
795
|
/>
|
|
689
796
|
<TreeView.Item
|
|
797
|
+
id="screenshot"
|
|
690
798
|
title="Screenshot.png"
|
|
691
|
-
isSelected={selected === 'screenshot'}
|
|
692
|
-
onSelectedChange={() => setSelected('screenshot')}
|
|
693
799
|
leadingVisual={<DocumentIconOutline />}
|
|
694
800
|
/>
|
|
695
801
|
</TreeView.Item>
|
|
@@ -700,6 +806,154 @@ const Component = () => {
|
|
|
700
806
|
export default Component;
|
|
701
807
|
```
|
|
702
808
|
|
|
809
|
+
### Root Selection
|
|
810
|
+
|
|
811
|
+
```tsx
|
|
812
|
+
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';
|
|
813
|
+
|
|
814
|
+
import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
|
|
815
|
+
import { TreeView } from '@neo4j-ndl/react/next';
|
|
816
|
+
import { useCallback, useState } from 'react';
|
|
817
|
+
|
|
818
|
+
type FileNode = {
|
|
819
|
+
children?: FileNode[];
|
|
820
|
+
id: string;
|
|
821
|
+
title: string;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const files: FileNode[] = [
|
|
825
|
+
{
|
|
826
|
+
children: [
|
|
827
|
+
{
|
|
828
|
+
children: [
|
|
829
|
+
{ id: 'report', title: 'Report.pdf' },
|
|
830
|
+
{ id: 'presentation', title: 'Presentation.pptx' },
|
|
831
|
+
],
|
|
832
|
+
id: 'work',
|
|
833
|
+
title: 'Work',
|
|
834
|
+
},
|
|
835
|
+
{ id: 'resume', title: 'Resume.pdf' },
|
|
836
|
+
],
|
|
837
|
+
id: 'documents',
|
|
838
|
+
title: 'Documents',
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
children: [
|
|
842
|
+
{ id: 'photo', title: 'Photo.jpg' },
|
|
843
|
+
{ id: 'screenshot', title: 'Screenshot.png' },
|
|
844
|
+
],
|
|
845
|
+
id: 'images',
|
|
846
|
+
title: 'Images',
|
|
847
|
+
},
|
|
848
|
+
];
|
|
849
|
+
|
|
850
|
+
const walk = (nodes: FileNode[], visit: (node: FileNode) => void) => {
|
|
851
|
+
nodes.forEach((node) => {
|
|
852
|
+
visit(node);
|
|
853
|
+
walk(node.children ?? [], visit);
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const getDescendantIds = (node: FileNode): string[] =>
|
|
858
|
+
(node.children ?? []).flatMap((child) => [
|
|
859
|
+
child.id,
|
|
860
|
+
...getDescendantIds(child),
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
const getLeafIds = (node: FileNode): string[] =>
|
|
864
|
+
node.children === undefined ? [node.id] : node.children.flatMap(getLeafIds);
|
|
865
|
+
|
|
866
|
+
/** Folders follow their contents: selected only when every leaf below them is. */
|
|
867
|
+
const syncFolders = (nodes: FileNode[], selected: Set<string>) => {
|
|
868
|
+
nodes.forEach((node) => {
|
|
869
|
+
if (node.children === undefined) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
syncFolders(node.children, selected);
|
|
873
|
+
if (getLeafIds(node).every((id) => selected.has(id))) {
|
|
874
|
+
selected.add(node.id);
|
|
875
|
+
} else {
|
|
876
|
+
selected.delete(node.id);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const Component = () => {
|
|
882
|
+
const [selectedIds, setSelectedIds] = useState<string[]>(['resume']);
|
|
883
|
+
const selected = new Set(selectedIds);
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The tree reports the whole selection at once, so cascading folders onto
|
|
887
|
+
* their contents takes a single pass even when a Shift range flipped many
|
|
888
|
+
* rows.
|
|
889
|
+
*/
|
|
890
|
+
const handleSelectedIdsChange = useCallback((nextIds: string[]) => {
|
|
891
|
+
setSelectedIds((previousIds) => {
|
|
892
|
+
const next = new Set(nextIds);
|
|
893
|
+
const previous = new Set(previousIds);
|
|
894
|
+
walk(files, (node) => {
|
|
895
|
+
const isSelected = next.has(node.id);
|
|
896
|
+
if (
|
|
897
|
+
node.children === undefined ||
|
|
898
|
+
isSelected === previous.has(node.id)
|
|
899
|
+
) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
getDescendantIds(node).forEach((id) => {
|
|
903
|
+
if (isSelected) {
|
|
904
|
+
next.add(id);
|
|
905
|
+
} else {
|
|
906
|
+
next.delete(id);
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
syncFolders(files, next);
|
|
911
|
+
return [...next];
|
|
912
|
+
});
|
|
913
|
+
}, []);
|
|
914
|
+
|
|
915
|
+
const renderNode = (node: FileNode) => {
|
|
916
|
+
const leafIds = getLeafIds(node);
|
|
917
|
+
const selectedLeafCount = leafIds.filter((id) => selected.has(id)).length;
|
|
918
|
+
return (
|
|
919
|
+
<TreeView.Item
|
|
920
|
+
key={node.id}
|
|
921
|
+
id={node.id}
|
|
922
|
+
title={node.title}
|
|
923
|
+
defaultExpanded
|
|
924
|
+
isIndeterminate={
|
|
925
|
+
node.children !== undefined &&
|
|
926
|
+
selectedLeafCount > 0 &&
|
|
927
|
+
selectedLeafCount < leafIds.length
|
|
928
|
+
}
|
|
929
|
+
leadingVisual={
|
|
930
|
+
node.children === undefined ? (
|
|
931
|
+
<DocumentIconOutline />
|
|
932
|
+
) : (
|
|
933
|
+
<FolderIconSolid />
|
|
934
|
+
)
|
|
935
|
+
}
|
|
936
|
+
>
|
|
937
|
+
{node.children?.map(renderNode)}
|
|
938
|
+
</TreeView.Item>
|
|
939
|
+
);
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
return (
|
|
943
|
+
<TreeView
|
|
944
|
+
ariaLabel="File explorer"
|
|
945
|
+
selectionMode="multiple"
|
|
946
|
+
selectedIds={selectedIds}
|
|
947
|
+
onSelectedIdsChange={handleSelectedIdsChange}
|
|
948
|
+
>
|
|
949
|
+
{files.map(renderNode)}
|
|
950
|
+
</TreeView>
|
|
951
|
+
);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
export default Component;
|
|
955
|
+
```
|
|
956
|
+
|
|
703
957
|
### Single
|
|
704
958
|
|
|
705
959
|
```tsx
|