@jielga/tmdatagrid 0.1.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.
@@ -0,0 +1,378 @@
1
+ import { Checkbox } from "@mantine/core";
2
+ import { useCreateStore } from "@tanstack/react-store";
3
+ import {
4
+ type ColumnDef,
5
+ columnFacetingFeature,
6
+ columnFilteringFeature,
7
+ columnOrderingFeature,
8
+ columnPinningFeature,
9
+ columnResizingFeature,
10
+ columnSizingFeature,
11
+ columnVisibilityFeature,
12
+ createColumnHelper,
13
+ createFacetedMinMaxValues,
14
+ createFacetedRowModel,
15
+ createFacetedUniqueValues,
16
+ createFilteredRowModel,
17
+ createPaginatedRowModel,
18
+ createSortedRowModel,
19
+ filterFns,
20
+ globalFilteringFeature,
21
+ metaHelper,
22
+ type RowData,
23
+ rowPaginationFeature,
24
+ rowSelectionFeature,
25
+ rowSortingFeature,
26
+ sortFns,
27
+ type Table,
28
+ tableFeatures,
29
+ type TableOptions,
30
+ useTable,
31
+ } from "@tanstack/react-table";
32
+ import type { Store } from "@tanstack/store";
33
+ import { useEffect, useMemo, useState } from "react";
34
+ import {
35
+ type TMDataGridColumnType,
36
+ tmDataGridFilterFn,
37
+ getDefaultOperator,
38
+ } from "./filterOperators";
39
+ import {
40
+ hasPersistenceKeys,
41
+ readPersistedState,
42
+ type TMDataGridPersistence,
43
+ writePersistedState,
44
+ } from "./persistence";
45
+ import { readFeatureFlags, type TMDataGridFeatureFlags } from "./capabilities";
46
+
47
+ export const SELECT_COLUMN_ID = "__select__";
48
+
49
+ /** Per-column configuration the TMDataGrid chrome reads. */
50
+ export type TMDataGridColumnMeta = {
51
+ /** Name shown in menus and the column manager. Falls back to a string header. */
52
+ label?: string;
53
+ /** Drives which filter operators are offered. Defaults to `"string"`. */
54
+ type?: TMDataGridColumnType;
55
+ /** Share of the leftover width this column claims. Defaults to `1`. */
56
+ flex?: number;
57
+ align?: "left" | "right" | "center";
58
+ /**
59
+ * `false` keeps the column where it is: no header dragging, no move items.
60
+ * Column ordering is the one feature TanStack defines no column option for,
61
+ * so its switch lives here rather than on the column definition.
62
+ */
63
+ enableOrdering?: boolean;
64
+ };
65
+
66
+ /** Grid-wide configuration passed through `options.meta`. */
67
+ export type TMDataGridTableMeta = {
68
+ loading?: boolean;
69
+ noResultsLabel?: string;
70
+ /** Row height used by the virtualizer. Defaults to `52`. */
71
+ rowHeight?: number;
72
+ /**
73
+ * Unfiltered row total. Only needed for server-driven grids, where the client
74
+ * never holds the full data set — `TMDataGrid.SummaryCount` uses it as denominator.
75
+ */
76
+ totalRowCount?: number;
77
+ };
78
+
79
+ /**
80
+ * Every feature the TMDataGrid chrome can drive. Defined at module scope so the
81
+ * reference stays stable across renders (see the TanStack table-state skill).
82
+ */
83
+ export const tmDataGridFeatures = tableFeatures({
84
+ columnFilteringFeature,
85
+ globalFilteringFeature,
86
+ rowSortingFeature,
87
+ rowPaginationFeature,
88
+ rowSelectionFeature,
89
+ columnPinningFeature,
90
+ columnVisibilityFeature,
91
+ columnOrderingFeature,
92
+ columnSizingFeature,
93
+ columnResizingFeature,
94
+ columnFacetingFeature,
95
+
96
+ filteredRowModel: createFilteredRowModel(),
97
+ sortedRowModel: createSortedRowModel(),
98
+ paginatedRowModel: createPaginatedRowModel(),
99
+ facetedRowModel: createFacetedRowModel(),
100
+ facetedMinMaxValues: createFacetedMinMaxValues(),
101
+ facetedUniqueValues: createFacetedUniqueValues(),
102
+
103
+ filterFns: { ...filterFns, tmDataGrid: tmDataGridFilterFn },
104
+ sortFns,
105
+
106
+ tableMeta: metaHelper<TMDataGridTableMeta>(),
107
+ columnMeta: metaHelper<TMDataGridColumnMeta>(),
108
+ });
109
+
110
+ export type TMDataGridFeatures = typeof tmDataGridFeatures;
111
+
112
+ export type TMDataGridTable<TData extends RowData> = Table<
113
+ TMDataGridFeatures,
114
+ TData
115
+ >;
116
+
117
+ export function createTMDataGridColumnHelper<TData extends RowData>() {
118
+ return createColumnHelper<TMDataGridFeatures, TData>();
119
+ }
120
+
121
+ /**
122
+ * Chrome state that is *not* table state: which panels are open, and which
123
+ * column opened the filter panel. Kept in a TanStack Store so consumers can
124
+ * subscribe to it the same way they subscribe to `table.store`.
125
+ */
126
+ export type TMDataGridUiState = {
127
+ filterPanelOpen: boolean;
128
+ columnsPanelOpen: boolean;
129
+ /** Column whose filter row should be focused when the panel opens. */
130
+ filterPanelColumnId: string | null;
131
+ /**
132
+ * Column being dragged by its header, if any. Held here rather than read from
133
+ * `dataTransfer`, which browsers keep unreadable until the drop.
134
+ */
135
+ draggedColumnId: string | null;
136
+ };
137
+
138
+ export type TMDataGridUiActions = {
139
+ openFilterPanel: (columnId?: string | null) => void;
140
+ closeFilterPanel: () => void;
141
+ setColumnsPanelOpen: (open: boolean) => void;
142
+ toggleColumnsPanel: () => void;
143
+ startColumnDrag: (columnId: string) => void;
144
+ endColumnDrag: () => void;
145
+ };
146
+
147
+ export type TMDataGridUiStore = Store<TMDataGridUiState, TMDataGridUiActions>;
148
+
149
+ /** What `useTMDataGrid` returns — spread straight onto `<TMDataGrid />`. */
150
+ export type TMDataGridApi<TData extends RowData> = {
151
+ table: TMDataGridTable<TData>;
152
+ ui: TMDataGridUiStore;
153
+ /** Table-level feature switches, re-read from options on every render. */
154
+ features: TMDataGridFeatureFlags;
155
+ };
156
+
157
+ export type UseTMDataGridOptions<TData extends RowData> = Omit<
158
+ TableOptions<TMDataGridFeatures, TData>,
159
+ "features"
160
+ > & {
161
+ /**
162
+ * Restore and persist table state across mounts. Two keys, because the two
163
+ * kinds of state have different lifetimes — see {@link TMDataGridPersistence}.
164
+ *
165
+ * Keep the object referentially stable (module scope or `useMemo`); it is a
166
+ * dependency of the subscription that writes back.
167
+ */
168
+ persist?: TMDataGridPersistence;
169
+ /**
170
+ * Header drag-and-drop and the move items in the column menu. Defaults to
171
+ * `true`.
172
+ *
173
+ * The one feature switch the grid defines itself: TanStack's
174
+ * `columnOrderingFeature` ships state and APIs but no `enable` option, since
175
+ * reordering is entirely a matter of interface.
176
+ */
177
+ enableColumnOrdering?: boolean;
178
+ };
179
+
180
+ type TMDataGridColumnDef<TData extends RowData> = ColumnDef<
181
+ TMDataGridFeatures,
182
+ TData,
183
+ unknown
184
+ >;
185
+
186
+ /**
187
+ * Point every column at the operator-dispatching filter function unless the
188
+ * column opted into its own.
189
+ */
190
+ function withTMDataGridDefaults<TData extends RowData>(
191
+ columns: ReadonlyArray<TMDataGridColumnDef<TData>>,
192
+ ): Array<TMDataGridColumnDef<TData>> {
193
+ return columns.map((column) => {
194
+ if ("columns" in column && Array.isArray(column.columns)) {
195
+ return {
196
+ ...column,
197
+ columns: withTMDataGridDefaults<TData>(column.columns),
198
+ };
199
+ }
200
+ return { filterFn: "tmDataGrid", ...column } as TMDataGridColumnDef<TData>;
201
+ });
202
+ }
203
+
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
+ /**
247
+ * Builds a TMDataGrid table plus its chrome store.
248
+ *
249
+ * Every `TableOptions` field passes straight through, so a server-driven grid
250
+ * only needs `manualPagination` / `manualFiltering` / `manualSorting`,
251
+ * `rowCount` and the matching `onXChange` callbacks — the chrome reads
252
+ * `getRowCount()` / `getPageCount()` / `getPaginatedRowModel()`, all of which
253
+ * already respect manual mode.
254
+ */
255
+ export function useTMDataGrid<TData extends RowData>({
256
+ persist,
257
+ // Not a TanStack option, so it is kept out of what `useTable` receives.
258
+ enableColumnOrdering,
259
+ ...options
260
+ }: UseTMDataGridOptions<TData>): TMDataGridApi<TData> {
261
+ const selectionEnabled = options.enableRowSelection !== false;
262
+ const pinningEnabled = options.enableColumnPinning !== false;
263
+
264
+ const columns = useMemo(() => {
265
+ const base = withTMDataGridDefaults<TData>(
266
+ options.columns as ReadonlyArray<TMDataGridColumnDef<TData>>,
267
+ );
268
+ return selectionEnabled ? [createSelectColumn<TData>(), ...base] : base;
269
+ }, [options.columns, selectionEnabled]);
270
+
271
+ // Read once on mount: `initialState` is only consumed on the first render,
272
+ // and re-reading later would fight the user's live edits.
273
+ const [persistedState] = useState(() => readPersistedState(persist));
274
+
275
+ const table = useTable({
276
+ columnResizeMode: "onChange",
277
+ enableSorting: true,
278
+ enableColumnResizing: true,
279
+ globalFilterFn: "includesString",
280
+ ...options,
281
+ features: tmDataGridFeatures,
282
+ columns: columns as TableOptions<TMDataGridFeatures, TData>["columns"],
283
+ initialState: {
284
+ ...options.initialState,
285
+ ...persistedState,
286
+ columnPinning: {
287
+ // The checkbox column is structurally pinned, so it is re-applied on
288
+ // top of anything restored from storage.
289
+ left: [
290
+ ...(selectionEnabled && pinningEnabled ? [SELECT_COLUMN_ID] : []),
291
+ ...(
292
+ persistedState.columnPinning?.left ??
293
+ options.initialState?.columnPinning?.left ??
294
+ []
295
+ ).filter((id) => id !== SELECT_COLUMN_ID),
296
+ ],
297
+ right:
298
+ persistedState.columnPinning?.right ??
299
+ options.initialState?.columnPinning?.right ??
300
+ [],
301
+ },
302
+ pagination: {
303
+ pageIndex: 0,
304
+ pageSize: 25,
305
+ ...options.initialState?.pagination,
306
+ ...persistedState.pagination,
307
+ },
308
+ },
309
+ });
310
+
311
+ // Mirror every state change back to storage. Subscribing (rather than writing
312
+ // from an effect on a state snapshot) means nothing is missed, including
313
+ // changes made straight through the table API by the consumer.
314
+ useEffect(() => {
315
+ if (!hasPersistenceKeys(persist)) return;
316
+ writePersistedState(table.store.state, persist);
317
+ const subscription = table.store.subscribe((state) => {
318
+ writePersistedState(state, persist);
319
+ });
320
+ return () => subscription.unsubscribe();
321
+ }, [persist, table]);
322
+
323
+ const ui = useCreateStore<TMDataGridUiState, TMDataGridUiActions>(
324
+ {
325
+ filterPanelOpen: false,
326
+ columnsPanelOpen: false,
327
+ filterPanelColumnId: null,
328
+ draggedColumnId: null,
329
+ },
330
+ ({ setState }) => ({
331
+ openFilterPanel: (columnId = null) =>
332
+ setState((prev) => ({
333
+ ...prev,
334
+ filterPanelOpen: true,
335
+ filterPanelColumnId: columnId,
336
+ })),
337
+ closeFilterPanel: () =>
338
+ setState((prev) => ({
339
+ ...prev,
340
+ filterPanelOpen: false,
341
+ filterPanelColumnId: null,
342
+ })),
343
+ setColumnsPanelOpen: (open) =>
344
+ setState((prev) => ({ ...prev, columnsPanelOpen: open })),
345
+ toggleColumnsPanel: () =>
346
+ setState((prev) => ({ ...prev, columnsPanelOpen: !prev.columnsPanelOpen })),
347
+ startColumnDrag: (columnId) =>
348
+ setState((prev) => ({ ...prev, draggedColumnId: columnId })),
349
+ endColumnDrag: () =>
350
+ setState((prev) => ({ ...prev, draggedColumnId: null })),
351
+ }),
352
+ );
353
+
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 });
358
+
359
+ return { table, ui, features };
360
+ }
361
+
362
+ /**
363
+ * Opens the filter panel for a column, seeding an empty filter row when the
364
+ * column has none yet — mirrors "Filter" in the column header menu.
365
+ */
366
+ export function openColumnFilter<TData extends RowData>(
367
+ api: TMDataGridApi<TData>,
368
+ columnId: string,
369
+ ): void {
370
+ const column = api.table.getColumn(columnId);
371
+ if (column && column.getFilterValue() === undefined) {
372
+ column.setFilterValue({
373
+ operator: getDefaultOperator(column.columnDef.meta?.type ?? "string"),
374
+ value: "",
375
+ });
376
+ }
377
+ api.ui.actions.openFilterPanel(columnId);
378
+ }