@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.
Files changed (185) hide show
  1. package/lib/cjs/_common/form-control/FormFieldLabel.js +52 -0
  2. package/lib/cjs/_common/form-control/FormFieldLabel.js.map +1 -0
  3. package/lib/cjs/_common/form-control/FormFieldMessage.js +72 -0
  4. package/lib/cjs/_common/form-control/FormFieldMessage.js.map +1 -0
  5. package/lib/cjs/_common/form-control/form-control-types.js +23 -0
  6. package/lib/cjs/_common/form-control/form-control-types.js.map +1 -0
  7. package/lib/cjs/_common/form-control/index.js +39 -0
  8. package/lib/cjs/_common/form-control/index.js.map +1 -0
  9. package/lib/cjs/_common/form-control/use-form-field.js +76 -0
  10. package/lib/cjs/_common/form-control/use-form-field.js.map +1 -0
  11. package/lib/cjs/_common/form-control/use-inline-field-label.js +76 -0
  12. package/lib/cjs/_common/form-control/use-inline-field-label.js.map +1 -0
  13. package/lib/cjs/checkbox/Checkbox.js +9 -21
  14. package/lib/cjs/checkbox/Checkbox.js.map +1 -1
  15. package/lib/cjs/combobox/Combobox.js +61 -45
  16. package/lib/cjs/combobox/Combobox.js.map +1 -1
  17. package/lib/cjs/combobox/combobox-types.js.map +1 -1
  18. package/lib/cjs/date-picker/DatePicker.js.map +1 -1
  19. package/lib/cjs/dropzone/stories/dropzone-full.story.js +5 -5
  20. package/lib/cjs/dropzone/stories/dropzone-full.story.js.map +1 -1
  21. package/lib/cjs/helpers/index.js +1 -0
  22. package/lib/cjs/helpers/index.js.map +1 -1
  23. package/lib/cjs/helpers/platform.js +59 -0
  24. package/lib/cjs/helpers/platform.js.map +1 -0
  25. package/lib/cjs/kbd/kbd-utils.js +6 -22
  26. package/lib/cjs/kbd/kbd-utils.js.map +1 -1
  27. package/lib/cjs/next/tree-view/TreeView.js +384 -66
  28. package/lib/cjs/next/tree-view/TreeView.js.map +1 -1
  29. package/lib/cjs/next/tree-view/selection.js +181 -0
  30. package/lib/cjs/next/tree-view/selection.js.map +1 -0
  31. package/lib/cjs/next/tree-view/stories/index.js +13 -1
  32. package/lib/cjs/next/tree-view/stories/index.js.map +1 -1
  33. package/lib/cjs/next/tree-view/stories/tree-view-large.story.js +57 -0
  34. package/lib/cjs/next/tree-view/stories/tree-view-large.story.js.map +1 -0
  35. package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js +1 -0
  36. package/lib/cjs/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
  37. package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js +16 -40
  38. package/lib/cjs/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
  39. package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js +38 -0
  40. package/lib/cjs/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
  41. package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
  42. package/lib/cjs/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
  43. package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js +125 -0
  44. package/lib/cjs/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
  45. package/lib/cjs/radio/Radio.js +12 -22
  46. package/lib/cjs/radio/Radio.js.map +1 -1
  47. package/lib/cjs/select/Select.js +14 -5
  48. package/lib/cjs/select/Select.js.map +1 -1
  49. package/lib/cjs/select/types.js.map +1 -1
  50. package/lib/cjs/switch/Switch.js +9 -21
  51. package/lib/cjs/switch/Switch.js.map +1 -1
  52. package/lib/cjs/text-area/TextArea.js +8 -21
  53. package/lib/cjs/text-area/TextArea.js.map +1 -1
  54. package/lib/cjs/text-input/TextInput.js +15 -38
  55. package/lib/cjs/text-input/TextInput.js.map +1 -1
  56. package/lib/cjs/text-input/stories/text-input-sizes.story.js +1 -1
  57. package/lib/cjs/text-input/stories/text-input-sizes.story.js.map +1 -1
  58. package/lib/cjs/time-picker/TimePicker.js +13 -7
  59. package/lib/cjs/time-picker/TimePicker.js.map +1 -1
  60. package/lib/cjs/timezone-picker/TimeZonePicker.js +13 -7
  61. package/lib/cjs/timezone-picker/TimeZonePicker.js.map +1 -1
  62. package/lib/cjs/toast/ToastControlled.js +1 -1
  63. package/lib/cjs/toast/ToastControlled.js.map +1 -1
  64. package/lib/esm/_common/form-control/FormFieldLabel.js +45 -0
  65. package/lib/esm/_common/form-control/FormFieldLabel.js.map +1 -0
  66. package/lib/esm/_common/form-control/FormFieldMessage.js +65 -0
  67. package/lib/esm/_common/form-control/FormFieldMessage.js.map +1 -0
  68. package/lib/esm/_common/form-control/form-control-types.js +22 -0
  69. package/lib/esm/_common/form-control/form-control-types.js.map +1 -0
  70. package/lib/esm/_common/form-control/index.js +32 -0
  71. package/lib/esm/_common/form-control/index.js.map +1 -0
  72. package/lib/esm/_common/form-control/use-form-field.js +72 -0
  73. package/lib/esm/_common/form-control/use-form-field.js.map +1 -0
  74. package/lib/esm/_common/form-control/use-inline-field-label.js +69 -0
  75. package/lib/esm/_common/form-control/use-inline-field-label.js.map +1 -0
  76. package/lib/esm/checkbox/Checkbox.js +10 -22
  77. package/lib/esm/checkbox/Checkbox.js.map +1 -1
  78. package/lib/esm/combobox/Combobox.js +62 -46
  79. package/lib/esm/combobox/Combobox.js.map +1 -1
  80. package/lib/esm/combobox/combobox-types.js.map +1 -1
  81. package/lib/esm/date-picker/DatePicker.js.map +1 -1
  82. package/lib/esm/dropzone/stories/dropzone-full.story.js +5 -5
  83. package/lib/esm/dropzone/stories/dropzone-full.story.js.map +1 -1
  84. package/lib/esm/helpers/index.js +1 -0
  85. package/lib/esm/helpers/index.js.map +1 -1
  86. package/lib/esm/helpers/platform.js +55 -0
  87. package/lib/esm/helpers/platform.js.map +1 -0
  88. package/lib/esm/kbd/kbd-utils.js +2 -18
  89. package/lib/esm/kbd/kbd-utils.js.map +1 -1
  90. package/lib/esm/next/tree-view/TreeView.js +385 -67
  91. package/lib/esm/next/tree-view/TreeView.js.map +1 -1
  92. package/lib/esm/next/tree-view/selection.js +175 -0
  93. package/lib/esm/next/tree-view/selection.js.map +1 -0
  94. package/lib/esm/next/tree-view/stories/index.js +9 -0
  95. package/lib/esm/next/tree-view/stories/index.js.map +1 -1
  96. package/lib/esm/next/tree-view/stories/tree-view-large.story.js +55 -0
  97. package/lib/esm/next/tree-view/stories/tree-view-large.story.js.map +1 -0
  98. package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js +1 -0
  99. package/lib/esm/next/tree-view/stories/tree-view-lazy.story.js.map +1 -1
  100. package/lib/esm/next/tree-view/stories/tree-view-multi.story.js +16 -40
  101. package/lib/esm/next/tree-view/stories/tree-view-multi.story.js.map +1 -1
  102. package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js +36 -0
  103. package/lib/esm/next/tree-view/stories/tree-view-multiple-replace.story.js.map +1 -0
  104. package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js +7 -4
  105. package/lib/esm/next/tree-view/stories/tree-view-non-selectable-folders.story.js.map +1 -1
  106. package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js +123 -0
  107. package/lib/esm/next/tree-view/stories/tree-view-root-selection.story.js.map +1 -0
  108. package/lib/esm/radio/Radio.js +12 -22
  109. package/lib/esm/radio/Radio.js.map +1 -1
  110. package/lib/esm/select/Select.js +14 -5
  111. package/lib/esm/select/Select.js.map +1 -1
  112. package/lib/esm/select/types.js.map +1 -1
  113. package/lib/esm/switch/Switch.js +10 -22
  114. package/lib/esm/switch/Switch.js.map +1 -1
  115. package/lib/esm/text-area/TextArea.js +9 -22
  116. package/lib/esm/text-area/TextArea.js.map +1 -1
  117. package/lib/esm/text-input/TextInput.js +15 -38
  118. package/lib/esm/text-input/TextInput.js.map +1 -1
  119. package/lib/esm/text-input/stories/text-input-sizes.story.js +1 -1
  120. package/lib/esm/text-input/stories/text-input-sizes.story.js.map +1 -1
  121. package/lib/esm/time-picker/TimePicker.js +13 -7
  122. package/lib/esm/time-picker/TimePicker.js.map +1 -1
  123. package/lib/esm/timezone-picker/TimeZonePicker.js +13 -7
  124. package/lib/esm/timezone-picker/TimeZonePicker.js.map +1 -1
  125. package/lib/esm/toast/ToastControlled.js +1 -1
  126. package/lib/esm/toast/ToastControlled.js.map +1 -1
  127. package/lib/types/_common/form-control/FormFieldLabel.d.ts +58 -0
  128. package/lib/types/_common/form-control/FormFieldLabel.d.ts.map +1 -0
  129. package/lib/types/_common/form-control/FormFieldMessage.d.ts +46 -0
  130. package/lib/types/_common/form-control/FormFieldMessage.d.ts.map +1 -0
  131. package/lib/types/_common/form-control/form-control-types.d.ts +92 -0
  132. package/lib/types/_common/form-control/form-control-types.d.ts.map +1 -0
  133. package/lib/types/_common/form-control/index.d.ts +33 -0
  134. package/lib/types/_common/form-control/index.d.ts.map +1 -0
  135. package/lib/types/_common/form-control/use-form-field.d.ts +53 -0
  136. package/lib/types/_common/form-control/use-form-field.d.ts.map +1 -0
  137. package/lib/types/_common/form-control/use-inline-field-label.d.ts +69 -0
  138. package/lib/types/_common/form-control/use-inline-field-label.d.ts.map +1 -0
  139. package/lib/types/checkbox/Checkbox.d.ts.map +1 -1
  140. package/lib/types/combobox/Combobox.d.ts.map +1 -1
  141. package/lib/types/combobox/combobox-types.d.ts +1 -1
  142. package/lib/types/combobox/combobox-types.d.ts.map +1 -1
  143. package/lib/types/helpers/index.d.ts +1 -0
  144. package/lib/types/helpers/index.d.ts.map +1 -1
  145. package/lib/types/helpers/platform.d.ts +29 -0
  146. package/lib/types/helpers/platform.d.ts.map +1 -0
  147. package/lib/types/kbd/kbd-utils.d.ts +2 -5
  148. package/lib/types/kbd/kbd-utils.d.ts.map +1 -1
  149. package/lib/types/next/tree-view/TreeView.d.ts +29 -5
  150. package/lib/types/next/tree-view/TreeView.d.ts.map +1 -1
  151. package/lib/types/next/tree-view/selection.d.ts +78 -0
  152. package/lib/types/next/tree-view/selection.d.ts.map +1 -0
  153. package/lib/types/next/tree-view/stories/index.d.ts +6 -0
  154. package/lib/types/next/tree-view/stories/index.d.ts.map +1 -1
  155. package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts +24 -0
  156. package/lib/types/next/tree-view/stories/tree-view-large.story.d.ts.map +1 -0
  157. package/lib/types/next/tree-view/stories/tree-view-lazy.story.d.ts.map +1 -1
  158. package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts +10 -0
  159. package/lib/types/next/tree-view/stories/tree-view-multi.story.d.ts.map +1 -1
  160. package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts +29 -0
  161. package/lib/types/next/tree-view/stories/tree-view-multiple-replace.story.d.ts.map +1 -0
  162. package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts +5 -0
  163. package/lib/types/next/tree-view/stories/tree-view-non-selectable-folders.story.d.ts.map +1 -1
  164. package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts +24 -0
  165. package/lib/types/next/tree-view/stories/tree-view-root-selection.story.d.ts.map +1 -0
  166. package/lib/types/radio/Radio.d.ts.map +1 -1
  167. package/lib/types/select/Select.d.ts.map +1 -1
  168. package/lib/types/select/types.d.ts.map +1 -1
  169. package/lib/types/switch/Switch.d.ts.map +1 -1
  170. package/lib/types/text-area/TextArea.d.ts.map +1 -1
  171. package/lib/types/text-input/TextInput.d.ts.map +1 -1
  172. package/lib/types/text-input/stories/text-input-sizes.story.d.ts.map +1 -1
  173. package/lib/types/time-picker/TimePicker.d.ts +3 -1
  174. package/lib/types/time-picker/TimePicker.d.ts.map +1 -1
  175. package/lib/types/timezone-picker/TimeZonePicker.d.ts +4 -1
  176. package/lib/types/timezone-picker/TimeZonePicker.d.ts.map +1 -1
  177. package/package.json +2 -2
  178. package/skills/ndl-react/SKILL.md +1 -1
  179. package/skills/ndl-react/components/color-picker.md +32 -32
  180. package/skills/ndl-react/components/combobox.md +2 -2
  181. package/skills/ndl-react/components/dropzone.md +5 -5
  182. package/skills/ndl-react/components/next/tree-view.md +444 -104
  183. package/skills/ndl-react/components/text-input.md +6 -0
  184. package/skills/ndl-react/components/time-picker.md +1 -0
  185. 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
- | `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. 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 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
@@ -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
- 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
-
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 [checked, setChecked] = useState<CheckedState>({});
643
+ const [selected, setSelected] = useState<Record<string, boolean>>({});
520
644
 
521
- const toggle = (key: string) =>
645
+ const toggleExpanded = (key: string) =>
522
646
  setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
523
647
 
524
- const toggleCheck = useCallback((key: string) => {
525
- setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
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={() => toggle('documents')}
557
- isSelected={docState.isChecked}
558
- isIndeterminate={docState.isIndeterminate}
559
- onSelectedChange={() =>
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={() => toggle('work')}
569
- isSelected={workState.isChecked}
570
- isIndeterminate={workState.isIndeterminate}
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={!!checked['report']}
577
- onSelectedChange={() => toggleCheck('report')}
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
- onSelectedChange={() => null}
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={!!checked['resume']}
590
- onSelectedChange={() => toggleCheck('resume')}
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={() => toggle('images')}
599
- isSelected={imageState.isChecked}
600
- isIndeterminate={imageState.isIndeterminate}
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={!!checked['photo']}
607
- onSelectedChange={() => toggleCheck('photo')}
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={!!checked['screenshot']}
613
- onSelectedChange={() => toggleCheck('screenshot')}
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 [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] }));
831
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
639
832
 
640
833
  return (
641
- <TreeView ariaLabel="File explorer">
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
- isExpanded={expanded['documents']}
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
- isExpanded={expanded['work']}
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
- isExpanded={expanded['images']}
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