@jielga/tmdatagrid 0.3.0 → 0.5.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 (43) hide show
  1. package/dist/index.d.ts +947 -64
  2. package/dist/index.js +2474 -1423
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +2 -2
  6. package/skills/columns/SKILL.md +1 -1
  7. package/skills/features/SKILL.md +61 -11
  8. package/skills/getting-started/SKILL.md +1 -1
  9. package/skills/options/SKILL.md +12 -2
  10. package/skills/server-side/SKILL.md +1 -1
  11. package/src/tmdatagrid/TMDataGridContext.ts +26 -0
  12. package/src/tmdatagrid/components/TMDataGrid.module.css +10 -0
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +23 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +5 -5
  15. package/src/tmdatagrid/components/TMDataGridDetailsColumn.module.css +33 -0
  16. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +160 -0
  17. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +111 -33
  18. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +5 -4
  19. package/src/tmdatagrid/components/TMDataGridFooter.tsx +11 -19
  20. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +23 -11
  21. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -3
  22. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +181 -52
  23. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  24. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +23 -13
  25. package/src/tmdatagrid/components/TMDataGridTable.module.css +127 -6
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +1003 -30
  27. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +30 -7
  28. package/src/tmdatagrid/components/icons.ts +1 -0
  29. package/src/tmdatagrid/components/sticky.module.css +77 -16
  30. package/src/tmdatagrid/core/autosize.ts +125 -0
  31. package/src/tmdatagrid/core/capabilities.ts +41 -0
  32. package/src/tmdatagrid/core/cellExport.ts +321 -0
  33. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  34. package/src/tmdatagrid/core/cellRange.ts +118 -0
  35. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  36. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  37. package/src/tmdatagrid/core/expanding.ts +93 -0
  38. package/src/tmdatagrid/core/filterOperators.ts +176 -15
  39. package/src/tmdatagrid/core/labels.ts +211 -0
  40. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  41. package/src/tmdatagrid/core/summary.ts +35 -0
  42. package/src/tmdatagrid/index.ts +70 -0
  43. package/src/tmdatagrid/useTMDataGrid.tsx +328 -12
@@ -0,0 +1,321 @@
1
+ import type { Column, Row, RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
4
+ import type { TMDataGridRangeBounds } from "./cellRange";
5
+ import { getColumnLabel, isControlColumn } from "./columnUtils";
6
+
7
+ /**
8
+ * The byte order mark Excel looks for before it will read a file as UTF-8.
9
+ * Built from its code point rather than pasted in: a literal BOM is invisible
10
+ * in a source file, and an invisible character nobody can see is one somebody
11
+ * eventually deletes.
12
+ */
13
+ const UTF8_BOM = String.fromCharCode(0xfeff);
14
+
15
+ /** The selected block, flattened to text. One inner array per row. */
16
+ export type TMDataGridCellMatrix = Array<Array<string>>;
17
+
18
+ /**
19
+ * How values are written out.
20
+ *
21
+ * The defaults are the Nordic ones, because they are the ones that need
22
+ * choosing: an Excel running a Swedish, Norwegian, Danish or Finnish locale
23
+ * reads `;` as its list separator and `,` as its decimal mark, and a file
24
+ * written the other way opens as one column of text. Both are settable for a
25
+ * grid whose users run something else.
26
+ */
27
+ export type TMDataGridCellExportOptions = {
28
+ /** CSV field separator. Defaults to `";"`. */
29
+ separator?: string;
30
+ /** Write numbers as `1,5` rather than `1.5`. Defaults to `true`. */
31
+ decimalComma?: boolean;
32
+ /** Column labels as the first row. Defaults to `true`. */
33
+ includeHeaders?: boolean;
34
+ /** Without extension. Defaults to `"export"`. */
35
+ fileName?: string;
36
+ };
37
+
38
+ type ResolvedExportOptions = Required<TMDataGridCellExportOptions>;
39
+
40
+ export const DEFAULT_CELL_EXPORT_OPTIONS: ResolvedExportOptions = {
41
+ separator: ";",
42
+ decimalComma: true,
43
+ includeHeaders: true,
44
+ fileName: "export",
45
+ };
46
+
47
+ /**
48
+ * One value as text.
49
+ *
50
+ * Deliberately not the rendered cell: what a cell renders is React, and often a
51
+ * badge, a link or an icon rather than the value. The value is what a
52
+ * spreadsheet wants, and it is the one thing every column is guaranteed to
53
+ * have.
54
+ */
55
+ export function formatExportValue(
56
+ value: unknown,
57
+ { decimalComma }: { decimalComma: boolean },
58
+ ): string {
59
+ if (value === null || value === undefined) return "";
60
+ if (typeof value === "number") {
61
+ if (!Number.isFinite(value)) return "";
62
+ const text = String(value);
63
+ return decimalComma ? text.replace(".", ",") : text;
64
+ }
65
+ if (typeof value === "string") return value;
66
+ if (typeof value === "boolean") return value ? "true" : "false";
67
+ // A multiSelect cell holds an array of values; a spreadsheet cell holds one
68
+ // string, so the elements are joined the way they read on screen.
69
+ if (Array.isArray(value)) {
70
+ return value
71
+ .map((entry) => formatExportValue(entry, { decimalComma }))
72
+ .join(", ");
73
+ }
74
+ // `sv-SE` is ISO-shaped — 2026-07-31, 2026-07-31 14:05:00 — which is both the
75
+ // Nordic form and the one Excel parses as a date rather than as text.
76
+ if (value instanceof Date) {
77
+ return Number.isNaN(value.getTime()) ? "" : value.toLocaleString("sv-SE");
78
+ }
79
+ // An object in a cell is a shape the grid cannot know. JSON at least keeps
80
+ // what was there, where `String(value)` would write "[object Object]".
81
+ if (typeof value === "object") {
82
+ try {
83
+ return JSON.stringify(value);
84
+ } catch {
85
+ return "";
86
+ }
87
+ }
88
+ return String(value);
89
+ }
90
+
91
+ export type BuildCellMatrixArgs = {
92
+ rows: ReadonlyArray<Row<TMDataGridFeatures, TMDataGridRowData>>;
93
+ /** Every visible column, in render order — the same list the bounds index into. */
94
+ columns: ReadonlyArray<Column<TMDataGridFeatures, TMDataGridRowData, unknown>>;
95
+ bounds: TMDataGridRangeBounds;
96
+ includeHeaders: boolean;
97
+ decimalComma: boolean;
98
+ };
99
+
100
+ /**
101
+ * The selected rectangle as rows of text.
102
+ *
103
+ * The generated lanes — the checkbox, the tree chevron, the details chevron —
104
+ * are dropped even when the rectangle covers them. They hold controls rather
105
+ * than data, so a column of empty strings is all they could contribute, and
106
+ * pasting one into a spreadsheet only shifts everything to its right.
107
+ */
108
+ export function buildCellMatrix({
109
+ rows,
110
+ columns,
111
+ bounds,
112
+ includeHeaders,
113
+ decimalComma,
114
+ }: BuildCellMatrixArgs): TMDataGridCellMatrix {
115
+ const selectedColumns = columns
116
+ .slice(bounds.left, bounds.right + 1)
117
+ .filter((column) => !isControlColumn(column.id));
118
+ if (selectedColumns.length === 0) return [];
119
+
120
+ const matrix: TMDataGridCellMatrix = [];
121
+ if (includeHeaders) {
122
+ matrix.push(selectedColumns.map((column) => getColumnLabel(column)));
123
+ }
124
+
125
+ for (let index = bounds.top; index <= bounds.bottom; index += 1) {
126
+ const row = rows[index];
127
+ if (row === undefined) continue;
128
+ matrix.push(
129
+ selectedColumns.map((column) =>
130
+ formatExportValue(row.getValue(column.id), { decimalComma }),
131
+ ),
132
+ );
133
+ }
134
+ return matrix;
135
+ }
136
+
137
+ /**
138
+ * The whole grid as rows of text: every filtered and sorted data row — all
139
+ * pages, not the one on screen — by every visible non-control column, in
140
+ * render order.
141
+ *
142
+ * The values are the same ones a cell-range export writes; only the bounds
143
+ * differ. Group rows are left out (their records are the rows themselves),
144
+ * and hidden columns are not exported — what you see is what you get, minus
145
+ * paging.
146
+ */
147
+ export function buildGridCellMatrix<TData extends RowData>({
148
+ table,
149
+ includeHeaders = DEFAULT_CELL_EXPORT_OPTIONS.includeHeaders,
150
+ decimalComma = DEFAULT_CELL_EXPORT_OPTIONS.decimalComma,
151
+ }: {
152
+ table: TMDataGridTable<TData>;
153
+ includeHeaders?: boolean;
154
+ decimalComma?: boolean;
155
+ }): TMDataGridCellMatrix {
156
+ // The same erasure the context provider performs: the matrix only ever reads
157
+ // generic row and column APIs.
158
+ const erased = table as unknown as TMDataGridTable<TMDataGridRowData>;
159
+ const columns = [
160
+ ...erased.getLeftVisibleLeafColumns(),
161
+ ...erased.getCenterVisibleLeafColumns(),
162
+ ...erased.getRightVisibleLeafColumns(),
163
+ ];
164
+ // The sorted model: after filtering, grouping and sorting, before expansion
165
+ // and paging — so a paged grid exports the whole filtered set, and a grouped
166
+ // grid exports the records under every group whether or not it is open.
167
+ //
168
+ // Walked by hand rather than taken from `flatRows`: in table-core
169
+ // 9.0.0-beta.21 the grouped model lists every leaf twice there (once as a
170
+ // leaf row, once re-parented under its group), and its order puts the
171
+ // groups last. Depth-first over `rows` is exactly render order, once each.
172
+ const rows: Array<Row<TMDataGridFeatures, TMDataGridRowData>> = [];
173
+ const walk = (
174
+ list: ReadonlyArray<Row<TMDataGridFeatures, TMDataGridRowData>>,
175
+ ) => {
176
+ for (const row of list) {
177
+ if (row.getIsGrouped()) walk(row.subRows);
178
+ else rows.push(row);
179
+ }
180
+ };
181
+ walk(erased.getSortedRowModel().rows);
182
+
183
+ return buildCellMatrix({
184
+ rows,
185
+ columns,
186
+ bounds: {
187
+ top: 0,
188
+ bottom: rows.length - 1,
189
+ left: 0,
190
+ right: columns.length - 1,
191
+ },
192
+ includeHeaders,
193
+ decimalComma,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Downloads the whole grid as a CSV for Excel — {@link buildGridCellMatrix}
199
+ * through {@link toExcelCsv}, with the same Nordic defaults and overrides as
200
+ * the cell-range export.
201
+ *
202
+ * No built-in button: wire it to your own toolbar.
203
+ *
204
+ * ```tsx
205
+ * <Button onClick={() => exportGridToCsv({ table: grid.table })}>Export</Button>
206
+ * ```
207
+ */
208
+ export function exportGridToCsv<TData extends RowData>({
209
+ table,
210
+ options,
211
+ }: {
212
+ table: TMDataGridTable<TData>;
213
+ options?: TMDataGridCellExportOptions;
214
+ }): void {
215
+ const resolved = { ...DEFAULT_CELL_EXPORT_OPTIONS, ...options };
216
+ const matrix = buildGridCellMatrix({
217
+ table,
218
+ includeHeaders: resolved.includeHeaders,
219
+ decimalComma: resolved.decimalComma,
220
+ });
221
+ if (matrix.length === 0) return;
222
+ downloadTextFile({
223
+ fileName: `${resolved.fileName}.csv`,
224
+ text: toExcelCsv(matrix, { separator: resolved.separator }),
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Quotes a field when it holds something that would otherwise end it early.
230
+ * Doubling the quote is how both CSV and Excel's own clipboard format escape
231
+ * one, so the same rule serves both.
232
+ */
233
+ function escapeField(value: string, separator: string): string {
234
+ const needsQuotes =
235
+ value.includes(separator) ||
236
+ value.includes('"') ||
237
+ value.includes("\n") ||
238
+ value.includes("\r");
239
+ return needsQuotes ? `"${value.replaceAll('"', '""')}"` : value;
240
+ }
241
+
242
+ /**
243
+ * The clipboard format spreadsheets read: tab between cells, CRLF between rows.
244
+ *
245
+ * Tabs rather than commas because that is what Excel, Sheets and Numbers all
246
+ * put on the clipboard themselves — paste it and the cells land in cells. A
247
+ * comma-separated string pastes into a single column, which is the thing this
248
+ * exists to avoid.
249
+ */
250
+ export function toClipboardText(matrix: TMDataGridCellMatrix): string {
251
+ return matrix
252
+ .map((row) => row.map((value) => escapeField(value, "\t")).join("\t"))
253
+ .join("\r\n");
254
+ }
255
+
256
+ /**
257
+ * A CSV that opens straight into columns in Excel.
258
+ *
259
+ * Three things make that true, and all three are needed:
260
+ *
261
+ * | Part | Why |
262
+ * | ---- | --- |
263
+ * | `sep=;` first line | Excel's own directive — it stops guessing and uses this |
264
+ * | UTF-8 BOM | without it Excel reads the file as ANSI, and å ä ö arrive broken |
265
+ * | CRLF line endings | what Excel writes, and what its importer is happiest with |
266
+ *
267
+ * The `sep=` line is Excel's alone; other readers show it as a first row. That
268
+ * is the trade this makes — the file is for Excel, and "it just opens" is worth
269
+ * more than being a well-behaved CSV nobody was going to feed to a parser.
270
+ */
271
+ export function toExcelCsv(
272
+ matrix: TMDataGridCellMatrix,
273
+ { separator }: { separator: string },
274
+ ): string {
275
+ const body = matrix
276
+ .map((row) => row.map((value) => escapeField(value, separator)).join(separator))
277
+ .join("\r\n");
278
+ return `${UTF8_BOM}sep=${separator}\r\n${body}\r\n`;
279
+ }
280
+
281
+ /**
282
+ * Puts text on the clipboard, reporting whether it landed.
283
+ *
284
+ * The async clipboard API only resolves for a document that has the focus and a
285
+ * user gesture behind it — both true when this runs off Ctrl+C or a menu item.
286
+ * It is still allowed to reject (a permissions policy, a page that lost focus
287
+ * mid-copy), so the result is a boolean rather than a promise nobody checks.
288
+ */
289
+ export async function writeClipboardText(text: string): Promise<boolean> {
290
+ try {
291
+ await navigator.clipboard.writeText(text);
292
+ return true;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Downloads text as a file, through the one mechanism a library can use: an
300
+ * anchor with an object URL behind it, clicked. Revoked on the next frame —
301
+ * immediately would race the browser's own read of it.
302
+ */
303
+ export function downloadTextFile({
304
+ fileName,
305
+ text,
306
+ mimeType = "text/csv;charset=utf-8",
307
+ }: {
308
+ fileName: string;
309
+ text: string;
310
+ mimeType?: string;
311
+ }): void {
312
+ const url = URL.createObjectURL(new Blob([text], { type: mimeType }));
313
+ const anchor = document.createElement("a");
314
+ anchor.href = url;
315
+ anchor.download = fileName;
316
+ anchor.style.display = "none";
317
+ document.body.append(anchor);
318
+ anchor.click();
319
+ anchor.remove();
320
+ setTimeout(() => URL.revokeObjectURL(url), 0);
321
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Which cell has the focus, by id rather than by position.
3
+ *
4
+ * Ids, because every other thing the grid does moves cells around: sorting
5
+ * reorders rows, filtering removes them, dragging a header reorders columns. A
6
+ * coordinate pair would silently come to mean a different cell after any of
7
+ * them, while a pair of ids either still resolves or does not resolve at all —
8
+ * and "does not resolve" is a state the grid can handle honestly.
9
+ *
10
+ * Indices are what navigation is actually computed in, so they are resolved
11
+ * from the ids on each keystroke and turned straight back. See resolveCellMove.
12
+ */
13
+ export type TMDataGridCellPosition = {
14
+ rowId: string;
15
+ columnId: string;
16
+ };
17
+
18
+ /** Where the focused cell sits in the rows and columns on screen. */
19
+ export type TMDataGridCellCoords = {
20
+ rowIndex: number;
21
+ columnIndex: number;
22
+ };
23
+
24
+ export type ResolveCellMoveArgs = {
25
+ /** `KeyboardEvent.key`. */
26
+ key: string;
27
+ /** Ctrl or Cmd — Home and End jump to the corner rather than the row end. */
28
+ ctrlKey: boolean;
29
+ from: TMDataGridCellCoords;
30
+ rowCount: number;
31
+ columnCount: number;
32
+ /**
33
+ * How far PageUp and PageDown travel: one viewport of rows. Passed in rather
34
+ * than assumed, since only the body knows how tall it is.
35
+ */
36
+ pageRows: number;
37
+ };
38
+
39
+ const clamp = (value: number, max: number) =>
40
+ Math.min(Math.max(value, 0), Math.max(max, 0));
41
+
42
+ /**
43
+ * The next cell for a navigation key, or `null` when the key is not one.
44
+ *
45
+ * Moves clamp at the edges instead of wrapping. Wrapping suits a menu, where
46
+ * the items are a short ring; a grid is a coordinate space, and a Right at the
47
+ * last column that lands on the first column of the next row loses the one
48
+ * thing arrow keys are for — knowing where you will end up without looking.
49
+ *
50
+ * A clamped move returns the position it started from rather than `null`. The
51
+ * caller still needs to know the key was handled: an arrow at the last row must
52
+ * not fall through to the scroll container, which would scroll the body out
53
+ * from under a focus that did not move.
54
+ */
55
+ export function resolveCellMove({
56
+ key,
57
+ ctrlKey,
58
+ from,
59
+ rowCount,
60
+ columnCount,
61
+ pageRows,
62
+ }: ResolveCellMoveArgs): TMDataGridCellCoords | null {
63
+ if (rowCount === 0 || columnCount === 0) return null;
64
+
65
+ const lastRow = rowCount - 1;
66
+ const lastColumn = columnCount - 1;
67
+ const at = (rowIndex: number, columnIndex: number): TMDataGridCellCoords => ({
68
+ rowIndex: clamp(rowIndex, lastRow),
69
+ columnIndex: clamp(columnIndex, lastColumn),
70
+ });
71
+
72
+ switch (key) {
73
+ case "ArrowDown":
74
+ return at(from.rowIndex + 1, from.columnIndex);
75
+ case "ArrowUp":
76
+ return at(from.rowIndex - 1, from.columnIndex);
77
+ case "ArrowRight":
78
+ return at(from.rowIndex, from.columnIndex + 1);
79
+ case "ArrowLeft":
80
+ return at(from.rowIndex, from.columnIndex - 1);
81
+ case "PageDown":
82
+ return at(from.rowIndex + Math.max(pageRows, 1), from.columnIndex);
83
+ case "PageUp":
84
+ return at(from.rowIndex - Math.max(pageRows, 1), from.columnIndex);
85
+ // Ctrl+Home / Ctrl+End address the grid, plain Home / End the row — the
86
+ // convention every spreadsheet and APG's own grid pattern share.
87
+ case "Home":
88
+ return ctrlKey ? at(0, 0) : at(from.rowIndex, 0);
89
+ case "End":
90
+ return ctrlKey ? at(lastRow, lastColumn) : at(from.rowIndex, lastColumn);
91
+ default:
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Whether two positions address the same cell. `null` equals only `null`. */
97
+ export function isSameCell(
98
+ a: TMDataGridCellPosition | null,
99
+ b: TMDataGridCellPosition | null,
100
+ ): boolean {
101
+ if (a === null || b === null) return a === b;
102
+ return a.rowId === b.rowId && a.columnId === b.columnId;
103
+ }
@@ -0,0 +1,118 @@
1
+ import type { TMDataGridCellPosition } from "./cellNavigation";
2
+
3
+ /**
4
+ * A selected rectangle of cells, held as the two cells that span it.
5
+ *
6
+ * Two corners rather than a list of cells, because a range is a gesture, not a
7
+ * set: dragging over 40 columns of a 100k-row grid would otherwise build — and
8
+ * rebuild, on every mouse move — a list with millions of entries in it. Two
9
+ * positions describe the same thing and cost nothing to extend.
10
+ *
11
+ * `anchor` is where the gesture started and stays put; `focus` is the end that
12
+ * moves. Which of the two is top-left depends on the direction it was dragged,
13
+ * so nothing here assumes an order — see {@link resolveRangeBounds}.
14
+ */
15
+ export type TMDataGridCellRange = {
16
+ anchor: TMDataGridCellPosition;
17
+ focus: TMDataGridCellPosition;
18
+ };
19
+
20
+ /** The rectangle a range covers, in row and column indices, both ends included. */
21
+ export type TMDataGridRangeBounds = {
22
+ top: number;
23
+ bottom: number;
24
+ left: number;
25
+ right: number;
26
+ };
27
+
28
+ export type ResolveRangeBoundsArgs = {
29
+ range: TMDataGridCellRange | null;
30
+ /** Index of a row id among the rows on screen, or `-1`. */
31
+ rowIndexOf: (rowId: string) => number;
32
+ columnIndexOf: (columnId: string) => number;
33
+ };
34
+
35
+ /**
36
+ * Turns a range into the rectangle to paint and copy, or `null` when either
37
+ * corner no longer exists.
38
+ *
39
+ * A corner goes missing whenever a filter drops its row or a column is hidden,
40
+ * and the honest answer then is that there is no rectangle — better than
41
+ * guessing at a replacement corner and quietly copying cells the user never
42
+ * selected. The range itself is left alone: clearing it here would throw away
43
+ * a selection that comes straight back when the filter is lifted.
44
+ */
45
+ export function resolveRangeBounds({
46
+ range,
47
+ rowIndexOf,
48
+ columnIndexOf,
49
+ }: ResolveRangeBoundsArgs): TMDataGridRangeBounds | null {
50
+ if (range === null) return null;
51
+
52
+ const anchorRow = rowIndexOf(range.anchor.rowId);
53
+ const focusRow = rowIndexOf(range.focus.rowId);
54
+ const anchorColumn = columnIndexOf(range.anchor.columnId);
55
+ const focusColumn = columnIndexOf(range.focus.columnId);
56
+ if (
57
+ anchorRow < 0 ||
58
+ focusRow < 0 ||
59
+ anchorColumn < 0 ||
60
+ focusColumn < 0
61
+ ) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ top: Math.min(anchorRow, focusRow),
67
+ bottom: Math.max(anchorRow, focusRow),
68
+ left: Math.min(anchorColumn, focusColumn),
69
+ right: Math.max(anchorColumn, focusColumn),
70
+ };
71
+ }
72
+
73
+ /** Whether a cell is inside the rectangle. */
74
+ export function isWithinBounds(
75
+ bounds: TMDataGridRangeBounds | null,
76
+ rowIndex: number,
77
+ columnIndex: number,
78
+ ): boolean {
79
+ if (bounds === null) return false;
80
+ return (
81
+ rowIndex >= bounds.top &&
82
+ rowIndex <= bounds.bottom &&
83
+ columnIndex >= bounds.left &&
84
+ columnIndex <= bounds.right
85
+ );
86
+ }
87
+
88
+ /** How many cells the rectangle covers. `0` for no range at all. */
89
+ export function boundsCellCount(bounds: TMDataGridRangeBounds | null): number {
90
+ if (bounds === null) return 0;
91
+ return (
92
+ (bounds.bottom - bounds.top + 1) * (bounds.right - bounds.left + 1)
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Which edges of the rectangle a cell sits on, for the outline.
98
+ *
99
+ * The border is drawn per cell rather than as one box over the top, because the
100
+ * body is a scrolling CSS grid with sticky columns in it: an overlay would have
101
+ * to be positioned against a moving target and would still be wrong the moment
102
+ * a pinned lane slid over it. Four booleans per cell need no positioning at all.
103
+ */
104
+ export function boundsEdges(
105
+ bounds: TMDataGridRangeBounds | null,
106
+ rowIndex: number,
107
+ columnIndex: number,
108
+ ): { top: boolean; bottom: boolean; left: boolean; right: boolean } | null {
109
+ if (!isWithinBounds(bounds, rowIndex, columnIndex) || bounds === null) {
110
+ return null;
111
+ }
112
+ return {
113
+ top: rowIndex === bounds.top,
114
+ bottom: rowIndex === bounds.bottom,
115
+ left: columnIndex === bounds.left,
116
+ right: columnIndex === bounds.right,
117
+ };
118
+ }
@@ -0,0 +1,123 @@
1
+ import type { Column, Row } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
4
+
5
+ /**
6
+ * One choice of a `select` / `multiSelect` column. A bare string in any options
7
+ * list is shorthand for `{ value }`.
8
+ */
9
+ export type TMDataGridOption = {
10
+ value: string;
11
+ /** Shown in dropdowns. Defaults to the value itself. */
12
+ label?: string;
13
+ /** Mantine colour for the value's badge, where a cell renders one. */
14
+ color?: string;
15
+ disabled?: boolean;
16
+ /** Dropdown group heading. Ungrouped options render first. */
17
+ group?: string;
18
+ };
19
+
20
+ export type TMDataGridOptionsArgs = {
21
+ table: TMDataGridTable<TMDataGridRowData>;
22
+ column: Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
23
+ /** The row an editor is standing on. Absent when the filter panel asks. */
24
+ row?: Row<TMDataGridFeatures, TMDataGridRowData>;
25
+ /**
26
+ * Source to use when the column declares none. The filter panel passes
27
+ * `"faceted"` so a select column with no `meta.options` still offers the
28
+ * values that exist, rather than an empty dropdown.
29
+ */
30
+ fallback?: TMDataGridOptionsSource;
31
+ };
32
+
33
+ /**
34
+ * Where a column's options come from — `meta.options`. One declaration feeds
35
+ * both the filter panel's value control and the cell editor.
36
+ *
37
+ * | Form | For |
38
+ * | --- | --- |
39
+ * | array | a known, fixed set |
40
+ * | `"faceted"` | low-cardinality data columns: the distinct values present, via `getFacetedUniqueValues` |
41
+ * | function | large or contextual sets — `row` is set when an editor asks, so options can depend on the record (city given country) |
42
+ */
43
+ export type TMDataGridOptionsSource =
44
+ | ReadonlyArray<TMDataGridOption | string>
45
+ | "faceted"
46
+ | ((args: TMDataGridOptionsArgs) => ReadonlyArray<TMDataGridOption | string>);
47
+
48
+ function addFacetValue(target: Set<string>, value: unknown): void {
49
+ if (value === null || value === undefined || value === "") return;
50
+ target.add(String(value));
51
+ }
52
+
53
+ /**
54
+ * A column's options as a normalised list, whatever form `meta.options` took.
55
+ * Empty for a column that declares none.
56
+ *
57
+ * `"faceted"` reads the distinct values actually present in the data. A
58
+ * multiSelect cell holds an array, so array values contribute their elements
59
+ * rather than the array itself.
60
+ */
61
+ export function resolveColumnOptions({
62
+ table,
63
+ column,
64
+ row,
65
+ fallback,
66
+ }: TMDataGridOptionsArgs): Array<TMDataGridOption> {
67
+ const source = column.columnDef.meta?.options ?? fallback;
68
+ if (!source) return [];
69
+
70
+ if (source === "faceted") {
71
+ const values = new Set<string>();
72
+ for (const key of column.getFacetedUniqueValues().keys()) {
73
+ if (Array.isArray(key)) {
74
+ for (const entry of key) addFacetValue(values, entry);
75
+ } else {
76
+ addFacetValue(values, key);
77
+ }
78
+ }
79
+ return [...values]
80
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
81
+ .map((value) => ({ value }));
82
+ }
83
+
84
+ const list = typeof source === "function" ? source({ table, column, row }) : source;
85
+ return list.map((option) =>
86
+ typeof option === "string" ? { value: option } : option,
87
+ );
88
+ }
89
+
90
+ type ComboboxItem = { value: string; label: string; disabled?: boolean };
91
+ type ComboboxData = Array<ComboboxItem | { group: string; items: ComboboxItem[] }>;
92
+
93
+ /**
94
+ * The same list in the shape Mantine's `Select` / `MultiSelect` take, with
95
+ * `group` fields folded into Mantine's group entries. Ungrouped options come
96
+ * first, then the groups in first-appearance order.
97
+ */
98
+ export function optionsToComboboxData(
99
+ options: ReadonlyArray<TMDataGridOption>,
100
+ ): ComboboxData {
101
+ const ungrouped: ComboboxItem[] = [];
102
+ const groups = new Map<string, ComboboxItem[]>();
103
+
104
+ for (const option of options) {
105
+ const item: ComboboxItem = {
106
+ value: option.value,
107
+ label: option.label ?? option.value,
108
+ ...(option.disabled ? { disabled: true } : {}),
109
+ };
110
+ if (option.group === undefined) {
111
+ ungrouped.push(item);
112
+ } else {
113
+ const bucket = groups.get(option.group);
114
+ if (bucket) bucket.push(item);
115
+ else groups.set(option.group, [item]);
116
+ }
117
+ }
118
+
119
+ return [
120
+ ...ungrouped,
121
+ ...[...groups].map(([group, items]) => ({ group, items })),
122
+ ];
123
+ }
@@ -1,4 +1,6 @@
1
1
  import type { TMDataGridColumnType } from "./filterOperators";
2
+ import { DETAILS_COLUMN_ID } from "../components/TMDataGridDetailsColumn";
3
+ import { SELECT_COLUMN_ID } from "../components/TMDataGridSelectColumn";
2
4
  import type { TMDataGridColumnMeta } from "../useTMDataGrid";
3
5
 
4
6
  /**
@@ -29,6 +31,22 @@ export function getColumnAlign(column: ColumnLike): "left" | "right" | "center"
29
31
  return column.columnDef.meta?.align ?? "left";
30
32
  }
31
33
 
34
+ /**
35
+ * A generated lane holding one control at a fixed width — the checkbox and the
36
+ * details chevron.
37
+ *
38
+ * They are laid out differently from every other column: cell padding is sized
39
+ * for text and grows with the scale, which at `xl` squeezed a 16px checkbox out
40
+ * of its track, so these lanes centre their control in an unpadded cell.
41
+ * Their headers are controls too, and carry no column menu.
42
+ *
43
+ * The tree column is deliberately not one of them: it holds a label as well as a
44
+ * chevron, and wants the padding.
45
+ */
46
+ export function isControlColumn(columnId: string): boolean {
47
+ return columnId === SELECT_COLUMN_ID || columnId === DETAILS_COLUMN_ID;
48
+ }
49
+
32
50
  /**
33
51
  * Whether a column may be moved. Ordering is the one column feature TanStack
34
52
  * has no column option for, so the switch lives in `meta.enableOrdering`.