@cdx-ui/data-grid 0.3.12 → 0.3.13

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 (67) hide show
  1. package/coverage.svg +1 -1
  2. package/lib/commonjs/DataGrid.js +279 -40
  3. package/lib/commonjs/DataGrid.js.map +1 -1
  4. package/lib/commonjs/DataGridFilterPanel.js +136 -9
  5. package/lib/commonjs/DataGridFilterPanel.js.map +1 -1
  6. package/lib/commonjs/adapter/columns.js +16 -8
  7. package/lib/commonjs/adapter/columns.js.map +1 -1
  8. package/lib/commonjs/adapter/pinning.js +5 -1
  9. package/lib/commonjs/adapter/pinning.js.map +1 -1
  10. package/lib/commonjs/adapter/selectColumn.js +11 -0
  11. package/lib/commonjs/adapter/selectColumn.js.map +1 -1
  12. package/lib/commonjs/adapter/treeData.js +195 -0
  13. package/lib/commonjs/adapter/treeData.js.map +1 -0
  14. package/lib/commonjs/styles.js +48 -2
  15. package/lib/commonjs/styles.js.map +1 -1
  16. package/lib/commonjs/types.js +11 -1
  17. package/lib/commonjs/types.js.map +1 -1
  18. package/lib/commonjs/useDataGridTable.js +143 -12
  19. package/lib/commonjs/useDataGridTable.js.map +1 -1
  20. package/lib/module/DataGrid.js +285 -46
  21. package/lib/module/DataGrid.js.map +1 -1
  22. package/lib/module/DataGridFilterPanel.js +139 -13
  23. package/lib/module/DataGridFilterPanel.js.map +1 -1
  24. package/lib/module/adapter/columns.js +17 -9
  25. package/lib/module/adapter/columns.js.map +1 -1
  26. package/lib/module/adapter/pinning.js +5 -1
  27. package/lib/module/adapter/pinning.js.map +1 -1
  28. package/lib/module/adapter/selectColumn.js +11 -1
  29. package/lib/module/adapter/selectColumn.js.map +1 -1
  30. package/lib/module/adapter/treeData.js +187 -0
  31. package/lib/module/adapter/treeData.js.map +1 -0
  32. package/lib/module/styles.js +47 -1
  33. package/lib/module/styles.js.map +1 -1
  34. package/lib/module/types.js +10 -0
  35. package/lib/module/types.js.map +1 -1
  36. package/lib/module/useDataGridTable.js +147 -16
  37. package/lib/module/useDataGridTable.js.map +1 -1
  38. package/lib/typescript/DataGrid.d.ts.map +1 -1
  39. package/lib/typescript/DataGridFilterPanel.d.ts +39 -4
  40. package/lib/typescript/DataGridFilterPanel.d.ts.map +1 -1
  41. package/lib/typescript/adapter/columns.d.ts +1 -1
  42. package/lib/typescript/adapter/columns.d.ts.map +1 -1
  43. package/lib/typescript/adapter/pinning.d.ts +1 -0
  44. package/lib/typescript/adapter/pinning.d.ts.map +1 -1
  45. package/lib/typescript/adapter/selectColumn.d.ts +8 -0
  46. package/lib/typescript/adapter/selectColumn.d.ts.map +1 -1
  47. package/lib/typescript/adapter/treeData.d.ts +36 -0
  48. package/lib/typescript/adapter/treeData.d.ts.map +1 -0
  49. package/lib/typescript/index.d.ts +1 -1
  50. package/lib/typescript/index.d.ts.map +1 -1
  51. package/lib/typescript/styles.d.ts +12 -1
  52. package/lib/typescript/styles.d.ts.map +1 -1
  53. package/lib/typescript/types.d.ts +68 -0
  54. package/lib/typescript/types.d.ts.map +1 -1
  55. package/lib/typescript/useDataGridTable.d.ts +25 -3
  56. package/lib/typescript/useDataGridTable.d.ts.map +1 -1
  57. package/package.json +5 -5
  58. package/src/DataGrid.tsx +399 -59
  59. package/src/DataGridFilterPanel.tsx +188 -17
  60. package/src/adapter/columns.ts +21 -7
  61. package/src/adapter/pinning.ts +10 -1
  62. package/src/adapter/selectColumn.ts +12 -1
  63. package/src/adapter/treeData.ts +228 -0
  64. package/src/index.ts +4 -0
  65. package/src/styles.ts +57 -1
  66. package/src/types.ts +76 -0
  67. package/src/useDataGridTable.ts +217 -15
@@ -1,6 +1,12 @@
1
- import { useRef, type RefObject } from 'react';
2
- import { View } from 'react-native';
3
- import { useDismissOverlay, useEscapeKey } from '@cdx-ui/primitives';
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import {
4
+ OverlayContainer,
5
+ useAnchoredOverlay,
6
+ useDismissOverlay,
7
+ useEscapeKey,
8
+ } from '@cdx-ui/primitives';
9
+ import { mergeRefs } from '@cdx-ui/utils';
4
10
  import {
5
11
  Button,
6
12
  DateField,
@@ -19,6 +25,7 @@ import {
19
25
  getFilterOperatorLabel,
20
26
  getGridFilterOperators,
21
27
  headerFilterValueFromText,
28
+ isActiveFilterItem,
22
29
  } from './adapter/filterOperators';
23
30
  import {
24
31
  dataGridFilterPanelColumnVariants,
@@ -171,6 +178,108 @@ export function createDefaultFilterItem<R extends GridValidRowModel>(
171
178
  return { field: col.field, operator: getDefaultFilterOperator(col.type) };
172
179
  }
173
180
 
181
+ function createFilterItemForField<R extends GridValidRowModel>(
182
+ columns: readonly GridColDef<R>[],
183
+ field: string | undefined,
184
+ ): GridFilterItem | undefined {
185
+ const col =
186
+ field === undefined ? undefined : columns.find((candidate) => candidate.field === field);
187
+ return col && col.filterable !== false
188
+ ? { field: col.field, operator: getDefaultFilterOperator(col.type) }
189
+ : undefined;
190
+ }
191
+
192
+ const FOCUSABLE_SELECTOR = 'input, textarea, button, [tabindex]:not([tabindex="-1"])';
193
+
194
+ function focusFirstControl(container: View | null): boolean {
195
+ const element = container as unknown as HTMLElement | null;
196
+ const control = element?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
197
+ if (!control) {
198
+ return false;
199
+ }
200
+ control.focus();
201
+ return true;
202
+ }
203
+
204
+ function focusOpener(ref: RefObject<View | null>) {
205
+ const element = ref.current as unknown as HTMLElement | null;
206
+ if (Platform.OS === 'web' && typeof element?.focus === 'function') {
207
+ element.focus();
208
+ }
209
+ }
210
+
211
+ /** The control that opened the panel and, for a column opener, the field it targets. */
212
+ export interface DataGridFilterPanelOpener {
213
+ ref: RefObject<View | null>;
214
+ field?: string;
215
+ /** Changes on every open-for-field call, so a repeat call moves focus again. */
216
+ request?: number;
217
+ }
218
+
219
+ export interface UseDataGridFilterPanelParams<R extends GridValidRowModel> {
220
+ /** The panel's columns. A field that is missing or has `filterable: false` does not open it. */
221
+ columns: readonly GridColDef<R>[];
222
+ filterModel: GridFilterModel;
223
+ /** Receives the appended item. It filters nothing, so callers should not reset the page. */
224
+ onFilterModelChange: (model: GridFilterModel) => void;
225
+ }
226
+
227
+ export interface UseDataGridFilterPanelResult {
228
+ opener: DataGridFilterPanelOpener | null;
229
+ /** Opens the panel on `field`, or retargets it when already open. Header filter cells and the
230
+ * column menu call this; the column menu passes its trigger, since the menu item unmounts. */
231
+ openForField: (field: string, ref: RefObject<View | null>) => void;
232
+ /** The toolbar button: opens the panel on the model as it is, or closes it. */
233
+ toggle: (ref: RefObject<View | null>) => void;
234
+ close: (options: { restoreFocus: boolean }) => void;
235
+ }
236
+
237
+ /**
238
+ * Open state for the filter panel. Opening for a field shows the field's first item, even a
239
+ * value-less one, and leaves the model alone. When the field has none, it appends
240
+ * `{ field, operator }` and drops value-less items on other fields, as the append branch of MUI X
241
+ * `showFilterPanel(field)` does. MUI also rewrites the model when the field has an item.
242
+ */
243
+ export function useDataGridFilterPanel<R extends GridValidRowModel>({
244
+ columns,
245
+ filterModel,
246
+ onFilterModelChange,
247
+ }: UseDataGridFilterPanelParams<R>): UseDataGridFilterPanelResult {
248
+ const [opener, setOpener] = useState<DataGridFilterPanelOpener | null>(null);
249
+ const requestRef = useRef(0);
250
+
251
+ const openForField = useCallback(
252
+ (field: string, ref: RefObject<View | null>) => {
253
+ const item = createFilterItemForField(columns, field);
254
+ if (!item) {
255
+ return;
256
+ }
257
+ if (!filterModel.some((candidate) => candidate.field === field)) {
258
+ onFilterModelChange([...filterModel.filter(isActiveFilterItem), item]);
259
+ }
260
+ requestRef.current += 1;
261
+ setOpener({ ref, field, request: requestRef.current });
262
+ },
263
+ [columns, filterModel, onFilterModelChange],
264
+ );
265
+
266
+ const toggle = useCallback((ref: RefObject<View | null>) => {
267
+ setOpener((current) => (current?.ref === ref ? null : { ref }));
268
+ }, []);
269
+
270
+ const close = useCallback(
271
+ ({ restoreFocus }: { restoreFocus: boolean }) => {
272
+ if (opener && restoreFocus) {
273
+ focusOpener(opener.ref);
274
+ }
275
+ setOpener(null);
276
+ },
277
+ [opener],
278
+ );
279
+
280
+ return { opener, openForField, toggle, close };
281
+ }
282
+
174
283
  function resolveItemOperator<R extends GridValidRowModel>(
175
284
  col: GridColDef<R> | undefined,
176
285
  item: GridFilterItem,
@@ -199,6 +308,8 @@ function itemWithValueText<R extends GridValidRowModel>(
199
308
 
200
309
  function FilterPanelRow<R extends GridValidRowModel>({
201
310
  index,
311
+ operatorFieldRef,
312
+ valueFieldRef,
202
313
  item,
203
314
  columns,
204
315
  logicOperator,
@@ -207,6 +318,8 @@ function FilterPanelRow<R extends GridValidRowModel>({
207
318
  onRemove,
208
319
  }: {
209
320
  index: number;
321
+ operatorFieldRef?: RefObject<View | null>;
322
+ valueFieldRef?: RefObject<View | null>;
210
323
  item: GridFilterItem;
211
324
  columns: readonly GridColDef<R>[];
212
325
  logicOperator: GridLogicOperator;
@@ -293,7 +406,10 @@ function FilterPanelRow<R extends GridValidRowModel>({
293
406
  </Select.Content>
294
407
  </Select>
295
408
  </View>
296
- <View className={dataGridFilterPanelFieldVariants({ size: 'operator' })}>
409
+ <View
410
+ ref={operatorFieldRef}
411
+ className={dataGridFilterPanelFieldVariants({ size: 'operator' })}
412
+ >
297
413
  <Text className="text-xs text-content-secondary">Operator</Text>
298
414
  <Select
299
415
  items={operators.map((candidate) => ({
@@ -332,7 +448,7 @@ function FilterPanelRow<R extends GridValidRowModel>({
332
448
  </Select.Content>
333
449
  </Select>
334
450
  </View>
335
- <View className={dataGridFilterPanelFieldVariants({ size: 'value' })}>
451
+ <View ref={valueFieldRef} className={dataGridFilterPanelFieldVariants({ size: 'value' })}>
336
452
  {filterOperatorNeedsValue(operator) ? (
337
453
  <>
338
454
  <Text className="text-xs text-content-secondary">Value</Text>
@@ -393,9 +509,10 @@ export interface DataGridFilterPanelProps<R extends GridValidRowModel> {
393
509
  onFilterModelChange: (model: GridFilterModel) => void;
394
510
  logicOperator: GridLogicOperator;
395
511
  onLogicOperatorChange: (operator: GridLogicOperator) => void;
396
- onClose: () => void;
397
- /** The control that toggles the panel; presses on it are not treated as outside presses. */
398
- triggerRef?: RefObject<View | null>;
512
+ /** `restoreFocus` is false for an outside press, which leaves focus where the user pressed. */
513
+ onClose: (options: { restoreFocus: boolean }) => void;
514
+ /** Presses on the opener are not outside presses. A column opener also anchors the panel. */
515
+ opener: DataGridFilterPanelOpener;
399
516
  }
400
517
 
401
518
  export function DataGridFilterPanel<R extends GridValidRowModel>({
@@ -405,14 +522,58 @@ export function DataGridFilterPanel<R extends GridValidRowModel>({
405
522
  logicOperator,
406
523
  onLogicOperatorChange,
407
524
  onClose,
408
- triggerRef,
525
+ opener,
409
526
  }: DataGridFilterPanelProps<R>) {
527
+ const anchored = opener.field !== undefined;
410
528
  const panelRef = useRef<View>(null);
411
- useDismissOverlay(true, panelRef, triggerRef ? [triggerRef] : [], onClose);
412
- useEscapeKey(true, onClose);
529
+ const operatorFieldRef = useRef<View>(null);
530
+ const valueFieldRef = useRef<View>(null);
531
+ const focusedRequestRef = useRef<number | undefined>(undefined);
532
+ const {
533
+ floatingRef,
534
+ onFloatingLayout,
535
+ style: anchoredStyle,
536
+ position,
537
+ } = useAnchoredOverlay(opener.ref, anchored, {
538
+ placement: 'bottom-start',
539
+ collisionBehavior: 'flip-shift',
540
+ width: 'content',
541
+ });
542
+ const dismiss = () => onClose({ restoreFocus: false });
543
+ const closeAndRestoreFocus = () => onClose({ restoreFocus: true });
544
+ useDismissOverlay(true, panelRef, [opener.ref], dismiss);
545
+ useEscapeKey(true, closeAndRestoreFocus);
413
546
 
414
547
  const defaultItem = createDefaultFilterItem(columns);
415
- const items = filterModel.length > 0 ? filterModel : defaultItem ? [defaultItem] : [];
548
+ const placeholderItem = createFilterItemForField(columns, opener.field) ?? defaultItem;
549
+ const items = filterModel.length > 0 ? filterModel : placeholderItem ? [placeholderItem] : [];
550
+ const targetIndex =
551
+ opener.field === undefined ? -1 : items.findIndex((item) => item.field === opener.field);
552
+ const targetItem = targetIndex >= 0 ? items[targetIndex] : undefined;
553
+ const targetNeedsValue =
554
+ targetItem !== undefined &&
555
+ filterOperatorNeedsValue(
556
+ resolveItemOperator(
557
+ columns.find((candidate) => candidate.field === targetItem.field),
558
+ targetItem,
559
+ ),
560
+ );
561
+
562
+ // Rows are keyed by index, so an appended target row can mount a render after the open request.
563
+ // Retry on each commit until the target control exists, then stop for that request.
564
+ useEffect(() => {
565
+ if (
566
+ Platform.OS !== 'web' ||
567
+ opener.request === undefined ||
568
+ focusedRequestRef.current === opener.request ||
569
+ targetItem === undefined
570
+ ) {
571
+ return;
572
+ }
573
+ if (focusFirstControl((targetNeedsValue ? valueFieldRef : operatorFieldRef).current)) {
574
+ focusedRequestRef.current = opener.request;
575
+ }
576
+ });
416
577
 
417
578
  const replaceItem = (index: number, next: GridFilterItem) => {
418
579
  onFilterModelChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)));
@@ -421,14 +582,20 @@ export function DataGridFilterPanel<R extends GridValidRowModel>({
421
582
  const next = items.filter((_, itemIndex) => itemIndex !== index);
422
583
  onFilterModelChange(next);
423
584
  if (next.length === 0) {
424
- onClose();
585
+ closeAndRestoreFocus();
425
586
  }
426
587
  };
427
588
 
428
- return (
589
+ const panel = (
429
590
  <View
430
- ref={panelRef}
431
- className={dataGridFilterPanelVariants()}
591
+ ref={anchored ? mergeRefs(panelRef, floatingRef) : panelRef}
592
+ className={dataGridFilterPanelVariants({ anchored })}
593
+ style={
594
+ anchored
595
+ ? [anchoredStyle, position ? { maxWidth: position.availableWidth } : undefined]
596
+ : undefined
597
+ }
598
+ onLayout={anchored ? onFloatingLayout : undefined}
432
599
  accessibilityLabel="Filters"
433
600
  testID="data-grid-filter-panel"
434
601
  >
@@ -436,6 +603,8 @@ export function DataGridFilterPanel<R extends GridValidRowModel>({
436
603
  <FilterPanelRow
437
604
  key={index}
438
605
  index={index}
606
+ operatorFieldRef={index === targetIndex ? operatorFieldRef : undefined}
607
+ valueFieldRef={index === targetIndex ? valueFieldRef : undefined}
439
608
  item={item}
440
609
  columns={columns}
441
610
  logicOperator={logicOperator}
@@ -465,7 +634,7 @@ export function DataGridFilterPanel<R extends GridValidRowModel>({
465
634
  size="small"
466
635
  onPress={() => {
467
636
  onFilterModelChange([]);
468
- onClose();
637
+ closeAndRestoreFocus();
469
638
  }}
470
639
  >
471
640
  <Button.Icon as={Delete} />
@@ -474,4 +643,6 @@ export function DataGridFilterPanel<R extends GridValidRowModel>({
474
643
  </View>
475
644
  </View>
476
645
  );
646
+
647
+ return anchored ? <OverlayContainer onDismiss={dismiss}>{panel}</OverlayContainer> : panel;
477
648
  }
@@ -1,6 +1,7 @@
1
1
  import type { TableAlignment } from '@cdx-ui/components';
2
2
  import type { ColumnPinningState } from '@tanstack/react-table';
3
3
  import {
4
+ DETAIL_PANEL_TOGGLE_ID,
4
5
  MUI_CHECKBOX_FIELD,
5
6
  SELECT_COLUMN_ID,
6
7
  type GridAlignment,
@@ -116,6 +117,7 @@ export function normalizePinnedColumns(
116
117
  pinnedColumns: GridPinnedColumnFields | undefined,
117
118
  knownFields: ReadonlySet<string>,
118
119
  checkboxSelection: boolean,
120
+ detailPanel = false,
119
121
  ): ColumnPinningState {
120
122
  if (pinnedColumns == null) {
121
123
  return { start: [], end: [] };
@@ -134,8 +136,7 @@ export function normalizePinnedColumns(
134
136
  if (seen.has(field)) {
135
137
  continue;
136
138
  }
137
- const isSelect = field === SELECT_COLUMN_ID;
138
- if (isSelect ? !checkboxSelection : !knownFields.has(field)) {
139
+ if (!isKnownPinField(field)) {
139
140
  warnUnknownPinnedField(raw);
140
141
  continue;
141
142
  }
@@ -144,6 +145,16 @@ export function normalizePinnedColumns(
144
145
  }
145
146
  };
146
147
 
148
+ const isKnownPinField = (field: string) => {
149
+ if (field === SELECT_COLUMN_ID) {
150
+ return checkboxSelection;
151
+ }
152
+ if (field === DETAIL_PANEL_TOGGLE_ID) {
153
+ return detailPanel;
154
+ }
155
+ return knownFields.has(field);
156
+ };
157
+
147
158
  const startSeen = new Set<string>();
148
159
  const start: string[] = [];
149
160
  appendUnique(start, startSeen, pinnedColumns.start);
@@ -166,8 +177,7 @@ export function normalizePinnedColumns(
166
177
  if (endSeen.has(field)) {
167
178
  continue;
168
179
  }
169
- const isSelect = field === SELECT_COLUMN_ID;
170
- if (isSelect ? !checkboxSelection : !knownFields.has(field)) {
180
+ if (!isKnownPinField(field)) {
171
181
  warnUnknownPinnedField(raw);
172
182
  continue;
173
183
  }
@@ -179,10 +189,14 @@ export function normalizePinnedColumns(
179
189
  // eslint-disable-next-line @typescript-eslint/no-deprecated -- alias merge
180
190
  appendEnd(pinnedColumns.right);
181
191
 
182
- if (checkboxSelection && start.length > 0) {
183
- const withoutSelect = start.filter((field) => field !== SELECT_COLUMN_ID);
192
+ if (start.length > 0) {
193
+ const leading = [
194
+ ...(checkboxSelection ? [SELECT_COLUMN_ID] : []),
195
+ ...(detailPanel ? [DETAIL_PANEL_TOGGLE_ID] : []),
196
+ ];
197
+ const rest = start.filter((field) => !leading.includes(field));
184
198
  start.length = 0;
185
- start.push(SELECT_COLUMN_ID, ...withoutSelect);
199
+ start.push(...leading, ...rest);
186
200
  }
187
201
 
188
202
  return { start, end };
@@ -1,9 +1,15 @@
1
1
  import type { ViewStyle } from 'react-native';
2
2
  import type { ColumnPinningState } from '@tanstack/react-table';
3
- import { SELECT_COLUMN_ID, type GridColDef, type GridValidRowModel } from '../types';
3
+ import {
4
+ DETAIL_PANEL_TOGGLE_ID,
5
+ SELECT_COLUMN_ID,
6
+ type GridColDef,
7
+ type GridValidRowModel,
8
+ } from '../types';
4
9
 
5
10
  export const DEFAULT_PINNED_COLUMN_WIDTH = 100;
6
11
  export const SELECT_COLUMN_WIDTH = 56;
12
+ export const DETAIL_PANEL_TOGGLE_WIDTH = 48;
7
13
 
8
14
  export type ColumnPinEdge = 'start' | 'end';
9
15
 
@@ -22,6 +28,9 @@ export function pinnedColumnPixelWidth<R extends GridValidRowModel>(
22
28
  if (columnId === SELECT_COLUMN_ID) {
23
29
  return SELECT_COLUMN_WIDTH;
24
30
  }
31
+ if (columnId === DETAIL_PANEL_TOGGLE_ID) {
32
+ return DETAIL_PANEL_TOGGLE_WIDTH;
33
+ }
25
34
  return Math.max(col?.width ?? DEFAULT_PINNED_COLUMN_WIDTH, col?.minWidth ?? 0);
26
35
  }
27
36
 
@@ -1,4 +1,4 @@
1
- import { SELECT_COLUMN_ID } from '../types';
1
+ import { DETAIL_PANEL_TOGGLE_ID, SELECT_COLUMN_ID } from '../types';
2
2
 
3
3
  export function createSelectColumn() {
4
4
  return {
@@ -10,3 +10,14 @@ export function createSelectColumn() {
10
10
  size: 48,
11
11
  } as const;
12
12
  }
13
+
14
+ export function createDetailPanelToggleColumn() {
15
+ return {
16
+ id: DETAIL_PANEL_TOGGLE_ID,
17
+ header: DETAIL_PANEL_TOGGLE_ID,
18
+ enableSorting: false,
19
+ enableColumnFilter: false,
20
+ enableGlobalFilter: false,
21
+ size: 48,
22
+ } as const;
23
+ }
@@ -0,0 +1,228 @@
1
+ export interface GridTreeIndex<R> {
2
+ roots: R[];
3
+ childrenOf: Map<R, R[]>;
4
+ rowOf: Map<string, R>;
5
+ parentKeyOf: Map<string, string>;
6
+ childKeysOf: Map<string, string[]>;
7
+ depthOf: Map<string, number>;
8
+ /** Deepest rows first, so a parent is visited after all of its descendants. */
9
+ keysDeepestFirst: string[];
10
+ }
11
+
12
+ export type GridTreeSelectionStatus = 'all' | 'some' | 'none';
13
+
14
+ /** The shape TanStack rows share; filtered and sorted rows carry their filtered `subRows`. */
15
+ export interface GridTreeRowLike {
16
+ id: string;
17
+ subRows: readonly GridTreeRowLike[];
18
+ }
19
+
20
+ const pathKey = (path: readonly string[]) => JSON.stringify(path);
21
+
22
+ const warnedDuplicatePaths = new Set<string>();
23
+
24
+ export function buildTreeIndex<R>(
25
+ rows: readonly R[],
26
+ getTreeDataPath: (row: R) => readonly string[],
27
+ keyOf: (row: R) => string,
28
+ ): GridTreeIndex<R> {
29
+ const byPath = new Map<string, R>();
30
+ const paths = new Map<R, readonly string[]>();
31
+ for (const row of rows) {
32
+ const path = getTreeDataPath(row);
33
+ const key = pathKey(path);
34
+ if (__DEV__ && byPath.has(key) && !warnedDuplicatePaths.has(key)) {
35
+ warnedDuplicatePaths.add(key);
36
+ console.warn(`[DataGrid] Duplicate tree data path ${key}. Paths must be unique.`);
37
+ }
38
+ byPath.set(key, row);
39
+ paths.set(row, path);
40
+ }
41
+
42
+ const roots: R[] = [];
43
+ const childrenOf = new Map<R, R[]>();
44
+ const rowOf = new Map<string, R>();
45
+ const parentKeyOf = new Map<string, string>();
46
+ const childKeysOf = new Map<string, string[]>();
47
+
48
+ for (const row of rows) {
49
+ const path = paths.get(row) ?? [];
50
+ const key = keyOf(row);
51
+ rowOf.set(key, row);
52
+ let parent: R | undefined;
53
+ for (let length = path.length - 1; length > 0 && parent === undefined; length -= 1) {
54
+ parent = byPath.get(pathKey(path.slice(0, length)));
55
+ }
56
+ if (parent === undefined || parent === row) {
57
+ roots.push(row);
58
+ continue;
59
+ }
60
+ const parentKey = keyOf(parent);
61
+ parentKeyOf.set(key, parentKey);
62
+ const siblings = childrenOf.get(parent);
63
+ if (siblings) {
64
+ siblings.push(row);
65
+ } else {
66
+ childrenOf.set(parent, [row]);
67
+ }
68
+ const siblingKeys = childKeysOf.get(parentKey);
69
+ if (siblingKeys) {
70
+ siblingKeys.push(key);
71
+ } else {
72
+ childKeysOf.set(parentKey, [key]);
73
+ }
74
+ }
75
+
76
+ const depthOf = new Map<string, number>();
77
+ const breadthFirst = roots.map((row) => keyOf(row));
78
+ for (const key of breadthFirst) {
79
+ depthOf.set(key, 0);
80
+ }
81
+ // The array iterator also visits children pushed during the loop.
82
+ for (const key of breadthFirst) {
83
+ for (const child of childKeysOf.get(key) ?? []) {
84
+ depthOf.set(child, (depthOf.get(key) ?? 0) + 1);
85
+ breadthFirst.push(child);
86
+ }
87
+ }
88
+
89
+ return {
90
+ roots,
91
+ childrenOf,
92
+ rowOf,
93
+ parentKeyOf,
94
+ childKeysOf,
95
+ depthOf,
96
+ keysDeepestFirst: breadthFirst.reverse(),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * With `propagateParents`, a parent is `all` exactly when every child is `all`, whatever the model
102
+ * says about the parent itself. Without it, a parent's own selection decides, and partly selected
103
+ * descendants show as `some`.
104
+ */
105
+ export function computeTreeSelection<R>(
106
+ index: GridTreeIndex<R>,
107
+ selected: ReadonlySet<string>,
108
+ propagateParents: boolean,
109
+ ): Map<string, GridTreeSelectionStatus> {
110
+ const status = new Map<string, GridTreeSelectionStatus>();
111
+ for (const key of index.keysDeepestFirst) {
112
+ const isSelected = selected.has(key);
113
+ const children = index.childKeysOf.get(key);
114
+ if (!children?.length) {
115
+ status.set(key, isSelected ? 'all' : 'none');
116
+ continue;
117
+ }
118
+ let all = true;
119
+ let some = false;
120
+ for (const child of children) {
121
+ const childStatus = status.get(child) ?? 'none';
122
+ if (childStatus !== 'all') {
123
+ all = false;
124
+ }
125
+ if (childStatus !== 'none') {
126
+ some = true;
127
+ }
128
+ }
129
+ if (propagateParents) {
130
+ status.set(key, all ? 'all' : some || isSelected ? 'some' : 'none');
131
+ } else {
132
+ status.set(key, isSelected ? 'all' : some ? 'some' : 'none');
133
+ }
134
+ }
135
+ return status;
136
+ }
137
+
138
+ export function applyTreeSelection<R>(
139
+ index: GridTreeIndex<R>,
140
+ selected: ReadonlySet<string>,
141
+ targets: readonly string[],
142
+ value: boolean,
143
+ propagateParents: boolean,
144
+ ): Set<string> {
145
+ const next = new Set(selected);
146
+ for (const key of targets) {
147
+ if (value) {
148
+ next.add(key);
149
+ } else {
150
+ next.delete(key);
151
+ }
152
+ }
153
+ if (propagateParents) {
154
+ for (const key of index.keysDeepestFirst) {
155
+ const children = index.childKeysOf.get(key);
156
+ if (!children?.length) {
157
+ continue;
158
+ }
159
+ if (children.every((child) => next.has(child))) {
160
+ next.add(key);
161
+ } else {
162
+ next.delete(key);
163
+ }
164
+ }
165
+ }
166
+ return next;
167
+ }
168
+
169
+ /**
170
+ * Rows a toggle writes (`targets`) and the rows whose state decides the toggle direction
171
+ * (`units`). With `descendants`, a row's filtered sub-rows are included; a row whose children were
172
+ * all filtered out takes its whole subtree, so a matching group can still be picked.
173
+ */
174
+ export function selectionScope<R>(
175
+ rows: readonly GridTreeRowLike[],
176
+ index: GridTreeIndex<R>,
177
+ descendants: boolean,
178
+ ): { targets: string[]; units: string[] } {
179
+ const targets: string[] = [];
180
+ const units: string[] = [];
181
+ const visitFullSubtree = (key: string) => {
182
+ targets.push(key);
183
+ const children = index.childKeysOf.get(key);
184
+ if (!children?.length) {
185
+ units.push(key);
186
+ return;
187
+ }
188
+ children.forEach(visitFullSubtree);
189
+ };
190
+ const visit = (row: GridTreeRowLike) => {
191
+ if (!descendants) {
192
+ targets.push(row.id);
193
+ units.push(row.id);
194
+ return;
195
+ }
196
+ if (row.subRows.length === 0) {
197
+ visitFullSubtree(row.id);
198
+ return;
199
+ }
200
+ targets.push(row.id);
201
+ row.subRows.forEach(visit);
202
+ };
203
+ rows.forEach(visit);
204
+ return { targets, units };
205
+ }
206
+
207
+ /** Keeps every row that matches and every ancestor of a match. */
208
+ export function keepMatchesWithAncestors<R>(
209
+ rows: readonly R[],
210
+ index: GridTreeIndex<R>,
211
+ keyOf: (row: R) => string,
212
+ matches: (row: R) => boolean,
213
+ ): R[] {
214
+ const keep = new Set<string>();
215
+ for (const row of rows) {
216
+ if (!matches(row)) {
217
+ continue;
218
+ }
219
+ for (let key: string | undefined = keyOf(row); key !== undefined; ) {
220
+ if (keep.has(key)) {
221
+ break;
222
+ }
223
+ keep.add(key);
224
+ key = index.parentKeyOf.get(key);
225
+ }
226
+ }
227
+ return rows.filter((row) => keep.has(keyOf(row)));
228
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  GridBaseColDef,
14
14
  GridColDef,
15
15
  GridColType,
16
+ GridColumnVisibilityModel,
16
17
  GridFeatureMode,
17
18
  GridFilterItem,
18
19
  GridFilterModel,
@@ -22,12 +23,15 @@ export type {
22
23
  GridPinnedColumnFields,
23
24
  GridRenderCellParams,
24
25
  GridRowId,
26
+ GridRowParams,
25
27
  GridRowSelectionModel,
28
+ GridRowSelectionPropagation,
26
29
  GridRowsProp,
27
30
  GridSingleSelectColDef,
28
31
  GridSortDirection,
29
32
  GridSortItem,
30
33
  GridSortModel,
34
+ GridTreeNodeParams,
31
35
  GridValidRowModel,
32
36
  GridValueOption,
33
37
  } from './types';