@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.
Files changed (82) hide show
  1. package/lib/cjs/dropzone/stories/dropzone-full.story.js +5 -5
  2. package/lib/cjs/dropzone/stories/dropzone-full.story.js.map +1 -1
  3. package/lib/cjs/helpers/index.js +1 -0
  4. package/lib/cjs/helpers/index.js.map +1 -1
  5. package/lib/cjs/helpers/platform.js +59 -0
  6. package/lib/cjs/helpers/platform.js.map +1 -0
  7. package/lib/cjs/kbd/kbd-utils.js +6 -22
  8. package/lib/cjs/kbd/kbd-utils.js.map +1 -1
  9. package/lib/cjs/next/tree-view/TreeView.js +384 -66
  10. package/lib/cjs/next/tree-view/TreeView.js.map +1 -1
  11. package/lib/cjs/next/tree-view/selection.js +181 -0
  12. package/lib/cjs/next/tree-view/selection.js.map +1 -0
  13. package/lib/cjs/next/tree-view/stories/index.js +13 -1
  14. package/lib/cjs/next/tree-view/stories/index.js.map +1 -1
  15. package/lib/cjs/next/tree-view/stories/tree-view-large.story.js +57 -0
  16. package/lib/cjs/next/tree-view/stories/tree-view-large.story.js.map +1 -0
  17. package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js +1 -0
  18. package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
  19. package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js +16 -40
  20. package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
  21. package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js +38 -0
  22. package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
  23. package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
  24. package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
  25. package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js +125 -0
  26. package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
  27. package/lib/cjs/toast/ToastControlled.js +1 -1
  28. package/lib/cjs/toast/ToastControlled.js.map +1 -1
  29. package/lib/esm/dropzone/stories/dropzone-full.story.js +5 -5
  30. package/lib/esm/dropzone/stories/dropzone-full.story.js.map +1 -1
  31. package/lib/esm/helpers/index.js +1 -0
  32. package/lib/esm/helpers/index.js.map +1 -1
  33. package/lib/esm/helpers/platform.js +55 -0
  34. package/lib/esm/helpers/platform.js.map +1 -0
  35. package/lib/esm/kbd/kbd-utils.js +2 -18
  36. package/lib/esm/kbd/kbd-utils.js.map +1 -1
  37. package/lib/esm/next/tree-view/TreeView.js +385 -67
  38. package/lib/esm/next/tree-view/TreeView.js.map +1 -1
  39. package/lib/esm/next/tree-view/selection.js +175 -0
  40. package/lib/esm/next/tree-view/selection.js.map +1 -0
  41. package/lib/esm/next/tree-view/stories/index.js +9 -0
  42. package/lib/esm/next/tree-view/stories/index.js.map +1 -1
  43. package/lib/esm/next/tree-view/stories/tree-view-large.story.js +55 -0
  44. package/lib/esm/next/tree-view/stories/tree-view-large.story.js.map +1 -0
  45. package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js +1 -0
  46. package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
  47. package/lib/esm/next/tree-view/stories/tree-view-multi.story.js +16 -40
  48. package/lib/esm/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
  49. package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js +36 -0
  50. package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
  51. package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
  52. package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
  53. package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js +123 -0
  54. package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
  55. package/lib/esm/toast/ToastControlled.js +1 -1
  56. package/lib/esm/toast/ToastControlled.js.map +1 -1
  57. package/lib/types/helpers/index.d.ts +1 -0
  58. package/lib/types/helpers/index.d.ts.map +1 -1
  59. package/lib/types/helpers/platform.d.ts +29 -0
  60. package/lib/types/helpers/platform.d.ts.map +1 -0
  61. package/lib/types/kbd/kbd-utils.d.ts +2 -5
  62. package/lib/types/kbd/kbd-utils.d.ts.map +1 -1
  63. package/lib/types/next/tree-view/TreeView.d.ts +29 -5
  64. package/lib/types/next/tree-view/TreeView.d.ts.map +1 -1
  65. package/lib/types/next/tree-view/selection.d.ts +78 -0
  66. package/lib/types/next/tree-view/selection.d.ts.map +1 -0
  67. package/lib/types/next/tree-view/stories/index.d.ts +6 -0
  68. package/lib/types/next/tree-view/stories/index.d.ts.map +1 -1
  69. package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts +24 -0
  70. package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts.map +1 -0
  71. package/lib/types/next/tree-view/stories/tree-view-lazy.story.d.ts.map +1 -1
  72. package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts +10 -0
  73. package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts.map +1 -1
  74. package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts +29 -0
  75. package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts.map +1 -0
  76. package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts +5 -0
  77. package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts.map +1 -1
  78. package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts +24 -0
  79. package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts.map +1 -0
  80. package/package.json +2 -2
  81. package/skills/ndl-react/SKILL.md +1 -1
  82. 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
- | `selectionMode` | `'multiple' \| 'single'` | | `single` | Selection mode for the tree. Defaults to "single". |
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
- | `isSelected` | `boolean` | | | Whether the item is selected |
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` | Toggles selection on the focused item, or toggles expansion if the item is not selectable |
62
- | `Space` | Toggles selection on the focused item, or toggles expansion if the item is not selectable |
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 mode, selected items set `aria-selected`. In multiple selection mode, selected items set `aria-checked`, with `aria-checked="mixed"` for indeterminate parent nodes
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), only one item should have `isSelected` set to `true` at a time. The component does not enforce this — it is the consumer's responsibility to manage the selection state
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
- type CheckedState = Record<string, boolean>;
493
-
494
- const WORK_CHILDREN = ['report', 'presentation'] as const;
495
- const DOC_CHILDREN = ['work', 'resume'] as const;
496
- const IMAGE_CHILDREN = ['photo', 'screenshot'] as const;
497
- const DISABLED_KEYS = new Set(['presentation']);
498
-
499
- function getParentChecked(
500
- checked: CheckedState,
501
- childKeys: readonly string[],
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 [checked, setChecked] = useState<CheckedState>({});
557
+ const [selected, setSelected] = useState<Record<string, boolean>>({});
520
558
 
521
- const toggle = (key: string) =>
559
+ const toggleExpanded = (key: string) =>
522
560
  setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
523
561
 
524
- const toggleCheck = useCallback((key: string) => {
525
- setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
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={() => toggle('documents')}
557
- isSelected={docState.isChecked}
558
- isIndeterminate={docState.isIndeterminate}
559
- onSelectedChange={() =>
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={() => toggle('work')}
569
- isSelected={workState.isChecked}
570
- isIndeterminate={workState.isIndeterminate}
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={!!checked['report']}
577
- onSelectedChange={() => toggleCheck('report')}
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
- onSelectedChange={() => null}
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={!!checked['resume']}
590
- onSelectedChange={() => toggleCheck('resume')}
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={() => toggle('images')}
599
- isSelected={imageState.isChecked}
600
- isIndeterminate={imageState.isIndeterminate}
601
- onSelectedChange={() => toggleParent(IMAGE_CHILDREN)}
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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
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 ariaLabel="File explorer">
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
- isExpanded={expanded['documents']}
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
- isExpanded={expanded['work']}
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
- isExpanded={expanded['images']}
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