@proyecto-viviana/solid-stately 0.3.0 → 0.4.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 (63) hide show
  1. package/dist/calendar/IncompleteDate.d.ts +61 -0
  2. package/dist/calendar/createCalendarState.d.ts +20 -10
  3. package/dist/calendar/createDateFieldState.d.ts +10 -3
  4. package/dist/calendar/createRangeCalendarState.d.ts +10 -2
  5. package/dist/calendar/index.d.ts +1 -1
  6. package/dist/collections/createListState.d.ts +4 -1
  7. package/dist/collections/createSelectionState.d.ts +17 -5
  8. package/dist/collections/getChildNodes.d.ts +11 -0
  9. package/dist/collections/index.d.ts +2 -1
  10. package/dist/combobox/createComboBoxState.d.ts +7 -0
  11. package/dist/dnd/index.d.ts +1 -1
  12. package/dist/dnd/types.d.ts +3 -1
  13. package/dist/grid/types.d.ts +17 -3
  14. package/dist/index.d.ts +6 -6
  15. package/dist/index.js +1570 -425
  16. package/dist/index.js.map +1 -1
  17. package/dist/select/createSelectState.d.ts +6 -0
  18. package/dist/selection/Selection.d.ts +16 -0
  19. package/dist/selection/SelectionManager.d.ts +110 -0
  20. package/dist/selection/createMultipleSelectionState.d.ts +59 -0
  21. package/dist/selection/index.d.ts +1 -0
  22. package/dist/table/TableCollection.d.ts +30 -0
  23. package/dist/table/createTreeGridState.d.ts +15 -0
  24. package/dist/table/index.d.ts +2 -1
  25. package/dist/table/types.d.ts +58 -0
  26. package/dist/toast/createToastState.d.ts +16 -0
  27. package/dist/toast/index.d.ts +1 -1
  28. package/dist/tree/types.d.ts +14 -6
  29. package/package.json +1 -1
  30. package/src/calendar/IncompleteDate.ts +430 -0
  31. package/src/calendar/createCalendarState.ts +104 -37
  32. package/src/calendar/createDateFieldState.ts +262 -261
  33. package/src/calendar/createRangeCalendarState.ts +131 -19
  34. package/src/calendar/index.ts +2 -0
  35. package/src/collections/createListState.ts +50 -19
  36. package/src/collections/createMenuState.ts +3 -0
  37. package/src/collections/createSelectionState.ts +38 -28
  38. package/src/collections/getChildNodes.ts +98 -0
  39. package/src/collections/index.ts +9 -0
  40. package/src/combobox/createComboBoxState.ts +8 -0
  41. package/src/dnd/index.d.ts +1 -0
  42. package/src/dnd/index.ts +1 -0
  43. package/src/dnd/types.d.ts +3 -1
  44. package/src/dnd/types.ts +4 -1
  45. package/src/grid/createGridState.ts +36 -2
  46. package/src/grid/types.ts +17 -3
  47. package/src/index.d.ts +1 -0
  48. package/src/index.ts +11 -0
  49. package/src/select/createSelectState.ts +15 -2
  50. package/src/selection/Selection.ts +28 -0
  51. package/src/selection/SelectionManager.ts +526 -0
  52. package/src/selection/createMultipleSelectionState.ts +247 -0
  53. package/src/selection/index.ts +6 -0
  54. package/src/table/TableCollection.ts +213 -43
  55. package/src/table/createTableState.ts +20 -0
  56. package/src/table/createTreeGridState.ts +146 -0
  57. package/src/table/index.ts +3 -0
  58. package/src/table/types.ts +66 -0
  59. package/src/tabs/createTabListState.ts +11 -9
  60. package/src/toast/createToastState.ts +43 -10
  61. package/src/toast/index.ts +1 -0
  62. package/src/tree/createTreeState.ts +37 -6
  63. package/src/tree/types.ts +14 -6
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Raw multiple-selection + focus state for a collection.
3
+ *
4
+ * The lower of the two selection layers ported from upstream
5
+ * (`@react-stately/selection`'s `useMultipleSelectionState`): it owns the
6
+ * selected keys, focus, selection behavior, and disabled keys, but is NOT
7
+ * collection-aware. The collection-aware operations (toggle, extend, select
8
+ * all, range, etc.) live in {@link SelectionManager}, which is built on top of
9
+ * this state.
10
+ *
11
+ * Based on @react-stately/selection.
12
+ */
13
+
14
+ import { createSignal, createMemo, createComputed, untrack, type Accessor } from "solid-js";
15
+ import { access, type MaybeAccessor } from "../utils";
16
+ import type { SelectionStateProps } from "../collections/createSelectionState";
17
+ import type {
18
+ DisabledBehavior,
19
+ FocusStrategy,
20
+ Key,
21
+ Selection,
22
+ SelectionBehavior,
23
+ SelectionMode,
24
+ } from "../collections/types";
25
+ import { Selection as SelectionClass } from "./Selection";
26
+
27
+ /**
28
+ * The raw state consumed by {@link SelectionManager}. Mirrors upstream's
29
+ * `MultipleSelectionState`: property reads (not Solid accessors) so the manager
30
+ * can read `state.selectionMode` etc. exactly as upstream does — the getters
31
+ * read the underlying signals, so reads inside reactive scopes still track.
32
+ */
33
+ export interface MultipleSelectionState {
34
+ /** The type of selection that is allowed in the collection. */
35
+ readonly selectionMode: SelectionMode;
36
+ /** The selection behavior for the collection. */
37
+ readonly selectionBehavior: SelectionBehavior;
38
+ /** Sets the selection behavior for the collection. */
39
+ setSelectionBehavior(selectionBehavior: SelectionBehavior): void;
40
+ /** Whether the collection allows empty selection. */
41
+ readonly disallowEmptySelection: boolean;
42
+ /** Whether the collection is currently focused. */
43
+ readonly isFocused: boolean;
44
+ /** Sets whether the collection is focused. */
45
+ setFocused(isFocused: boolean): void;
46
+ /** The current focused key in the collection. */
47
+ readonly focusedKey: Key | null;
48
+ /** Whether the first or last child of the focused key should receive focus. */
49
+ readonly childFocusStrategy: FocusStrategy | null;
50
+ /** Sets the focused key, and optionally, whether the first or last child of that key should receive focus. */
51
+ setFocusedKey(key: Key | null, childFocusStrategy?: FocusStrategy): void;
52
+ /** The currently selected keys in the collection. */
53
+ readonly selectedKeys: Selection;
54
+ /** The last selection payload emitted through onSelectionChange. @internal */
55
+ readonly lastSelectionEvent: Selection | null;
56
+ /** Sets the selected keys in the collection. */
57
+ setSelectedKeys(keys: Selection): void;
58
+ /** Re-emits a selection payload without recomputing or mutating selection. @internal */
59
+ emitDuplicateSelectionEvent(keys?: Selection): void;
60
+ /** The currently disabled keys in the collection. */
61
+ readonly disabledKeys: Set<Key>;
62
+ /** Whether `disabledKeys` applies to all interactions, or only selection. */
63
+ readonly disabledBehavior: DisabledBehavior;
64
+ }
65
+
66
+ function equalSets(setA: Set<Key>, setB: Set<Key>): boolean {
67
+ if (setA.size !== setB.size) {
68
+ return false;
69
+ }
70
+
71
+ for (const item of setA) {
72
+ if (!setB.has(item)) {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ return true;
78
+ }
79
+
80
+ function convertSelection(
81
+ selection: "all" | Iterable<Key> | null | undefined,
82
+ defaultValue: Selection,
83
+ ): Selection;
84
+ function convertSelection(
85
+ selection: "all" | Iterable<Key> | null | undefined,
86
+ ): Selection | undefined;
87
+ function convertSelection(
88
+ selection: "all" | Iterable<Key> | null | undefined,
89
+ defaultValue?: Selection,
90
+ ): Selection | undefined {
91
+ if (!selection) {
92
+ return defaultValue;
93
+ }
94
+
95
+ return selection === "all" ? "all" : new SelectionClass(selection);
96
+ }
97
+
98
+ /**
99
+ * Manages raw state for multiple selection and focus in a collection.
100
+ */
101
+ export function createMultipleSelectionState(
102
+ props: MaybeAccessor<SelectionStateProps> = {},
103
+ ): MultipleSelectionState {
104
+ const getProps = () => access(props);
105
+
106
+ // Focus is a plain signal in Solid; upstream needs a ref + state pair only
107
+ // because React requires both a synchronous read and a re-render trigger.
108
+ const [isFocused, setIsFocused] = createSignal(false);
109
+ const [focusedKey, setFocusedKeySignal] = createSignal<Key | null>(null);
110
+ const [childFocusStrategy, setChildFocusStrategy] = createSignal<FocusStrategy | null>(null);
111
+
112
+ const defaultSelectedKeys = convertSelection(
113
+ getProps().defaultSelectedKeys,
114
+ new SelectionClass(),
115
+ );
116
+ const [uncontrolledSelectedKeys, setUncontrolledSelectedKeys] =
117
+ createSignal<Selection>(defaultSelectedKeys);
118
+
119
+ const selectedKeys = createMemo<Selection>(() => {
120
+ const p = getProps();
121
+ if (p.selectedKeys !== undefined) {
122
+ return convertSelection(p.selectedKeys, new SelectionClass());
123
+ }
124
+ return uncontrolledSelectedKeys();
125
+ });
126
+
127
+ // Selection behavior is seeded from the prop but kept in internal state so
128
+ // that `setSelectionBehavior` (e.g. the long-press-to-select touch flow) can
129
+ // override it. Two reactive rules mirror upstream's render-time logic.
130
+ const selectionBehaviorProp = (): SelectionBehavior => getProps().selectionBehavior ?? "toggle";
131
+
132
+ const [selectionBehavior, setSelectionBehaviorState] =
133
+ createSignal<SelectionBehavior>(selectionBehaviorProp());
134
+
135
+ // If the selectionBehavior prop is 'replace' but the current state is 'toggle'
136
+ // (e.g. due to a long press to enter selection mode on touch) and the
137
+ // selection becomes empty, reset the behavior back to 'replace'.
138
+ createComputed(() => {
139
+ const keys = selectedKeys();
140
+ if (
141
+ selectionBehaviorProp() === "replace" &&
142
+ untrack(selectionBehavior) === "toggle" &&
143
+ typeof keys === "object" &&
144
+ keys.size === 0
145
+ ) {
146
+ setSelectionBehaviorState("replace");
147
+ }
148
+ });
149
+
150
+ // If the selectionBehavior prop changes, sync it into state.
151
+ let lastSelectionBehavior = selectionBehaviorProp();
152
+ createComputed(() => {
153
+ const prop = selectionBehaviorProp();
154
+ if (prop !== lastSelectionBehavior) {
155
+ setSelectionBehaviorState(prop);
156
+ lastSelectionBehavior = prop;
157
+ }
158
+ });
159
+
160
+ const disabledKeys = createMemo<Set<Key>>(() => {
161
+ const keys = getProps().disabledKeys;
162
+ return keys ? new Set<Key>(keys) : new Set<Key>();
163
+ });
164
+
165
+ let lastSelectionEvent: Selection | null = null;
166
+
167
+ const emitSelectionEvent = (keys: Selection) => {
168
+ lastSelectionEvent = keys;
169
+ getProps().onSelectionChange?.(keys);
170
+ };
171
+
172
+ const setSelectedKeys = (keys: Selection) => {
173
+ const p = getProps();
174
+ const current = selectedKeys();
175
+ const shouldUpdate =
176
+ p.allowDuplicateSelectionEvents ||
177
+ keys === "all" ||
178
+ current === "all" ||
179
+ !equalSets(keys, current);
180
+
181
+ if (!shouldUpdate) {
182
+ return;
183
+ }
184
+
185
+ // Uncontrolled: store internally. Controlled: only notify.
186
+ if (p.selectedKeys === undefined) {
187
+ setUncontrolledSelectedKeys(keys);
188
+ }
189
+ emitSelectionEvent(keys);
190
+ };
191
+
192
+ const emitDuplicateSelectionEvent = (keys?: Selection) => {
193
+ const eventKeys = keys ?? lastSelectionEvent;
194
+ if (eventKeys == null) {
195
+ return;
196
+ }
197
+ emitSelectionEvent(eventKeys);
198
+ };
199
+
200
+ return {
201
+ get selectionMode() {
202
+ return getProps().selectionMode ?? "none";
203
+ },
204
+ get selectionBehavior() {
205
+ return selectionBehavior();
206
+ },
207
+ setSelectionBehavior(behavior: SelectionBehavior) {
208
+ setSelectionBehaviorState(behavior);
209
+ },
210
+ get disallowEmptySelection() {
211
+ return getProps().disallowEmptySelection ?? false;
212
+ },
213
+ get isFocused() {
214
+ return isFocused();
215
+ },
216
+ setFocused(f: boolean) {
217
+ setIsFocused(f);
218
+ },
219
+ get focusedKey() {
220
+ return focusedKey();
221
+ },
222
+ get childFocusStrategy() {
223
+ return childFocusStrategy();
224
+ },
225
+ setFocusedKey(key: Key | null, strategy: FocusStrategy = "first") {
226
+ setFocusedKeySignal(key);
227
+ setChildFocusStrategy(strategy);
228
+ },
229
+ get selectedKeys() {
230
+ return selectedKeys();
231
+ },
232
+ get lastSelectionEvent() {
233
+ return lastSelectionEvent;
234
+ },
235
+ setSelectedKeys,
236
+ emitDuplicateSelectionEvent,
237
+ get disabledKeys() {
238
+ return disabledKeys();
239
+ },
240
+ get disabledBehavior() {
241
+ return getProps().disabledBehavior ?? "all";
242
+ },
243
+ };
244
+ }
245
+
246
+ // Re-export the accessor type for convenience where a reactive read is wanted.
247
+ export type { Accessor };
@@ -11,6 +11,12 @@ export {
11
11
  type SelectionState,
12
12
  } from "../collections/createSelectionState";
13
13
 
14
+ export {
15
+ SelectionManager,
16
+ type SelectionManagerOptions,
17
+ type LayoutDelegate,
18
+ } from "./SelectionManager";
19
+
14
20
  export type {
15
21
  Key,
16
22
  Selection,
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * TableCollection - A collection class for table data.
3
3
  * Based on @react-stately/table/TableCollection.
4
+ *
5
+ * Supports a plain (flat) table and, when an `expandedKeys` option is supplied, a
6
+ * tree grid: `childRows` are walked recursively, each parent row keeps its full
7
+ * structure (cells followed by every child row), and only rows whose ancestors are
8
+ * all expanded are flattened into the navigable body. Mirrors the structure produced
9
+ * by `@react-stately/table`'s `generateTreeGridCollection`.
4
10
  */
5
11
 
6
12
  import type { Key } from "../collections/types";
@@ -24,6 +30,10 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
24
30
  private _keyMap: Map<Key, GridNode<T>> = new Map();
25
31
  private _rowHeaderColumnKeys: Set<Key>;
26
32
  private _size: number = 0;
33
+ /** Visible rows in DFS order. Drives size, navigation, iteration and the `rows` list. */
34
+ private _flattenedRows: GridNode<T>[] = [];
35
+ private _treeColumn: Key | null = null;
36
+ private _userColumnCount: number = 0;
27
37
 
28
38
  constructor(options: TableCollectionOptions<T>) {
29
39
  const {
@@ -33,8 +43,12 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
33
43
  getTextValue,
34
44
  showSelectionCheckboxes = false,
35
45
  rowHeaderColumnKeys,
46
+ expandedKeys,
36
47
  } = options;
37
48
 
49
+ // Passing `expandedKeys` (even an empty set) selects tree-grid mode.
50
+ const isTreeGrid = expandedKeys !== undefined;
51
+
38
52
  this._columns = this.buildColumns(columns, showSelectionCheckboxes);
39
53
 
40
54
  this._rowHeaderColumnKeys = rowHeaderColumnKeys ?? this.getDefaultRowHeaderColumnKeys();
@@ -55,8 +69,25 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
55
69
  this._keyMap.set("header", this._head);
56
70
  }
57
71
 
58
- const bodyRows = this.buildRows(rows, getKey, getTextValue);
59
- this._size = bodyRows.length;
72
+ let bodyChildren: GridNode<T>[];
73
+ if (isTreeGrid) {
74
+ const built = this.buildTreeRows(rows, expandedKeys, getKey, getTextValue);
75
+ bodyChildren = built.topLevelRows;
76
+ this._flattenedRows = built.flattenedRows;
77
+ this._userColumnCount = this.countLeafColumns();
78
+ this._treeColumn =
79
+ options.treeColumn !== undefined
80
+ ? options.treeColumn
81
+ : (this._rowHeaderColumnKeys.values().next().value ?? null);
82
+ } else {
83
+ const bodyRows = this.buildRows(rows, getKey, getTextValue);
84
+ bodyChildren = bodyRows;
85
+ this._flattenedRows = bodyRows;
86
+ this._userColumnCount = this.countLeafColumns();
87
+ this._treeColumn = null;
88
+ }
89
+
90
+ this._size = this._flattenedRows.length;
60
91
 
61
92
  this._body = {
62
93
  type: "item" as GridNodeType,
@@ -64,13 +95,17 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
64
95
  index: this._headerRows.length,
65
96
  level: 0,
66
97
  hasChildNodes: true,
67
- childNodes: bodyRows,
98
+ // The body's children retain the full tree structure (top-level rows with their
99
+ // child rows nested inside) so sibling-based aria-posinset/aria-setsize work; the
100
+ // flattened visible list lives in `_flattenedRows`. For a flat table these are the
101
+ // same array.
102
+ childNodes: bodyChildren,
68
103
  value: null,
69
104
  textValue: "",
70
105
  };
71
106
  this._keyMap.set("body", this._body);
72
107
 
73
- this._rows = [...this._headerRows, ...bodyRows];
108
+ this._rows = [...this._headerRows, ...this._flattenedRows];
74
109
  }
75
110
 
76
111
  private buildColumns(
@@ -158,6 +193,23 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
158
193
  return new Set();
159
194
  }
160
195
 
196
+ /** Counts the leaf columns supplied by the user (excludes the selection column). */
197
+ private countLeafColumns(): number {
198
+ let count = 0;
199
+ const visit = (col: GridNode<T>) => {
200
+ if (col.key === "__selection__") {
201
+ return;
202
+ }
203
+ if (col.childNodes.length > 0) {
204
+ col.childNodes.forEach(visit);
205
+ } else {
206
+ count++;
207
+ }
208
+ };
209
+ this._columns.forEach(visit);
210
+ return count;
211
+ }
212
+
161
213
  private buildHeaderRows(): GridNode<T>[] {
162
214
  if (this._columns.length === 0) {
163
215
  return [];
@@ -194,6 +246,47 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
194
246
  return [headerRow];
195
247
  }
196
248
 
249
+ /**
250
+ * Builds the cell nodes for one row. Does not register the cells in the key map;
251
+ * the caller registers cells once the owning row is known to be present.
252
+ */
253
+ private buildCells(
254
+ rowKey: Key,
255
+ value: T,
256
+ rowLevel: number,
257
+ getTextValue?: (item: T, column: ColumnDefinition<T>) => string,
258
+ ): GridNode<T>[] {
259
+ return this._columns.map((col, colIndex) => {
260
+ const cellKey = `${rowKey}-${col.key}`;
261
+ const columnDefinition =
262
+ (col.props?.columnDefinition as ColumnDefinition<T> | undefined) ??
263
+ ({ key: col.key } as ColumnDefinition<T>);
264
+ const cellTextValue =
265
+ col.key === "__selection__"
266
+ ? "Selection"
267
+ : getTextValue
268
+ ? getTextValue(value, columnDefinition)
269
+ : String((value as Record<string, unknown>)?.[String(col.key)] ?? "");
270
+
271
+ const cell: GridNode<T> = {
272
+ type: this._rowHeaderColumnKeys.has(col.key)
273
+ ? ("rowheader" as GridNodeType)
274
+ : ("cell" as GridNodeType),
275
+ key: cellKey,
276
+ index: colIndex,
277
+ column: colIndex,
278
+ level: rowLevel + 1,
279
+ hasChildNodes: false,
280
+ childNodes: [],
281
+ value,
282
+ textValue: cellTextValue,
283
+ parentKey: rowKey,
284
+ };
285
+
286
+ return cell;
287
+ });
288
+ }
289
+
197
290
  private buildRows(
198
291
  rows: RowDefinition<T>[] | T[],
199
292
  getKey?: (item: T) => Key,
@@ -214,36 +307,8 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
214
307
  ? ((row as Record<string, unknown>).textValue as string)
215
308
  : undefined;
216
309
 
217
- const cells: GridNode<T>[] = this._columns.map((col, colIndex) => {
218
- const cellKey = `${key}-${col.key}`;
219
- const columnDefinition =
220
- (col.props?.columnDefinition as ColumnDefinition<T> | undefined) ??
221
- ({ key: col.key } as ColumnDefinition<T>);
222
- const cellTextValue =
223
- col.key === "__selection__"
224
- ? "Selection"
225
- : getTextValue
226
- ? getTextValue(value, columnDefinition)
227
- : String((value as Record<string, unknown>)?.[String(col.key)] ?? "");
228
-
229
- const cell: GridNode<T> = {
230
- type: this._rowHeaderColumnKeys.has(col.key)
231
- ? ("rowheader" as GridNodeType)
232
- : ("cell" as GridNodeType),
233
- key: cellKey,
234
- index: colIndex,
235
- column: colIndex,
236
- level: 1,
237
- hasChildNodes: false,
238
- childNodes: [],
239
- value,
240
- textValue: cellTextValue,
241
- parentKey: key,
242
- };
243
-
244
- this._keyMap.set(cellKey, cell);
245
- return cell;
246
- });
310
+ const cells = this.buildCells(key, value as T, 0, getTextValue);
311
+ cells.forEach((cell) => this._keyMap.set(cell.key, cell));
247
312
 
248
313
  const rowNode: GridNode<T> = {
249
314
  type: "item" as GridNodeType,
@@ -264,6 +329,97 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
264
329
  return result;
265
330
  }
266
331
 
332
+ /**
333
+ * Builds the tree-grid rows: every row keeps its full structure (cells then all child
334
+ * rows, expanded or not) while only the rows whose ancestors are all expanded are
335
+ * registered in the key map and pushed into the flattened body. Mirrors
336
+ * `generateTreeGridCollection` upstream.
337
+ */
338
+ private buildTreeRows(
339
+ rows: RowDefinition<T>[] | T[],
340
+ expandedKeys: "all" | Set<Key>,
341
+ getKey?: (item: T) => Key,
342
+ getTextValue?: (item: T, column: ColumnDefinition<T>) => string,
343
+ ): { topLevelRows: GridNode<T>[]; flattenedRows: GridNode<T>[] } {
344
+ const isExpanded = (key: Key): boolean => expandedKeys === "all" || expandedKeys.has(key);
345
+
346
+ // Build the full tree structure without touching the key map. `index` is the row's
347
+ // position among its same-level siblings, so aria-posinset/aria-setsize read directly.
348
+ const buildRowNode = (
349
+ row: RowDefinition<T> | T,
350
+ level: number,
351
+ parentKey: Key | null,
352
+ indexAmongSiblings: number,
353
+ ): GridNode<T> => {
354
+ const isRowDef = this.isRowDefinition(row);
355
+ const key = isRowDef ? row.key : (getKey?.(row as T) ?? indexAmongSiblings);
356
+ const value = (isRowDef ? row.value : (row as T)) as T;
357
+ const textValue = isRowDef
358
+ ? row.textValue
359
+ : typeof (row as Record<string, unknown>)?.textValue === "string"
360
+ ? ((row as Record<string, unknown>).textValue as string)
361
+ : undefined;
362
+
363
+ const cells = this.buildCells(key, value, level, getTextValue);
364
+
365
+ const childRowDefs = isRowDef ? (row.childRows ?? []) : [];
366
+ const hasChildRows = childRowDefs.length > 0 || (isRowDef && !!row.hasChildRows);
367
+
368
+ const childRowNodes = childRowDefs.map((child, childIdx) =>
369
+ buildRowNode(child, level + 1, key, childIdx),
370
+ );
371
+
372
+ const childNodes = [...cells, ...childRowNodes];
373
+ const rowNode: GridNode<T> = {
374
+ type: "item" as GridNodeType,
375
+ key,
376
+ index: indexAmongSiblings,
377
+ level,
378
+ parentKey,
379
+ hasChildNodes: true,
380
+ childNodes,
381
+ value,
382
+ textValue: textValue ?? cells.map((c) => c.textValue).join(" "),
383
+ isExpandable: hasChildRows,
384
+ isExpanded: hasChildRows && isExpanded(key),
385
+ firstChildKey: childNodes[0]?.key ?? null,
386
+ lastChildKey: childNodes[childNodes.length - 1]?.key ?? null,
387
+ };
388
+
389
+ return rowNode;
390
+ };
391
+
392
+ const topLevelRows = rows.map((row, i) => buildRowNode(row, 0, "body", i));
393
+
394
+ // Flatten the visible rows (DFS). A row is visited iff every ancestor is expanded;
395
+ // visiting a row registers it and its cells, then descends into its child rows only
396
+ // when the row itself is expanded.
397
+ const flattenedRows: GridNode<T>[] = [];
398
+ const rowIndexOffset = this._headerRows.length;
399
+ const visit = (node: GridNode<T>) => {
400
+ node.rowIndex = rowIndexOffset + flattenedRows.length;
401
+ flattenedRows.push(node);
402
+ this._keyMap.set(node.key, node);
403
+ for (const child of node.childNodes) {
404
+ if (child.type !== "item") {
405
+ this._keyMap.set(child.key, child);
406
+ }
407
+ }
408
+ if (isExpanded(node.key)) {
409
+ for (const child of node.childNodes) {
410
+ if (child.type === "item") {
411
+ visit(child);
412
+ }
413
+ }
414
+ }
415
+ };
416
+ for (const node of topLevelRows) {
417
+ visit(node);
418
+ }
419
+
420
+ return { topLevelRows, flattenedRows };
421
+ }
422
+
267
423
  private isRowDefinition(row: unknown): row is RowDefinition<T> {
268
424
  return typeof row === "object" && row !== null && "key" in row && "value" in row;
269
425
  }
@@ -304,6 +460,21 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
304
460
  return this._rowHeaderColumnKeys;
305
461
  }
306
462
 
463
+ /** The key of the column that renders the tree-grid affordance, or `null` for a flat table. */
464
+ get treeColumn(): Key | null {
465
+ return this._treeColumn;
466
+ }
467
+
468
+ /** The number of leaf columns provided by the user (excludes the selection column). */
469
+ get userColumnCount(): number {
470
+ return this._userColumnCount;
471
+ }
472
+
473
+ /** The key map of all present nodes (visible rows, their cells, columns and the body). */
474
+ get keyMap(): Map<Key, GridNode<T>> {
475
+ return this._keyMap;
476
+ }
477
+
307
478
  getKeys(): Iterable<Key> {
308
479
  return this._keyMap.keys();
309
480
  }
@@ -313,11 +484,10 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
313
484
  }
314
485
 
315
486
  at(index: number): GridNode<T> | null {
316
- const bodyRows = this._body.childNodes;
317
- if (index < 0 || index >= bodyRows.length) {
487
+ if (index < 0 || index >= this._flattenedRows.length) {
318
488
  return null;
319
489
  }
320
- return bodyRows[index];
490
+ return this._flattenedRows[index];
321
491
  }
322
492
 
323
493
  getChildren(key: Key): Iterable<GridNode<T>> {
@@ -336,12 +506,12 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
336
506
  }
337
507
 
338
508
  getFirstKey(): Key | null {
339
- const bodyRows = this._body.childNodes;
509
+ const bodyRows = this._flattenedRows;
340
510
  return bodyRows.length > 0 ? bodyRows[0].key : null;
341
511
  }
342
512
 
343
513
  getLastKey(): Key | null {
344
- const bodyRows = this._body.childNodes;
514
+ const bodyRows = this._flattenedRows;
345
515
  return bodyRows.length > 0 ? bodyRows[bodyRows.length - 1].key : null;
346
516
  }
347
517
 
@@ -349,7 +519,7 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
349
519
  const node = this._keyMap.get(key);
350
520
  if (!node) return null;
351
521
 
352
- const bodyRows = this._body.childNodes;
522
+ const bodyRows = this._flattenedRows;
353
523
  const index = bodyRows.findIndex((r) => r.key === key);
354
524
  if (index > 0) {
355
525
  return bodyRows[index - 1].key;
@@ -361,7 +531,7 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
361
531
  const node = this._keyMap.get(key);
362
532
  if (!node) return null;
363
533
 
364
- const bodyRows = this._body.childNodes;
534
+ const bodyRows = this._flattenedRows;
365
535
  const index = bodyRows.findIndex((r) => r.key === key);
366
536
  if (index >= 0 && index < bodyRows.length - 1) {
367
537
  return bodyRows[index + 1].key;
@@ -370,8 +540,8 @@ export class TableCollection<T = unknown> implements ITableCollection<T> {
370
540
  }
371
541
 
372
542
  *[Symbol.iterator](): Iterator<GridNode<T>> {
373
- // Only iterate body rows, not header rows
374
- for (const row of this._body.childNodes) {
543
+ // Only iterate visible body rows, not header rows.
544
+ for (const row of this._flattenedRows) {
375
545
  yield row;
376
546
  }
377
547
  }
@@ -33,6 +33,7 @@ export function createTableState<
33
33
  const gridState = createGridState<T, C>(() => ({
34
34
  collection: getOptions().collection,
35
35
  disabledKeys: getOptions().disabledKeys,
36
+ disabledBehavior: getOptions().disabledBehavior,
36
37
  focusMode: getOptions().focusMode,
37
38
  selectionMode: getOptions().selectionMode,
38
39
  selectionBehavior: getOptions().selectionBehavior,
@@ -83,6 +84,9 @@ export function createTableState<
83
84
  get disabledKeys() {
84
85
  return gridState.disabledKeys;
85
86
  },
87
+ get disabledBehavior() {
88
+ return gridState.disabledBehavior;
89
+ },
86
90
  get isKeyboardNavigationDisabled() {
87
91
  return gridState.isKeyboardNavigationDisabled;
88
92
  },
@@ -101,6 +105,9 @@ export function createTableState<
101
105
  get selectionBehavior() {
102
106
  return gridState.selectionBehavior;
103
107
  },
108
+ get disallowEmptySelection() {
109
+ return gridState.disallowEmptySelection;
110
+ },
104
111
  get selectedKeys() {
105
112
  return gridState.selectedKeys;
106
113
  },
@@ -112,6 +119,8 @@ export function createTableState<
112
119
  setFocused: gridState.setFocused,
113
120
  toggleSelection: gridState.toggleSelection,
114
121
  replaceSelection: gridState.replaceSelection,
122
+ setSelectionBehavior: gridState.setSelectionBehavior,
123
+ setSelectedKeys: gridState.setSelectedKeys,
115
124
  extendSelection: gridState.extendSelection,
116
125
  selectAll: gridState.selectAll,
117
126
  clearSelection: gridState.clearSelection,
@@ -126,5 +135,16 @@ export function createTableState<
126
135
  return sortDescriptor();
127
136
  },
128
137
  sort,
138
+
139
+ // A plain table has no expandable rows.
140
+ get expandedKeys(): "all" | Set<Key> {
141
+ return EMPTY_EXPANDED_KEYS;
142
+ },
143
+ get treeColumn(): Key | null {
144
+ return getOptions().collection.treeColumn ?? null;
145
+ },
146
+ toggleKey: () => {},
129
147
  };
130
148
  }
149
+
150
+ const EMPTY_EXPANDED_KEYS: Set<Key> = new Set();