@jielga/tmdatagrid 0.2.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 (50) hide show
  1. package/dist/index.d.ts +1337 -94
  2. package/dist/index.js +2812 -1189
  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 +41 -6
  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 +21 -1
  13. package/src/tmdatagrid/components/TMDataGrid.tsx +29 -1
  14. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +12 -6
  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.module.css +15 -0
  18. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +192 -37
  19. package/src/tmdatagrid/components/TMDataGridFilterPills.module.css +35 -0
  20. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +136 -0
  21. package/src/tmdatagrid/components/TMDataGridFooter.module.css +8 -0
  22. package/src/tmdatagrid/components/TMDataGridFooter.tsx +63 -43
  23. package/src/tmdatagrid/components/TMDataGridGroupColumn.module.css +48 -0
  24. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +159 -0
  25. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +18 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +249 -44
  27. package/src/tmdatagrid/components/TMDataGridSearch.tsx +122 -0
  28. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +103 -16
  29. package/src/tmdatagrid/components/TMDataGridTable.module.css +160 -5
  30. package/src/tmdatagrid/components/TMDataGridTable.tsx +1380 -43
  31. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +33 -7
  32. package/src/tmdatagrid/components/icons.ts +6 -0
  33. package/src/tmdatagrid/components/sticky.module.css +77 -16
  34. package/src/tmdatagrid/core/autosize.ts +125 -0
  35. package/src/tmdatagrid/core/capabilities.ts +122 -11
  36. package/src/tmdatagrid/core/cellExport.ts +321 -0
  37. package/src/tmdatagrid/core/cellNavigation.ts +103 -0
  38. package/src/tmdatagrid/core/cellRange.ts +118 -0
  39. package/src/tmdatagrid/core/columnOptions.ts +123 -0
  40. package/src/tmdatagrid/core/columnUtils.ts +18 -0
  41. package/src/tmdatagrid/core/expanding.ts +93 -0
  42. package/src/tmdatagrid/core/filterOperators.ts +198 -12
  43. package/src/tmdatagrid/core/grouping.ts +21 -0
  44. package/src/tmdatagrid/core/labels.ts +211 -0
  45. package/src/tmdatagrid/core/labelsSv.ts +108 -0
  46. package/src/tmdatagrid/core/persistence.ts +12 -0
  47. package/src/tmdatagrid/core/rowSelection.ts +202 -0
  48. package/src/tmdatagrid/core/summary.ts +35 -0
  49. package/src/tmdatagrid/index.ts +96 -2
  50. package/src/tmdatagrid/useTMDataGrid.tsx +558 -37
@@ -1,10 +1,11 @@
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
 
@@ -70,7 +71,7 @@ export function TMDataGridFooter({
70
71
  pageSizeOptions = [10, 25, 50, 100],
71
72
  pagination,
72
73
  }: TMDataGridFooterProps) {
73
- const { table, features, controlSize } = useTMDataGridContext();
74
+ const { table, features, labels, controlSize } = useTMDataGridContext();
74
75
  useSelector(table.store);
75
76
 
76
77
  if (!getGridCapabilities(table, features).canPaginate) return null;
@@ -83,6 +84,10 @@ export function TMDataGridFooter({
83
84
  );
84
85
  }
85
86
 
87
+ // Grouping suspends paging — see isPagingActive. The pager is greyed out
88
+ // rather than dropped, so that a footer going quiet reads as a state the grid
89
+ // is in rather than as something that broke.
90
+ const paging = isPagingActive(table, features);
86
91
  const { pageIndex, pageSize } = table.store.state.pagination;
87
92
  const total = table.getRowCount();
88
93
  const from = total === 0 ? 0 : pageIndex * pageSize + 1;
@@ -95,49 +100,64 @@ export function TMDataGridFooter({
95
100
  : [...pageSizeOptions, pageSize].sort((a, b) => a - b);
96
101
 
97
102
  return (
98
- <div className={classes.footer}>
99
- <Group gap="xs" wrap="nowrap">
103
+ <Tooltip
104
+ label={labels.pagingSuspendedHint}
105
+ disabled={paging}
106
+ withArrow
107
+ multiline
108
+ w={260}
109
+ position="top"
110
+ >
111
+ <div className={classes.footer} data-paging-suspended={!paging}>
112
+ <Group gap="xs" wrap="nowrap">
113
+ <Text size={controlSize} c="dimmed">
114
+ {labels.rowsPerPage}
115
+ </Text>
116
+ <Select
117
+ size={controlSize}
118
+ w={78}
119
+ allowDeselect={false}
120
+ variant="unstyled"
121
+ disabled={!paging}
122
+ data={sizeOptions.map(String)}
123
+ value={String(pageSize)}
124
+ onChange={(value) => {
125
+ table.setPageSize(Number(value) || pageSizeOptions[0]);
126
+ table.setPageIndex(0);
127
+ }}
128
+ />
129
+ </Group>
130
+
100
131
  <Text size={controlSize} c="dimmed">
101
- Rows per page:
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)}
102
138
  </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
-
117
- <Text size={controlSize} c="dimmed">
118
- {from}–{to} of {total}
119
- </Text>
120
139
 
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>
140
+ <Group gap={4} wrap="nowrap">
141
+ <ActionIcon
142
+ variant="subtle"
143
+ color="gray"
144
+ aria-label={labels.previousPage}
145
+ disabled={!paging || !table.getCanPreviousPage()}
146
+ onClick={() => table.previousPage()}
147
+ >
148
+ <ChevronLeftIcon size={18} stroke={1.6} />
149
+ </ActionIcon>
150
+ <ActionIcon
151
+ variant="subtle"
152
+ color="gray"
153
+ aria-label={labels.nextPage}
154
+ disabled={!paging || !table.getCanNextPage()}
155
+ onClick={() => table.nextPage()}
156
+ >
157
+ <ChevronRightIcon size={18} stroke={1.6} />
158
+ </ActionIcon>
159
+ </Group>
160
+ </div>
161
+ </Tooltip>
142
162
  );
143
163
  }
@@ -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,159 @@
1
+ import { UnstyledButton } from "@mantine/core";
2
+ import {
3
+ useCellControlTabIndex,
4
+ useTMDataGridContext,
5
+ } from "../TMDataGridContext";
6
+ import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
7
+ import { useSelector } from "@tanstack/react-store";
8
+ import { shallow } from "@tanstack/store";
9
+ import classes from "./TMDataGridGroupColumn.module.css";
10
+ import { ChevronRightIcon } from "./icons";
11
+ import { getColumnLabel } from "../core/columnUtils";
12
+ import { getGroupDataRows } from "../core/grouping";
13
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
14
+
15
+ export const GROUP_COLUMN_ID = "__group__";
16
+
17
+ /** Indent added per level of nesting, in px. */
18
+ const INDENT_STEP = 16;
19
+
20
+ /** Shown for a group whose value is empty — `String(null)` would read as "null". */
21
+ const BLANK_GROUP_LABEL = "(Blank)";
22
+
23
+ /**
24
+ * How a grouping value is written into the tree cell.
25
+ *
26
+ * Deliberately not the grouped column's own `cell` renderer: that renderer is
27
+ * written for a data row and is free to reach into `row.original`, which on a
28
+ * group row is the first leaf's record rather than anything about the group.
29
+ */
30
+ export function formatGroupValue(
31
+ value: unknown,
32
+ blankLabel = BLANK_GROUP_LABEL,
33
+ ): string {
34
+ if (value === null || value === undefined || value === "") {
35
+ return blankLabel;
36
+ }
37
+ if (value instanceof Date) return value.toLocaleDateString();
38
+ return String(value);
39
+ }
40
+
41
+ /**
42
+ * The tree cell: chevron, group value and leaf count, indented by depth.
43
+ *
44
+ * Expansion is read through a subscription rather than from `row.getIsExpanded()`
45
+ * in the component body for the same reason the select checkbox does it — the
46
+ * `row` identity survives an expand, so the React Compiler would cache the call
47
+ * along with it and the chevron would never turn. See TMDataGridSelectColumn.
48
+ */
49
+ function GroupCell<TData extends RowData>({
50
+ row,
51
+ }: {
52
+ row: Row<TMDataGridFeatures, TData>;
53
+ }) {
54
+ const { labels } = useTMDataGridContext();
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();
58
+
59
+ // Leaf rows keep the lane empty: their values are in the data columns, and
60
+ // the indent alone is what places them under their group.
61
+ if (!row.getIsGrouped()) return null;
62
+
63
+ const label = formatGroupValue(row.groupingValue, labels.blankGroupValue);
64
+
65
+ return (
66
+ <UnstyledButton
67
+ className={classes.groupToggle}
68
+ // Padding rather than margin, so the whole indented width stays clickable.
69
+ style={{ paddingInlineStart: row.depth * INDENT_STEP }}
70
+ tabIndex={tabIndex}
71
+ aria-expanded={expanded}
72
+ aria-label={
73
+ expanded ? labels.collapseGroup(label) : labels.expandGroup(label)
74
+ }
75
+ // The row underneath may select or highlight on click; expanding is its
76
+ // own gesture and must not also trigger those.
77
+ onClick={(event) => {
78
+ event.stopPropagation();
79
+ row.toggleExpanded();
80
+ }}
81
+ >
82
+ <span className={classes.chevron} data-expanded={expanded}>
83
+ <ChevronRightIcon size={16} stroke={1.6} />
84
+ </span>
85
+ <span className={classes.groupLabel}>{label}</span>
86
+ {/* Data rows, not direct children and not `getLeafRows()`: a nested group
87
+ counts the records under it, never the sub-groups in between. */}
88
+ <span className={classes.groupCount}>
89
+ ({getGroupDataRows(row).length})
90
+ </span>
91
+ </UnstyledButton>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Names the columns currently grouped on, so the lane says what it is showing
97
+ * rather than a static "Group".
98
+ */
99
+ function GroupHeader<TData extends RowData>({
100
+ table,
101
+ }: {
102
+ table: TMDataGridTable<TData>;
103
+ }) {
104
+ const { labels } = useTMDataGridContext();
105
+ const grouping = useSelector(table.store, (state) => state.grouping, {
106
+ compare: shallow,
107
+ });
108
+
109
+ if (grouping.length === 0) return labels.groupColumnLabel;
110
+ return grouping
111
+ .map((columnId) => {
112
+ const column = table.getColumn(columnId);
113
+ return column ? getColumnLabel(column) : columnId;
114
+ })
115
+ .join(" / ");
116
+ }
117
+
118
+ /**
119
+ * The generated tree column, prepended whenever grouping is enabled and hidden
120
+ * again while `grouping` is empty — see the visibility effect in
121
+ * `useTMDataGrid`.
122
+ *
123
+ * It exists because TanStack ships no auto group column: `groupedColumnMode:
124
+ * "remove"` takes the grouped column out of the grid, so something has to hold
125
+ * the tree. Modelled on the checkbox column, which is generated the same way.
126
+ *
127
+ * Not groupable itself, and nothing had to be written to make that true —
128
+ * `column.getCanGroup()` requires an `accessorFn`, which a display column has
129
+ * no reason to have.
130
+ */
131
+ export function createGroupColumn<TData extends RowData>(
132
+ label = "Group",
133
+ ): ColumnDef<TMDataGridFeatures, TData, unknown> {
134
+ return {
135
+ id: GROUP_COLUMN_ID,
136
+ meta: {
137
+ label,
138
+ // Structurally the first column after the checkbox lane.
139
+ enableOrdering: false,
140
+ },
141
+ size: 260,
142
+ minSize: 180,
143
+ enableSorting: false,
144
+ enableColumnFilter: false,
145
+ enableGlobalFilter: false,
146
+ // Keeps it out of the columns panel and out of the header menu: its
147
+ // visibility is not the user's to set, it follows the grouping state.
148
+ enableHiding: false,
149
+ // Structurally pinned to the left; users shouldn't be able to move it.
150
+ enablePinning: false,
151
+ cell: ({ row }) => <GroupCell row={row} />,
152
+ // A group row has subRows, so every cell on it that is not the grouped
153
+ // column reports `getIsAggregated()` — this lane included. Without an
154
+ // `aggregatedCell` the body would take that as "nothing to summarise" and
155
+ // render the tree lane blank on exactly the rows it exists for.
156
+ aggregatedCell: ({ row }) => <GroupCell row={row} />,
157
+ header: ({ table }) => <GroupHeader table={table} />,
158
+ };
159
+ }
@@ -21,6 +21,13 @@
21
21
  justify-content: center;
22
22
  }
23
23
 
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"] {
28
+ padding: 0;
29
+ }
30
+
24
31
  /* Sorted or filtered columns read as active, like the MUI data grid. */
25
32
  .headerCell[data-active="true"] .headerTitle {
26
33
  color: var(--mantine-primary-color-filled);
@@ -78,6 +85,17 @@
78
85
  cursor: pointer;
79
86
  }
80
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
+
81
99
  /* Column reordering. The dragged header stays in place and dims — the browser
82
100
  already carries a snapshot of it under the pointer — while the header being
83
101
  dragged over draws a bar on the edge the column will land against. */