@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.
@@ -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 (actions.hasActions) totalColumns++;
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
- {enableFiltering && (
145
- <button type="button" className="xeplr-table-toolbar-btn" onClick={resetFilters}>
146
- Clear Filters
147
- </button>
148
- )}
149
- <button type="button" className="xeplr-table-toolbar-btn" onClick={resetSorting}>
150
- Clear Sort
151
- </button>
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
- <table className="xeplr-table">
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
- <th key={header.id} className="xeplr-table-th">
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' + (canSort ? ' sortable' : '')}
179
- onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
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
- {actions.hasActions && (
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 key={cell.id} className="xeplr-table-td" style={condStyle}>
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
- {actions.hasActions && (
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
- {[10, 20, 50, 100].map(function(size) { return <option key={size} value={size}>{size} rows</option>; })}
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
- * Shows view (always), copy, edit (if onSave), delete (if onDelete).
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">