@xeplr/ui-table 1.0.1 → 1.0.3
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.
- package/package.json +4 -1
- package/src/CellZoom.jsx +77 -0
- package/src/XeplrTable.jsx +338 -22
- package/src/columnWidths.js +333 -0
- package/src/filters/FilterWrapper.jsx +79 -30
- package/src/index.js +14 -0
- package/src/tableStyles.js +236 -0
- package/src/useColumnWidths.js +280 -0
- package/src/useTableController.js +62 -6
- package/src/xeplr-table.css +151 -4
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/ui-table",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Controlled TanStack Table wrapper with auto-detected column types and smart filters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "for f in test/*.test.js; do node \"$f\" || exit 1; done"
|
|
9
|
+
},
|
|
7
10
|
"files": [
|
|
8
11
|
"src/"
|
|
9
12
|
],
|
package/src/CellZoom.jsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useLayoutEffect } from 'react';
|
|
2
|
+
import { flexRender } from '@tanstack/react-table';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A cell, popped out and enlarged.
|
|
6
|
+
*
|
|
7
|
+
* The problem it solves is plain readability: report grids run small type so
|
|
8
|
+
* more fits on a page, and a long value in a narrow column wraps to three
|
|
9
|
+
* cramped lines. Double-clicking gives you that one value at a size you can
|
|
10
|
+
* actually read, without changing the table's font for everyone or making the
|
|
11
|
+
* column wider.
|
|
12
|
+
*
|
|
13
|
+
* It renders the column's OWN cell renderer, not the raw value — so a currency
|
|
14
|
+
* cell zooms as ₹36,260.80 and a badge zooms as a badge. Seeing something
|
|
15
|
+
* different up close from what was on the page would defeat the point.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately not a modal: it doesn't trap focus or block the page, because
|
|
18
|
+
* it's a reading aid, not a task. Escape, a click anywhere else, or scrolling
|
|
19
|
+
* the table all dismiss it.
|
|
20
|
+
*/
|
|
21
|
+
export default function CellZoom({ cell, anchorRect, font, onClose }) {
|
|
22
|
+
var boxRef = useRef(null);
|
|
23
|
+
|
|
24
|
+
// Position after render, once the box's real size is known — a value's
|
|
25
|
+
// width isn't predictable before it's laid out at the larger size.
|
|
26
|
+
useLayoutEffect(function() {
|
|
27
|
+
var box = boxRef.current;
|
|
28
|
+
if (!box || !anchorRect) return;
|
|
29
|
+
var margin = 8;
|
|
30
|
+
var rect = box.getBoundingClientRect();
|
|
31
|
+
|
|
32
|
+
// Grows out of the cell it came from, so the eye doesn't lose its place.
|
|
33
|
+
var left = anchorRect.left;
|
|
34
|
+
var top = anchorRect.top;
|
|
35
|
+
if (left + rect.width > window.innerWidth - margin) left = window.innerWidth - rect.width - margin;
|
|
36
|
+
if (top + rect.height > window.innerHeight - margin) top = anchorRect.bottom - rect.height;
|
|
37
|
+
box.style.left = Math.max(margin, left) + 'px';
|
|
38
|
+
box.style.top = Math.max(margin, top) + 'px';
|
|
39
|
+
box.focus();
|
|
40
|
+
}, [anchorRect]);
|
|
41
|
+
|
|
42
|
+
useEffect(function() {
|
|
43
|
+
function onKey(e) { if (e.key === 'Escape') onClose(); }
|
|
44
|
+
function onPointer(e) { if (boxRef.current && !boxRef.current.contains(e.target)) onClose(); }
|
|
45
|
+
// Scroll closes rather than re-anchoring: a box chasing its cell across a
|
|
46
|
+
// scrolling table is harder to read than one that simply gets out of the way.
|
|
47
|
+
document.addEventListener('keydown', onKey);
|
|
48
|
+
document.addEventListener('mousedown', onPointer);
|
|
49
|
+
window.addEventListener('scroll', onClose, true);
|
|
50
|
+
return function() {
|
|
51
|
+
document.removeEventListener('keydown', onKey);
|
|
52
|
+
document.removeEventListener('mousedown', onPointer);
|
|
53
|
+
window.removeEventListener('scroll', onClose, true);
|
|
54
|
+
};
|
|
55
|
+
}, [onClose]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={boxRef}
|
|
60
|
+
className="xeplr-table-cell-zoom"
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-label="Enlarged cell"
|
|
63
|
+
tabIndex={-1}
|
|
64
|
+
style={{
|
|
65
|
+
minWidth: anchorRect ? Math.min(anchorRect.width, 320) + 'px' : undefined,
|
|
66
|
+
// Everything inside is sized in em, so this one value sets the scale.
|
|
67
|
+
fontSize: font ? font.fontSize : undefined,
|
|
68
|
+
fontFamily: font ? font.fontFamily : undefined
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<div className="xeplr-table-cell-zoom-label">{String(cell.column.columnDef.header || '')}</div>
|
|
72
|
+
<div className="xeplr-table-cell-zoom-value">
|
|
73
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/XeplrTable.jsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState, useCallback, useRef, useLayoutEffect } from 'react';
|
|
2
2
|
import { flexRender } from '@tanstack/react-table';
|
|
3
3
|
import useTableController from './useTableController.js';
|
|
4
4
|
import useActionsController from './useActionsController.js';
|
|
5
|
+
import useColumnWidths from './useColumnWidths.js';
|
|
6
|
+
import CellZoom from './CellZoom.jsx';
|
|
5
7
|
import { TYPES } from './detectTypes.js';
|
|
6
8
|
import StringFilter from './filters/StringFilter.jsx';
|
|
7
9
|
import NumberFilter from './filters/NumberFilter.jsx';
|
|
@@ -37,8 +39,91 @@ var CHILD_DISPLAY = { POPUP: 'popup', INNER: 'inner' };
|
|
|
37
39
|
* @param {string} [props.childDisplay] - 'popup' (default) | 'inner'
|
|
38
40
|
* @param {number} [props.pageSize] - Default: 20
|
|
39
41
|
* @param {boolean} [props.enableSorting] - Default: true
|
|
42
|
+
* @param {Array} [props.sorting] - Controlled sort state, e.g. [{ id, desc }].
|
|
43
|
+
* Supplying it hands the ORDER to the host: the table stops sorting the
|
|
44
|
+
* rows itself and reports header clicks through onSortingChange instead.
|
|
45
|
+
* Needed wherever the rows are already ordered for a reason the view layer
|
|
46
|
+
* cannot see — a grouped report's subtotals belong with their group, and a
|
|
47
|
+
* running total is a statement about the order it was computed in.
|
|
48
|
+
* @param {Function} [props.onSortingChange] - Called with the next sort state.
|
|
40
49
|
* @param {boolean} [props.enableFiltering] - Default: true
|
|
41
50
|
* @param {boolean} [props.enablePagination] - Default: true
|
|
51
|
+
* @param {Function} [props.rowClassName] - (row) => string|falsy. Extra
|
|
52
|
+
* CSS class per row, so a host can mark rows that mean something to IT.
|
|
53
|
+
* @param {Function} [props.toolbarActions] - (ctx) => ReactNode.
|
|
54
|
+
* Replaces the toolbar's trailing Clear Filters/Clear Sort buttons. The
|
|
55
|
+
* handlers are supplied; the presentation is entirely the consumer's, so no
|
|
56
|
+
* host-specific styling lives in here.
|
|
57
|
+
* @param {Function} [props.toolbarLeading] - (ctx) => ReactNode. The
|
|
58
|
+
* toolbar's LEADING area — the run of empty space before the trailing
|
|
59
|
+
* actions. The built-in Add/Delete/Commit buttons live there when editing is
|
|
60
|
+
* enabled, so on a read-only table it is simply blank; this hands that space
|
|
61
|
+
* to the host for a caption, a count, a status line, whatever it has.
|
|
62
|
+
*
|
|
63
|
+
* Both slots receive the same context: { rowCount, totalCount, selectedCount,
|
|
64
|
+
* resetFilters, resetSorting, enableFiltering }. rowCount is what's left
|
|
65
|
+
* after filtering, totalCount is everything — a table can say "showing 12 of
|
|
66
|
+
* 400" without the host recounting rows it already handed over.
|
|
67
|
+
* @param {string} [props.columnSizing] - undefined (default) fills
|
|
68
|
+
* the container, as a table always has. 'content' instead sizes each column
|
|
69
|
+
* to its text at the current font, and leaves any width it doesn't need
|
|
70
|
+
* blank rather than stretching to fill. See columnWidths.js for the rule.
|
|
71
|
+
* @param {Function} [props.columnText] - (row, column) => string.
|
|
72
|
+
* Only used by 'content' sizing, and only worth passing if cells are
|
|
73
|
+
* formatted: measuring 36260.8 when the screen shows ₹36,260.80 sizes the
|
|
74
|
+
* column too narrow. Defaults to the raw value.
|
|
75
|
+
* @param {number} [props.sizingSampleRows] - How many rows content
|
|
76
|
+
* sizing measures. Default 200 — enough to be right on normal data, cheap
|
|
77
|
+
* enough to be invisible. Raise it (or pass Infinity) when precision on a
|
|
78
|
+
* wide dataset is worth the scan; the cost is linear in rows × columns and
|
|
79
|
+
* is paid once per dataset, not per render or per page.
|
|
80
|
+
* @param {number} [props.minColumnWidth] - Default 40
|
|
81
|
+
* @param {number} [props.maxColumnWidth] - Default 420. Stops one
|
|
82
|
+
* long-text column demanding the whole table.
|
|
83
|
+
* @param {*} [props.sizingKey] - Changing this re-measures.
|
|
84
|
+
* Nothing can observe a font change, so a host that restyles the table
|
|
85
|
+
* (a compact theme, a different type scale) signals it here.
|
|
86
|
+
* @param {object} [props.columnWidths] - { columnIndex: percent }.
|
|
87
|
+
* Explicit widths for individual columns, as a PERCENTAGE of the width the
|
|
88
|
+
* columns have to divide (so two columns at 50 fill the table exactly, with
|
|
89
|
+
* or without a checkbox column in front of them). Those columns are held at
|
|
90
|
+
* that share, never measured, and never squeezed; the rest divide whatever
|
|
91
|
+
* is left. Percent rather than px so a pin keeps its proportion when the
|
|
92
|
+
* container resizes — a widget on a dashboard is resized constantly. Keyed by
|
|
93
|
+
* INDEX because a generated column's key is data — a pivot's `Q3 · Sum`
|
|
94
|
+
* becomes `Q4 · Sum` on the next refresh, and a width keyed to it would be
|
|
95
|
+
* lost by a plain reload. Position is what the width actually belongs to.
|
|
96
|
+
* Only used by 'content' sizing.
|
|
97
|
+
* @param {Function} [props.columnClassName] - (column, index) => string|falsy.
|
|
98
|
+
* Extra CSS class for one column, applied to its header AND every body cell,
|
|
99
|
+
* so a marked column reads as one thing down the table rather than stopping
|
|
100
|
+
* at the label. `index` is the same leaf index columnWidths uses, so a host
|
|
101
|
+
* that pins column 3 marks column 3 without a second way of naming it.
|
|
102
|
+
* @param {Function} [props.onHeaderClick] - (columnKey, isGroupHeader)
|
|
103
|
+
* => void. TAKES OVER the header click, replacing sorting rather than sharing
|
|
104
|
+
* with it: a host that wants the click for selecting a column would otherwise
|
|
105
|
+
* get a sort it never asked for on every selection. Hosts that use this are
|
|
106
|
+
* expected to offer sorting elsewhere.
|
|
107
|
+
* @param {Function} [props.onColumnSizing] - Receives the full sizing
|
|
108
|
+
* record — per column: natural width, floor, assigned width, and WHY. For
|
|
109
|
+
* hosts that need to explain a width back to a user.
|
|
110
|
+
* @param {boolean} [props.cellZoom] - Double-click a cell to
|
|
111
|
+
* pop it out enlarged, for reading a small or long value. A reading aid, so
|
|
112
|
+
* hosts generally enable it on read-only views and leave it off while
|
|
113
|
+
* editing. Takes over the double-click gesture, so it is ignored when
|
|
114
|
+
* childDisplay is 'popup' (there, double-click already opens the record).
|
|
115
|
+
* @param {Function} [props.cellStyle] - ({ row, rowIndex,
|
|
116
|
+
* columnKey, value }) => style object | null. Applied per cell, on top of
|
|
117
|
+
* any conditional formatting from the schema. The host decides WHERE the
|
|
118
|
+
* style comes from (theme, saved overrides); tableStyles.js is exported to
|
|
119
|
+
* resolve those layers, but this component only applies what it's handed.
|
|
120
|
+
* Its presence also switches the table to separated borders — see the CSS.
|
|
121
|
+
* @param {Function} [props.headerStyle] - ({ columnKey, isGroupHeader,
|
|
122
|
+
* depth }) => style object | null. The header's counterpart to cellStyle,
|
|
123
|
+
* and it exists for the same reason: a host that can style every row band
|
|
124
|
+
* but not the header can restyle a table into something the header no
|
|
125
|
+
* longer belongs to. Resolved by the host from the same vocabulary
|
|
126
|
+
* (tableStyles.js); this component only applies what it's handed.
|
|
42
127
|
* @param {string} [props.className] - Additional CSS class
|
|
43
128
|
* @param {Function} [props.onCommit] - async (changeSet[]) => void
|
|
44
129
|
* @param {Array<{key: string, label: string, icon?: any, onClick: (row) => void,
|
|
@@ -70,16 +155,50 @@ export default function XeplrTable(props) {
|
|
|
70
155
|
minDetectionRows: props.minDetectionRows,
|
|
71
156
|
pageSize: props.pageSize,
|
|
72
157
|
enableSorting: props.enableSorting,
|
|
158
|
+
// Controlled sorting, when the host owns the order — see
|
|
159
|
+
// useTableController. Passing `sorting` is what switches it on; the
|
|
160
|
+
// header still reads and clicks exactly as before.
|
|
161
|
+
sorting: props.sorting,
|
|
162
|
+
onSortingChange: props.onSortingChange,
|
|
73
163
|
enableFiltering: props.enableFiltering,
|
|
74
164
|
enablePagination: props.enablePagination
|
|
75
165
|
});
|
|
76
166
|
|
|
77
167
|
var enableFiltering = props.enableFiltering !== false;
|
|
78
168
|
var enablePagination = props.enablePagination !== false;
|
|
79
|
-
|
|
80
169
|
var headerGroups = table.getHeaderGroups();
|
|
81
170
|
var rows = table.getRowModel().rows;
|
|
82
171
|
|
|
172
|
+
// A consumer that wants a frozen header opts in with its own CSS
|
|
173
|
+
// (`position: sticky; top: 0` on .xeplr-table-th) — this component has no
|
|
174
|
+
// opinion on whether stickiness is on. What it DOES have to get right,
|
|
175
|
+
// once it opts in, is WHERE each row sticks: a single-row header can
|
|
176
|
+
// hardcode top:0 and be done, but a grouped one (a pivoted date over its
|
|
177
|
+
// measures) has a SECOND row that also needs to freeze, directly below
|
|
178
|
+
// the first — and it doesn't know that row's height in advance, because
|
|
179
|
+
// that depends on the font/theme/padding a THEME sets, not anything this
|
|
180
|
+
// component controls. Measured after layout and set as an inline `top`
|
|
181
|
+
// per row, which wins over a consumer's blanket `top: 0` by specificity —
|
|
182
|
+
// row 0 keeps top:0 (same value, so nothing changes there), every row
|
|
183
|
+
// below it gets pushed down by the actual height of the rows above it.
|
|
184
|
+
// A no-op for every table that has exactly one header row (offsets[0] is
|
|
185
|
+
// always 0) and for every table that never opts into sticky at all (an
|
|
186
|
+
// inline `top` with no `position: sticky` does nothing).
|
|
187
|
+
var headerRowRefs = useRef([]);
|
|
188
|
+
var [stickyTops, setStickyTops] = useState([]);
|
|
189
|
+
useLayoutEffect(function() {
|
|
190
|
+
var heights = headerRowRefs.current.slice(0, headerGroups.length).map(function(el) {
|
|
191
|
+
return el ? el.offsetHeight : 0;
|
|
192
|
+
});
|
|
193
|
+
var offsets = [];
|
|
194
|
+
var acc = 0;
|
|
195
|
+
for (var i = 0; i < heights.length; i++) {
|
|
196
|
+
offsets.push(acc);
|
|
197
|
+
acc += heights[i];
|
|
198
|
+
}
|
|
199
|
+
setStickyTops(offsets);
|
|
200
|
+
}, [headerGroups.length]);
|
|
201
|
+
|
|
83
202
|
function getRowId(row) {
|
|
84
203
|
return row._tempId || row.id;
|
|
85
204
|
}
|
|
@@ -92,6 +211,53 @@ export default function XeplrTable(props) {
|
|
|
92
211
|
|
|
93
212
|
var hasRowActions = actions.hasActions || !!(props.rowActions && props.rowActions.length > 0);
|
|
94
213
|
|
|
214
|
+
// Content sizing measures the whole dataset's first N rows, not the current
|
|
215
|
+
// page — otherwise every page turn would re-measure and the columns would
|
|
216
|
+
// visibly jump.
|
|
217
|
+
var widths = useColumnWidths({
|
|
218
|
+
enabled: props.columnSizing === 'content',
|
|
219
|
+
data: props.data,
|
|
220
|
+
columns: columns,
|
|
221
|
+
columnText: props.columnText,
|
|
222
|
+
sizingKey: props.sizingKey,
|
|
223
|
+
columnWidths: props.columnWidths,
|
|
224
|
+
onSizing: props.onColumnSizing,
|
|
225
|
+
sampleRows: props.sizingSampleRows,
|
|
226
|
+
minColumnWidth: props.minColumnWidth,
|
|
227
|
+
maxColumnWidth: props.maxColumnWidth
|
|
228
|
+
});
|
|
229
|
+
var sizing = widths.sizing;
|
|
230
|
+
|
|
231
|
+
// PER-COLUMN CLASS, resolved by leaf index — the same index `columnWidths`
|
|
232
|
+
// uses, so a host that pins column 3 highlights column 3 without a second
|
|
233
|
+
// way of naming it. Header and body share one answer: a selected column is
|
|
234
|
+
// selected all the way down, or the highlight stops at the header and the
|
|
235
|
+
// thing being resized looks like just a label.
|
|
236
|
+
var columnClassName = props.columnClassName;
|
|
237
|
+
var classForColumn = useCallback(function(accessor) {
|
|
238
|
+
if (!columnClassName) return '';
|
|
239
|
+
var index = columns.findIndex(function(c) { return c.accessor === accessor; });
|
|
240
|
+
if (index < 0) return '';
|
|
241
|
+
return columnClassName(columns[index], index) || '';
|
|
242
|
+
}, [columnClassName, columns]);
|
|
243
|
+
|
|
244
|
+
// Zoom is addressed by row+column id rather than by holding the cell object,
|
|
245
|
+
// so paging, sorting or a refresh can't leave a stale cell open — the lookup
|
|
246
|
+
// below simply stops finding it and the box closes.
|
|
247
|
+
var [zoom, setZoom] = useState(null);
|
|
248
|
+
var closeZoom = useCallback(function() { setZoom(null); }, []);
|
|
249
|
+
var cellZoom = !!props.cellZoom && !usePopup;
|
|
250
|
+
|
|
251
|
+
// One context for both toolbar slots.
|
|
252
|
+
var toolbarContext = {
|
|
253
|
+
rowCount: table.getFilteredRowModel().rows.length,
|
|
254
|
+
totalCount: (props.data || []).length,
|
|
255
|
+
selectedCount: actions.selectedIds.size,
|
|
256
|
+
resetFilters: resetFilters,
|
|
257
|
+
resetSorting: resetSorting,
|
|
258
|
+
enableFiltering: enableFiltering
|
|
259
|
+
};
|
|
260
|
+
|
|
95
261
|
var totalColumns = headerGroups[0]?.headers.length || 1;
|
|
96
262
|
if (showExpandCol) totalColumns++;
|
|
97
263
|
if (actions.hasDelete) totalColumns++;
|
|
@@ -114,6 +280,16 @@ export default function XeplrTable(props) {
|
|
|
114
280
|
}
|
|
115
281
|
}
|
|
116
282
|
|
|
283
|
+
// Resolved fresh each render: if the row has paged, sorted or filtered away,
|
|
284
|
+
// there is nothing to find and the box simply isn't rendered.
|
|
285
|
+
var zoomCell = null;
|
|
286
|
+
if (cellZoom && zoom) {
|
|
287
|
+
var zoomRow = rows.find(function(r) { return r.id === zoom.rowId; });
|
|
288
|
+
if (zoomRow) {
|
|
289
|
+
zoomCell = zoomRow.getVisibleCells().find(function(c) { return c.column.id === zoom.columnId; }) || null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
117
293
|
return (
|
|
118
294
|
<div className={'xeplr-table-container' + (props.className ? ' ' + props.className : '')}>
|
|
119
295
|
<div className="xeplr-table-toolbar">
|
|
@@ -147,26 +323,70 @@ export default function XeplrTable(props) {
|
|
|
147
323
|
</button>
|
|
148
324
|
</div>
|
|
149
325
|
)}
|
|
326
|
+
{/* Rendered AFTER the built-in buttons: those are driven by the
|
|
327
|
+
table's own behaviour (there is a pending commit, rows are
|
|
328
|
+
selected) and a host slot shouldn't be able to displace them. */}
|
|
329
|
+
{props.toolbarLeading && props.toolbarLeading(toolbarContext)}
|
|
330
|
+
|
|
150
331
|
<div className="xeplr-table-toolbar-spacer" />
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
332
|
+
{/* The toolbar's trailing actions. A consumer can replace them
|
|
333
|
+
entirely by passing `toolbarActions` — it receives the behaviour
|
|
334
|
+
(the reset handlers) and returns whatever presentation it wants.
|
|
335
|
+
The library owns what the buttons DO; how they look, and whether
|
|
336
|
+
they're text or icons or sit beside other controls, is the
|
|
337
|
+
consumer's business, not something this component should carry an
|
|
338
|
+
opinion about. */}
|
|
339
|
+
{props.toolbarActions
|
|
340
|
+
? props.toolbarActions(toolbarContext)
|
|
341
|
+
: (
|
|
342
|
+
<>
|
|
343
|
+
{enableFiltering && (
|
|
344
|
+
<button type="button" className="xeplr-table-toolbar-btn" onClick={resetFilters}>
|
|
345
|
+
Clear Filters
|
|
346
|
+
</button>
|
|
347
|
+
)}
|
|
348
|
+
<button type="button" className="xeplr-table-toolbar-btn" onClick={resetSorting}>
|
|
349
|
+
Clear Sort
|
|
350
|
+
</button>
|
|
351
|
+
</>
|
|
352
|
+
)}
|
|
159
353
|
</div>
|
|
160
354
|
|
|
161
|
-
<div className="xeplr-table-scroll">
|
|
162
|
-
|
|
355
|
+
<div className="xeplr-table-scroll" ref={widths.scrollRef}>
|
|
356
|
+
{/* When sized to content the table stops at the width it needs; the
|
|
357
|
+
container's own background fills the rest. No filler cells, so row
|
|
358
|
+
styling (banding, selection, host row classes) ends cleanly at the
|
|
359
|
+
table edge instead of being stretched across empty space. */}
|
|
360
|
+
<table
|
|
361
|
+
ref={widths.tableRef}
|
|
362
|
+
className={'xeplr-table' + (sizing ? ' xeplr-table-sized' : '') + (props.cellStyle ? ' xeplr-table-styled' : '')}
|
|
363
|
+
style={sizing ? { width: sizing.tableWidth + 'px' } : undefined}
|
|
364
|
+
>
|
|
365
|
+
{sizing && (
|
|
366
|
+
<colgroup>
|
|
367
|
+
{showExpandCol && <col style={{ width: (sizing.extraWidths.expand || 36) + 'px' }} />}
|
|
368
|
+
{actions.hasDelete && <col style={{ width: (sizing.extraWidths.checkbox || 40) + 'px' }} />}
|
|
369
|
+
{sizing.widths.map(function(width, i) {
|
|
370
|
+
return <col key={columns[i] ? columns[i].accessor : i} style={{ width: width + 'px' }} />;
|
|
371
|
+
})}
|
|
372
|
+
{hasRowActions && <col style={{ width: (sizing.extraWidths.actions || 80) + 'px' }} />}
|
|
373
|
+
</colgroup>
|
|
374
|
+
)}
|
|
163
375
|
<thead>
|
|
164
|
-
{headerGroups.map(function(headerGroup) {
|
|
376
|
+
{headerGroups.map(function(headerGroup, rowIndex) {
|
|
377
|
+
// Every cell in a header row that ISN'T the last one needs the
|
|
378
|
+
// divider under it, not just the ones that happen to be real
|
|
379
|
+
// groups — otherwise the line drawn under "2025-05-01" simply
|
|
380
|
+
// stops where a plain (never-grouped) column like Category
|
|
381
|
+
// Name sits beside it, and a divider that quits partway across
|
|
382
|
+
// reads as broken, not as "this column has nothing under it."
|
|
383
|
+
var isNotLastHeaderRow = rowIndex < headerGroups.length - 1;
|
|
384
|
+
var stickyTop = stickyTops[rowIndex] || 0;
|
|
165
385
|
return (
|
|
166
|
-
<tr key={headerGroup.id}>
|
|
167
|
-
{showExpandCol && <th className="xeplr-table-th xeplr-table-th-expand" />}
|
|
386
|
+
<tr key={headerGroup.id} ref={function(el) { headerRowRefs.current[rowIndex] = el; }}>
|
|
387
|
+
{showExpandCol && <th className="xeplr-table-th xeplr-table-th-expand" style={{ top: stickyTop }} />}
|
|
168
388
|
{actions.hasDelete && (
|
|
169
|
-
<th className="xeplr-table-th xeplr-table-th-checkbox">
|
|
389
|
+
<th className="xeplr-table-th xeplr-table-th-checkbox" style={{ top: stickyTop }}>
|
|
170
390
|
<input type="checkbox" className="xeplr-table-checkbox" checked={allSelected}
|
|
171
391
|
onChange={function() { actions.toggleSelectAll(visibleIds); }} />
|
|
172
392
|
</th>
|
|
@@ -177,13 +397,61 @@ export default function XeplrTable(props) {
|
|
|
177
397
|
var dataType = header.column.columnDef.meta?.dataType || TYPES.STRING;
|
|
178
398
|
var FilterComponent = filterComponentMap[dataType];
|
|
179
399
|
var canFilter = header.column.getCanFilter() && enableFiltering;
|
|
400
|
+
// A REAL group column (built via columnHelper.group, e.g.
|
|
401
|
+
// a pivoted date) carries its child column defs on
|
|
402
|
+
// header.column.columns. header.subHeaders.length > 0 is
|
|
403
|
+
// NOT this check — TanStack also pads every OTHER
|
|
404
|
+
// top-level column to the same depth with a placeholder
|
|
405
|
+
// header the moment ANY column in the table is grouped,
|
|
406
|
+
// and that placeholder reports subHeaders too, which
|
|
407
|
+
// would misclassify a plain ungrouped column (Category
|
|
408
|
+
// here) as a group and center/border its empty top cell.
|
|
409
|
+
var isGroupHeader = Boolean(header.column.columns && header.column.columns.length);
|
|
410
|
+
// The FIRST/LAST column under a group is where that
|
|
411
|
+
// group's own block actually begins/ends — that edge
|
|
412
|
+
// gets the heavy boundary, carried down through the data
|
|
413
|
+
// rows too. The group header cell itself spans the whole
|
|
414
|
+
// block by definition (that's what colSpan means), so
|
|
415
|
+
// its own left/right edges are always both boundaries.
|
|
416
|
+
var parentCols = header.column.parent && header.column.parent.columns;
|
|
417
|
+
var isGroupStart = isGroupHeader || (parentCols ? parentCols[0].id === header.column.id : false);
|
|
418
|
+
var isGroupEnd = isGroupHeader || (parentCols ? parentCols[parentCols.length - 1].id === header.column.id : false);
|
|
419
|
+
var colClass = isGroupHeader ? '' : classForColumn(header.column.id);
|
|
420
|
+
var thClass = 'xeplr-table-th' +
|
|
421
|
+
(colClass ? ' ' + colClass : '') +
|
|
422
|
+
(isGroupHeader ? ' xeplr-table-th-group' : '') +
|
|
423
|
+
(isNotLastHeaderRow ? ' xeplr-table-th-row-divider' : '') +
|
|
424
|
+
(isGroupStart ? ' xeplr-table-th-group-start' : '') +
|
|
425
|
+
(isGroupEnd ? ' xeplr-table-th-group-end' : '');
|
|
180
426
|
|
|
181
427
|
return (
|
|
182
|
-
|
|
428
|
+
// colSpan defaults to 1 for a flat leaf column — every
|
|
429
|
+
// table that never nests columns renders exactly as
|
|
430
|
+
// before. It only widens for a GROUP header (a pivoted
|
|
431
|
+
// date spanning the measures under it), which is what
|
|
432
|
+
// TanStack computed it for.
|
|
433
|
+
<th key={header.id} colSpan={header.colSpan} className={thClass}
|
|
434
|
+
// The host's header style sits UNDER `top`, so a
|
|
435
|
+
// sticky header stays stuck whatever it is styled with.
|
|
436
|
+
style={Object.assign(
|
|
437
|
+
{},
|
|
438
|
+
props.headerStyle
|
|
439
|
+
? props.headerStyle({ columnKey: header.column.id, isGroupHeader: isGroupHeader, depth: header.depth })
|
|
440
|
+
: null,
|
|
441
|
+
{ top: stickyTop }
|
|
442
|
+
)}>
|
|
183
443
|
<div className="xeplr-table-header-cell">
|
|
444
|
+
{/* onHeaderClick TAKES THE GESTURE. A host that wants
|
|
445
|
+
the click for something else (selecting a column to
|
|
446
|
+
resize) gets it outright rather than having to fight
|
|
447
|
+
the sort handler — two things on one click is how you
|
|
448
|
+
get a sort nobody asked for on every selection. */}
|
|
184
449
|
<span
|
|
185
|
-
className={'xeplr-table-header-label' +
|
|
186
|
-
|
|
450
|
+
className={'xeplr-table-header-label' +
|
|
451
|
+
(props.onHeaderClick ? ' clickable' : (canSort ? ' sortable' : ''))}
|
|
452
|
+
onClick={props.onHeaderClick
|
|
453
|
+
? function() { props.onHeaderClick(header.column.id, isGroupHeader); }
|
|
454
|
+
: (canSort ? header.column.getToggleSortingHandler() : undefined)}
|
|
187
455
|
>
|
|
188
456
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
189
457
|
{sorted === 'asc' && <span className="xeplr-table-sort-icon"> ▲</span>}
|
|
@@ -195,7 +463,7 @@ export default function XeplrTable(props) {
|
|
|
195
463
|
);
|
|
196
464
|
})}
|
|
197
465
|
{hasRowActions && (
|
|
198
|
-
<th className="xeplr-table-th xeplr-table-th-actions">
|
|
466
|
+
<th className="xeplr-table-th xeplr-table-th-actions" style={{ top: stickyTop }}>
|
|
199
467
|
<div className="xeplr-table-header-cell">
|
|
200
468
|
<span className="xeplr-table-header-label">Actions</span>
|
|
201
469
|
</div>
|
|
@@ -219,6 +487,13 @@ export default function XeplrTable(props) {
|
|
|
219
487
|
var isNew = !!original._tempId;
|
|
220
488
|
|
|
221
489
|
var rowClass = 'xeplr-table-row';
|
|
490
|
+
// Consumer-supplied per-row class. Lets a host mark rows that
|
|
491
|
+
// mean something to IT without this component needing to know
|
|
492
|
+
// what those categories are, or that they exist.
|
|
493
|
+
if (props.rowClassName) {
|
|
494
|
+
var extraClass = props.rowClassName(original);
|
|
495
|
+
if (extraClass) rowClass += ' ' + extraClass;
|
|
496
|
+
}
|
|
222
497
|
if (isSelected) rowClass += ' xeplr-table-row-selected';
|
|
223
498
|
if (isNew) rowClass += ' xeplr-table-row-new';
|
|
224
499
|
if (usePopup) rowClass += ' xeplr-table-row-clickable';
|
|
@@ -248,8 +523,47 @@ export default function XeplrTable(props) {
|
|
|
248
523
|
var cellStyleDef = meta?.cellStyle;
|
|
249
524
|
var condStyle = cellStyleDef
|
|
250
525
|
? resolveCellStyle(cell.getValue(), cellStyleDef, cell.column.id, row.original) : null;
|
|
526
|
+
// Host-supplied styling wins over the schema's conditional
|
|
527
|
+
// formatting: the conditional rule is a default the report
|
|
528
|
+
// author set once, the override is what they just chose.
|
|
529
|
+
var hostStyle = props.cellStyle
|
|
530
|
+
? props.cellStyle({ row: row.original, rowIndex: row.index,
|
|
531
|
+
columnKey: cell.column.id, value: cell.getValue() })
|
|
532
|
+
: null;
|
|
533
|
+
var tdStyle = condStyle || hostStyle
|
|
534
|
+
? Object.assign({}, condStyle, hostStyle) : undefined;
|
|
535
|
+
// Same boundary the header drew for this column — so a
|
|
536
|
+
// group's divider is one continuous line down the table,
|
|
537
|
+
// not a border that stops after the header row.
|
|
538
|
+
var cellParentCols = cell.column.parent && cell.column.parent.columns;
|
|
539
|
+
var tdColClass = classForColumn(cell.column.id);
|
|
540
|
+
var tdClass = 'xeplr-table-td' +
|
|
541
|
+
(tdColClass ? ' ' + tdColClass : '') +
|
|
542
|
+
(cellParentCols && cellParentCols[0].id === cell.column.id ? ' xeplr-table-td-group-start' : '') +
|
|
543
|
+
(cellParentCols && cellParentCols[cellParentCols.length - 1].id === cell.column.id
|
|
544
|
+
? ' xeplr-table-td-group-end' : '');
|
|
251
545
|
return (
|
|
252
|
-
<td
|
|
546
|
+
<td
|
|
547
|
+
key={cell.id}
|
|
548
|
+
className={tdClass}
|
|
549
|
+
style={tdStyle}
|
|
550
|
+
onDoubleClick={cellZoom ? function(e) {
|
|
551
|
+
// Stopped so the row's own double-click handler
|
|
552
|
+
// doesn't also fire — the gesture belongs to the cell.
|
|
553
|
+
e.stopPropagation();
|
|
554
|
+
// The cell's own font travels with it, so the box
|
|
555
|
+
// scales off THIS cell's size rather than the
|
|
556
|
+
// container's — a compact theme zooms proportionally
|
|
557
|
+
// instead of jumping to some fixed size.
|
|
558
|
+
var cellStyle = window.getComputedStyle(e.currentTarget);
|
|
559
|
+
setZoom({
|
|
560
|
+
rowId: row.id,
|
|
561
|
+
columnId: cell.column.id,
|
|
562
|
+
rect: e.currentTarget.getBoundingClientRect(),
|
|
563
|
+
font: { fontSize: cellStyle.fontSize, fontFamily: cellStyle.fontFamily }
|
|
564
|
+
});
|
|
565
|
+
} : undefined}
|
|
566
|
+
>
|
|
253
567
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
254
568
|
</td>
|
|
255
569
|
);
|
|
@@ -299,12 +613,14 @@ export default function XeplrTable(props) {
|
|
|
299
613
|
<button type="button" onClick={function() { table.setPageIndex(table.getPageCount() - 1); }} disabled={!table.getCanNextPage()}>»»</button>
|
|
300
614
|
<select className="xeplr-table-page-size" value={table.getState().pagination.pageSize}
|
|
301
615
|
onChange={function(e) { table.setPageSize(Number(e.target.value)); }}>
|
|
302
|
-
{[
|
|
616
|
+
{[100, 200, 500, 1000].map(function(size) { return <option key={size} value={size}>{size} rows</option>; })}
|
|
303
617
|
</select>
|
|
304
618
|
<span className="xeplr-table-row-count">({table.getFilteredRowModel().rows.length} total)</span>
|
|
305
619
|
</div>
|
|
306
620
|
)}
|
|
307
621
|
|
|
622
|
+
{zoomCell && <CellZoom cell={zoomCell} anchorRect={zoom.rect} font={zoom.font} onClose={closeZoom} />}
|
|
623
|
+
|
|
308
624
|
{/* Detail popup (childDisplay === 'popup') */}
|
|
309
625
|
{actions.detailRow && usePopup && (
|
|
310
626
|
<RecordDetail
|