@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,13 +1,18 @@
1
- import { ActionIcon, Group, Select, Text } from "@mantine/core";
1
+ import { ActionIcon, Group, Select, Text, Tooltip } from "@mantine/core";
2
2
  import type { RowData } from "@tanstack/react-table";
3
3
  import { useSelector } from "@tanstack/react-store";
4
4
  import type { ReactNode } from "react";
5
5
  import classes from "./TMDataGridFooter.module.css";
6
6
  import { useTMDataGridContext } from "../TMDataGridContext";
7
7
  import { getGridCapabilities } from "../core/capabilities";
8
+ import { isPagingActive } from "../core/rowSelection";
8
9
  import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
9
10
  import type { TMDataGridTable } from "../useTMDataGrid";
10
11
 
12
+ /** Shown on the greyed-out pager, and the reason it is greyed out. */
13
+ const PAGING_SUSPENDED_HINT =
14
+ "Paging is off while the rows are grouped: the whole tree is rendered and virtualized. Ungroup to page again.";
15
+
11
16
  /** Distilled pagination state and actions for building a custom pager. */
12
17
  export type TMDataGridPaginationApi = {
13
18
  pageIndex: number;
@@ -83,6 +88,10 @@ export function TMDataGridFooter({
83
88
  );
84
89
  }
85
90
 
91
+ // Grouping suspends paging — see isPagingActive. The pager is greyed out
92
+ // rather than dropped, so that a footer going quiet reads as a state the grid
93
+ // is in rather than as something that broke.
94
+ const paging = isPagingActive(table, features);
86
95
  const { pageIndex, pageSize } = table.store.state.pagination;
87
96
  const total = table.getRowCount();
88
97
  const from = total === 0 ? 0 : pageIndex * pageSize + 1;
@@ -95,49 +104,68 @@ export function TMDataGridFooter({
95
104
  : [...pageSizeOptions, pageSize].sort((a, b) => a - b);
96
105
 
97
106
  return (
98
- <div className={classes.footer}>
99
- <Group gap="xs" wrap="nowrap">
107
+ <Tooltip
108
+ label={PAGING_SUSPENDED_HINT}
109
+ disabled={paging}
110
+ withArrow
111
+ multiline
112
+ w={260}
113
+ position="top"
114
+ >
115
+ <div className={classes.footer} data-paging-suspended={!paging}>
116
+ <Group gap="xs" wrap="nowrap">
117
+ <Text size={controlSize} c="dimmed">
118
+ Rows per page:
119
+ </Text>
120
+ <Select
121
+ size={controlSize}
122
+ w={78}
123
+ allowDeselect={false}
124
+ variant="unstyled"
125
+ disabled={!paging}
126
+ data={sizeOptions.map(String)}
127
+ value={String(pageSize)}
128
+ onChange={(value) => {
129
+ table.setPageSize(Number(value) || pageSizeOptions[0]);
130
+ table.setPageIndex(0);
131
+ }}
132
+ />
133
+ </Group>
134
+
100
135
  <Text size={controlSize} c="dimmed">
101
- Rows per page:
136
+ {paging ? (
137
+ <>
138
+ {from}–{to} of {total}
139
+ </>
140
+ ) : (
141
+ // Not a range: nothing is being sliced, so a range would be a lie.
142
+ // Counted before grouping too — `getRowCount()` would be counting
143
+ // the tree, so a collapsed grid would claim to hold eight rows.
144
+ <>Grouped · all {table.getFilteredRowModel().rows.length} rows</>
145
+ )}
102
146
  </Text>
103
- <Select
104
- size={controlSize}
105
- w={78}
106
- allowDeselect={false}
107
- variant="unstyled"
108
- data={sizeOptions.map(String)}
109
- value={String(pageSize)}
110
- onChange={(value) => {
111
- table.setPageSize(Number(value) || pageSizeOptions[0]);
112
- table.setPageIndex(0);
113
- }}
114
- />
115
- </Group>
116
147
 
117
- <Text size={controlSize} c="dimmed">
118
- {from}–{to} of {total}
119
- </Text>
120
-
121
- <Group gap={4} wrap="nowrap">
122
- <ActionIcon
123
- variant="subtle"
124
- color="gray"
125
- aria-label="Previous page"
126
- disabled={!table.getCanPreviousPage()}
127
- onClick={() => table.previousPage()}
128
- >
129
- <ChevronLeftIcon size={18} stroke={1.6} />
130
- </ActionIcon>
131
- <ActionIcon
132
- variant="subtle"
133
- color="gray"
134
- aria-label="Next page"
135
- disabled={!table.getCanNextPage()}
136
- onClick={() => table.nextPage()}
137
- >
138
- <ChevronRightIcon size={18} stroke={1.6} />
139
- </ActionIcon>
140
- </Group>
141
- </div>
148
+ <Group gap={4} wrap="nowrap">
149
+ <ActionIcon
150
+ variant="subtle"
151
+ color="gray"
152
+ aria-label="Previous page"
153
+ disabled={!paging || !table.getCanPreviousPage()}
154
+ onClick={() => table.previousPage()}
155
+ >
156
+ <ChevronLeftIcon size={18} stroke={1.6} />
157
+ </ActionIcon>
158
+ <ActionIcon
159
+ variant="subtle"
160
+ color="gray"
161
+ aria-label="Next page"
162
+ disabled={!paging || !table.getCanNextPage()}
163
+ onClick={() => table.nextPage()}
164
+ >
165
+ <ChevronRightIcon size={18} stroke={1.6} />
166
+ </ActionIcon>
167
+ </Group>
168
+ </div>
169
+ </Tooltip>
142
170
  );
143
171
  }
@@ -0,0 +1,48 @@
1
+ /* The tree lane. Fills its cell so the whole indented width is a hit target,
2
+ which is what makes a deeply nested group as easy to click as a top-level
3
+ one. */
4
+ .groupToggle {
5
+ display: flex;
6
+ align-items: center;
7
+ gap: 6px;
8
+ width: 100%;
9
+ min-width: 0;
10
+ font-size: var(--dg-font-size);
11
+ color: inherit;
12
+ }
13
+
14
+ .chevron {
15
+ display: flex;
16
+ flex: 0 0 auto;
17
+ align-items: center;
18
+ color: var(--mantine-color-dimmed);
19
+ transition: transform 120ms ease;
20
+ }
21
+
22
+ .chevron[data-expanded="true"] {
23
+ transform: rotate(90deg);
24
+ }
25
+
26
+ /* One icon rotated rather than two swapped: the turn is what reads as the
27
+ expand, and a swap would jump. */
28
+ @media (prefers-reduced-motion: reduce) {
29
+ .chevron {
30
+ transition: none;
31
+ }
32
+ }
33
+
34
+ .groupLabel {
35
+ min-width: 0;
36
+ overflow: hidden;
37
+ white-space: nowrap;
38
+ text-overflow: ellipsis;
39
+ font-weight: 600;
40
+ }
41
+
42
+ /* Never squeezed out by a long group value — the count is the part that tells
43
+ you whether the group is worth opening. */
44
+ .groupCount {
45
+ flex: 0 0 auto;
46
+ color: var(--mantine-color-dimmed);
47
+ font-variant-numeric: tabular-nums;
48
+ }
@@ -0,0 +1,147 @@
1
+ import { UnstyledButton } from "@mantine/core";
2
+ import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
3
+ import { useSelector } from "@tanstack/react-store";
4
+ import { shallow } from "@tanstack/store";
5
+ import classes from "./TMDataGridGroupColumn.module.css";
6
+ import { ChevronRightIcon } from "./icons";
7
+ import { getColumnLabel } from "../core/columnUtils";
8
+ import { getGroupDataRows } from "../core/grouping";
9
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
10
+
11
+ export const GROUP_COLUMN_ID = "__group__";
12
+
13
+ /** Indent added per level of nesting, in px. */
14
+ const INDENT_STEP = 16;
15
+
16
+ /** Shown for a group whose value is empty — `String(null)` would read as "null". */
17
+ const BLANK_GROUP_LABEL = "(Blank)";
18
+
19
+ /**
20
+ * How a grouping value is written into the tree cell.
21
+ *
22
+ * Deliberately not the grouped column's own `cell` renderer: that renderer is
23
+ * written for a data row and is free to reach into `row.original`, which on a
24
+ * group row is the first leaf's record rather than anything about the group.
25
+ */
26
+ export function formatGroupValue(value: unknown): string {
27
+ if (value === null || value === undefined || value === "") {
28
+ return BLANK_GROUP_LABEL;
29
+ }
30
+ if (value instanceof Date) return value.toLocaleDateString();
31
+ return String(value);
32
+ }
33
+
34
+ /**
35
+ * The tree cell: chevron, group value and leaf count, indented by depth.
36
+ *
37
+ * Expansion is read through a subscription rather than from `row.getIsExpanded()`
38
+ * in the component body for the same reason the select checkbox does it — the
39
+ * `row` identity survives an expand, so the React Compiler would cache the call
40
+ * along with it and the chevron would never turn. See TMDataGridSelectColumn.
41
+ */
42
+ function GroupCell<TData extends RowData>({
43
+ row,
44
+ }: {
45
+ row: Row<TMDataGridFeatures, TData>;
46
+ }) {
47
+ const expanded = useSelector(row.table.store, () => row.getIsExpanded());
48
+
49
+ // Leaf rows keep the lane empty: their values are in the data columns, and
50
+ // the indent alone is what places them under their group.
51
+ if (!row.getIsGrouped()) return null;
52
+
53
+ const label = formatGroupValue(row.groupingValue);
54
+
55
+ return (
56
+ <UnstyledButton
57
+ className={classes.groupToggle}
58
+ // Padding rather than margin, so the whole indented width stays clickable.
59
+ style={{ paddingInlineStart: row.depth * INDENT_STEP }}
60
+ aria-expanded={expanded}
61
+ aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`}
62
+ // The row underneath may select or highlight on click; expanding is its
63
+ // own gesture and must not also trigger those.
64
+ onClick={(event) => {
65
+ event.stopPropagation();
66
+ row.toggleExpanded();
67
+ }}
68
+ >
69
+ <span className={classes.chevron} data-expanded={expanded}>
70
+ <ChevronRightIcon size={16} stroke={1.6} />
71
+ </span>
72
+ <span className={classes.groupLabel}>{label}</span>
73
+ {/* Data rows, not direct children and not `getLeafRows()`: a nested group
74
+ counts the records under it, never the sub-groups in between. */}
75
+ <span className={classes.groupCount}>
76
+ ({getGroupDataRows(row).length})
77
+ </span>
78
+ </UnstyledButton>
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Names the columns currently grouped on, so the lane says what it is showing
84
+ * rather than a static "Group".
85
+ */
86
+ function GroupHeader<TData extends RowData>({
87
+ table,
88
+ }: {
89
+ table: TMDataGridTable<TData>;
90
+ }) {
91
+ const grouping = useSelector(table.store, (state) => state.grouping, {
92
+ compare: shallow,
93
+ });
94
+
95
+ if (grouping.length === 0) return "Group";
96
+ return grouping
97
+ .map((columnId) => {
98
+ const column = table.getColumn(columnId);
99
+ return column ? getColumnLabel(column) : columnId;
100
+ })
101
+ .join(" / ");
102
+ }
103
+
104
+ /**
105
+ * The generated tree column, prepended whenever grouping is enabled and hidden
106
+ * again while `grouping` is empty — see the visibility effect in
107
+ * `useTMDataGrid`.
108
+ *
109
+ * It exists because TanStack ships no auto group column: `groupedColumnMode:
110
+ * "remove"` takes the grouped column out of the grid, so something has to hold
111
+ * the tree. Modelled on the checkbox column, which is generated the same way.
112
+ *
113
+ * Not groupable itself, and nothing had to be written to make that true —
114
+ * `column.getCanGroup()` requires an `accessorFn`, which a display column has
115
+ * no reason to have.
116
+ */
117
+ export function createGroupColumn<TData extends RowData>(): ColumnDef<
118
+ TMDataGridFeatures,
119
+ TData,
120
+ unknown
121
+ > {
122
+ return {
123
+ id: GROUP_COLUMN_ID,
124
+ meta: {
125
+ label: "Group",
126
+ // Structurally the first column after the checkbox lane.
127
+ enableOrdering: false,
128
+ },
129
+ size: 260,
130
+ minSize: 180,
131
+ enableSorting: false,
132
+ enableColumnFilter: false,
133
+ enableGlobalFilter: false,
134
+ // Keeps it out of the columns panel and out of the header menu: its
135
+ // visibility is not the user's to set, it follows the grouping state.
136
+ enableHiding: false,
137
+ // Structurally pinned to the left; users shouldn't be able to move it.
138
+ enablePinning: false,
139
+ cell: ({ row }) => <GroupCell row={row} />,
140
+ // A group row has subRows, so every cell on it that is not the grouped
141
+ // column reports `getIsAggregated()` — this lane included. Without an
142
+ // `aggregatedCell` the body would take that as "nothing to summarise" and
143
+ // render the tree lane blank on exactly the rows it exists for.
144
+ aggregatedCell: ({ row }) => <GroupCell row={row} />,
145
+ header: ({ table }) => <GroupHeader table={table} />,
146
+ };
147
+ }
@@ -21,6 +21,12 @@
21
21
  justify-content: center;
22
22
  }
23
23
 
24
+ /* Same as the body cells: the select-all box sits in a fixed 48px track, which
25
+ the text padding would squeeze it out of at the larger sizes. */
26
+ .headerCell[data-select-column="true"] {
27
+ padding: 0;
28
+ }
29
+
24
30
  /* Sorted or filtered columns read as active, like the MUI data grid. */
25
31
  .headerCell[data-active="true"] .headerTitle {
26
32
  color: var(--mantine-primary-color-filled);
@@ -23,16 +23,21 @@ import { isFilterActive } from "../core/filterOperators";
23
23
  import {
24
24
  ArrowDownIcon,
25
25
  ArrowUpIcon,
26
+ CollapseAllIcon,
26
27
  ColumnsIcon,
27
28
  DotsVerticalIcon,
29
+ ExpandAllIcon,
28
30
  EyeOffIcon,
29
31
  FilterIcon,
32
+ GroupIcon,
30
33
  MoveLeftIcon,
31
34
  MoveRightIcon,
32
35
  PinLeftIcon,
33
36
  PinOffIcon,
34
37
  PinRightIcon,
38
+ UngroupIcon,
35
39
  } from "./icons";
40
+ import { GROUP_COLUMN_ID } from "./TMDataGridGroupColumn";
36
41
  import { SELECT_COLUMN_ID } from "./TMDataGridSelectColumn";
37
42
  import { type TMDataGridFeatures, openColumnFilter } from "../useTMDataGrid";
38
43
 
@@ -63,7 +68,7 @@ export function TMDataGridHeaderCell({
63
68
  // surrounding tree is memoized (React Compiler) or the table lives elsewhere.
64
69
  // `columnOrder` is in the list because the move menu items are derived from
65
70
  // the column's neighbours, which is exactly what an order change invalidates.
66
- const { sorting, columnFilters, resizingColumnId, columnPinning } =
71
+ const { sorting, columnFilters, resizingColumnId, columnPinning, grouping } =
67
72
  useSelector(
68
73
  table.store,
69
74
  (state) => ({
@@ -72,6 +77,7 @@ export function TMDataGridHeaderCell({
72
77
  resizingColumnId: state.columnResizing.isResizingColumn,
73
78
  columnPinning: state.columnPinning,
74
79
  columnOrder: state.columnOrder,
80
+ grouping: state.grouping,
75
81
  }),
76
82
  { compare: shallow },
77
83
  );
@@ -97,9 +103,18 @@ export function TMDataGridHeaderCell({
97
103
  const canPin = isLeaf && capabilities.canPin;
98
104
  const canResize = capabilities.canResize;
99
105
  const canReorder = isLeaf && capabilities.canReorder;
106
+ const canGroup = isLeaf && capabilities.canGroup;
100
107
  const canManageColumns =
101
108
  isLeaf && getGridCapabilities(table, features).canHideAny;
102
109
 
110
+ // Read off the subscribed slice, not from `column.getIsGrouped()`: that is a
111
+ // call on a `column` whose identity survives a grouping change, so the React
112
+ // Compiler would cache the answer and the menu would keep offering to group
113
+ // a column that already is. Same reason the sort and filter flags above are
114
+ // derived from state rather than from `getIsSorted()`.
115
+ const isGrouped = grouping.includes(column.id);
116
+ const isGroupingActive = grouping.length > 0;
117
+
103
118
  // A column only moves inside its own pinned lane, so a header in another one
104
119
  // never lights up as a target. See TMDataGridColumnRegion.
105
120
  const isDragged = draggedColumnId === column.id;
@@ -205,6 +220,64 @@ export function TMDataGridHeaderCell({
205
220
  <Menu.Divider key="filter-divider" />,
206
221
  );
207
222
  }
223
+ if (canGroup) {
224
+ menuItems.push(
225
+ <Menu.Item
226
+ key="group"
227
+ className={isGrouped ? classes.menuItemActive : undefined}
228
+ leftSection={
229
+ isGrouped ? (
230
+ <UngroupIcon size={16} stroke={1.6} />
231
+ ) : (
232
+ <GroupIcon size={16} stroke={1.6} />
233
+ )
234
+ }
235
+ onClick={column.getToggleGroupingHandler()}
236
+ >
237
+ {isGrouped ? `Ungroup ${label}` : `Group by ${label}`}
238
+ </Menu.Item>,
239
+ );
240
+ }
241
+ if (isGroupingActive) {
242
+ // Under the default `groupedColumnMode: "remove"` a grouped column is taken
243
+ // out of the grid, so its own header — and the Ungroup item on it — is no
244
+ // longer reachable. The tree column that replaced it carries them instead,
245
+ // which is also where someone would look for them.
246
+ if (column.id === GROUP_COLUMN_ID) {
247
+ for (const groupedId of grouping) {
248
+ const groupedColumn = table.getColumn(groupedId);
249
+ if (!groupedColumn) continue;
250
+ menuItems.push(
251
+ <Menu.Item
252
+ key={`ungroup-${groupedId}`}
253
+ leftSection={<UngroupIcon size={16} stroke={1.6} />}
254
+ onClick={() => groupedColumn.toggleGrouping()}
255
+ >
256
+ {`Ungroup ${getColumnLabel(groupedColumn)}`}
257
+ </Menu.Item>,
258
+ );
259
+ }
260
+ }
261
+ menuItems.push(
262
+ <Menu.Item
263
+ key="expand-all"
264
+ leftSection={<ExpandAllIcon size={16} stroke={1.6} />}
265
+ onClick={() => table.toggleAllRowsExpanded(true)}
266
+ >
267
+ Expand all groups
268
+ </Menu.Item>,
269
+ <Menu.Item
270
+ key="collapse-all"
271
+ leftSection={<CollapseAllIcon size={16} stroke={1.6} />}
272
+ onClick={() => table.toggleAllRowsExpanded(false)}
273
+ >
274
+ Collapse all groups
275
+ </Menu.Item>,
276
+ );
277
+ }
278
+ if (canGroup || isGroupingActive) {
279
+ menuItems.push(<Menu.Divider key="group-divider" />);
280
+ }
208
281
  if (canPin) {
209
282
  menuItems.push(
210
283
  <Menu.Item
@@ -322,6 +395,9 @@ export function TMDataGridHeaderCell({
322
395
  data-testid={`dg-header-${column.id}`}
323
396
  data-active={isSorted || isFiltered}
324
397
  data-align={align}
398
+ // The checkbox lane is a fixed track, so it cannot take the cell padding
399
+ // the scale grows for text. See TMDataGridHeaderCell.module.css.
400
+ data-select-column={column.id === SELECT_COLUMN_ID}
325
401
  // Only meaningful on a sortable column: "none" advertises that this
326
402
  // header sorts, which is a lie on one that doesn't.
327
403
  aria-sort={
@@ -1,6 +1,12 @@
1
1
  import { Checkbox } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
4
+ import { useTMDataGridContext } from "../TMDataGridContext";
5
+ import {
6
+ getDisplayedRows,
7
+ getSelectableRowIds,
8
+ resolveRowSelectionClick,
9
+ } from "../core/rowSelection";
4
10
  import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
5
11
 
6
12
  export const SELECT_COLUMN_ID = "__select__";
@@ -43,24 +49,90 @@ function SelectAllCheckbox<TData extends RowData>({
43
49
  );
44
50
  }
45
51
 
52
+ /**
53
+ * Drops the select-all box when only one row may be selected. Read from the
54
+ * feature flags rather than `table.options.enableMultiRowSelection` directly:
55
+ * the flags are re-derived from a fresh object every render, so the switch
56
+ * cannot be cached along with a `table` identity that survives an options
57
+ * change — the same reason the rest of the chrome reads them. See
58
+ * readFeatureFlags.
59
+ */
60
+ function SelectAllHeader<TData extends RowData>({
61
+ table,
62
+ }: {
63
+ table: TMDataGridTable<TData>;
64
+ }) {
65
+ const { features } = useTMDataGridContext();
66
+ if (!features.multiRowSelection) return null;
67
+ return <SelectAllCheckbox table={table} />;
68
+ }
69
+
46
70
  function SelectRowCheckbox<TData extends RowData>({
47
71
  row,
48
72
  }: {
49
73
  row: Row<TMDataGridFeatures, TData>;
50
74
  }) {
51
- const selected = useSelector(row.table.store, () => row.getIsSelected());
75
+ // Cells render inside the grid's provider, so the checkbox can reach the
76
+ // chrome store — it needs the shift-click pivot, and the feature flags to know
77
+ // which row model a range is measured over.
78
+ const { ui, features } = useTMDataGridContext();
79
+ const isGroupRow = row.subRows.length > 0;
80
+
81
+ // A group row is never selected by id: `rowSelection` only ever holds the
82
+ // leaves, and TanStack's `getIsSelected()` is a plain lookup in that map. So
83
+ // a group asks about its descendants instead, which is also the honest
84
+ // reading — the box means "all of these", and it goes indeterminate as soon
85
+ // as that stops being true.
86
+ const selected = useSelector(row.table.store, () =>
87
+ isGroupRow ? row.getIsAllSubRowsSelected() : row.getIsSelected(),
88
+ );
52
89
  const someSelected = useSelector(row.table.store, () =>
53
90
  row.getIsSomeSelected(),
54
91
  );
55
92
 
93
+ // The resolver expands a tick back out to the descendant ids; all the group
94
+ // case needs here is to know whether any of them may be selected at all.
95
+ const selectableIds = getSelectableRowIds(row);
96
+
97
+ // Single-select has no way to express "this group": one box for several rows
98
+ // would either overrun the limit or lie about what it did.
99
+ if (isGroupRow && !row.getCanMultiSelect()) return null;
100
+
56
101
  return (
57
102
  <Checkbox
58
103
  size="xs"
59
- aria-label="Select row"
104
+ aria-label={isGroupRow ? "Select group" : "Select row"}
60
105
  checked={selected}
61
- disabled={!row.getCanSelect()}
106
+ disabled={selectableIds.length === 0}
62
107
  indeterminate={someSelected && !selected}
63
- onChange={row.getToggleSelectedHandler()}
108
+ // Every tick goes through the resolver, shift held or not — plain becomes
109
+ // a toggle that moves the pivot, so a later shift-click extends from the
110
+ // box the user last touched.
111
+ //
112
+ // All of it in `onChange`, not split with an `onClick`: React derives a
113
+ // checkbox's change event from the click, so the modifier keys ride along
114
+ // on the native event, and a `preventDefault()` in a click handler cannot
115
+ // stop the change from firing anyway. Trying to do the range in `onClick`
116
+ // silently loses the clicked row, which `onChange` then toggles back off.
117
+ onChange={(event) => {
118
+ const native = event.nativeEvent;
119
+ const isMouse = native instanceof MouseEvent;
120
+ const resolved = resolveRowSelectionClick({
121
+ rows: getDisplayedRows(row.table, features),
122
+ rowId: row.id,
123
+ anchorRowId: ui.state.selectionAnchorRowId,
124
+ modifiers: {
125
+ toggle: isMouse && (native.ctrlKey || native.metaKey),
126
+ extend: isMouse && native.shiftKey,
127
+ },
128
+ selection: row.table.store.state.rowSelection,
129
+ // A checkbox is only ever additive — ticking one has never cleared
130
+ // the others, so `canReplaceSelection` stays false whatever is held.
131
+ canReplaceSelection: false,
132
+ });
133
+ row.table.setRowSelection(resolved.selection);
134
+ ui.actions.setSelectionAnchor(resolved.anchorRowId);
135
+ }}
64
136
  // A row can select on click too; the checkbox must not toggle twice.
65
137
  onClick={(event) => event.stopPropagation()}
66
138
  />
@@ -69,7 +141,7 @@ function SelectRowCheckbox<TData extends RowData>({
69
141
 
70
142
  /**
71
143
  * The generated checkbox column, prepended under
72
- * `rowSelectionMode: "checkbox"` — the default whenever row selection is on.
144
+ * `selectionMode: "checkbox"` (the default) or `"checkboxAndHighlight"`.
73
145
  */
74
146
  export function createSelectColumn<TData extends RowData>(): ColumnDef<
75
147
  TMDataGridFeatures,
@@ -94,7 +166,12 @@ export function createSelectColumn<TData extends RowData>(): ColumnDef<
94
166
  enableGlobalFilter: false,
95
167
  // Structurally pinned to the left; users shouldn't be able to move it.
96
168
  enablePinning: false,
97
- header: ({ table }) => <SelectAllCheckbox table={table} />,
169
+ header: ({ table }) => <SelectAllHeader table={table} />,
98
170
  cell: ({ row }) => <SelectRowCheckbox row={row} />,
171
+ // Every cell on a group row that is not the grouped column counts as
172
+ // aggregated, this lane included, and an aggregated cell with nothing
173
+ // declared renders blank. Without this the checkbox would disappear from
174
+ // exactly the rows that select a whole group. See renderCellContent.
175
+ aggregatedCell: ({ row }) => <SelectRowCheckbox row={row} />,
99
176
  };
100
177
  }
@@ -42,13 +42,39 @@
42
42
  --row-bg: var(--mantine-color-default-hover);
43
43
  }
44
44
 
45
- /* Selection and highlight are separate: a row is always `data-selected`, but it
46
- only takes the highlight colour when `highlightSelectedRows` is on — the
47
- default in row selection mode, off with checkboxes. */
48
- .bodyRow[data-highlighted="true"] {
45
+ /* A group row takes no background of its own. Collapsed, every row on screen is
46
+ a group row, so a tint would grey out the whole grid and read as if
47
+ everything were selected. The bold label and the chevron carry the
48
+ distinction instead — and `--dg-row-group-bg` is there for a consumer who
49
+ wants the tint back. Declared before the selection rules, so a selected or
50
+ highlighted group still reads as one. */
51
+ .bodyRow[data-grouped="true"] {
52
+ --row-bg: var(--dg-row-group-bg);
53
+ }
54
+
55
+ /* The row an open context menu is about. The pointer has usually left it by
56
+ then, so without this the menu would be the only thing saying which row it
57
+ acts on. Declared before the selection rules, which are the stronger
58
+ statement and win at equal specificity. */
59
+ .bodyRow[data-context-menu="true"] {
60
+ --row-bg: var(--mantine-color-default-hover);
61
+ }
62
+
63
+ /* Being selected and being painted for it are separate: a row is always
64
+ `data-selected`, but only takes the background when `showSelectedBackground`
65
+ is on — the default under `"row"`, off with checkboxes. */
66
+ .bodyRow[data-selected-bg="true"] {
49
67
  --row-bg: var(--dg-row-selected-bg);
50
68
  }
51
69
 
70
+ /* The highlighted row wins over the selected background: under
71
+ `"checkboxAndHighlight"` the two coexist, and the highlighted row is the one
72
+ the detail panel is about, so it must stay distinguishable inside a
73
+ selection. Declared after, so the later rule takes it at equal specificity. */
74
+ .bodyRow[data-highlighted="true"] {
75
+ --row-bg: var(--dg-row-highlight-bg);
76
+ }
77
+
52
78
  /* Row selection mode: the row is the control, so it takes focus and must not
53
79
  start a text selection on the click that toggles it. */
54
80
  .bodyRow[data-selects-on-click="true"] {
@@ -72,6 +98,14 @@
72
98
  font-size: var(--dg-font-size);
73
99
  }
74
100
 
101
+ /* The checkbox column is a fixed 48px track holding a control of a known width,
102
+ not text that can ellipsize. Cell padding is sized for text and grows with
103
+ the scale, so at `xl` its 18px a side left 12px for a 16px box and the
104
+ checkbox was clipped. The lane centres its control instead. */
105
+ .bodyCell[data-select-column="true"] {
106
+ padding: 0;
107
+ }
108
+
75
109
  .cellContent {
76
110
  min-width: 0;
77
111
  overflow: hidden;