@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,4 +1,4 @@
1
- import { ActionIcon, Popover, Text, Tooltip } from "@mantine/core";
1
+ import { ActionIcon, Loader, Popover, Text, Tooltip } from "@mantine/core";
2
2
  import { useSelector } from "@tanstack/react-store";
3
3
  import type { ReactNode } from "react";
4
4
  import classes from "./TMDataGridToolbar.module.css";
@@ -19,6 +19,29 @@ export function TMDataGridToolbarSpacer() {
19
19
  return <div className={classes.toolbarSpacer} />;
20
20
  }
21
21
 
22
+ /**
23
+ * A small spinner shown while `meta.loading` is true, and nothing otherwise.
24
+ *
25
+ * The body only shows its loading state while the grid is *empty* — a
26
+ * server-driven grid refetching with rows on screen keeps showing them, which
27
+ * is right, but leaves nothing saying a fetch is running. This is that signal,
28
+ * and the consumer decides where it sits by placing it in the toolbar:
29
+ *
30
+ * ```tsx
31
+ * <TMDataGrid.Toolbar>
32
+ * <TMDataGrid.SummaryCount />
33
+ * <TMDataGrid.Spacer />
34
+ * <TMDataGrid.LoadingIndicator />
35
+ * <TMDataGrid.FilterButton />
36
+ * </TMDataGrid.Toolbar>
37
+ * ```
38
+ */
39
+ export function TMDataGridLoadingIndicator() {
40
+ const { table, labels } = useTMDataGridContext();
41
+ if (table.options.meta?.loading !== true) return null;
42
+ return <Loader size="xs" aria-label={labels.loading} />;
43
+ }
44
+
22
45
  /**
23
46
  * Visible rows over total rows. On a server-driven grid, set
24
47
  * `meta.totalRowCount` for the denominator — the client never sees every row.
@@ -52,7 +75,7 @@ export function TMDataGridSummaryCount({ children }: { children?: ReactNode }) {
52
75
  * Renders nothing when no column can be hidden (`enableHiding: false`).
53
76
  */
54
77
  export function TMDataGridColumnsButton() {
55
- const { table, ui, features, controlSize } = useTMDataGridContext();
78
+ const { table, ui, features, labels, controlSize } = useTMDataGridContext();
56
79
  const opened = useSelector(ui, (state) => state.columnsPanelOpen);
57
80
 
58
81
  if (!getGridCapabilities(table, features).canHideAny) return null;
@@ -68,12 +91,12 @@ export function TMDataGridColumnsButton() {
68
91
  trapFocus
69
92
  >
70
93
  <Popover.Target>
71
- <Tooltip label="Manage columns" openDelay={400}>
94
+ <Tooltip label={labels.manageColumns} openDelay={400}>
72
95
  <ActionIcon
73
96
  variant="subtle"
74
97
  color="gray"
75
98
  size={controlSize}
76
- aria-label="Manage columns"
99
+ aria-label={labels.manageColumns}
77
100
  onClick={ui.actions.toggleColumnsPanel}
78
101
  >
79
102
  <BurgerIcon size={18} stroke={1.6} />
@@ -93,7 +116,7 @@ export function TMDataGridColumnsButton() {
93
116
  */
94
117
  export function TMDataGridFilterButton() {
95
118
  const api = useTMDataGridContext();
96
- const { table, ui, features, controlSize } = api;
119
+ const { table, ui, features, labels, controlSize } = api;
97
120
  const opened = useSelector(ui, (state) => state.filterPanelOpen);
98
121
  const columnFilters = useSelector(
99
122
  table.store,
@@ -106,12 +129,12 @@ export function TMDataGridFilterButton() {
106
129
  if (!getGridCapabilities(table, features).canFilterAny) return null;
107
130
 
108
131
  return (
109
- <Tooltip label="Filters" openDelay={400}>
132
+ <Tooltip label={labels.filters} openDelay={400}>
110
133
  <ActionIcon
111
134
  variant={opened || activeCount > 0 ? "light" : "subtle"}
112
135
  color={activeCount > 0 ? undefined : "gray"}
113
136
  size={controlSize}
114
- aria-label="Filters"
137
+ aria-label={labels.filters}
115
138
  // Marks this as the panel's own toggle, so the panel's click-away
116
139
  // handler leaves it alone and the button stays a toggle.
117
140
  data-dg-filter-toggle
@@ -6,6 +6,7 @@
6
6
  * `sideEffects: false`, so only the icons re-exported here reach the bundle.
7
7
  */
8
8
  export {
9
+ IconArrowAutofitWidth as AutosizeIcon,
9
10
  IconArrowDown as ArrowDownIcon,
10
11
  IconArrowLeft as MoveLeftIcon,
11
12
  IconArrowRight as MoveRightIcon,
@@ -1,36 +1,38 @@
1
1
  /* Boundary of a pinned column group. Shared, because the edge is cast by both
2
- the header cell and every body cell in the same column — the two have to
3
- draw an identical band or the seam shows at the header.
2
+ the header cell and every body cell in the same column — the two have to draw
3
+ an identical band or the seam shows at the header.
4
4
 
5
5
  A box-shadow is drawn per cell, and its blur fades in at each cell's own top
6
6
  and bottom edge — stacked down a column that reads as banding at every row
7
- seam. Instead: a crisp 1px border for the hard edge, plus a gradient pseudo
8
- element pinned to `top: 0; bottom: 0` so consecutive cells butt together into
9
- one unbroken band. Mantine sets `color-scheme` on the root, so light-dark()
10
- resolves without a second selector. */
11
- .stickyLeft {
12
- border-right: 1px solid var(--mantine-color-default-border);
13
- }
14
-
15
- .stickyRight {
16
- border-left: 1px solid var(--mantine-color-default-border);
17
- }
7
+ seam. Instead: a gradient pseudo element pinned to `top: 0; bottom: 0` so
8
+ consecutive cells butt together into one unbroken band. Mantine sets
9
+ `color-scheme` on the root, so light-dark() resolves without a second
10
+ selector.
18
11
 
12
+ No border. A 1px line the colour of the grid's own borders drew a hard rule
13
+ between the pinned lanes and the data, so the pinned columns read as a
14
+ separate table rather than as part of this one — and at the system lanes,
15
+ which are only a control wide, it framed them like a sidebar. */
19
16
  .stickyLeft::after,
20
17
  .stickyRight::after {
21
18
  content: "";
22
19
  position: absolute;
23
20
  top: 0;
24
21
  bottom: 0;
25
- width: 4px;
22
+ width: 8px;
26
23
  pointer-events: none;
27
24
  }
28
25
 
26
+ /* Three stops rather than two. A straight linear ramp to `transparent` fades
27
+ fastest at the start, so its own outer edge stays visible as a faint line —
28
+ the sharpness a hard border was blamed for. Front-loading the falloff puts
29
+ the density against the pinned cell and lets the tail reach zero unnoticed. */
29
30
  .stickyLeft::after {
30
31
  left: 100%;
31
32
  background: linear-gradient(
32
33
  to right,
33
- light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
34
+ light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.26)),
35
+ light-dark(rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.09)) 45%,
34
36
  transparent
35
37
  );
36
38
  }
@@ -39,7 +41,66 @@
39
41
  right: 100%;
40
42
  background: linear-gradient(
41
43
  to left,
42
- light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.28)),
44
+ light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.26)),
45
+ light-dark(rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.09)) 45%,
43
46
  transparent
44
47
  );
45
48
  }
49
+
50
+ /* The band says "there is more table under this lane". So it should only be
51
+ there while that is true: nothing is hidden at either end of the scroll, and
52
+ nothing at all is hidden by a grid narrow enough not to scroll.
53
+
54
+ Driven by the scroll position itself rather than by a listener writing a
55
+ class. A scroll-driven animation is evaluated on the compositor, so the band
56
+ tracks the scroll frame for frame without a single React render — and the
57
+ body re-renders on every scroll frame already, from virtualization.
58
+
59
+ `scroll(nearest inline)` resolves to `.scrollContainer`, the one horizontal
60
+ scroller above these cells. An inactive timeline — which is what a container
61
+ with nothing to scroll gives — leaves the base style in place, so `opacity:
62
+ 0` inside this block is also the answer for a grid that does not scroll. */
63
+ @supports (animation-timeline: scroll()) {
64
+ .stickyLeft::after,
65
+ .stickyRight::after {
66
+ /* Distance over which the band arrives. Short enough to read as an edge
67
+ appearing under the lane rather than as a fade. */
68
+ --dg-sticky-edge-range: 20px;
69
+ opacity: 0;
70
+ animation-timeline: scroll(nearest inline);
71
+ animation-timing-function: linear;
72
+ animation-fill-mode: both;
73
+ }
74
+
75
+ .stickyLeft::after {
76
+ animation-name: stickyEdgeIn;
77
+ animation-range: 0 var(--dg-sticky-edge-range);
78
+ }
79
+
80
+ /* The mirror: on at rest, gone as the last column arrives. The range is
81
+ measured from the end, and `both` holds the first value everywhere before
82
+ it — so the band stays fully on until the scroller is within 20px of home.
83
+ */
84
+ .stickyRight::after {
85
+ animation-name: stickyEdgeOut;
86
+ animation-range: calc(100% - var(--dg-sticky-edge-range)) 100%;
87
+ }
88
+ }
89
+
90
+ @keyframes stickyEdgeIn {
91
+ from {
92
+ opacity: 0;
93
+ }
94
+ to {
95
+ opacity: 1;
96
+ }
97
+ }
98
+
99
+ @keyframes stickyEdgeOut {
100
+ from {
101
+ opacity: 1;
102
+ }
103
+ to {
104
+ opacity: 0;
105
+ }
106
+ }
@@ -0,0 +1,125 @@
1
+ import type { RowData } from "@tanstack/react-table";
2
+ import type { TMDataGridRowData } from "../TMDataGridContext";
3
+ import type { TMDataGridTable } from "../useTMDataGrid";
4
+
5
+ /**
6
+ * Room the header keeps for its hover-revealed actions (sort arrow, menu) and
7
+ * the resize divider, which measure zero while idle. Without it a column sized
8
+ * exactly to its header text puts the actions on top of the title.
9
+ */
10
+ const HEADER_ACTIONS_ALLOWANCE = 30;
11
+
12
+ /** Breathing room so the widest value does not touch the next divider. */
13
+ const CONTENT_ALLOWANCE = 2;
14
+
15
+ /**
16
+ * What the content inside one cell actually spans, in px.
17
+ *
18
+ * A Range over the children rather than the span's `scrollWidth`, because the
19
+ * two disagree in opposite directions: a left-aligned cell stretches its
20
+ * content span to fill the cell (`flex: 1 1 auto`), so `scrollWidth` reports
21
+ * the stretched box and autosize could never *shrink* a column — while for
22
+ * clipped content both see past the `overflow: hidden`. The Range measures
23
+ * the laid-out text and elements themselves, whatever box they sit in.
24
+ *
25
+ * `scrollWidth` stays as the fallback for environments whose Ranges do not
26
+ * measure (jsdom reports 0 there).
27
+ */
28
+ function contentSpanWidth(content: HTMLElement): number {
29
+ const range = document.createRange();
30
+ range.selectNodeContents(content);
31
+ // jsdom's Range has no getBoundingClientRect at all, hence the guard
32
+ // rather than a zero-check alone.
33
+ const width =
34
+ typeof range.getBoundingClientRect === "function"
35
+ ? range.getBoundingClientRect().width
36
+ : 0;
37
+ return width > 0 ? width : content.scrollWidth;
38
+ }
39
+
40
+ /**
41
+ * Widest rendered content of one column, in px, measured from the DOM.
42
+ *
43
+ * Mounted cells only: under virtualization the unmounted rows do not exist to
44
+ * be measured, so this reads the visible window plus overscan — the same
45
+ * trade AG Grid's autosize makes by default.
46
+ */
47
+ export function measureColumnContentWidth({
48
+ container,
49
+ columnId,
50
+ }: {
51
+ /** The grid's scroll container — anything enclosing the column's cells. */
52
+ container: HTMLElement;
53
+ columnId: string;
54
+ }): number {
55
+ let widest = 0;
56
+
57
+ const cells = container.querySelectorAll<HTMLElement>(
58
+ `[role="cell"][data-column-id="${CSS.escape(columnId)}"],` +
59
+ `[role="gridcell"][data-column-id="${CSS.escape(columnId)}"]`,
60
+ );
61
+ for (const cell of cells) {
62
+ const content = cell.firstElementChild;
63
+ if (!(content instanceof HTMLElement)) continue;
64
+ const styles = getComputedStyle(cell);
65
+ const padding =
66
+ (Number.parseFloat(styles.paddingLeft) || 0) +
67
+ (Number.parseFloat(styles.paddingRight) || 0);
68
+ widest = Math.max(
69
+ widest,
70
+ contentSpanWidth(content) + padding + CONTENT_ALLOWANCE,
71
+ );
72
+ }
73
+
74
+ const title = container.querySelector<HTMLElement>(
75
+ `[data-testid="dg-header-${CSS.escape(columnId)}"] [data-dg-header-title]`,
76
+ );
77
+ if (title) {
78
+ const header = title.closest<HTMLElement>('[role="columnheader"]');
79
+ const styles = header ? getComputedStyle(header) : null;
80
+ const padding = styles
81
+ ? (Number.parseFloat(styles.paddingLeft) || 0) +
82
+ (Number.parseFloat(styles.paddingRight) || 0)
83
+ : 0;
84
+ widest = Math.max(
85
+ widest,
86
+ contentSpanWidth(title) + padding + HEADER_ACTIONS_ALLOWANCE,
87
+ );
88
+ }
89
+
90
+ return widest;
91
+ }
92
+
93
+ /**
94
+ * Sizes a column to its widest mounted content — what double-clicking the
95
+ * resize divider does, exported so a menu item or consumer code can trigger
96
+ * the same thing.
97
+ *
98
+ * The width lands in `columnSizing`, so it persists with the other widths and
99
+ * a later resize drag takes over from it. Clamped to the column's
100
+ * `minSize`/`maxSize`; a no-op (returning `false`) for a column that cannot
101
+ * be resized or has nothing mounted to measure.
102
+ */
103
+ export function autosizeColumn<TData extends RowData>({
104
+ table,
105
+ columnId,
106
+ container,
107
+ }: {
108
+ table: TMDataGridTable<TData>;
109
+ columnId: string;
110
+ container: HTMLElement;
111
+ }): boolean {
112
+ const erased = table as unknown as TMDataGridTable<TMDataGridRowData>;
113
+ const column = erased.getColumn(columnId);
114
+ if (!column || !column.getCanResize()) return false;
115
+
116
+ const measured = measureColumnContentWidth({ container, columnId });
117
+ if (measured <= 0) return false;
118
+
119
+ const minSize = column.columnDef.minSize ?? 80;
120
+ const maxSize = column.columnDef.maxSize ?? Number.POSITIVE_INFINITY;
121
+ const width = Math.round(Math.min(Math.max(measured, minSize), maxSize));
122
+
123
+ erased.setColumnSizing((previous) => ({ ...previous, [columnId]: width }));
124
+ return true;
125
+ }
@@ -38,9 +38,27 @@ export type TMDataGridSelectionMode =
38
38
  /** No selection at all — a row click only highlights. Master-detail. */
39
39
  | "highlight";
40
40
 
41
+ /**
42
+ * How cells are selected — see `cellSelection` on {@link UseTMDataGridOptions}.
43
+ *
44
+ * A mode rather than a pair of booleans, for the reason
45
+ * {@link TMDataGridSelectionMode} is one: "cells can be selected" and "more
46
+ * than one at a time" are not independent, and a range that cannot be anchored
47
+ * anywhere is not a state worth being able to write down.
48
+ */
49
+ export type TMDataGridCellSelectionMode =
50
+ /** No cell cursor at all. The body's tab stop stays on the row. */
51
+ | "none"
52
+ /** One cell takes the focus and the arrow keys move it. */
53
+ | "single"
54
+ /** As `"single"`, plus a rectangle: drag, Shift+click, Shift+arrows, Ctrl+C. */
55
+ | "range";
56
+
41
57
  export type TMDataGridFeatureFlags = {
42
58
  sorting: boolean;
43
59
  filtering: boolean;
60
+ /** Whether the quick search may filter — `enableGlobalFilter`. */
61
+ globalFiltering: boolean;
44
62
  hiding: boolean;
45
63
  pinning: boolean;
46
64
  resizing: boolean;
@@ -70,6 +88,17 @@ export type TMDataGridFeatureFlags = {
70
88
  * what lets it coexist with a checkbox multi-selection.
71
89
  */
72
90
  highlightRow: boolean;
91
+ /**
92
+ * Whether cells can be selected at all. Off unless `cellSelection` asks for
93
+ * it — it takes the body's tab stop off the row and puts it on a cell, and
94
+ * reports the grid to assistive technology as a `grid` rather than a `table`,
95
+ * which is a different promise about what the keyboard does.
96
+ */
97
+ cellSelection: boolean;
98
+ /** Defaults to `"none"`. */
99
+ cellSelectionMode: TMDataGridCellSelectionMode;
100
+ /** Whether a rectangle of cells can be selected. Only under `"range"`. */
101
+ cellRangeSelection: boolean;
73
102
  /**
74
103
  * The one default-off flag: pagination must be asked for, either with the
75
104
  * grid's `enablePagination` or implicitly by declaring `manualPagination`.
@@ -95,6 +124,7 @@ export function readFeatureFlags<TData extends RowData>(
95
124
  UseTMDataGridOptions<TData>,
96
125
  | "enableSorting"
97
126
  | "enableColumnFilters"
127
+ | "enableGlobalFilter"
98
128
  | "enableHiding"
99
129
  | "enableColumnPinning"
100
130
  | "enableColumnResizing"
@@ -103,12 +133,14 @@ export function readFeatureFlags<TData extends RowData>(
103
133
  | "enableMultiRowSelection"
104
134
  | "selectionMode"
105
135
  | "showSelectedBackground"
136
+ | "cellSelection"
106
137
  | "enablePagination"
107
138
  | "manualPagination"
108
139
  | "enableGrouping"
109
140
  >,
110
141
  ): TMDataGridFeatureFlags {
111
142
  const selectionMode = options.selectionMode ?? "checkbox";
143
+ const cellSelectionMode = options.cellSelection ?? "none";
112
144
  // `"highlight"` is the master-detail mode: no selection to speak of, so the
113
145
  // checkbox column and the click-to-select behaviour both fall away. Otherwise
114
146
  // `enableRowSelection` still has the final say, including its predicate form.
@@ -118,6 +150,7 @@ export function readFeatureFlags<TData extends RowData>(
118
150
  return {
119
151
  sorting: options.enableSorting !== false,
120
152
  filtering: options.enableColumnFilters !== false,
153
+ globalFiltering: options.enableGlobalFilter !== false,
121
154
  hiding: options.enableHiding !== false,
122
155
  pinning: options.enableColumnPinning !== false,
123
156
  resizing: options.enableColumnResizing !== false,
@@ -135,6 +168,9 @@ export function readFeatureFlags<TData extends RowData>(
135
168
  options.showSelectedBackground ?? selectionMode === "row",
136
169
  highlightRow:
137
170
  selectionMode === "checkboxAndHighlight" || selectionMode === "highlight",
171
+ cellSelection: cellSelectionMode !== "none",
172
+ cellSelectionMode,
173
+ cellRangeSelection: cellSelectionMode === "range",
138
174
  pagination:
139
175
  options.enablePagination === true || options.manualPagination === true,
140
176
  grouping: options.enableGrouping ?? options.manualPagination !== true,
@@ -192,6 +228,8 @@ export type TMDataGridCapabilities = {
192
228
  canGroupAny: boolean;
193
229
  canSelectRows: boolean;
194
230
  canPaginate: boolean;
231
+ /** At least one leaf column takes part in the global quick search. */
232
+ canSearch: boolean;
195
233
  };
196
234
 
197
235
  export function getGridCapabilities(
@@ -211,5 +249,8 @@ export function getGridCapabilities(
211
249
  canGroupAny: any((c) => c.canGroup),
212
250
  canSelectRows: features.rowSelection,
213
251
  canPaginate: features.pagination,
252
+ canSearch:
253
+ features.globalFiltering &&
254
+ columns.some((column) => column.getCanGlobalFilter()),
214
255
  };
215
256
  }