@jielga/tmdatagrid 0.1.0 → 0.3.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 (47) hide show
  1. package/README.md +35 -2
  2. package/dist/index.d.ts +792 -327
  3. package/dist/index.js +1581 -892
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/package.json +15 -7
  7. package/skills/columns/SKILL.md +8 -7
  8. package/skills/features/SKILL.md +104 -16
  9. package/skills/getting-started/SKILL.md +78 -20
  10. package/skills/options/SKILL.md +12 -7
  11. package/skills/server-side/SKILL.md +5 -2
  12. package/src/tmdatagrid/TMDataGridContext.ts +1 -1
  13. package/src/tmdatagrid/components/TMDataGrid.module.css +73 -0
  14. package/src/tmdatagrid/{TMDataGrid.tsx → components/TMDataGrid.tsx} +15 -4
  15. package/src/tmdatagrid/components/TMDataGridColumnsPanel.module.css +25 -0
  16. package/src/tmdatagrid/{TMDataGridColumnsPanel.tsx → components/TMDataGridColumnsPanel.tsx} +10 -4
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +35 -0
  18. package/src/tmdatagrid/{TMDataGridFilterPanel.tsx → components/TMDataGridFilterPanel.tsx} +91 -14
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +18 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +171 -0
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +159 -0
  26. package/src/tmdatagrid/{TMDataGridHeaderCell.tsx → components/TMDataGridHeaderCell.tsx} +99 -14
  27. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +177 -0
  28. package/src/tmdatagrid/components/TMDataGridTable.module.css +137 -0
  29. package/src/tmdatagrid/components/TMDataGridTable.tsx +720 -0
  30. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +12 -0
  31. package/src/tmdatagrid/{TMDataGridToolbar.tsx → components/TMDataGridToolbar.tsx} +8 -5
  32. package/src/tmdatagrid/{icons.ts → components/icons.ts} +5 -0
  33. package/src/tmdatagrid/components/sticky.module.css +45 -0
  34. package/src/tmdatagrid/core/capabilities.ts +215 -0
  35. package/src/tmdatagrid/{columnOrdering.ts → core/columnOrdering.ts} +2 -2
  36. package/src/tmdatagrid/{columnUtils.ts → core/columnUtils.ts} +1 -1
  37. package/src/tmdatagrid/{filterOperators.ts → core/filterOperators.ts} +25 -0
  38. package/src/tmdatagrid/core/grouping.ts +21 -0
  39. package/src/tmdatagrid/{persistence.ts → core/persistence.ts} +92 -2
  40. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  41. package/src/tmdatagrid/index.ts +39 -9
  42. package/src/tmdatagrid/useTMDataGrid.tsx +318 -66
  43. package/src/tmdatagrid/TMDataGrid.module.css +0 -410
  44. package/src/tmdatagrid/TMDataGridFooter.tsx +0 -74
  45. package/src/tmdatagrid/TMDataGridTable.tsx +0 -297
  46. package/src/tmdatagrid/capabilities.ts +0 -117
  47. /package/src/tmdatagrid/{sizes.ts → core/sizes.ts} +0 -0
@@ -1,25 +1,29 @@
1
- import { Checkbox } from "@mantine/core";
2
1
  import { useCreateStore } from "@tanstack/react-store";
3
2
  import {
3
+ aggregationFns,
4
4
  type ColumnDef,
5
5
  columnFacetingFeature,
6
6
  columnFilteringFeature,
7
+ columnGroupingFeature,
7
8
  columnOrderingFeature,
8
9
  columnPinningFeature,
9
10
  columnResizingFeature,
10
11
  columnSizingFeature,
11
12
  columnVisibilityFeature,
12
13
  createColumnHelper,
14
+ createExpandedRowModel,
13
15
  createFacetedMinMaxValues,
14
16
  createFacetedRowModel,
15
17
  createFacetedUniqueValues,
16
18
  createFilteredRowModel,
19
+ createGroupedRowModel,
17
20
  createPaginatedRowModel,
18
21
  createSortedRowModel,
19
22
  filterFns,
20
23
  globalFilteringFeature,
21
24
  metaHelper,
22
25
  type RowData,
26
+ rowExpandingFeature,
23
27
  rowPaginationFeature,
24
28
  rowSelectionFeature,
25
29
  rowSortingFeature,
@@ -27,24 +31,42 @@ import {
27
31
  type Table,
28
32
  tableFeatures,
29
33
  type TableOptions,
34
+ type TableState,
30
35
  useTable,
31
36
  } from "@tanstack/react-table";
32
37
  import type { Store } from "@tanstack/store";
33
- import { useEffect, useMemo, useState } from "react";
38
+ import { useEffect, useMemo, useRef, useState } from "react";
34
39
  import {
35
40
  type TMDataGridColumnType,
36
41
  tmDataGridFilterFn,
37
42
  getDefaultOperator,
38
- } from "./filterOperators";
43
+ } from "./core/filterOperators";
39
44
  import {
40
45
  hasPersistenceKeys,
41
46
  readPersistedState,
42
47
  type TMDataGridPersistence,
43
48
  writePersistedState,
44
- } from "./persistence";
45
- import { readFeatureFlags, type TMDataGridFeatureFlags } from "./capabilities";
49
+ } from "./core/persistence";
50
+ import {
51
+ readFeatureFlags,
52
+ type TMDataGridFeatureFlags,
53
+ type TMDataGridSelectionMode,
54
+ } from "./core/capabilities";
55
+ import {
56
+ createSelectColumn,
57
+ SELECT_COLUMN_ID,
58
+ } from "./components/TMDataGridSelectColumn";
59
+ import {
60
+ createGroupColumn,
61
+ GROUP_COLUMN_ID,
62
+ } from "./components/TMDataGridGroupColumn";
46
63
 
47
- export const SELECT_COLUMN_ID = "__select__";
64
+ /**
65
+ * How long the grid waits after the last state change before writing to
66
+ * storage. Long enough to collapse a resize drag into one write, short enough
67
+ * that a reload right after a change still sees it.
68
+ */
69
+ const PERSIST_DEBOUNCE_MS = 200;
48
70
 
49
71
  /** Per-column configuration the TMDataGrid chrome reads. */
50
72
  export type TMDataGridColumnMeta = {
@@ -92,9 +114,16 @@ export const tmDataGridFeatures = tableFeatures({
92
114
  columnSizingFeature,
93
115
  columnResizingFeature,
94
116
  columnFacetingFeature,
117
+ columnGroupingFeature,
118
+ // Registered for grouping's sake rather than for tree data: the grouped row
119
+ // model builds the parent rows, and this is what flattens the expanded ones
120
+ // back into the flat list the body virtualizes.
121
+ rowExpandingFeature,
95
122
 
96
123
  filteredRowModel: createFilteredRowModel(),
124
+ groupedRowModel: createGroupedRowModel(),
97
125
  sortedRowModel: createSortedRowModel(),
126
+ expandedRowModel: createExpandedRowModel(),
98
127
  paginatedRowModel: createPaginatedRowModel(),
99
128
  facetedRowModel: createFacetedRowModel(),
100
129
  facetedMinMaxValues: createFacetedMinMaxValues(),
@@ -102,6 +131,10 @@ export const tmDataGridFeatures = tableFeatures({
102
131
 
103
132
  filterFns: { ...filterFns, tmDataGrid: tmDataGridFilterFn },
104
133
  sortFns,
134
+ // The names `columnDef.aggregationFn` accepts. Only consulted for columns
135
+ // that ask for one — a column with no `aggregationFn` reports `undefined` on
136
+ // a group row, which is what keeps a plain "group by" free of aggregates.
137
+ aggregationFns,
105
138
 
106
139
  tableMeta: metaHelper<TMDataGridTableMeta>(),
107
140
  columnMeta: metaHelper<TMDataGridColumnMeta>(),
@@ -133,6 +166,20 @@ export type TMDataGridUiState = {
133
166
  * `dataTransfer`, which browsers keep unreadable until the drop.
134
167
  */
135
168
  draggedColumnId: string | null;
169
+ /**
170
+ * The single highlighted row — the one a detail panel would be showing. Its
171
+ * own concept, not a slice of `rowSelection`: under
172
+ * `selectionMode: "checkboxAndHighlight"` the two coexist, and TanStack's one
173
+ * selection map cannot hold both.
174
+ *
175
+ * Not pruned when the row is filtered out, paged away or dropped from `data`,
176
+ * matching how TanStack treats `rowSelection` — nothing there resets it
177
+ * either. The row simply renders unhighlighted, and highlights again if it
178
+ * comes back.
179
+ */
180
+ highlightedRowId: string | null;
181
+ /** The pivot a shift-click extends from. See resolveRowSelectionClick. */
182
+ selectionAnchorRowId: string | null;
136
183
  };
137
184
 
138
185
  export type TMDataGridUiActions = {
@@ -142,6 +189,12 @@ export type TMDataGridUiActions = {
142
189
  toggleColumnsPanel: () => void;
143
190
  startColumnDrag: (columnId: string) => void;
144
191
  endColumnDrag: () => void;
192
+ /**
193
+ * Moves the active row, or clears it with `null` — which is how a consumer
194
+ * closing its detail panel puts the grid back in step.
195
+ */
196
+ setHighlightedRow: (rowId: string | null) => void;
197
+ setSelectionAnchor: (rowId: string | null) => void;
145
198
  };
146
199
 
147
200
  export type TMDataGridUiStore = Store<TMDataGridUiState, TMDataGridUiActions>;
@@ -175,6 +228,79 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
175
228
  * reordering is entirely a matter of interface.
176
229
  */
177
230
  enableColumnOrdering?: boolean;
231
+ /**
232
+ * Client-side pagination and the built-in `TMDataGrid.Footer` pager.
233
+ * Defaults to `false`: the grid renders every filtered row and relies on
234
+ * virtualization.
235
+ *
236
+ * The second grid-defined switch (TanStack defines no `enablePagination`
237
+ * option). `manualPagination: true` implies it — a server-paged grid needs
238
+ * no extra flag.
239
+ */
240
+ enablePagination?: boolean;
241
+ /**
242
+ * How rows are selected. Defaults to `"checkbox"`.
243
+ *
244
+ * | Mode | Checkbox column | Row click |
245
+ * | ---- | --------------- | --------- |
246
+ * | `"checkbox"` | yes, multi-select | nothing |
247
+ * | `"row"` | no | multi-selects, Ctrl/Shift modifiers |
248
+ * | `"checkboxAndHighlight"` | yes, multi-select | highlights one row |
249
+ * | `"highlight"` | no | highlights one row, no selection at all |
250
+ *
251
+ * One option rather than two, so the combination that cannot work — a click
252
+ * that both toggles a multi-selection and moves the highlight — is not
253
+ * expressible.
254
+ *
255
+ * The first two write to TanStack's `rowSelection`. The highlight is separate
256
+ * state, which is what lets `"checkboxAndHighlight"` run both at once: tick
257
+ * rows for a bulk action, click one to open its detail panel. See
258
+ * `defaultHighlightedRowId` / `onHighlightedRowChange`.
259
+ *
260
+ * `enableRowSelection` still gates the selection half, predicate form
261
+ * included; under `"highlight"` there is nothing for it to gate.
262
+ */
263
+ selectionMode?: TMDataGridSelectionMode;
264
+ /**
265
+ * Give selected rows the highlight background. Defaults to `true` under
266
+ * `selectionMode: "row"`, where the highlight is the only feedback a click
267
+ * gives, and `false` under `"checkbox"`, where the box already shows it.
268
+ *
269
+ * The colour is the `--dg-row-selected-bg` CSS variable, so it can be changed
270
+ * without turning the flag on or off:
271
+ *
272
+ * ```tsx
273
+ * <TMDataGrid
274
+ * {...grid}
275
+ * style={{ "--dg-row-selected-bg": "var(--mantine-color-blue-0)" }}
276
+ * />
277
+ * ```
278
+ */
279
+ showSelectedBackground?: boolean;
280
+ /**
281
+ * The row that starts out highlighted, under a `selectionMode` that has a
282
+ * highlight. Read once on mount, like `initialState`.
283
+ *
284
+ * The grid never persists the highlighted row. Pair this with
285
+ * {@link onHighlightedRowChange} and keep it wherever it belongs — for a
286
+ * detail panel that is usually the route, which gets you a shareable link and
287
+ * a working back button as well as surviving a reload:
288
+ *
289
+ * ```tsx
290
+ * const { rowId } = useParams();
291
+ * useTMDataGrid({
292
+ * selectionMode: "highlight",
293
+ * defaultHighlightedRowId: rowId,
294
+ * onHighlightedRowChange: (id) => navigate(id ? `/employees/${id}` : "/employees"),
295
+ * });
296
+ * ```
297
+ */
298
+ defaultHighlightedRowId?: string | null;
299
+ /**
300
+ * Called with the newly highlighted row id, or `null` when it is cleared.
301
+ * Fires for `ui.actions.setHighlightedRow` too, not only for clicks.
302
+ */
303
+ onHighlightedRowChange?: (rowId: string | null) => void;
178
304
  };
179
305
 
180
306
  type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
@@ -184,8 +310,17 @@ type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
184
310
  >;
185
311
 
186
312
  /**
187
- * Point every column at the operator-dispatching filter function unless the
188
- * column opted into its own.
313
+ * Point every column at the operator-dispatching filter function, and take
314
+ * grouping's aggregation defaults back off, unless the column opted into its
315
+ * own. Anything the consumer set wins — it is spread over these.
316
+ *
317
+ * The aggregation pair needs explaining. TanStack's grouping feature hands
318
+ * every column `aggregationFn: "auto"` and an `aggregatedCell` that stringifies
319
+ * whatever comes out, so merely registering the feature would have every
320
+ * numeric column silently sum itself and every date column show a range the
321
+ * moment anything is grouped. That is a summary table, and "group by" is not a
322
+ * request for one. Cleared here, so a grouped grid is a tree until a column
323
+ * says otherwise — `aggregationFn: "sum"` on the column that wants it.
189
324
  */
190
325
  function withTMDataGridDefaults<TData extends RowData>(
191
326
  columns: ReadonlyArray<TMDataGridColumnDef<TData>>,
@@ -197,52 +332,15 @@ function withTMDataGridDefaults<TData extends RowData>(
197
332
  columns: withTMDataGridDefaults<TData>(column.columns),
198
333
  };
199
334
  }
200
- return { filterFn: "tmDataGrid", ...column } as TMDataGridColumnDef<TData>;
335
+ return {
336
+ filterFn: "tmDataGrid",
337
+ aggregationFn: undefined,
338
+ aggregatedCell: undefined,
339
+ ...column,
340
+ } as TMDataGridColumnDef<TData>;
201
341
  });
202
342
  }
203
343
 
204
- function createSelectColumn<TData extends RowData>(): TMDataGridColumnDef<TData> {
205
- return {
206
- id: SELECT_COLUMN_ID,
207
- meta: {
208
- label: "Checkbox selection",
209
- align: "center",
210
- // Structurally the first column; it also anchors the left pinned lane, so
211
- // no other column can be moved in front of it.
212
- enableOrdering: false,
213
- },
214
- size: 48,
215
- minSize: 48,
216
- maxSize: 48,
217
- enableResizing: false,
218
- enableSorting: false,
219
- enableColumnFilter: false,
220
- enableGlobalFilter: false,
221
- // Structurally pinned to the left; users shouldn't be able to move it.
222
- enablePinning: false,
223
- header: ({ table }) => (
224
- <Checkbox
225
- size="xs"
226
- aria-label="Select all rows"
227
- checked={table.getIsAllRowsSelected()}
228
- indeterminate={table.getIsSomeRowsSelected()}
229
- onChange={table.getToggleAllRowsSelectedHandler()}
230
- />
231
- ),
232
- cell: ({ row }) => (
233
- <Checkbox
234
- size="xs"
235
- aria-label="Select row"
236
- checked={row.getIsSelected()}
237
- disabled={!row.getCanSelect()}
238
- indeterminate={row.getIsSomeSelected()}
239
- onChange={row.getToggleSelectedHandler()}
240
- onClick={(event) => event.stopPropagation()}
241
- />
242
- ),
243
- };
244
- }
245
-
246
344
  /**
247
345
  * Builds a TMDataGrid table plus its chrome store.
248
346
  *
@@ -250,49 +348,100 @@ function createSelectColumn<TData extends RowData>(): TMDataGridColumnDef<TData>
250
348
  * only needs `manualPagination` / `manualFiltering` / `manualSorting`,
251
349
  * `rowCount` and the matching `onXChange` callbacks — the chrome reads
252
350
  * `getRowCount()` / `getPageCount()` / `getPaginatedRowModel()`, all of which
253
- * already respect manual mode.
351
+ * already respect manual mode. `manualPagination` also switches the pagination
352
+ * flag on, so `<TMDataGrid.Footer />` renders its pager without further
353
+ * options.
254
354
  */
255
355
  export function useTMDataGrid<TData extends RowData>({
256
356
  persist,
257
- // Not a TanStack option, so it is kept out of what `useTable` receives.
357
+ // Not TanStack options, so they are kept out of what `useTable` receives.
258
358
  enableColumnOrdering,
359
+ enablePagination,
360
+ selectionMode,
361
+ showSelectedBackground,
362
+ defaultHighlightedRowId,
363
+ onHighlightedRowChange,
259
364
  ...options
260
365
  }: UseTMDataGridOptions<TData>): TMDataGridApi<TData> {
261
- const selectionEnabled = options.enableRowSelection !== false;
366
+ // Derived up here, rather than just before the return, because the rest of the
367
+ // hook needs `selectColumn` — one place decides what each mode means.
368
+ //
369
+ // Deliberately not memoized on `table`: the flags must re-derive whenever the
370
+ // caller passes different options, and `table` keeps the same identity when
371
+ // they do. See readFeatureFlags.
372
+ const features = readFeatureFlags({
373
+ ...options,
374
+ enableColumnOrdering,
375
+ enablePagination,
376
+ selectionMode,
377
+ showSelectedBackground,
378
+ });
379
+
262
380
  const pinningEnabled = options.enableColumnPinning !== false;
381
+ const selectColumnEnabled = features.selectColumn;
382
+ const groupColumnEnabled = features.grouping;
263
383
 
264
384
  const columns = useMemo(() => {
265
385
  const base = withTMDataGridDefaults<TData>(
266
386
  options.columns as ReadonlyArray<TMDataGridColumnDef<TData>>,
267
387
  );
268
- return selectionEnabled ? [createSelectColumn<TData>(), ...base] : base;
269
- }, [options.columns, selectionEnabled]);
388
+ // Both generated columns are always present when their feature is on, and
389
+ // the tree column hides itself while nothing is grouped. Adding it to the
390
+ // array only once a column is grouped would make the column list depend on
391
+ // table state, which is the one thing that cannot be a `useMemo` dependency
392
+ // here — the table is built from these columns.
393
+ return [
394
+ ...(selectColumnEnabled ? [createSelectColumn<TData>()] : []),
395
+ ...(groupColumnEnabled ? [createGroupColumn<TData>()] : []),
396
+ ...base,
397
+ ];
398
+ }, [options.columns, selectColumnEnabled, groupColumnEnabled]);
270
399
 
271
400
  // Read once on mount: `initialState` is only consumed on the first render,
272
401
  // and re-reading later would fight the user's live edits.
273
402
  const [persistedState] = useState(() => readPersistedState(persist));
274
403
 
404
+ // Restored grouping decides whether the tree column starts out visible, so a
405
+ // reload comes back to the tree the user left rather than to a hidden lane.
406
+ const initialGrouping =
407
+ persistedState.grouping ?? options.initialState?.grouping ?? [];
408
+
275
409
  const table = useTable({
276
410
  columnResizeMode: "onChange",
277
411
  enableSorting: true,
278
412
  enableColumnResizing: true,
279
413
  globalFilterFn: "includesString",
414
+ // Grouping by a column takes it out of the grid, the way AG Grid does it:
415
+ // its values have moved into the tree column, so leaving it in place would
416
+ // show every row the same value it was grouped under. Overridable — pass
417
+ // `"reorder"` to keep the column and have it moved to the front instead.
418
+ groupedColumnMode: "remove",
280
419
  ...options,
281
420
  features: tmDataGridFeatures,
282
421
  columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
283
422
  initialState: {
284
423
  ...options.initialState,
285
424
  ...persistedState,
425
+ columnVisibility: {
426
+ ...options.initialState?.columnVisibility,
427
+ ...persistedState.columnVisibility,
428
+ // Last word, because the tree column's visibility is not a user setting
429
+ // — it tracks the grouping state. See the effect below.
430
+ ...(groupColumnEnabled
431
+ ? { [GROUP_COLUMN_ID]: initialGrouping.length > 0 }
432
+ : {}),
433
+ },
286
434
  columnPinning: {
287
- // The checkbox column is structurally pinned, so it is re-applied on
288
- // top of anything restored from storage.
435
+ // The generated columns are structurally pinned, so they are re-applied
436
+ // on top of anything restored from storage.
289
437
  left: [
290
- ...(selectionEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
438
+ ...(selectColumnEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
439
+ ...(groupColumnEnabled && pinningEnabled ? [GROUP_COLUMN_ID] : []),
291
440
  ...(
292
441
  persistedState.columnPinning?.left ??
293
442
  options.initialState?.columnPinning?.left ??
294
443
  []
295
- ).filter((id) => id !== SELECT_COLUMN_ID),
444
+ ).filter((id) => id !== SELECT_COLUMN_ID && id !== GROUP_COLUMN_ID),
296
445
  ],
297
446
  right:
298
447
  persistedState.columnPinning?.right ??
@@ -308,16 +457,94 @@ export function useTMDataGrid<TData extends RowData>({
308
457
  },
309
458
  });
310
459
 
460
+ // Two things have to happen whenever `grouping` changes.
461
+ //
462
+ // One: the tree column appears with the first grouped column and goes away
463
+ // with the last, so an ungrouped grid looks exactly as it did before grouping
464
+ // existed. Driven from a subscription rather than by rebuilding the column
465
+ // array, because the array is what the table is built from — deriving it from
466
+ // table state would close the loop. Visibility is the one column property
467
+ // that can be changed after the fact without touching the definitions.
468
+ //
469
+ // Two, and this one is a workaround. In table-core 9.0.0-beta.21 the
470
+ // per-region column APIs do not list `grouping` among their memo
471
+ // dependencies, even though they all derive from `getAllLeafColumns()`, which
472
+ // does:
473
+ //
474
+ // | API | Declares |
475
+ // | --- | --- |
476
+ // | `getLeft/Center/RightVisibleLeafColumns` | columns, columnPinning, columnVisibility, columnOrder |
477
+ // | `getLeft/Center/RightHeaderGroups` | columnPinning, columnOrder |
478
+ // | `row.getLeft/Center/RightVisibleCells` | columnPinning, columnVisibility |
479
+ //
480
+ // So grouping a *second* column leaves every one of them returning the
481
+ // previous list: the column TanStack removed keeps its header and its grid
482
+ // track, and the row cells no longer line up with them. The first grouping
483
+ // appears to work only because the visibility write above happens to touch a
484
+ // dependency they share.
485
+ //
486
+ // Re-publishing `columnVisibility` and `columnOrder` — same contents, new
487
+ // identity — invalidates all three families. `columnOrder` is the only
488
+ // dependency the header groups declare, and `columnVisibility` the only one
489
+ // the cells do, so both are needed. Remove this once the deps are fixed
490
+ // upstream; the test that fails without it groups two columns and asserts the
491
+ // second one leaves the grid.
492
+ //
493
+ // Writing back into the store from its own subscriber is safe: the guard is
494
+ // on `grouping`'s identity, and neither write touches it, so the callback
495
+ // these writes trigger short-circuits.
496
+ useEffect(() => {
497
+ if (!groupColumnEnabled) return;
498
+ let previousGrouping = table.store.state.grouping;
499
+
500
+ const subscription = table.store.subscribe((state) => {
501
+ if (state.grouping === previousGrouping) return;
502
+ previousGrouping = state.grouping;
503
+
504
+ table.setColumnVisibility((old) => ({
505
+ ...old,
506
+ [GROUP_COLUMN_ID]: state.grouping.length > 0,
507
+ }));
508
+ table.setColumnOrder((old) => [...old]);
509
+ });
510
+
511
+ return () => subscription.unsubscribe();
512
+ }, [groupColumnEnabled, table]);
513
+
311
514
  // Mirror every state change back to storage. Subscribing (rather than writing
312
515
  // from an effect on a state snapshot) means nothing is missed, including
313
516
  // changes made straight through the table API by the consumer.
517
+ //
518
+ // Writes are debounced because `columnResizeMode: "onChange"` publishes a new
519
+ // state on every pointer move of a resize drag, and `setItem` serialises
520
+ // synchronously on the main thread. The trailing edge is enough: storage only
521
+ // has to agree with the table once the user stops.
314
522
  useEffect(() => {
315
523
  if (!hasPersistenceKeys(persist)) return;
316
524
  writePersistedState(table.store.state, persist);
525
+
526
+ let timeout: ReturnType<typeof setTimeout> | undefined;
527
+ let pending: TableState<TMDataGridFeatures> | null = null;
528
+
529
+ const flush = () => {
530
+ timeout = undefined;
531
+ if (pending === null) return;
532
+ writePersistedState(pending, persist);
533
+ pending = null;
534
+ };
535
+
317
536
  const subscription = table.store.subscribe((state) => {
318
- writePersistedState(state, persist);
537
+ pending = state;
538
+ if (timeout !== undefined) clearTimeout(timeout);
539
+ timeout = setTimeout(flush, PERSIST_DEBOUNCE_MS);
319
540
  });
320
- return () => subscription.unsubscribe();
541
+
542
+ return () => {
543
+ subscription.unsubscribe();
544
+ if (timeout !== undefined) clearTimeout(timeout);
545
+ // An unmount mid-debounce would otherwise drop the last change.
546
+ flush();
547
+ };
321
548
  }, [persist, table]);
322
549
 
323
550
  const ui = useCreateStore<TMDataGridUiState, TMDataGridUiActions>(
@@ -326,6 +553,10 @@ export function useTMDataGrid<TData extends RowData>({
326
553
  columnsPanelOpen: false,
327
554
  filterPanelColumnId: null,
328
555
  draggedColumnId: null,
556
+ // `useCreateStore` builds the store once per mount, so this is a genuine
557
+ // default rather than a value that would fight later clicks.
558
+ highlightedRowId: defaultHighlightedRowId ?? null,
559
+ selectionAnchorRowId: null,
329
560
  },
330
561
  ({ setState }) => ({
331
562
  openFilterPanel: (columnId = null) =>
@@ -348,13 +579,34 @@ export function useTMDataGrid<TData extends RowData>({
348
579
  setState((prev) => ({ ...prev, draggedColumnId: columnId })),
349
580
  endColumnDrag: () =>
350
581
  setState((prev) => ({ ...prev, draggedColumnId: null })),
582
+ setHighlightedRow: (rowId) =>
583
+ setState((prev) => ({ ...prev, highlightedRowId: rowId })),
584
+ setSelectionAnchor: (rowId) =>
585
+ setState((prev) => ({ ...prev, selectionAnchorRowId: rowId })),
351
586
  }),
352
587
  );
353
588
 
354
- // Deliberately not memoized on `table`: the flags must re-derive whenever the
355
- // caller passes different options, and `table` keeps the same identity when
356
- // they do. See readFeatureFlags.
357
- const features = readFeatureFlags({ ...options, enableColumnOrdering });
589
+ // `onHighlightedRowChange` is fired from a subscription rather than from the store
590
+ // action, so it covers every route to a new active row — a row click, and a
591
+ // consumer calling `setHighlightedRow` itself. Held in a ref because the store is
592
+ // built once and its actions would otherwise close over the first render's
593
+ // callback.
594
+ const onHighlightedRowChangeRef = useRef(onHighlightedRowChange);
595
+ useEffect(() => {
596
+ onHighlightedRowChangeRef.current = onHighlightedRowChange;
597
+ }, [onHighlightedRowChange]);
598
+
599
+ useEffect(() => {
600
+ // Seeded from the current value so mounting with a `defaultHighlightedRowId`
601
+ // does not report a change that nothing made.
602
+ let previous = ui.state.highlightedRowId;
603
+ const subscription = ui.subscribe((state) => {
604
+ if (state.highlightedRowId === previous) return;
605
+ previous = state.highlightedRowId;
606
+ onHighlightedRowChangeRef.current?.(state.highlightedRowId);
607
+ });
608
+ return () => subscription.unsubscribe();
609
+ }, [ui]);
358
610
 
359
611
  return { table, ui, features };
360
612
  }