@jielga/tmdatagrid 0.2.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 (35) hide show
  1. package/dist/index.d.ts +397 -37
  2. package/dist/index.js +1550 -978
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -1
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +1 -1
  8. package/skills/getting-started/SKILL.md +41 -6
  9. package/skills/options/SKILL.md +1 -1
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -1
  12. package/src/tmdatagrid/components/TMDataGrid.tsx +6 -0
  13. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +7 -1
  14. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +15 -0
  15. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +87 -10
  16. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +135 -0
  18. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +70 -42
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  21. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +147 -0
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +6 -0
  23. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +77 -1
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +83 -6
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +38 -4
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +396 -32
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +3 -0
  28. package/src/tmdatagrid/components/icons.ts +5 -0
  29. package/src/tmdatagrid/core/capabilities.ts +81 -11
  30. package/src/tmdatagrid/core/filterOperators.ts +25 -0
  31. package/src/tmdatagrid/core/grouping.ts +21 -0
  32. package/src/tmdatagrid/core/persistence.ts +12 -0
  33. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  34. package/src/tmdatagrid/index.ts +26 -2
  35. package/src/tmdatagrid/useTMDataGrid.tsx +240 -35
@@ -1,24 +1,29 @@
1
1
  import { useCreateStore } from "@tanstack/react-store";
2
2
  import {
3
+ aggregationFns,
3
4
  type ColumnDef,
4
5
  columnFacetingFeature,
5
6
  columnFilteringFeature,
7
+ columnGroupingFeature,
6
8
  columnOrderingFeature,
7
9
  columnPinningFeature,
8
10
  columnResizingFeature,
9
11
  columnSizingFeature,
10
12
  columnVisibilityFeature,
11
13
  createColumnHelper,
14
+ createExpandedRowModel,
12
15
  createFacetedMinMaxValues,
13
16
  createFacetedRowModel,
14
17
  createFacetedUniqueValues,
15
18
  createFilteredRowModel,
19
+ createGroupedRowModel,
16
20
  createPaginatedRowModel,
17
21
  createSortedRowModel,
18
22
  filterFns,
19
23
  globalFilteringFeature,
20
24
  metaHelper,
21
25
  type RowData,
26
+ rowExpandingFeature,
22
27
  rowPaginationFeature,
23
28
  rowSelectionFeature,
24
29
  rowSortingFeature,
@@ -30,7 +35,7 @@ import {
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,
@@ -45,12 +50,16 @@ import {
45
50
  import {
46
51
  readFeatureFlags,
47
52
  type TMDataGridFeatureFlags,
48
- type TMDataGridRowSelectionMode,
53
+ type TMDataGridSelectionMode,
49
54
  } from "./core/capabilities";
50
55
  import {
51
56
  createSelectColumn,
52
57
  SELECT_COLUMN_ID,
53
58
  } from "./components/TMDataGridSelectColumn";
59
+ import {
60
+ createGroupColumn,
61
+ GROUP_COLUMN_ID,
62
+ } from "./components/TMDataGridGroupColumn";
54
63
 
55
64
  /**
56
65
  * How long the grid waits after the last state change before writing to
@@ -105,9 +114,16 @@ export const tmDataGridFeatures = tableFeatures({
105
114
  columnSizingFeature,
106
115
  columnResizingFeature,
107
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,
108
122
 
109
123
  filteredRowModel: createFilteredRowModel(),
124
+ groupedRowModel: createGroupedRowModel(),
110
125
  sortedRowModel: createSortedRowModel(),
126
+ expandedRowModel: createExpandedRowModel(),
111
127
  paginatedRowModel: createPaginatedRowModel(),
112
128
  facetedRowModel: createFacetedRowModel(),
113
129
  facetedMinMaxValues: createFacetedMinMaxValues(),
@@ -115,6 +131,10 @@ export const tmDataGridFeatures = tableFeatures({
115
131
 
116
132
  filterFns: { ...filterFns, tmDataGrid: tmDataGridFilterFn },
117
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,
118
138
 
119
139
  tableMeta: metaHelper<TMDataGridTableMeta>(),
120
140
  columnMeta: metaHelper<TMDataGridColumnMeta>(),
@@ -146,6 +166,20 @@ export type TMDataGridUiState = {
146
166
  * `dataTransfer`, which browsers keep unreadable until the drop.
147
167
  */
148
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;
149
183
  };
150
184
 
151
185
  export type TMDataGridUiActions = {
@@ -155,6 +189,12 @@ export type TMDataGridUiActions = {
155
189
  toggleColumnsPanel: () => void;
156
190
  startColumnDrag: (columnId: string) => void;
157
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;
158
198
  };
159
199
 
160
200
  export type TMDataGridUiStore = Store<TMDataGridUiState, TMDataGridUiActions>;
@@ -201,18 +241,29 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
201
241
  /**
202
242
  * How rows are selected. Defaults to `"checkbox"`.
203
243
  *
204
- * - `"checkbox"` — the generated checkbox column selects; clicking a row
205
- * elsewhere does not.
206
- * - `"row"` — no checkbox column; clicking a row toggles it. Other rows keep
207
- * their state, so a click never clears the rest of the selection.
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.
208
254
  *
209
- * Ignored when `enableRowSelection` is `false`. Both modes write to the same
210
- * `rowSelection` state.
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.
211
262
  */
212
- rowSelectionMode?: TMDataGridRowSelectionMode;
263
+ selectionMode?: TMDataGridSelectionMode;
213
264
  /**
214
265
  * Give selected rows the highlight background. Defaults to `true` under
215
- * `rowSelectionMode: "row"`, where the highlight is the only feedback a click
266
+ * `selectionMode: "row"`, where the highlight is the only feedback a click
216
267
  * gives, and `false` under `"checkbox"`, where the box already shows it.
217
268
  *
218
269
  * The colour is the `--dg-row-selected-bg` CSS variable, so it can be changed
@@ -225,7 +276,31 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
225
276
  * />
226
277
  * ```
227
278
  */
228
- highlightSelectedRows?: boolean;
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;
229
304
  };
230
305
 
231
306
  type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
@@ -235,8 +310,17 @@ type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
235
310
  >;
236
311
 
237
312
  /**
238
- * Point every column at the operator-dispatching filter function unless the
239
- * 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.
240
324
  */
241
325
  function withTMDataGridDefaults<TData extends RowData>(
242
326
  columns: ReadonlyArray<TMDataGridColumnDef<TData>>,
@@ -248,7 +332,12 @@ function withTMDataGridDefaults<TData extends RowData>(
248
332
  columns: withTMDataGridDefaults<TData>(column.columns),
249
333
  };
250
334
  }
251
- return { filterFn: "tmDataGrid", ...column } as TMDataGridColumnDef<TData>;
335
+ return {
336
+ filterFn: "tmDataGrid",
337
+ aggregationFn: undefined,
338
+ aggregatedCell: undefined,
339
+ ...column,
340
+ } as TMDataGridColumnDef<TData>;
252
341
  });
253
342
  }
254
343
 
@@ -268,48 +357,91 @@ export function useTMDataGrid<TData extends RowData>({
268
357
  // Not TanStack options, so they are kept out of what `useTable` receives.
269
358
  enableColumnOrdering,
270
359
  enablePagination,
271
- rowSelectionMode,
272
- highlightSelectedRows,
360
+ selectionMode,
361
+ showSelectedBackground,
362
+ defaultHighlightedRowId,
363
+ onHighlightedRowChange,
273
364
  ...options
274
365
  }: UseTMDataGridOptions<TData>): TMDataGridApi<TData> {
275
- 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
+
276
380
  const pinningEnabled = options.enableColumnPinning !== false;
277
- // Only "checkbox" mode owns a column; "row" mode selects from the row itself.
278
- const selectColumnEnabled =
279
- selectionEnabled && rowSelectionMode !== "row";
381
+ const selectColumnEnabled = features.selectColumn;
382
+ const groupColumnEnabled = features.grouping;
280
383
 
281
384
  const columns = useMemo(() => {
282
385
  const base = withTMDataGridDefaults<TData>(
283
386
  options.columns as ReadonlyArray<TMDataGridColumnDef<TData>>,
284
387
  );
285
- return selectColumnEnabled ? [createSelectColumn<TData>(), ...base] : base;
286
- }, [options.columns, selectColumnEnabled]);
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]);
287
399
 
288
400
  // Read once on mount: `initialState` is only consumed on the first render,
289
401
  // and re-reading later would fight the user's live edits.
290
402
  const [persistedState] = useState(() => readPersistedState(persist));
291
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
+
292
409
  const table = useTable({
293
410
  columnResizeMode: "onChange",
294
411
  enableSorting: true,
295
412
  enableColumnResizing: true,
296
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",
297
419
  ...options,
298
420
  features: tmDataGridFeatures,
299
421
  columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
300
422
  initialState: {
301
423
  ...options.initialState,
302
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
+ },
303
434
  columnPinning: {
304
- // The checkbox column is structurally pinned, so it is re-applied on
305
- // 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.
306
437
  left: [
307
438
  ...(selectColumnEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
439
+ ...(groupColumnEnabled && pinningEnabled ? [GROUP_COLUMN_ID] : []),
308
440
  ...(
309
441
  persistedState.columnPinning?.left ??
310
442
  options.initialState?.columnPinning?.left ??
311
443
  []
312
- ).filter((id) => id !== SELECT_COLUMN_ID),
444
+ ).filter((id) => id !== SELECT_COLUMN_ID && id !== GROUP_COLUMN_ID),
313
445
  ],
314
446
  right:
315
447
  persistedState.columnPinning?.right ??
@@ -325,6 +457,60 @@ export function useTMDataGrid<TData extends RowData>({
325
457
  },
326
458
  });
327
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
+
328
514
  // Mirror every state change back to storage. Subscribing (rather than writing
329
515
  // from an effect on a state snapshot) means nothing is missed, including
330
516
  // changes made straight through the table API by the consumer.
@@ -367,6 +553,10 @@ export function useTMDataGrid<TData extends RowData>({
367
553
  columnsPanelOpen: false,
368
554
  filterPanelColumnId: null,
369
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,
370
560
  },
371
561
  ({ setState }) => ({
372
562
  openFilterPanel: (columnId = null) =>
@@ -389,19 +579,34 @@ export function useTMDataGrid<TData extends RowData>({
389
579
  setState((prev) => ({ ...prev, draggedColumnId: columnId })),
390
580
  endColumnDrag: () =>
391
581
  setState((prev) => ({ ...prev, draggedColumnId: null })),
582
+ setHighlightedRow: (rowId) =>
583
+ setState((prev) => ({ ...prev, highlightedRowId: rowId })),
584
+ setSelectionAnchor: (rowId) =>
585
+ setState((prev) => ({ ...prev, selectionAnchorRowId: rowId })),
392
586
  }),
393
587
  );
394
588
 
395
- // Deliberately not memoized on `table`: the flags must re-derive whenever the
396
- // caller passes different options, and `table` keeps the same identity when
397
- // they do. See readFeatureFlags.
398
- const features = readFeatureFlags({
399
- ...options,
400
- enableColumnOrdering,
401
- enablePagination,
402
- rowSelectionMode,
403
- highlightSelectedRows,
404
- });
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]);
405
610
 
406
611
  return { table, ui, features };
407
612
  }