@xeplr/ui-table 1.0.0 → 1.0.2
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/README.md +151 -0
- package/package.json +21 -5
- package/src/CellZoom.jsx +77 -0
- package/src/XeplrTable.jsx +349 -25
- package/src/actions/ActionsCell.jsx +36 -1
- package/src/columnWidths.js +333 -0
- package/src/filters/FilterWrapper.jsx +79 -30
- package/src/index.js +18 -0
- package/src/renderers/_helpers.js +43 -0
- package/src/renderers/avatarName.jsx +31 -0
- package/src/renderers/currency.jsx +25 -0
- package/src/renderers/dateDisplay.jsx +38 -0
- package/src/renderers/index.js +22 -0
- package/src/renderers/link.jsx +24 -0
- package/src/renderers/memberChips.jsx +35 -0
- package/src/renderers/statusBadge.jsx +15 -0
- package/src/renderers/tags.jsx +23 -0
- package/src/renderers/twoLine.jsx +16 -0
- package/src/tableStyles.js +236 -0
- package/src/useColumnWidths.js +280 -0
- package/src/useTableController.js +74 -7
- package/src/xeplr-table.css +399 -253
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,10 +39,98 @@ 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
|
|
129
|
+
* @param {Array<{key: string, label: string, icon?: any, onClick: (row) => void,
|
|
130
|
+
* visible?: (row) => boolean, disabled?: (row) => boolean, variant?: string}>} [props.rowActions]
|
|
131
|
+
* - Custom per-row action buttons (e.g. Rollback, Delete), replacing the built-in
|
|
132
|
+
* view/copy/edit/delete set. Fires immediately via each action's onClick — works
|
|
133
|
+
* standalone, without onCommit.
|
|
44
134
|
*/
|
|
45
135
|
export default function XeplrTable(props) {
|
|
46
136
|
var schema = props.schema || {};
|
|
@@ -65,16 +155,50 @@ export default function XeplrTable(props) {
|
|
|
65
155
|
minDetectionRows: props.minDetectionRows,
|
|
66
156
|
pageSize: props.pageSize,
|
|
67
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,
|
|
68
163
|
enableFiltering: props.enableFiltering,
|
|
69
164
|
enablePagination: props.enablePagination
|
|
70
165
|
});
|
|
71
166
|
|
|
72
167
|
var enableFiltering = props.enableFiltering !== false;
|
|
73
168
|
var enablePagination = props.enablePagination !== false;
|
|
74
|
-
|
|
75
169
|
var headerGroups = table.getHeaderGroups();
|
|
76
170
|
var rows = table.getRowModel().rows;
|
|
77
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
|
+
|
|
78
202
|
function getRowId(row) {
|
|
79
203
|
return row._tempId || row.id;
|
|
80
204
|
}
|
|
@@ -85,10 +209,59 @@ export default function XeplrTable(props) {
|
|
|
85
209
|
// Show expand column only in inner mode
|
|
86
210
|
var showExpandCol = hasChildren && useInner;
|
|
87
211
|
|
|
212
|
+
var hasRowActions = actions.hasActions || !!(props.rowActions && props.rowActions.length > 0);
|
|
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
|
+
|
|
88
261
|
var totalColumns = headerGroups[0]?.headers.length || 1;
|
|
89
262
|
if (showExpandCol) totalColumns++;
|
|
90
263
|
if (actions.hasDelete) totalColumns++;
|
|
91
|
-
if (
|
|
264
|
+
if (hasRowActions) totalColumns++;
|
|
92
265
|
|
|
93
266
|
// Resolve modal schema columns for form hints
|
|
94
267
|
var modalSchemaColumns = columns;
|
|
@@ -107,6 +280,16 @@ export default function XeplrTable(props) {
|
|
|
107
280
|
}
|
|
108
281
|
}
|
|
109
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
|
+
|
|
110
293
|
return (
|
|
111
294
|
<div className={'xeplr-table-container' + (props.className ? ' ' + props.className : '')}>
|
|
112
295
|
<div className="xeplr-table-toolbar">
|
|
@@ -140,26 +323,70 @@ export default function XeplrTable(props) {
|
|
|
140
323
|
</button>
|
|
141
324
|
</div>
|
|
142
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
|
+
|
|
143
331
|
<div className="xeplr-table-toolbar-spacer" />
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
)}
|
|
152
353
|
</div>
|
|
153
354
|
|
|
154
|
-
<div className="xeplr-table-scroll">
|
|
155
|
-
|
|
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
|
+
)}
|
|
156
375
|
<thead>
|
|
157
|
-
{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;
|
|
158
385
|
return (
|
|
159
|
-
<tr key={headerGroup.id}>
|
|
160
|
-
{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 }} />}
|
|
161
388
|
{actions.hasDelete && (
|
|
162
|
-
<th className="xeplr-table-th xeplr-table-th-checkbox">
|
|
389
|
+
<th className="xeplr-table-th xeplr-table-th-checkbox" style={{ top: stickyTop }}>
|
|
163
390
|
<input type="checkbox" className="xeplr-table-checkbox" checked={allSelected}
|
|
164
391
|
onChange={function() { actions.toggleSelectAll(visibleIds); }} />
|
|
165
392
|
</th>
|
|
@@ -170,13 +397,61 @@ export default function XeplrTable(props) {
|
|
|
170
397
|
var dataType = header.column.columnDef.meta?.dataType || TYPES.STRING;
|
|
171
398
|
var FilterComponent = filterComponentMap[dataType];
|
|
172
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' : '');
|
|
173
426
|
|
|
174
427
|
return (
|
|
175
|
-
|
|
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
|
+
)}>
|
|
176
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. */}
|
|
177
449
|
<span
|
|
178
|
-
className={'xeplr-table-header-label' +
|
|
179
|
-
|
|
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)}
|
|
180
455
|
>
|
|
181
456
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
182
457
|
{sorted === 'asc' && <span className="xeplr-table-sort-icon"> ▲</span>}
|
|
@@ -187,8 +462,8 @@ export default function XeplrTable(props) {
|
|
|
187
462
|
</th>
|
|
188
463
|
);
|
|
189
464
|
})}
|
|
190
|
-
{
|
|
191
|
-
<th className="xeplr-table-th xeplr-table-th-actions">
|
|
465
|
+
{hasRowActions && (
|
|
466
|
+
<th className="xeplr-table-th xeplr-table-th-actions" style={{ top: stickyTop }}>
|
|
192
467
|
<div className="xeplr-table-header-cell">
|
|
193
468
|
<span className="xeplr-table-header-label">Actions</span>
|
|
194
469
|
</div>
|
|
@@ -212,6 +487,13 @@ export default function XeplrTable(props) {
|
|
|
212
487
|
var isNew = !!original._tempId;
|
|
213
488
|
|
|
214
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
|
+
}
|
|
215
497
|
if (isSelected) rowClass += ' xeplr-table-row-selected';
|
|
216
498
|
if (isNew) rowClass += ' xeplr-table-row-new';
|
|
217
499
|
if (usePopup) rowClass += ' xeplr-table-row-clickable';
|
|
@@ -241,15 +523,55 @@ export default function XeplrTable(props) {
|
|
|
241
523
|
var cellStyleDef = meta?.cellStyle;
|
|
242
524
|
var condStyle = cellStyleDef
|
|
243
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' : '');
|
|
244
545
|
return (
|
|
245
|
-
<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
|
+
>
|
|
246
567
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
247
568
|
</td>
|
|
248
569
|
);
|
|
249
570
|
})}
|
|
250
|
-
{
|
|
571
|
+
{hasRowActions && (
|
|
251
572
|
<td className="xeplr-table-td xeplr-table-td-actions">
|
|
252
573
|
<ActionsCell row={original} hasSave={actions.hasSave} hasDelete={actions.hasDelete}
|
|
574
|
+
rowActions={props.rowActions}
|
|
253
575
|
onView={function() { usePopup ? actions.openDetail(original) : actions.openView(original); }}
|
|
254
576
|
onCopy={actions.openCopy} onEdit={actions.openEdit}
|
|
255
577
|
onDelete={function() { actions.handleDeleteRow(rowId); }} />
|
|
@@ -291,12 +613,14 @@ export default function XeplrTable(props) {
|
|
|
291
613
|
<button type="button" onClick={function() { table.setPageIndex(table.getPageCount() - 1); }} disabled={!table.getCanNextPage()}>»»</button>
|
|
292
614
|
<select className="xeplr-table-page-size" value={table.getState().pagination.pageSize}
|
|
293
615
|
onChange={function(e) { table.setPageSize(Number(e.target.value)); }}>
|
|
294
|
-
{[
|
|
616
|
+
{[100, 200, 500, 1000].map(function(size) { return <option key={size} value={size}>{size} rows</option>; })}
|
|
295
617
|
</select>
|
|
296
618
|
<span className="xeplr-table-row-count">({table.getFilteredRowModel().rows.length} total)</span>
|
|
297
619
|
</div>
|
|
298
620
|
)}
|
|
299
621
|
|
|
622
|
+
{zoomCell && <CellZoom cell={zoomCell} anchorRect={zoom.rect} font={zoom.font} onClose={closeZoom} />}
|
|
623
|
+
|
|
300
624
|
{/* Detail popup (childDisplay === 'popup') */}
|
|
301
625
|
{actions.detailRow && usePopup && (
|
|
302
626
|
<RecordDetail
|
|
@@ -2,7 +2,15 @@ import React from 'react';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Actions cell rendered in the last column of each row.
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
|
+
* Two modes:
|
|
7
|
+
* - props.rowActions given: renders exactly those custom actions, firing
|
|
8
|
+
* immediately via each action's own onClick (no staged CRUD queue).
|
|
9
|
+
* - otherwise: the built-in set — view (always), copy, edit (if onSave),
|
|
10
|
+
* delete (if onDelete) — driven by the table's onCommit staged queue.
|
|
11
|
+
*
|
|
12
|
+
* @param {Array<{key: string, label: string, icon?: any, onClick: (row) => void,
|
|
13
|
+
* visible?: (row) => boolean, disabled?: (row) => boolean, variant?: string}>} [props.rowActions]
|
|
6
14
|
*/
|
|
7
15
|
export default function ActionsCell(props) {
|
|
8
16
|
var row = props.row;
|
|
@@ -12,6 +20,33 @@ export default function ActionsCell(props) {
|
|
|
12
20
|
var onCopy = props.onCopy;
|
|
13
21
|
var onEdit = props.onEdit;
|
|
14
22
|
var onDelete = props.onDelete;
|
|
23
|
+
var rowActions = props.rowActions;
|
|
24
|
+
|
|
25
|
+
if (rowActions && rowActions.length > 0) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="xeplr-table-actions-cell">
|
|
28
|
+
{rowActions.map(function(action) {
|
|
29
|
+
if (action.visible && !action.visible(row)) return null;
|
|
30
|
+
var disabled = action.disabled ? action.disabled(row) : false;
|
|
31
|
+
var className = 'xeplr-table-action-btn xeplr-table-action-' + action.key
|
|
32
|
+
+ (action.icon ? '' : ' xeplr-table-action-label')
|
|
33
|
+
+ (action.variant ? ' xeplr-table-action-' + action.variant : '');
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
key={action.key}
|
|
37
|
+
type="button"
|
|
38
|
+
className={className}
|
|
39
|
+
title={action.label}
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
onClick={function() { action.onClick(row); }}
|
|
42
|
+
>
|
|
43
|
+
{action.icon || action.label}
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
15
50
|
|
|
16
51
|
return (
|
|
17
52
|
<div className="xeplr-table-actions-cell">
|