@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
@@ -1,17 +1,29 @@
1
- import { ActionIcon, Button, Select, Stack, Text, TextInput } from "@mantine/core";
1
+ import {
2
+ ActionIcon,
3
+ Button,
4
+ MultiSelect,
5
+ Select,
6
+ Stack,
7
+ Text,
8
+ TextInput,
9
+ } from "@mantine/core";
2
10
  import { useSelector } from "@tanstack/react-store";
3
11
  import { useEffect, useRef } from "react";
4
12
  import classes from "./TMDataGridFilterPanel.module.css";
5
13
  import { useTMDataGridContext } from "../TMDataGridContext";
14
+ import {
15
+ optionsToComboboxData,
16
+ resolveColumnOptions,
17
+ } from "../core/columnOptions";
6
18
  import { getColumnLabel, getColumnType } from "../core/columnUtils";
7
19
  import {
8
20
  type TMDataGridFilterOperator,
9
21
  type TMDataGridFilterValue,
10
- FILTER_OPERATOR_LABELS,
11
22
  getDefaultOperator,
12
23
  getOperatorsForType,
13
24
  isTMDataGridFilterValue,
14
25
  operatorNeedsValue,
26
+ operatorTakesArrayValue,
15
27
  } from "../core/filterOperators";
16
28
  import { CloseIcon } from "./icons";
17
29
 
@@ -28,7 +40,7 @@ function asFilterValue(value: unknown): TMDataGridFilterValue {
28
40
  * free — the state is forwarded to the server instead of a row model.
29
41
  */
30
42
  export function TMDataGridFilterPanel() {
31
- const { table, ui, controlSize } = useTMDataGridContext();
43
+ const { table, ui, labels, controlSize } = useTMDataGridContext();
32
44
  const opened = useSelector(ui, (state) => state.filterPanelOpen);
33
45
  const columnFilters = useSelector(
34
46
  table.store,
@@ -78,6 +90,18 @@ export function TMDataGridFilterPanel() {
78
90
  columnFilters.find((filter) => filter.id === fromColumnId)?.value,
79
91
  );
80
92
  const target = table.getColumn(toColumnId);
93
+ const operator = getDefaultOperator(
94
+ target ? getColumnType(target) : "string",
95
+ );
96
+ // The typed value only survives the move while it still fits the new
97
+ // operator — a text needle has no meaning to an `isAnyOf` set, or a set
98
+ // to a text input.
99
+ const value =
100
+ operatorTakesArrayValue(operator) === Array.isArray(current.value)
101
+ ? current.value
102
+ : operatorTakesArrayValue(operator)
103
+ ? []
104
+ : "";
81
105
  table.setColumnFilters(
82
106
  columnFilters
83
107
  .filter((filter) => filter.id !== toColumnId)
@@ -85,12 +109,7 @@ export function TMDataGridFilterPanel() {
85
109
  filter.id === fromColumnId
86
110
  ? {
87
111
  id: toColumnId,
88
- value: {
89
- operator: getDefaultOperator(
90
- target ? getColumnType(target) : "string",
91
- ),
92
- value: current.value,
93
- } satisfies TMDataGridFilterValue,
112
+ value: { operator, value } satisfies TMDataGridFilterValue,
94
113
  }
95
114
  : filter,
96
115
  ),
@@ -108,6 +127,21 @@ export function TMDataGridFilterPanel() {
108
127
  );
109
128
  }
110
129
 
130
+ function changeOperator(columnId: string, operator: TMDataGridFilterOperator) {
131
+ const current = asFilterValue(
132
+ columnFilters.find((filter) => filter.id === columnId)?.value,
133
+ );
134
+ // Same arity rule as changing the column: keep the value across operators
135
+ // of the same shape, reset it across the array/scalar boundary.
136
+ const value =
137
+ operatorTakesArrayValue(operator) === Array.isArray(current.value)
138
+ ? current.value
139
+ : operatorTakesArrayValue(operator)
140
+ ? []
141
+ : "";
142
+ patchFilter(columnId, { operator, value });
143
+ }
144
+
111
145
  /** Clears every filter, including half-typed ones the pills never showed. */
112
146
  function clearAllFilters() {
113
147
  table.setColumnFilters([]);
@@ -141,7 +175,7 @@ export function TMDataGridFilterPanel() {
141
175
  <div
142
176
  ref={panelRef}
143
177
  role="group"
144
- aria-label="Filters"
178
+ aria-label={labels.filters}
145
179
  className={classes.filterPanel}
146
180
  // Escape is what closes a floating surface, and every control that can
147
181
  // hold focus in here sits inside this element.
@@ -153,13 +187,13 @@ export function TMDataGridFilterPanel() {
153
187
  >
154
188
  <div className={classes.filterPanelHeader}>
155
189
  <Text size={controlSize} fw={600}>
156
- Filters
190
+ {labels.filters}
157
191
  </Text>
158
192
  <ActionIcon
159
193
  variant="subtle"
160
194
  color="gray"
161
195
  size="sm"
162
- aria-label="Close filters"
196
+ aria-label={labels.closeFilters}
163
197
  onClick={ui.actions.closeFilterPanel}
164
198
  >
165
199
  <CloseIcon size={16} stroke={1.6} />
@@ -172,6 +206,8 @@ export function TMDataGridFilterPanel() {
172
206
  const value = asFilterValue(filter.value);
173
207
  const type = column ? getColumnType(column) : "string";
174
208
  const needsValue = operatorNeedsValue(value.operator);
209
+ const takesArray = operatorTakesArrayValue(value.operator);
210
+ const scalarValue = typeof value.value === "string" ? value.value : "";
175
211
 
176
212
  return (
177
213
  <div key={filter.id} className={classes.filterRow}>
@@ -179,14 +215,14 @@ export function TMDataGridFilterPanel() {
179
215
  variant="subtle"
180
216
  color="gray"
181
217
  size="lg"
182
- aria-label="Remove filter"
218
+ aria-label={labels.removeFilter}
183
219
  onClick={() => removeFilter(filter.id)}
184
220
  >
185
221
  <CloseIcon size={18} stroke={1.6} />
186
222
  </ActionIcon>
187
223
 
188
224
  <Select
189
- label="Column"
225
+ label={labels.filterColumn}
190
226
  size={controlSize}
191
227
  w={160}
192
228
  allowDeselect={false}
@@ -200,36 +236,78 @@ export function TMDataGridFilterPanel() {
200
236
  />
201
237
 
202
238
  <Select
203
- label="Operator"
239
+ label={labels.filterOperator}
204
240
  size={controlSize}
205
241
  w={170}
206
242
  allowDeselect={false}
207
243
  comboboxProps={{ withinPortal: false }}
208
244
  data={getOperatorsForType(type).map((operator) => ({
209
245
  value: operator,
210
- label: FILTER_OPERATOR_LABELS[operator],
246
+ label: labels.operators[operator],
211
247
  }))}
212
248
  value={value.operator}
213
249
  onChange={(next) =>
214
250
  next &&
215
- patchFilter(filter.id, {
216
- operator: next as TMDataGridFilterOperator,
217
- })
251
+ changeOperator(filter.id, next as TMDataGridFilterOperator)
218
252
  }
219
253
  />
220
254
 
221
- <TextInput
222
- label="Value"
223
- size={controlSize}
224
- w={180}
225
- type={type === "number" && needsValue ? "number" : "text"}
226
- disabled={!needsValue}
227
- placeholder={needsValue ? "Filter value" : ""}
228
- value={needsValue ? value.value : ""}
229
- onChange={(event) =>
230
- patchFilter(filter.id, { value: event.currentTarget.value })
231
- }
232
- />
255
+ {takesArray && column ? (
256
+ // The set the cell is tested against. Options come from
257
+ // `meta.options`; a select column that declares none still
258
+ // gets the values present in the data, via the faceted index.
259
+ <MultiSelect
260
+ label={labels.filterValue}
261
+ size={controlSize}
262
+ w={180}
263
+ comboboxProps={{ withinPortal: false }}
264
+ searchable
265
+ data={optionsToComboboxData(
266
+ resolveColumnOptions({ table, column, fallback: "faceted" }),
267
+ )}
268
+ value={Array.isArray(value.value) ? [...value.value] : []}
269
+ onChange={(next) => patchFilter(filter.id, { value: next })}
270
+ />
271
+ ) : type === "boolean" ? (
272
+ <Select
273
+ label={labels.filterValue}
274
+ size={controlSize}
275
+ w={180}
276
+ comboboxProps={{ withinPortal: false }}
277
+ disabled={!needsValue}
278
+ clearable
279
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
280
+ data={[
281
+ { value: "true", label: labels.booleanTrue },
282
+ { value: "false", label: labels.booleanFalse },
283
+ ]}
284
+ value={needsValue && scalarValue !== "" ? scalarValue : null}
285
+ onChange={(next) =>
286
+ patchFilter(filter.id, { value: next ?? "" })
287
+ }
288
+ />
289
+ ) : (
290
+ <TextInput
291
+ label={labels.filterValue}
292
+ size={controlSize}
293
+ w={180}
294
+ type={
295
+ !needsValue
296
+ ? "text"
297
+ : type === "number"
298
+ ? "number"
299
+ : type === "date"
300
+ ? "date"
301
+ : "text"
302
+ }
303
+ disabled={!needsValue}
304
+ placeholder={needsValue ? labels.filterValuePlaceholder : ""}
305
+ value={needsValue ? scalarValue : ""}
306
+ onChange={(event) =>
307
+ patchFilter(filter.id, { value: event.currentTarget.value })
308
+ }
309
+ />
310
+ )}
233
311
  </div>
234
312
  );
235
313
  })}
@@ -241,7 +319,7 @@ export function TMDataGridFilterPanel() {
241
319
  disabled={!canAddFilter}
242
320
  onClick={addFilter}
243
321
  >
244
- Add filter
322
+ {labels.addFilter}
245
323
  </Button>
246
324
  <Button
247
325
  variant="subtle"
@@ -250,7 +328,7 @@ export function TMDataGridFilterPanel() {
250
328
  disabled={columnFilters.length === 0}
251
329
  onClick={clearAllFilters}
252
330
  >
253
- Clear all
331
+ {labels.clearAllFilters}
254
332
  </Button>
255
333
  </div>
256
334
  </Stack>
@@ -53,7 +53,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
53
53
  onPillClick,
54
54
  className,
55
55
  }: TMDataGridFilterPillsProps<TData>) {
56
- const { table } = api;
56
+ const { table, labels } = api;
57
57
  const columnFilters = useSelector(
58
58
  table.store,
59
59
  (state) => state.columnFilters,
@@ -67,7 +67,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
67
67
  return (
68
68
  <div
69
69
  role="group"
70
- aria-label="Active filters"
70
+ aria-label={labels.activeFilters}
71
71
  className={[classes.filterPills, className].filter(Boolean).join(" ")}
72
72
  >
73
73
  <Pill.Group size={size}>
@@ -94,7 +94,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
94
94
  // way out. These pills stand on their own, so the ✕ has to be a
95
95
  // real button.
96
96
  removeButtonProps={{
97
- "aria-label": `Clear ${label} filter`,
97
+ "aria-label": labels.clearFilter(label),
98
98
  "aria-hidden": false,
99
99
  tabIndex: 0,
100
100
  }}
@@ -111,6 +111,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
111
111
  label,
112
112
  type: column ? getColumnType(column) : "string",
113
113
  filter: value,
114
+ operatorLabels: labels.operators,
114
115
  })}
115
116
  </UnstyledButton>
116
117
  </Pill>
@@ -127,7 +128,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
127
128
  size="compact-xs"
128
129
  onClick={() => table.setColumnFilters([])}
129
130
  >
130
- Clear all
131
+ {labels.clearAllFilters}
131
132
  </Button>
132
133
  )}
133
134
  </div>
@@ -9,10 +9,6 @@ import { isPagingActive } from "../core/rowSelection";
9
9
  import { ChevronLeftIcon, ChevronRightIcon } from "./icons";
10
10
  import type { TMDataGridTable } from "../useTMDataGrid";
11
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
-
16
12
  /** Distilled pagination state and actions for building a custom pager. */
17
13
  export type TMDataGridPaginationApi = {
18
14
  pageIndex: number;
@@ -75,7 +71,7 @@ export function TMDataGridFooter({
75
71
  pageSizeOptions = [10, 25, 50, 100],
76
72
  pagination,
77
73
  }: TMDataGridFooterProps) {
78
- const { table, features, controlSize } = useTMDataGridContext();
74
+ const { table, features, labels, controlSize } = useTMDataGridContext();
79
75
  useSelector(table.store);
80
76
 
81
77
  if (!getGridCapabilities(table, features).canPaginate) return null;
@@ -105,7 +101,7 @@ export function TMDataGridFooter({
105
101
 
106
102
  return (
107
103
  <Tooltip
108
- label={PAGING_SUSPENDED_HINT}
104
+ label={labels.pagingSuspendedHint}
109
105
  disabled={paging}
110
106
  withArrow
111
107
  multiline
@@ -115,7 +111,7 @@ export function TMDataGridFooter({
115
111
  <div className={classes.footer} data-paging-suspended={!paging}>
116
112
  <Group gap="xs" wrap="nowrap">
117
113
  <Text size={controlSize} c="dimmed">
118
- Rows per page:
114
+ {labels.rowsPerPage}
119
115
  </Text>
120
116
  <Select
121
117
  size={controlSize}
@@ -133,23 +129,19 @@ export function TMDataGridFooter({
133
129
  </Group>
134
130
 
135
131
  <Text size={controlSize} c="dimmed">
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
- )}
132
+ {paging
133
+ ? labels.pageRange({ from, to, total })
134
+ : // Not a range: nothing is being sliced, so a range would be a lie.
135
+ // Counted before grouping too — `getRowCount()` would be counting
136
+ // the tree, so a collapsed grid would claim to hold eight rows.
137
+ labels.groupedAllRows(table.getFilteredRowModel().rows.length)}
146
138
  </Text>
147
139
 
148
140
  <Group gap={4} wrap="nowrap">
149
141
  <ActionIcon
150
142
  variant="subtle"
151
143
  color="gray"
152
- aria-label="Previous page"
144
+ aria-label={labels.previousPage}
153
145
  disabled={!paging || !table.getCanPreviousPage()}
154
146
  onClick={() => table.previousPage()}
155
147
  >
@@ -158,7 +150,7 @@ export function TMDataGridFooter({
158
150
  <ActionIcon
159
151
  variant="subtle"
160
152
  color="gray"
161
- aria-label="Next page"
153
+ aria-label={labels.nextPage}
162
154
  disabled={!paging || !table.getCanNextPage()}
163
155
  onClick={() => table.nextPage()}
164
156
  >
@@ -1,4 +1,8 @@
1
1
  import { UnstyledButton } from "@mantine/core";
2
+ import {
3
+ useCellControlTabIndex,
4
+ useTMDataGridContext,
5
+ } from "../TMDataGridContext";
2
6
  import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
3
7
  import { useSelector } from "@tanstack/react-store";
4
8
  import { shallow } from "@tanstack/store";
@@ -23,9 +27,12 @@ const BLANK_GROUP_LABEL = "(Blank)";
23
27
  * written for a data row and is free to reach into `row.original`, which on a
24
28
  * group row is the first leaf's record rather than anything about the group.
25
29
  */
26
- export function formatGroupValue(value: unknown): string {
30
+ export function formatGroupValue(
31
+ value: unknown,
32
+ blankLabel = BLANK_GROUP_LABEL,
33
+ ): string {
27
34
  if (value === null || value === undefined || value === "") {
28
- return BLANK_GROUP_LABEL;
35
+ return blankLabel;
29
36
  }
30
37
  if (value instanceof Date) return value.toLocaleDateString();
31
38
  return String(value);
@@ -44,21 +51,27 @@ function GroupCell<TData extends RowData>({
44
51
  }: {
45
52
  row: Row<TMDataGridFeatures, TData>;
46
53
  }) {
54
+ const { labels } = useTMDataGridContext();
47
55
  const expanded = useSelector(row.table.store, () => row.getIsExpanded());
56
+ // See useCellControlTabIndex: reached by stepping into the cell, not by Tab.
57
+ const tabIndex = useCellControlTabIndex();
48
58
 
49
59
  // Leaf rows keep the lane empty: their values are in the data columns, and
50
60
  // the indent alone is what places them under their group.
51
61
  if (!row.getIsGrouped()) return null;
52
62
 
53
- const label = formatGroupValue(row.groupingValue);
63
+ const label = formatGroupValue(row.groupingValue, labels.blankGroupValue);
54
64
 
55
65
  return (
56
66
  <UnstyledButton
57
67
  className={classes.groupToggle}
58
68
  // Padding rather than margin, so the whole indented width stays clickable.
59
69
  style={{ paddingInlineStart: row.depth * INDENT_STEP }}
70
+ tabIndex={tabIndex}
60
71
  aria-expanded={expanded}
61
- aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`}
72
+ aria-label={
73
+ expanded ? labels.collapseGroup(label) : labels.expandGroup(label)
74
+ }
62
75
  // The row underneath may select or highlight on click; expanding is its
63
76
  // own gesture and must not also trigger those.
64
77
  onClick={(event) => {
@@ -88,11 +101,12 @@ function GroupHeader<TData extends RowData>({
88
101
  }: {
89
102
  table: TMDataGridTable<TData>;
90
103
  }) {
104
+ const { labels } = useTMDataGridContext();
91
105
  const grouping = useSelector(table.store, (state) => state.grouping, {
92
106
  compare: shallow,
93
107
  });
94
108
 
95
- if (grouping.length === 0) return "Group";
109
+ if (grouping.length === 0) return labels.groupColumnLabel;
96
110
  return grouping
97
111
  .map((columnId) => {
98
112
  const column = table.getColumn(columnId);
@@ -114,15 +128,13 @@ function GroupHeader<TData extends RowData>({
114
128
  * `column.getCanGroup()` requires an `accessorFn`, which a display column has
115
129
  * no reason to have.
116
130
  */
117
- export function createGroupColumn<TData extends RowData>(): ColumnDef<
118
- TMDataGridFeatures,
119
- TData,
120
- unknown
121
- > {
131
+ export function createGroupColumn<TData extends RowData>(
132
+ label = "Group",
133
+ ): ColumnDef<TMDataGridFeatures, TData, unknown> {
122
134
  return {
123
135
  id: GROUP_COLUMN_ID,
124
136
  meta: {
125
- label: "Group",
137
+ label,
126
138
  // Structurally the first column after the checkbox lane.
127
139
  enableOrdering: false,
128
140
  },
@@ -21,9 +21,10 @@
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"] {
24
+ /* Same as the body cells: a control lane's header — the select-all box, the
25
+ expand-all chevron — sits in a fixed 36px track, which the text padding would
26
+ squeeze it out of at the larger sizes. */
27
+ .headerCell[data-control-column="true"] {
27
28
  padding: 0;
28
29
  }
29
30
 
@@ -84,6 +85,17 @@
84
85
  cursor: pointer;
85
86
  }
86
87
 
88
+ /* Sort priority under multi-sort — "1", "2", … beside the arrow. Only rendered
89
+ while more than one column sorts, so a single sort stays as it always was. */
90
+ .sortIndex {
91
+ font-size: 10px;
92
+ font-weight: 600;
93
+ line-height: 1;
94
+ color: var(--mantine-color-dimmed);
95
+ min-width: 10px;
96
+ text-align: center;
97
+ }
98
+
87
99
  /* Column reordering. The dragged header stays in place and dims — the browser
88
100
  already carries a snapshot of it under the pointer — while the header being
89
101
  dragged over draws a bar on the edge the column will land against. */