@navikt/ds-react 8.10.2 → 8.10.3

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 (98) hide show
  1. package/cjs/data/table/column-header/DataTableColumnHeader.js +3 -1
  2. package/cjs/data/table/column-header/DataTableColumnHeader.js.map +1 -1
  3. package/cjs/data/table/column-header/useTableColumnResize.d.ts +24 -2
  4. package/cjs/data/table/column-header/useTableColumnResize.js +31 -15
  5. package/cjs/data/table/column-header/useTableColumnResize.js.map +1 -1
  6. package/cjs/data/table/helpers/collectTableRowEntries.d.ts +10 -2
  7. package/cjs/data/table/helpers/collectTableRowEntries.js +25 -17
  8. package/cjs/data/table/helpers/collectTableRowEntries.js.map +1 -1
  9. package/cjs/data/table/helpers/selection/SelectionSubtreeHelper.d.ts +46 -0
  10. package/cjs/data/table/helpers/selection/SelectionSubtreeHelper.js +112 -0
  11. package/cjs/data/table/helpers/selection/SelectionSubtreeHelper.js.map +1 -0
  12. package/cjs/data/table/helpers/selection/getMultipleSelectProps.d.ts +3 -2
  13. package/cjs/data/table/helpers/selection/getMultipleSelectProps.js +43 -19
  14. package/cjs/data/table/helpers/selection/getMultipleSelectProps.js.map +1 -1
  15. package/cjs/data/table/helpers/selection/selection.types.d.ts +1 -0
  16. package/cjs/data/table/helpers/table-keyboard.d.ts +1 -2
  17. package/cjs/data/table/helpers/table-keyboard.js +1 -2
  18. package/cjs/data/table/helpers/table-keyboard.js.map +1 -1
  19. package/cjs/data/table/hooks/useTableExpansion.d.ts +1 -3
  20. package/cjs/data/table/hooks/useTableExpansion.js +7 -1
  21. package/cjs/data/table/hooks/useTableExpansion.js.map +1 -1
  22. package/cjs/data/table/hooks/useTableItems.d.ts +7 -3
  23. package/cjs/data/table/hooks/useTableItems.js +18 -7
  24. package/cjs/data/table/hooks/useTableItems.js.map +1 -1
  25. package/cjs/data/table/hooks/useTableSelection.d.ts +3 -2
  26. package/cjs/data/table/hooks/useTableSelection.js +5 -4
  27. package/cjs/data/table/hooks/useTableSelection.js.map +1 -1
  28. package/cjs/data/table/root/DataTable.types.d.ts +5 -4
  29. package/cjs/data/table/root/DataTableAuto.d.ts +9 -1
  30. package/cjs/data/table/root/DataTableAuto.js +50 -50
  31. package/cjs/data/table/root/DataTableAuto.js.map +1 -1
  32. package/cjs/data/table/root/DataTableRoot.js +2 -3
  33. package/cjs/data/table/root/DataTableRoot.js.map +1 -1
  34. package/cjs/form/checkbox/Checkbox.js +1 -0
  35. package/cjs/form/checkbox/Checkbox.js.map +1 -1
  36. package/cjs/form/radio/Radio.js +7 -1
  37. package/cjs/form/radio/Radio.js.map +1 -1
  38. package/cjs/modal/types.d.ts +8 -4
  39. package/esm/data/table/column-header/DataTableColumnHeader.js +3 -1
  40. package/esm/data/table/column-header/DataTableColumnHeader.js.map +1 -1
  41. package/esm/data/table/column-header/useTableColumnResize.d.ts +24 -2
  42. package/esm/data/table/column-header/useTableColumnResize.js +32 -16
  43. package/esm/data/table/column-header/useTableColumnResize.js.map +1 -1
  44. package/esm/data/table/helpers/collectTableRowEntries.d.ts +10 -2
  45. package/esm/data/table/helpers/collectTableRowEntries.js +25 -17
  46. package/esm/data/table/helpers/collectTableRowEntries.js.map +1 -1
  47. package/esm/data/table/helpers/selection/SelectionSubtreeHelper.d.ts +46 -0
  48. package/esm/data/table/helpers/selection/SelectionSubtreeHelper.js +109 -0
  49. package/esm/data/table/helpers/selection/SelectionSubtreeHelper.js.map +1 -0
  50. package/esm/data/table/helpers/selection/getMultipleSelectProps.d.ts +3 -2
  51. package/esm/data/table/helpers/selection/getMultipleSelectProps.js +43 -19
  52. package/esm/data/table/helpers/selection/getMultipleSelectProps.js.map +1 -1
  53. package/esm/data/table/helpers/selection/selection.types.d.ts +1 -0
  54. package/esm/data/table/helpers/table-keyboard.d.ts +1 -2
  55. package/esm/data/table/helpers/table-keyboard.js +1 -2
  56. package/esm/data/table/helpers/table-keyboard.js.map +1 -1
  57. package/esm/data/table/hooks/useTableExpansion.d.ts +1 -3
  58. package/esm/data/table/hooks/useTableExpansion.js +7 -1
  59. package/esm/data/table/hooks/useTableExpansion.js.map +1 -1
  60. package/esm/data/table/hooks/useTableItems.d.ts +7 -3
  61. package/esm/data/table/hooks/useTableItems.js +18 -7
  62. package/esm/data/table/hooks/useTableItems.js.map +1 -1
  63. package/esm/data/table/hooks/useTableSelection.d.ts +3 -2
  64. package/esm/data/table/hooks/useTableSelection.js +5 -4
  65. package/esm/data/table/hooks/useTableSelection.js.map +1 -1
  66. package/esm/data/table/root/DataTable.types.d.ts +5 -4
  67. package/esm/data/table/root/DataTableAuto.d.ts +9 -1
  68. package/esm/data/table/root/DataTableAuto.js +51 -51
  69. package/esm/data/table/root/DataTableAuto.js.map +1 -1
  70. package/esm/data/table/root/DataTableRoot.js +3 -4
  71. package/esm/data/table/root/DataTableRoot.js.map +1 -1
  72. package/esm/form/checkbox/Checkbox.js +1 -0
  73. package/esm/form/checkbox/Checkbox.js.map +1 -1
  74. package/esm/form/radio/Radio.js +7 -1
  75. package/esm/form/radio/Radio.js.map +1 -1
  76. package/esm/modal/types.d.ts +8 -4
  77. package/package.json +3 -3
  78. package/src/data/table/column-header/DataTableColumnHeader.tsx +5 -1
  79. package/src/data/table/column-header/useTableColumnResize.ts +73 -17
  80. package/src/data/table/helpers/collectTableRowEntries.ts +57 -25
  81. package/src/data/table/helpers/selection/SelectionSubtreeHelper.test.ts +66 -0
  82. package/src/data/table/helpers/selection/SelectionSubtreeHelper.ts +162 -0
  83. package/src/data/table/helpers/selection/getMultipleSelectProps.ts +57 -20
  84. package/src/data/table/helpers/selection/selection.types.ts +1 -0
  85. package/src/data/table/helpers/table-keyboard.ts +1 -2
  86. package/src/data/table/hooks/__tests__/useTableItems.test.ts +14 -0
  87. package/src/data/table/hooks/__tests__/useTableSelection.test.ts +132 -21
  88. package/src/data/table/hooks/useTableExpansion.tsx +8 -3
  89. package/src/data/table/hooks/useTableItems.ts +50 -27
  90. package/src/data/table/hooks/useTableSelection.ts +10 -6
  91. package/src/data/table/root/DataTable.types.ts +5 -4
  92. package/src/data/table/root/DataTableAuto.test.tsx +128 -2
  93. package/src/data/table/root/DataTableAuto.tsx +144 -135
  94. package/src/data/table/root/DataTableRoot.tsx +6 -7
  95. package/src/form/checkbox/Checkbox.tsx +1 -0
  96. package/src/form/radio/Radio.tsx +7 -1
  97. package/src/modal/types.ts +8 -4
  98. package/src/data/table/hooks/__tests__/useTableExpansion.test.tsx +0 -115
@@ -1,11 +1,13 @@
1
1
  import type { CheckboxInputProps } from "../../../../form/checkbox/checkbox-input/CheckboxInput";
2
+ import { SelectionSubtreeHelper } from "./SelectionSubtreeHelper";
2
3
 
3
4
  type GetMultipleSelectPropsArgs = {
4
5
  selectedKeysSet: Set<string | number>;
5
6
  selectedKeys: (string | number)[];
6
7
  setSelectedKeys: (keys: (string | number)[]) => void;
7
8
  disabledKeysSet: Set<string | number>;
8
- allRowKeys: (string | number)[];
9
+ visibleRowIds: (string | number)[];
10
+ childRowIdsById?: Map<string | number, (string | number)[]>;
9
11
  };
10
12
 
11
13
  function getMultipleSelectProps({
@@ -13,34 +15,56 @@ function getMultipleSelectProps({
13
15
  selectedKeys,
14
16
  setSelectedKeys,
15
17
  disabledKeysSet,
16
- allRowKeys,
18
+ visibleRowIds,
19
+ childRowIdsById,
17
20
  }: GetMultipleSelectPropsArgs) {
18
- const allRowKeysSet = new Set(allRowKeys);
19
- const selectableKeys = allRowKeys.filter((k) => !disabledKeysSet.has(k));
21
+ const subtreeHelper = new SelectionSubtreeHelper({
22
+ childRowIdsById,
23
+ disabledKeysSet,
24
+ selectedKeysSet,
25
+ });
20
26
 
21
- const selectedSelectableCount = selectableKeys.filter((k) =>
27
+ // Header selection traverses the visible roots and skips already visited
28
+ // descendants, so expanded trees stay linear in the number of rows.
29
+ const headerSelectableKeys = subtreeHelper.getSelectableKeys(visibleRowIds);
30
+ const headerSelectableKeysSet = new Set(headerSelectableKeys);
31
+
32
+ const selectedSelectableCount = headerSelectableKeys.filter((k) =>
22
33
  selectedKeysSet.has(k),
23
34
  ).length;
24
35
 
25
36
  const allSelectableSelected =
26
- selectableKeys.length > 0 &&
27
- selectedSelectableCount === selectableKeys.length;
37
+ headerSelectableKeys.length > 0 &&
38
+ selectedSelectableCount === headerSelectableKeys.length;
28
39
 
29
40
  const indeterminate =
30
41
  selectedSelectableCount > 0 &&
31
- selectedSelectableCount < selectableKeys.length;
42
+ selectedSelectableCount < headerSelectableKeys.length;
32
43
 
33
44
  const selectedKeysNotInView = selectedKeys.filter(
34
- (k) => !allRowKeysSet.has(k),
45
+ (k) => !headerSelectableKeysSet.has(k),
35
46
  );
36
47
  const disabledSelected = selectedKeys.filter((k) => disabledKeysSet.has(k));
37
- const preservedKeys = [...selectedKeysNotInView, ...disabledSelected];
48
+ const preservedKeys = [
49
+ ...new Set([...selectedKeysNotInView, ...disabledSelected]),
50
+ ];
51
+
52
+ const isGroupFullySelected = (key: string | number) => {
53
+ const groupStats = subtreeHelper.getSelectionStats(key);
54
+
55
+ return (
56
+ groupStats.selectableCount > 0 &&
57
+ groupStats.selectedCount === groupStats.selectableCount
58
+ );
59
+ };
38
60
 
39
61
  const handleToggleAll = () => {
40
62
  if (allSelectableSelected) {
41
63
  setSelectedKeys(preservedKeys);
42
64
  } else {
43
- setSelectedKeys([...new Set([...preservedKeys, ...selectableKeys])]);
65
+ setSelectedKeys([
66
+ ...new Set([...preservedKeys, ...headerSelectableKeys]),
67
+ ]);
44
68
  }
45
69
  };
46
70
 
@@ -48,10 +72,16 @@ function getMultipleSelectProps({
48
72
  if (disabledKeysSet.has(key)) {
49
73
  return;
50
74
  }
51
- if (selectedKeysSet.has(key)) {
52
- setSelectedKeys(selectedKeys.filter((k) => k !== key));
75
+
76
+ const groupKeys = subtreeHelper.getSelectableKeys([key]);
77
+
78
+ if (isGroupFullySelected(key)) {
79
+ const groupKeysSet = new Set(groupKeys);
80
+ setSelectedKeys(
81
+ selectedKeys.filter((selectedKey) => !groupKeysSet.has(selectedKey)),
82
+ );
53
83
  } else {
54
- setSelectedKeys([...selectedKeys, key]);
84
+ setSelectedKeys([...new Set([...selectedKeys, ...groupKeys])]);
55
85
  }
56
86
  };
57
87
 
@@ -60,13 +90,20 @@ function getMultipleSelectProps({
60
90
  onChange: handleToggleAll,
61
91
  checked: allSelectableSelected,
62
92
  indeterminate,
63
- disabled: selectableKeys.length === 0,
64
- }),
65
- getRowCheckboxProps: (key: string | number): CheckboxInputProps => ({
66
- onChange: () => handleToggleRow(key),
67
- checked: selectedKeysSet.has(key),
68
- disabled: disabledKeysSet.has(key),
93
+ disabled: headerSelectableKeys.length === 0,
69
94
  }),
95
+ getRowCheckboxProps: (key: string | number): CheckboxInputProps => {
96
+ const groupStats = subtreeHelper.getSelectionStats(key);
97
+
98
+ return {
99
+ onChange: () => handleToggleRow(key),
100
+ checked: isGroupFullySelected(key),
101
+ indeterminate:
102
+ groupStats.selectedCount > 0 &&
103
+ groupStats.selectedCount < groupStats.selectableCount,
104
+ disabled: disabledKeysSet.has(key),
105
+ };
106
+ },
70
107
  toggleSelection: handleToggleRow,
71
108
  };
72
109
  }
@@ -10,6 +10,7 @@ type SelectionProps = {
10
10
  * When set to "single", only one row can be selected at a time (renders radio buttons).
11
11
  *
12
12
  * When set to "multiple", multiple rows can be selected (renders checkboxes).
13
+ * Nested rows use cascading selection, so selecting a parent toggles its descendants too.
13
14
  *
14
15
  * @default "none"
15
16
  */
@@ -16,8 +16,7 @@ type NavigationAction =
16
16
 
17
17
  /**
18
18
  * Maps keyboard events to navigation actions.
19
- * Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation),
20
- * and PageUp/PageDown (multi-row navigation).
19
+ * Supports arrow keys, Home/End (row navigation), Ctrl/Cmd+Home/End (table navigation).
21
20
  */
22
21
  function getNavigationAction(event: KeyboardEvent): NavigationAction | null {
23
22
  const key = event.key;
@@ -86,6 +86,20 @@ describe("useTableItems", () => {
86
86
  expect(getVisibleIds(result.current.items)).toEqual(["a", "a1", "a2", "b"]);
87
87
  });
88
88
 
89
+ test("collects direct child row ids even when nested rows are collapsed", () => {
90
+ const { result } = renderHook(() =>
91
+ useTableItems({
92
+ items: nestedRows,
93
+ getRowId: (row) => row.id,
94
+ getSubRows,
95
+ }),
96
+ );
97
+
98
+ expect(result.current.childRowIdsById.get("a")).toEqual(["a1", "a2"]);
99
+ expect(result.current.childRowIdsById.get("a2")).toEqual(["a2a"]);
100
+ expect(result.current.childRowIdsById.get("b")).toEqual(["b1"]);
101
+ });
102
+
89
103
  test("uses the same fallback root id to reveal child rows when getRowId is omitted", () => {
90
104
  const { result } = renderHook(() =>
91
105
  useTableItems({
@@ -17,7 +17,13 @@ const items: Item[] = [
17
17
  { id: "c", name: "Charlie" },
18
18
  ];
19
19
 
20
- const allRowKeys = items.map((item) => item.id);
20
+ const visibleRowIds = items.map((item) => item.id);
21
+ const childRowIdsById = new Map<string | number, (string | number)[]>([
22
+ ["a", ["a1", "a2"]],
23
+ ["a1", []],
24
+ ["a2", ["a2a"]],
25
+ ["a2a", []],
26
+ ]);
21
27
 
22
28
  function asSingle(result: {
23
29
  current: UseTableSelectionReturn;
@@ -37,7 +43,7 @@ describe("useTableSelection", () => {
37
43
  const { result } = renderHook(() =>
38
44
  useTableSelection({
39
45
  selectionMode: "none",
40
- allRowKeys,
46
+ visibleRowIds,
41
47
  }),
42
48
  );
43
49
 
@@ -51,7 +57,7 @@ describe("useTableSelection", () => {
51
57
  const { result } = renderHook(() =>
52
58
  useTableSelection({
53
59
  selectionMode: "single",
54
- allRowKeys,
60
+ visibleRowIds,
55
61
  }),
56
62
  );
57
63
 
@@ -64,7 +70,7 @@ describe("useTableSelection", () => {
64
70
  const { result } = renderHook(() =>
65
71
  useTableSelection({
66
72
  selectionMode: "single",
67
- allRowKeys,
73
+ visibleRowIds,
68
74
  onSelectionChange: onChange,
69
75
  }),
70
76
  );
@@ -83,7 +89,7 @@ describe("useTableSelection", () => {
83
89
  const { result } = renderHook(() =>
84
90
  useTableSelection({
85
91
  selectionMode: "single",
86
- allRowKeys,
92
+ visibleRowIds,
87
93
  defaultSelectedKeys: ["a"],
88
94
  }),
89
95
  );
@@ -101,7 +107,7 @@ describe("useTableSelection", () => {
101
107
  const { result } = renderHook(() =>
102
108
  useTableSelection({
103
109
  selectionMode: "single",
104
- allRowKeys,
110
+ visibleRowIds,
105
111
  defaultSelectedKeys: ["a"],
106
112
  }),
107
113
  );
@@ -119,7 +125,7 @@ describe("useTableSelection", () => {
119
125
  const { result } = renderHook(() =>
120
126
  useTableSelection({
121
127
  selectionMode: "single",
122
- allRowKeys,
128
+ visibleRowIds,
123
129
  disabledSelectionKeys: ["b"],
124
130
  }),
125
131
  );
@@ -133,7 +139,7 @@ describe("useTableSelection", () => {
133
139
  ({ selectedKeys }) =>
134
140
  useTableSelection({
135
141
  selectionMode: "single",
136
- allRowKeys,
142
+ visibleRowIds,
137
143
  selectedKeys,
138
144
  }),
139
145
  { initialProps: { selectedKeys: ["a"] as (string | number)[] } },
@@ -151,7 +157,7 @@ describe("useTableSelection", () => {
151
157
  const { result } = renderHook(() =>
152
158
  useTableSelection({
153
159
  selectionMode: "multiple",
154
- allRowKeys,
160
+ visibleRowIds,
155
161
  }),
156
162
  );
157
163
 
@@ -164,7 +170,7 @@ describe("useTableSelection", () => {
164
170
  const { result } = renderHook(() =>
165
171
  useTableSelection({
166
172
  selectionMode: "multiple",
167
- allRowKeys,
173
+ visibleRowIds,
168
174
  }),
169
175
  );
170
176
 
@@ -189,7 +195,7 @@ describe("useTableSelection", () => {
189
195
  const { result } = renderHook(() =>
190
196
  useTableSelection({
191
197
  selectionMode: "multiple",
192
- allRowKeys,
198
+ visibleRowIds,
193
199
  defaultSelectedKeys: ["a", "b"],
194
200
  }),
195
201
  );
@@ -207,7 +213,7 @@ describe("useTableSelection", () => {
207
213
  const { result } = renderHook(() =>
208
214
  useTableSelection({
209
215
  selectionMode: "multiple",
210
- allRowKeys,
216
+ visibleRowIds,
211
217
  }),
212
218
  );
213
219
 
@@ -220,11 +226,29 @@ describe("useTableSelection", () => {
220
226
  expect(asMultiple(result).selectedKeys).toEqual(["a", "b", "c"]);
221
227
  });
222
228
 
229
+ test("select all via thead includes hidden descendants for visible parents", () => {
230
+ const { result } = renderHook(() =>
231
+ useTableSelection({
232
+ selectionMode: "multiple",
233
+ visibleRowIds: ["a"],
234
+ childRowIdsById,
235
+ }),
236
+ );
237
+
238
+ act(() => {
239
+ asMultiple(result)
240
+ .getTheadCheckboxProps()
241
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
242
+ });
243
+
244
+ expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
245
+ });
246
+
223
247
  test("deselect all when all are selected", () => {
224
248
  const { result } = renderHook(() =>
225
249
  useTableSelection({
226
250
  selectionMode: "multiple",
227
- allRowKeys,
251
+ visibleRowIds,
228
252
  defaultSelectedKeys: ["a", "b", "c"],
229
253
  }),
230
254
  );
@@ -238,11 +262,30 @@ describe("useTableSelection", () => {
238
262
  expect(asMultiple(result).selectedKeys).toEqual([]);
239
263
  });
240
264
 
265
+ test("deselect all clears hidden descendants for visible parents but preserves unrelated keys", () => {
266
+ const { result } = renderHook(() =>
267
+ useTableSelection({
268
+ selectionMode: "multiple",
269
+ visibleRowIds: ["a"],
270
+ childRowIdsById,
271
+ defaultSelectedKeys: ["a", "a1", "a2", "a2a", "external"],
272
+ }),
273
+ );
274
+
275
+ act(() => {
276
+ asMultiple(result)
277
+ .getTheadCheckboxProps()
278
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
279
+ });
280
+
281
+ expect(asMultiple(result).selectedKeys).toEqual(["external"]);
282
+ });
283
+
241
284
  test("select all skips disabled keys", () => {
242
285
  const { result } = renderHook(() =>
243
286
  useTableSelection({
244
287
  selectionMode: "multiple",
245
- allRowKeys,
288
+ visibleRowIds,
246
289
  disabledSelectionKeys: ["b"],
247
290
  }),
248
291
  );
@@ -260,7 +303,7 @@ describe("useTableSelection", () => {
260
303
  const { result } = renderHook(() =>
261
304
  useTableSelection({
262
305
  selectionMode: "multiple",
263
- allRowKeys,
306
+ visibleRowIds,
264
307
  defaultSelectedKeys: ["a", "b", "c"],
265
308
  disabledSelectionKeys: ["b"],
266
309
  }),
@@ -279,7 +322,7 @@ describe("useTableSelection", () => {
279
322
  const { result } = renderHook(() =>
280
323
  useTableSelection({
281
324
  selectionMode: "multiple",
282
- allRowKeys,
325
+ visibleRowIds,
283
326
  defaultSelectedKeys: ["a"],
284
327
  }),
285
328
  );
@@ -293,7 +336,7 @@ describe("useTableSelection", () => {
293
336
  const { result } = renderHook(() =>
294
337
  useTableSelection({
295
338
  selectionMode: "multiple",
296
- allRowKeys,
339
+ visibleRowIds,
297
340
  defaultSelectedKeys: ["a", "b", "c"],
298
341
  }),
299
342
  );
@@ -307,7 +350,7 @@ describe("useTableSelection", () => {
307
350
  const { result } = renderHook(() =>
308
351
  useTableSelection({
309
352
  selectionMode: "multiple",
310
- allRowKeys,
353
+ visibleRowIds,
311
354
  defaultSelectedKeys: ["a", "c"],
312
355
  disabledSelectionKeys: ["b"],
313
356
  }),
@@ -322,7 +365,7 @@ describe("useTableSelection", () => {
322
365
  const { result } = renderHook(() =>
323
366
  useTableSelection({
324
367
  selectionMode: "multiple",
325
- allRowKeys,
368
+ visibleRowIds,
326
369
  defaultSelectedKeys: ["a", "b", "c"],
327
370
  }),
328
371
  );
@@ -340,7 +383,7 @@ describe("useTableSelection", () => {
340
383
  const { result } = renderHook(() =>
341
384
  useTableSelection({
342
385
  selectionMode: "multiple",
343
- allRowKeys,
386
+ visibleRowIds,
344
387
  disabledSelectionKeys: ["b"],
345
388
  }),
346
389
  );
@@ -353,12 +396,80 @@ describe("useTableSelection", () => {
353
396
  const { result } = renderHook(() =>
354
397
  useTableSelection({
355
398
  selectionMode: "multiple",
356
- allRowKeys,
399
+ visibleRowIds,
357
400
  disabledSelectionKeys: ["a", "b", "c"],
358
401
  }),
359
402
  );
360
403
 
361
404
  expect(asMultiple(result).getTheadCheckboxProps().disabled).toBe(true);
362
405
  });
406
+
407
+ test("parent rows show indeterminate when visible descendants are partially selected", () => {
408
+ const { result } = renderHook(() =>
409
+ useTableSelection({
410
+ selectionMode: "multiple",
411
+ visibleRowIds: ["a", "a1", "a2"],
412
+ childRowIdsById,
413
+ defaultSelectedKeys: ["a1"],
414
+ }),
415
+ );
416
+
417
+ const parentProps = asMultiple(result).getRowCheckboxProps("a");
418
+
419
+ expect(parentProps.checked).toBe(false);
420
+ expect(parentProps.indeterminate).toBe(true);
421
+ });
422
+
423
+ test("toggling a parent row selects and deselects its descendants", () => {
424
+ const { result } = renderHook(() =>
425
+ useTableSelection({
426
+ selectionMode: "multiple",
427
+ visibleRowIds: ["a", "a1", "a2"],
428
+ childRowIdsById,
429
+ }),
430
+ );
431
+
432
+ act(() => {
433
+ asMultiple(result)
434
+ .getRowCheckboxProps("a")
435
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
436
+ });
437
+
438
+ expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
439
+
440
+ act(() => {
441
+ asMultiple(result)
442
+ .getRowCheckboxProps("a")
443
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
444
+ });
445
+
446
+ expect(asMultiple(result).selectedKeys).toEqual([]);
447
+ });
448
+
449
+ test("toggling a collapsed parent selects and deselects hidden descendants", () => {
450
+ const { result } = renderHook(() =>
451
+ useTableSelection({
452
+ selectionMode: "multiple",
453
+ visibleRowIds: ["a"],
454
+ childRowIdsById,
455
+ }),
456
+ );
457
+
458
+ act(() => {
459
+ asMultiple(result)
460
+ .getRowCheckboxProps("a")
461
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
462
+ });
463
+
464
+ expect(asMultiple(result).selectedKeys).toEqual(["a", "a1", "a2", "a2a"]);
465
+
466
+ act(() => {
467
+ asMultiple(result)
468
+ .getRowCheckboxProps("a")
469
+ .onChange?.({} as React.ChangeEvent<HTMLInputElement>);
470
+ });
471
+
472
+ expect(asMultiple(result).selectedKeys).toEqual([]);
473
+ });
363
474
  });
364
475
  });
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback } from "react";
2
2
  import { createStrictContext } from "../../../utils/helpers";
3
3
  import { useControllableState } from "../../../utils/hooks";
4
- import type { ItemDetail } from "./useTableItems";
4
+ import { useTableItemsContext } from "./useTableItems";
5
5
 
6
6
  type DataTableExpansionContextT = {
7
7
  isExpanded: (id: string | number) => boolean;
@@ -28,7 +28,6 @@ type TableExpansionOptions<T> = {
28
28
  detailsPanelRowIds?: (string | number)[];
29
29
  defaultDetailsPanelRowIds?: (string | number)[];
30
30
  onDetailsPanelChange?: (ids: (string | number)[]) => void;
31
- itemDetails: Map<T, ItemDetail<T>>;
32
31
  getDetailsPanelContent?: (row: T) => React.ReactNode;
33
32
  isDetailsPanelExpandable?: (rowData: T) => boolean;
34
33
  getDetailsPanelHeight?: (row: T) => number | "auto";
@@ -44,7 +43,6 @@ function DataTableExpansionProvider<T>({
44
43
  detailsPanelRowIds,
45
44
  defaultDetailsPanelRowIds = [],
46
45
  onDetailsPanelChange,
47
- itemDetails,
48
46
  getDetailsPanelContent,
49
47
  isDetailsPanelExpandable,
50
48
  getDetailsPanelHeight,
@@ -56,6 +54,13 @@ function DataTableExpansionProvider<T>({
56
54
  onChange: onDetailsPanelChange,
57
55
  });
58
56
 
57
+ /* TODO: False is just fallback until auto and root is merged */
58
+ const tableItemsContext = useTableItemsContext(false);
59
+
60
+ const { itemDetails } = tableItemsContext ?? {
61
+ itemDetails: new Map(),
62
+ };
63
+
59
64
  const expandableIds = React.useMemo(() => {
60
65
  if (!getDetailsPanelContent) {
61
66
  return new Set<string | number>();
@@ -3,6 +3,7 @@ import { createStrictContext } from "../../../utils/helpers";
3
3
  import { useControllableState } from "../../../utils/hooks";
4
4
  import {
5
5
  type ItemDetail,
6
+ type TableRowEntryId,
6
7
  collectTableRowEntries,
7
8
  } from "../helpers/collectTableRowEntries";
8
9
 
@@ -32,6 +33,10 @@ type UseTableItemsArgs<T> = {
32
33
  type useTableItemsReturn<T> = {
33
34
  items: T[];
34
35
  itemDetails: Map<T, ItemDetail<T>>;
36
+ /** Row ids for the rows currently rendered in the table body. */
37
+ visibleRowIds: TableRowEntryId[];
38
+ /** Direct child ids for each row, used to traverse selection groups lazily. */
39
+ childRowIdsById: Map<TableRowEntryId, TableRowEntryId[]>;
35
40
  onExpandedSubRowIdsChange: (id: string | number) => void;
36
41
  isSubRowExpanded: (id: string | number) => boolean;
37
42
  };
@@ -59,38 +64,52 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
59
64
  [nestedSubRowsExpandedIds],
60
65
  );
61
66
 
62
- const { itemDetails, visibleItems } = useMemo(() => {
63
- const rowEntriesMap = collectTableRowEntries({
64
- items,
65
- getRowId,
66
- getSubRows,
67
- isSubRowExpandable,
68
- });
67
+ const { itemDetails, visibleItems, visibleRowIds, childRowIdsById } =
68
+ useMemo(() => {
69
+ const { itemDetails: rowEntriesMap, childRowIdsById: _childRowIdsById } =
70
+ collectTableRowEntries({
71
+ items,
72
+ getRowId,
73
+ getSubRows,
74
+ isSubRowExpandable,
75
+ });
69
76
 
70
- const localVisibleItems: T[] = [];
71
- const addVisibleRows = (rowData: T) => {
72
- localVisibleItems.push(rowData);
77
+ const localVisibleItems: T[] = [];
78
+ const localVisibleRowIds: TableRowEntryId[] = [];
73
79
 
74
- const details = rowEntriesMap.get(rowData);
80
+ const addVisibleRows = (rowData: T): TableRowEntryId[] => {
81
+ const details = rowEntriesMap.get(rowData);
75
82
 
76
- if (!details || !expandedIdsSet.has(details.id)) {
77
- return;
78
- }
83
+ if (!details) {
84
+ return [];
85
+ }
79
86
 
80
- for (const childRow of details.children) {
81
- addVisibleRows(childRow);
82
- }
83
- };
87
+ localVisibleItems.push(rowData);
88
+ localVisibleRowIds.push(details.id);
89
+
90
+ const visibleDescendantRowIds: TableRowEntryId[] = [];
84
91
 
85
- for (const rowData of items) {
86
- addVisibleRows(rowData);
87
- }
92
+ if (expandedIdsSet.has(details.id)) {
93
+ for (const childRow of details.children) {
94
+ const childVisibleRowIds = addVisibleRows(childRow);
95
+ visibleDescendantRowIds.push(...childVisibleRowIds);
96
+ }
97
+ }
98
+
99
+ return [details.id, ...visibleDescendantRowIds];
100
+ };
101
+
102
+ for (const rowData of items) {
103
+ addVisibleRows(rowData);
104
+ }
88
105
 
89
- return {
90
- visibleItems: localVisibleItems,
91
- itemDetails: rowEntriesMap,
92
- };
93
- }, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
106
+ return {
107
+ visibleItems: localVisibleItems,
108
+ visibleRowIds: localVisibleRowIds,
109
+ childRowIdsById: _childRowIdsById,
110
+ itemDetails: rowEntriesMap,
111
+ };
112
+ }, [getSubRows, items, getRowId, isSubRowExpandable, expandedIdsSet]);
94
113
 
95
114
  const handleExpandedSubRowIdChange = useCallback(
96
115
  (id: string | number) => {
@@ -106,6 +125,8 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
106
125
  return {
107
126
  items: visibleItems,
108
127
  itemDetails,
128
+ visibleRowIds,
129
+ childRowIdsById,
109
130
  onExpandedSubRowIdsChange: handleExpandedSubRowIdChange,
110
131
  isSubRowExpanded: (id: string | number) => expandedIdsSet.has(id),
111
132
  };
@@ -113,7 +134,9 @@ function useTableItems<T>(args: UseTableItemsArgs<T>): useTableItemsReturn<T> {
113
134
 
114
135
  const { Provider: TableItemsProvider, useContext: useTableItemsContext } =
115
136
  /* TODO: Can we type this better? */
116
- createStrictContext<useTableItemsReturn<any>>({
137
+ createStrictContext<
138
+ Omit<useTableItemsReturn<any>, "visibleRowIds" | "childRowIdsById">
139
+ >({
117
140
  name: "TableItemsContext",
118
141
  errorMessage:
119
142
  "useTableItemsContext must be used within a TableItemsProvider",
@@ -10,8 +10,10 @@ import type {
10
10
  } from "../helpers/selection/selection.types";
11
11
 
12
12
  type UseTableSelectionArgs = SelectionProps & {
13
- /* This is needed for multiple selection to know which keys to select when "select all" is used */
14
- allRowKeys: (string | number)[];
13
+ /* Visible rows manage the header checkbox state and render selection cells. */
14
+ visibleRowIds: (string | number)[];
15
+ /* Direct child ids let selection walk nested rows lazily. */
16
+ childRowIdsById?: Map<string | number, (string | number)[]>;
15
17
  };
16
18
 
17
19
  type UseTableSelectionReturn = {
@@ -25,7 +27,8 @@ function useTableSelection({
25
27
  selectedKeys: selectedKeysProp,
26
28
  onSelectionChange,
27
29
  disabledSelectionKeys = [],
28
- allRowKeys,
30
+ visibleRowIds = [],
31
+ childRowIdsById,
29
32
  }: UseTableSelectionArgs): UseTableSelectionReturn {
30
33
  const radioGroupName = useId();
31
34
 
@@ -72,7 +75,7 @@ function useTableSelection({
72
75
  name: radioGroupName,
73
76
  }),
74
77
  },
75
- renderSelection: allRowKeys.length !== 0,
78
+ renderSelection: visibleRowIds.length !== 0,
76
79
  };
77
80
  }
78
81
 
@@ -85,10 +88,11 @@ function useTableSelection({
85
88
  selectedKeys,
86
89
  setSelectedKeys,
87
90
  disabledKeysSet,
88
- allRowKeys,
91
+ visibleRowIds,
92
+ childRowIdsById,
89
93
  }),
90
94
  },
91
- renderSelection: allRowKeys.length !== 0,
95
+ renderSelection: visibleRowIds.length !== 0,
92
96
  };
93
97
  }
94
98