@xeplr/ui-table 1.0.1 → 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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The table's style vocabulary, and the rule for resolving it.
3
+ *
4
+ * This is the "clear interface" the whole styling feature hangs off: one
5
+ * registry naming every style property a table cell can take, and one pure
6
+ * function that decides which layer's value wins. No React, no DOM, no
7
+ * persistence — a host supplies layers (theme defaults, workspace theme,
8
+ * widget theme, per-column/row/cell overrides) and gets back a CSS object.
9
+ *
10
+ * THREE THINGS WORTH KNOWING BEFORE CHANGING ANY OF THIS:
11
+ *
12
+ * 1. Properties are addressed by PATH, not name — 'border.top.width', not
13
+ * 'borderWidth'. Merging happens per leaf path, which is what stops a
14
+ * column setting border.bottom.color from wiping a cell's border.top.width.
15
+ *
16
+ * 2. `border.all` is expanded into the four sides at FLATTEN time, inside its
17
+ * own layer. That ordering is load-bearing. If it were expanded after
18
+ * merging, a theme that set border.top.width would survive a column that
19
+ * set border.all.width — the user would set the column's border to thin
20
+ * and watch one edge stay thick.
21
+ *
22
+ * 3. `margin` is deliberately absent. A <td> is not a block box and discards
23
+ * margin entirely; offering the control would be offering a lie. Margin
24
+ * belongs to the table as a whole, which a host lays out itself.
25
+ */
26
+
27
+ var LENGTH_UNITS = ['px', 'em', 'rem'];
28
+
29
+ var FONT_FAMILY_OPTIONS = [
30
+ ['inherit', 'Inherit'], ['sans-serif', 'Sans'], ['serif', 'Serif'], ['monospace', 'Mono']
31
+ ];
32
+ var FONT_WEIGHT_OPTIONS = [['400', 'Regular'], ['500', 'Medium'], ['600', 'Semibold'], ['700', 'Bold']];
33
+ var BORDER_STYLE_OPTIONS = [
34
+ ['none', 'None'], ['solid', 'Solid'], ['dashed', 'Dashed'], ['dotted', 'Dotted'], ['double', 'Double']
35
+ ];
36
+
37
+ var TEXT_FIELDS = [
38
+ { key: 'text.color', label: 'Text color', type: 'color', css: 'color' },
39
+ { key: 'text.fontFamily', label: 'Font', type: 'select', css: 'fontFamily', options: FONT_FAMILY_OPTIONS },
40
+ { key: 'text.fontSize', label: 'Size', type: 'length', css: 'fontSize', units: LENGTH_UNITS },
41
+ { key: 'text.fontWeight', label: 'Weight', type: 'select', css: 'fontWeight', options: FONT_WEIGHT_OPTIONS },
42
+ { key: 'text.fontStyle', label: 'Style', type: 'select', css: 'fontStyle', options: [['normal', 'Normal'], ['italic', 'Italic']] },
43
+ { key: 'text.textAlign', label: 'Align', type: 'select', css: 'textAlign', options: [['left', 'Left'], ['center', 'Center'], ['right', 'Right'], ['justify', 'Justify']] },
44
+ { key: 'text.verticalAlign', label: 'Vertical', type: 'select', css: 'verticalAlign', options: [['top', 'Top'], ['middle', 'Middle'], ['bottom', 'Bottom']] },
45
+ { key: 'text.textTransform', label: 'Case', type: 'select', css: 'textTransform', options: [['none', 'None'], ['uppercase', 'UPPER'], ['lowercase', 'lower'], ['capitalize', 'Capitalize']] },
46
+ { key: 'text.textDecoration', label: 'Decoration', type: 'select', css: 'textDecoration', options: [['none', 'None'], ['underline', 'Underline'], ['line-through', 'Strikethrough']] },
47
+ { key: 'text.lineHeight', label: 'Line height', type: 'number', css: 'lineHeight', min: 0.8, max: 3, step: 0.1 },
48
+ { key: 'text.letterSpacing', label: 'Letter spacing', type: 'length', css: 'letterSpacing', units: LENGTH_UNITS },
49
+ // Wrapping is a style choice a report designer genuinely makes per column —
50
+ // a code column reads better on one line even if it clips.
51
+ { key: 'text.whiteSpace', label: 'Wrapping', type: 'select', css: 'whiteSpace', options: [['normal', 'Wrap'], ['nowrap', 'No wrap'], ['pre-wrap', 'Preserve']] }
52
+ ];
53
+
54
+ var FILL_FIELDS = [
55
+ { key: 'fill.background', label: 'Background', type: 'color', css: 'background' }
56
+ ];
57
+
58
+ var BOX_FIELDS = [
59
+ { key: 'box.padding.top', label: 'Padding top', type: 'length', css: 'paddingTop', units: LENGTH_UNITS },
60
+ { key: 'box.padding.right', label: 'Padding right', type: 'length', css: 'paddingRight', units: LENGTH_UNITS },
61
+ { key: 'box.padding.bottom', label: 'Padding bottom', type: 'length', css: 'paddingBottom', units: LENGTH_UNITS },
62
+ { key: 'box.padding.left', label: 'Padding left', type: 'length', css: 'paddingLeft', units: LENGTH_UNITS },
63
+ { key: 'box.height', label: 'Height', type: 'length', css: 'height', units: LENGTH_UNITS }
64
+ ];
65
+
66
+ /** 'all' is a shorthand the user edits; it never reaches CSS (see note 2). */
67
+ export var BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
68
+
69
+ function borderFieldsFor(side) {
70
+ var prefix = 'border.' + side + '.';
71
+ var cap = side === 'all' ? null : side.charAt(0).toUpperCase() + side.slice(1);
72
+ return [
73
+ { key: prefix + 'width', label: 'Width', type: 'length', units: LENGTH_UNITS, css: cap && 'border' + cap + 'Width', side: side },
74
+ { key: prefix + 'style', label: 'Style', type: 'select', options: BORDER_STYLE_OPTIONS, css: cap && 'border' + cap + 'Style', side: side },
75
+ { key: prefix + 'color', label: 'Color', type: 'color', css: cap && 'border' + cap + 'Color', side: side }
76
+ ];
77
+ }
78
+
79
+ var BORDER_FIELDS = [{ key: 'border.radius', label: 'Radius', type: 'length', css: 'borderRadius', units: LENGTH_UNITS }]
80
+ .concat(borderFieldsFor('all'))
81
+ .concat(BORDER_SIDES.reduce(function(acc, side) { return acc.concat(borderFieldsFor(side)); }, []));
82
+
83
+ /**
84
+ * The whole vocabulary, grouped for panel generation. A host builds its panel
85
+ * from this and nothing else — adding a stylable property means adding one
86
+ * entry here, and it appears everywhere with no other change.
87
+ */
88
+ export var STYLE_GROUPS = [
89
+ { key: 'text', label: 'Text', fields: TEXT_FIELDS },
90
+ { key: 'fill', label: 'Fill', fields: FILL_FIELDS },
91
+ { key: 'box', label: 'Spacing & size', fields: BOX_FIELDS },
92
+ // Sides are collapsed behind an expander in the UI: 'all' by default, with
93
+ // each side openable to override just that edge.
94
+ { key: 'border', label: 'Border', fields: BORDER_FIELDS, expandable: BORDER_SIDES }
95
+ ];
96
+
97
+ export var STYLE_FIELDS = STYLE_GROUPS.reduce(function(acc, g) { return acc.concat(g.fields); }, []);
98
+
99
+ var FIELD_BY_PATH = {};
100
+ STYLE_FIELDS.forEach(function(f) { FIELD_BY_PATH[f.key] = f; });
101
+
102
+ export function styleField(path) { return FIELD_BY_PATH[path] || null; }
103
+
104
+ // ── Values ────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * A length is stored as { value, unit } so the unit survives a round trip and
108
+ * the panel can show it. Bare numbers mean px; strings pass through untouched
109
+ * (so a host can store 'auto' or a var() reference).
110
+ */
111
+ export function cssLength(value) {
112
+ if (value === null || value === undefined || value === '') return null;
113
+ if (typeof value === 'number') return value + 'px';
114
+ if (typeof value === 'string') return value;
115
+ if (typeof value.value !== 'number' && !value.value) return null;
116
+ return value.value + (value.unit || 'px');
117
+ }
118
+
119
+ // ── Flatten ───────────────────────────────────────────────────────────────
120
+
121
+ function walk(node, prefix, out) {
122
+ Object.keys(node).forEach(function(key) {
123
+ var value = node[key];
124
+ var path = prefix ? prefix + '.' + key : key;
125
+ // A {value, unit} pair is a leaf, not a branch to descend into.
126
+ var isLeaf = value === null || typeof value !== 'object' ||
127
+ Object.prototype.hasOwnProperty.call(value, 'value');
128
+ if (isLeaf) out[path] = value;
129
+ else walk(value, path, out);
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Nested style object -> flat { path: value }, with border.all expanded into
135
+ * the four sides. An explicit side inside the SAME object beats 'all'; see
136
+ * note 2 at the top for why that expansion has to happen here.
137
+ */
138
+ export function flattenStyle(style) {
139
+ var flat = {};
140
+ if (!style) return flat;
141
+ walk(style, '', flat);
142
+
143
+ ['width', 'style', 'color'].forEach(function(part) {
144
+ var all = flat['border.all.' + part];
145
+ if (all === undefined) return;
146
+ BORDER_SIDES.forEach(function(side) {
147
+ var path = 'border.' + side + '.' + part;
148
+ if (flat[path] === undefined) flat[path] = all;
149
+ });
150
+ });
151
+ delete flat['border.all.width'];
152
+ delete flat['border.all.style'];
153
+ delete flat['border.all.color'];
154
+ return flat;
155
+ }
156
+
157
+ // ── Resolve ───────────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Decide, per property, which layer wins.
161
+ *
162
+ * @param {Array<{id, style, rank?, at?}>} layers - Low precedence first.
163
+ * `rank` defaults to array position. Layers that genuinely compete — a cell,
164
+ * row and column override all landing on the same cell — share one rank and
165
+ * are separated by `at` (a timestamp), so the most recent edit wins.
166
+ *
167
+ * The timestamp is a TIEBREAK, not a winner-takes-all: it is consulted per
168
+ * property. A column setting a background and a cell setting a font weight
169
+ * both apply, whichever was edited last. Only when both set the SAME
170
+ * property does recency decide. Whole-record precedence would mean
171
+ * restyling a column silently discarded deliberate per-cell work.
172
+ *
173
+ * @returns {object} { path: { value, source, at } }
174
+ */
175
+ export function resolveStyle(layers) {
176
+ var resolved = {};
177
+ (layers || []).forEach(function(layer, index) {
178
+ if (!layer || !layer.style) return;
179
+ var rank = layer.rank === undefined ? index : layer.rank;
180
+ var at = layer.at === undefined ? null : layer.at;
181
+ var flat = flattenStyle(layer.style);
182
+
183
+ Object.keys(flat).forEach(function(path) {
184
+ var value = flat[path];
185
+ if (value === null || value === undefined || value === '') return;
186
+ var held = resolved[path];
187
+ if (held) {
188
+ if (rank < held.rank) return;
189
+ if (rank === held.rank && at !== null && held.at !== null && at < held.at) return;
190
+ if (rank === held.rank && at === null && held.at !== null) return;
191
+ }
192
+ resolved[path] = { value: value, source: layer.id, at: at, rank: rank };
193
+ });
194
+ });
195
+ return resolved;
196
+ }
197
+
198
+ // ── Emit ──────────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Resolved paths -> a React style object.
202
+ *
203
+ * Accepts either the output of resolveStyle or a plain { path: value } map,
204
+ * so a caller with a single style object can skip resolution entirely.
205
+ */
206
+ export function toCssProperties(resolved) {
207
+ var css = {};
208
+ if (!resolved) return css;
209
+
210
+ Object.keys(resolved).forEach(function(path) {
211
+ var field = FIELD_BY_PATH[path];
212
+ if (!field || !field.css) return; // unknown path, or border.all — never emitted
213
+ var entry = resolved[path];
214
+ var value = entry && typeof entry === 'object' && Object.prototype.hasOwnProperty.call(entry, 'source')
215
+ ? entry.value : entry;
216
+ if (value === null || value === undefined || value === '') return;
217
+ css[field.css] = field.type === 'length' ? cssLength(value) : value;
218
+ });
219
+
220
+ // A border width with no style renders nothing at all — CSS defaults
221
+ // border-style to none. Someone setting "3px" and seeing no border would
222
+ // reasonably call that broken, so an unstated style means solid.
223
+ BORDER_SIDES.forEach(function(side) {
224
+ var cap = side.charAt(0).toUpperCase() + side.slice(1);
225
+ if (css['border' + cap + 'Width'] && !css['border' + cap + 'Style']) {
226
+ css['border' + cap + 'Style'] = 'solid';
227
+ }
228
+ });
229
+
230
+ return css;
231
+ }
232
+
233
+ /** The common case: layers in, CSS out. */
234
+ export function resolveCellCss(layers) {
235
+ return toCssProperties(resolveStyle(layers));
236
+ }
@@ -0,0 +1,280 @@
1
+ import { useState, useRef, useLayoutEffect, useCallback } from 'react';
2
+ import { measureColumnDemand, allocateColumnWidths, resolvePercentWidths } from './columnWidths.js';
3
+
4
+ /**
5
+ * Content-based column widths — the DOM half. See columnWidths.js for the
6
+ * allocation rule itself; this file only supplies it with real numbers.
7
+ *
8
+ * "Use the font scale" is handled by measuring rather than configuring: the
9
+ * font is read off an actual rendered <td>/<th>, so if a host shrinks the type
10
+ * (a compact theme, say) every column narrows to match with no width setting
11
+ * anywhere. The same goes for cell padding — it's read from the DOM, not
12
+ * duplicated as a constant that would drift the first time the CSS changes.
13
+ */
14
+
15
+ // Sentinel handed to the pure measurer in place of a font string, meaning
16
+ // "whichever of the body's fonts renders this widest" — see measureWidest.
17
+ var BODY_FONTS = '@@body-fonts@@';
18
+ var MAX_BODY_FONTS = 3;
19
+
20
+ var DEFAULTS = {
21
+ sampleRows: 200,
22
+ minWidth: 40,
23
+ maxWidth: 420,
24
+ maxFloor: 220
25
+ };
26
+
27
+ // Canvas measureText is exact for proportional fonts and costs microseconds.
28
+ // One canvas is shared across every table on the page; it's never attached to
29
+ // the document, so it holds a single backing store regardless of table count.
30
+ var sharedCanvas = null;
31
+ function textMeasurer() {
32
+ if (typeof document === 'undefined') return null;
33
+ if (!sharedCanvas) sharedCanvas = document.createElement('canvas');
34
+ var ctx = sharedCanvas.getContext('2d');
35
+ if (!ctx) return null;
36
+ var cache = new Map();
37
+ return function measure(text, font) {
38
+ if (!text) return 0;
39
+ var cacheKey = font + '' + text;
40
+ var hit = cache.get(cacheKey);
41
+ if (hit !== undefined) return hit;
42
+ ctx.font = font;
43
+ var width = ctx.measureText(text).width;
44
+ cache.set(cacheKey, width);
45
+ return width;
46
+ };
47
+ }
48
+
49
+ function fontOf(style) {
50
+ return (style.fontStyle && style.fontStyle !== 'normal' ? style.fontStyle + ' ' : '') +
51
+ (style.fontWeight || 400) + ' ' +
52
+ (style.fontSize || '14px') + ' ' +
53
+ (style.fontFamily || 'sans-serif');
54
+ }
55
+
56
+ /**
57
+ * Hosts routinely rebuild their column array inline on every render, so the
58
+ * effect below cannot use array identity to decide whether to re-measure —
59
+ * it would re-measure forever. This is the real question: has anything that
60
+ * could change a width changed?
61
+ *
62
+ * Cell FORMATTING is deliberately not part of this (the host's columnText is
63
+ * usually a fresh closure each render too). Hosts signal that with sizingKey.
64
+ */
65
+ function signatureOf(columns, containerWidth, sizingKey, overrides) {
66
+ var parts = [containerWidth, columns.length];
67
+ for (var i = 0; i < columns.length; i++) {
68
+ parts.push(columns[i].accessor, columns[i].header || '');
69
+ }
70
+ parts.push(typeof sizingKey === 'object' ? JSON.stringify(sizingKey) : String(sizingKey));
71
+ // Overrides MUST be in here. They change widths, and this signature is the
72
+ // only thing deciding whether the effect below does anything at all — leave
73
+ // them out and setting a width is a no-op with no error and no clue.
74
+ // Stringified rather than identity-compared because a host will rebuild the
75
+ // object inline, exactly as it does the column array.
76
+ parts.push(overrides ? JSON.stringify(overrides) : '');
77
+ return parts.join('');
78
+ }
79
+
80
+ /** Widths that came out identical must not be published — that's a render loop. */
81
+ function sameSizing(a, b) {
82
+ if (!a || !b || a.tableWidth !== b.tableWidth || a.widths.length !== b.widths.length) return false;
83
+ for (var i = 0; i < a.widths.length; i++) {
84
+ if (a.widths[i] !== b.widths[i]) return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function horizontalPadding(el) {
90
+ if (!el) return 0;
91
+ var style = window.getComputedStyle(el);
92
+ return parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0) +
93
+ parseFloat(style.borderLeftWidth || 0) + parseFloat(style.borderRightWidth || 0);
94
+ }
95
+
96
+ /**
97
+ * @param {object} opts
98
+ * @param {boolean} opts.enabled - Off by default; hosts opt in
99
+ * @param {Array} opts.data - Rows to sample. The FULL set, not the
100
+ * current page: widths measured per page would jump every time you paged.
101
+ * @param {Array} opts.columns - [{ accessor, header, ... }]
102
+ * @param {Function} [opts.columnText] - (row, column) => displayed string.
103
+ * Defaults to the raw value; hosts that format their cells should pass their
104
+ * formatter, or currency/date columns get measured as bare numbers.
105
+ * @param {*} [opts.sizingKey] - Changing this re-measures. For font or
106
+ * theme changes, which nothing else can observe.
107
+ * @param {object} [opts.columnWidths] - { columnIndex: percent }. Those
108
+ * columns are held at that share of the available width and never measured;
109
+ * everything else divides what's left. PERCENT, not px, so a pin keeps its
110
+ * proportion when the container resizes. BY INDEX, not by key — see
111
+ * pinnedWidth() in columnWidths.js.
112
+ * @param {Function} [opts.onSizing] - Called with the full decision record.
113
+ * @returns {{ scrollRef, tableRef, sizing }}
114
+ */
115
+ export default function useColumnWidths(opts) {
116
+ var enabled = !!opts.enabled;
117
+ var data = opts.data;
118
+ var columns = opts.columns;
119
+ var sizingKey = opts.sizingKey;
120
+ var onSizing = opts.onSizing;
121
+ var sampleRows = opts.sampleRows || DEFAULTS.sampleRows;
122
+ var overrides = opts.columnWidths;
123
+
124
+ var scrollRef = useRef(null);
125
+ var tableRef = useRef(null);
126
+ var lastWidthRef = useRef(0);
127
+ var signatureRef = useRef(null);
128
+ var dataRef = useRef(null);
129
+ var sizingRef = useRef(null);
130
+ var [containerWidth, setContainerWidth] = useState(0);
131
+ var [sizing, setSizing] = useState(null);
132
+
133
+ var columnText = opts.columnText;
134
+ var textOf = useCallback(function(row, column) {
135
+ if (columnText) return columnText(row, column);
136
+ var value = row ? row[column.accessor] : null;
137
+ return value === null || value === undefined ? '' : String(value);
138
+ }, [columnText]);
139
+
140
+ // ── Track the available width ──
141
+ // clientWidth of the scroll port, deliberately: overflow-x means a horizontal
142
+ // scrollbar eats height, not width, so this number can't oscillate with the
143
+ // scrollbar it produces.
144
+ useLayoutEffect(function() {
145
+ if (!enabled) return undefined;
146
+ var el = scrollRef.current;
147
+ if (!el || typeof ResizeObserver === 'undefined') return undefined;
148
+
149
+ function read() {
150
+ var width = el.clientWidth;
151
+ // Sub-pixel churn from zoom/fractional layout would otherwise re-run the
152
+ // whole measure pass on every frame during a window drag.
153
+ if (Math.abs(width - lastWidthRef.current) < 2) return;
154
+ lastWidthRef.current = width;
155
+ setContainerWidth(width);
156
+ }
157
+
158
+ read();
159
+ var observer = new ResizeObserver(read);
160
+ observer.observe(el);
161
+ return function() { observer.disconnect(); };
162
+ }, [enabled]);
163
+
164
+ // ── Measure and allocate ──
165
+ useLayoutEffect(function() {
166
+ if (!enabled || !containerWidth || !columns || !columns.length) {
167
+ if (sizingRef.current) { sizingRef.current = null; setSizing(null); }
168
+ return;
169
+ }
170
+ var measure = textMeasurer();
171
+ var table = tableRef.current;
172
+ if (!measure || !table) return;
173
+
174
+ // Cheap bail-out before the expensive part: measuring 200 rows across
175
+ // every column on a render that changed nothing is pure waste.
176
+ var signature = signatureOf(columns, containerWidth, sizingKey, overrides);
177
+ if (signature === signatureRef.current && data === dataRef.current && sizingRef.current) return;
178
+ signatureRef.current = signature;
179
+ dataRef.current = data;
180
+
181
+ // Fonts and padding come from real cells so this stays in step with the
182
+ // stylesheet automatically. Before the first row renders there's no <td>,
183
+ // so the header cell stands in — one pass slightly off, corrected as soon
184
+ // as rows arrive and the effect re-runs.
185
+ var bodyCell = table.querySelector('tbody .xeplr-table-td');
186
+ var headCell = table.querySelector('thead .xeplr-table-header-cell');
187
+ var headerFont = fontOf(window.getComputedStyle(headCell || bodyCell || table));
188
+
189
+ // Rows are not guaranteed to share a font. A host that styles a total row
190
+ // bold, or bands alternate rows differently, makes some rows wider than
191
+ // others at the same character count — sizing off row one alone would
192
+ // leave exactly those rows wrapping. So collect the distinct fonts in play
193
+ // and give every column enough width for the widest of them.
194
+ var bodyFonts = [];
195
+ var sampleCells = table.querySelectorAll('tbody tr .xeplr-table-td:first-child');
196
+ for (var s = 0; s < sampleCells.length && bodyFonts.length < MAX_BODY_FONTS; s++) {
197
+ var font = fontOf(window.getComputedStyle(sampleCells[s]));
198
+ if (bodyFonts.indexOf(font) === -1) bodyFonts.push(font);
199
+ }
200
+ if (!bodyFonts.length) bodyFonts.push(headerFont);
201
+
202
+ // measureColumnDemand deals in one font per call, which keeps it pure and
203
+ // trivially testable; the multi-font case is resolved here instead.
204
+ var measureWidest = function(text, font) {
205
+ if (font !== BODY_FONTS) return measure(text, font);
206
+ var widest = 0;
207
+ for (var f = 0; f < bodyFonts.length; f++) {
208
+ var width = measure(text, bodyFonts[f]);
209
+ if (width > widest) widest = width;
210
+ }
211
+ return widest;
212
+ };
213
+
214
+ // Header chrome: the label shares its row with the sort caret and the
215
+ // filter trigger, so the text needs less room than the cell does.
216
+ var headerPadding = horizontalPadding(headCell);
217
+ var trigger = table.querySelector('thead .xeplr-table-filter-trigger');
218
+ if (trigger) headerPadding += trigger.offsetWidth + 4;
219
+ else headerPadding += 16; // sort caret
220
+
221
+ // The table's own structural columns — expand arrow, select checkbox, row
222
+ // actions — aren't content columns and don't compete for space. Their
223
+ // width is reserved off the top, so the content columns divide what's
224
+ // actually left. Read from the DOM because two of them are sized in CSS
225
+ // and the third shrink-wraps whatever buttons the host supplied.
226
+ var extraWidths = {};
227
+ var reserved = 0;
228
+ ['expand', 'checkbox', 'actions'].forEach(function(kind) {
229
+ var el = table.querySelector('thead .xeplr-table-th-' + kind);
230
+ if (!el) return;
231
+ var width = Math.ceil(el.offsetWidth);
232
+ extraWidths[kind] = width;
233
+ reserved += width;
234
+ });
235
+
236
+ var rows = data && data.length > sampleRows ? data.slice(0, sampleRows) : (data || []);
237
+
238
+ // Percentages resolve against `available` — the container minus the
239
+ // structural columns — so 100 means "all the room the columns have".
240
+ // The conversion itself is pure and lives in columnWidths.js; this is
241
+ // just the only place that knows what `available` is.
242
+ //
243
+ // A percentage is also why this survives a resize: containerWidth is in
244
+ // the signature, so the effect re-runs and a pinned column re-resolves to
245
+ // the right px rather than staying at yesterday's.
246
+ var available = containerWidth - reserved;
247
+ var pxOverrides = resolvePercentWidths(overrides, available);
248
+
249
+ var demands = measureColumnDemand({
250
+ columns: columns,
251
+ rows: rows,
252
+ textOf: textOf,
253
+ headerOf: function(column) { return column.header || column.accessor; },
254
+ keyOf: function(column) { return column.accessor; },
255
+ measure: measureWidest,
256
+ bodyFont: BODY_FONTS,
257
+ headerFont: headerFont,
258
+ cellPadding: horizontalPadding(bodyCell) || 25,
259
+ headerPadding: headerPadding,
260
+ minWidth: opts.minColumnWidth || DEFAULTS.minWidth,
261
+ maxWidth: opts.maxColumnWidth || DEFAULTS.maxWidth,
262
+ maxFloor: opts.maxFloorWidth || DEFAULTS.maxFloor,
263
+ overrides: pxOverrides
264
+ });
265
+
266
+ var next = allocateColumnWidths(demands, containerWidth - reserved);
267
+ next.extraWidths = extraWidths;
268
+ next.tableWidth += reserved;
269
+
270
+ if (sameSizing(next, sizingRef.current)) return;
271
+ sizingRef.current = next;
272
+ setSizing(next);
273
+ if (onSizing) onSizing(next);
274
+ // State is tracked through refs above, so it is deliberately absent here.
275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
276
+ }, [enabled, containerWidth, data, columns, textOf, sampleRows, sizingKey, overrides,
277
+ opts.minColumnWidth, opts.maxColumnWidth, opts.maxFloorWidth]);
278
+
279
+ return { scrollRef: scrollRef, tableRef: tableRef, sizing: enabled ? sizing : null };
280
+ }
@@ -25,12 +25,27 @@ var filterFnMap = {
25
25
 
26
26
  var columnHelper = createColumnHelper();
27
27
 
28
+ // A column def may itself be a GROUP — `{ header, columns: [...] }` instead of
29
+ // `{ accessor, ... }` — which becomes a spanning header cell over its
30
+ // children (a pivoted date over the measures under it), same as Excel merges
31
+ // a date across the columns it covers. Optional and recursive: a caller that
32
+ // never nests columns gets exactly the flat header it always got.
33
+ function flattenLeaves(cols) {
34
+ var out = [];
35
+ for (var i = 0; i < cols.length; i++) {
36
+ var col = cols[i];
37
+ if (col.columns && col.columns.length) out = out.concat(flattenLeaves(col.columns));
38
+ else out.push(col);
39
+ }
40
+ return out;
41
+ }
42
+
28
43
  /**
29
44
  * Hook that wires TanStack Table with auto-detected column types and smart filters.
30
45
  *
31
46
  * @param {object} options
32
47
  * @param {Array<object>} options.data - Row array
33
- * @param {Array<object>} options.columns - [{ accessor, header, dataType?, cell? }]
48
+ * @param {Array<object>} options.columns - [{ accessor, header, dataType?, cell? } | { header, columns: [...] }]
34
49
  * @param {number} [options.minDetectionRows] - Min samples for confident detection (default: 10)
35
50
  * @param {number} [options.pageSize] - Rows per page (default: 20)
36
51
  * @param {boolean} [options.enableSorting] - Default: true
@@ -65,14 +80,28 @@ export default function useTableController(options) {
65
80
  }
66
81
 
67
82
  // Run detection
68
- var result = detectTypes(data, columns, minDetectionRows);
83
+ // Leaves only — a group column has no accessor and no data of its own to
84
+ // sample, it is purely a spanning header over the columns that do.
85
+ var result = detectTypes(data, flattenLeaves(columns), minDetectionRows);
69
86
  detectionRef.current = { types: result.types, confident: result.confident, dataRef: data };
70
87
  return result.types;
71
88
  }, [data, columns, minDetectionRows]);
72
89
 
73
90
  // ── Build TanStack column defs ──
74
91
  var tanstackColumns = useMemo(function() {
75
- return columns.map(function(col) {
92
+ function build(col) {
93
+ // A group has no data of its own — column.group() renders it as a
94
+ // spanning header cell (TanStack's own colSpan) over whichever leaf
95
+ // columns are nested under it, and nothing else about those leaves
96
+ // changes: same filters, same sort, same cells they'd have flat.
97
+ if (col.columns && col.columns.length) {
98
+ return columnHelper.group({
99
+ id: col.id || col.header,
100
+ header: col.header,
101
+ columns: col.columns.map(build)
102
+ });
103
+ }
104
+
76
105
  var type = detectedTypes.get(col.accessor) || TYPES.STRING;
77
106
  var filterFn = filterFnMap[type] || stringFilterFn;
78
107
 
@@ -95,11 +124,34 @@ export default function useTableController(options) {
95
124
  colDef.cell = rendererFn(rendererConfig);
96
125
  }
97
126
  return columnHelper.accessor(col.accessor, colDef);
98
- });
127
+ }
128
+ return columns.map(build);
99
129
  }, [columns, detectedTypes]);
100
130
 
101
131
  // ── Table state ──
102
- var [sorting, setSorting] = useState([]);
132
+ //
133
+ // Sorting can be CONTROLLED by the host. Uncontrolled is the default and is
134
+ // what a plain data grid wants: click a header, the table reorders itself.
135
+ //
136
+ // A host takes it over when the ordering is not the table's to decide.
137
+ // A grouped report is the case that forces it — its rows carry subtotals
138
+ // that must stay with their group, and columns whose values are derived
139
+ // from the row above them. Re-sorting such a result in the view layer
140
+ // scatters the subtotals and leaves every running total describing an order
141
+ // that is no longer on screen. So the host supplies the order and takes the
142
+ // header click as an instruction, which is what `manualSorting` means to
143
+ // TanStack: "already sorted, do not sort it again".
144
+ var controlledSorting = options.sorting !== undefined;
145
+ var [ownSorting, setOwnSorting] = useState([]);
146
+ var sorting = controlledSorting ? options.sorting : ownSorting;
147
+ var setSorting = function (updater) {
148
+ var next = typeof updater === 'function' ? updater(sorting) : updater;
149
+ if (controlledSorting) {
150
+ if (options.onSortingChange) options.onSortingChange(next);
151
+ return;
152
+ }
153
+ setOwnSorting(next);
154
+ };
103
155
  var [columnFilters, setColumnFilters] = useState([]);
104
156
  var [pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize });
105
157
 
@@ -117,7 +169,11 @@ export default function useTableController(options) {
117
169
  onPaginationChange: setPagination,
118
170
  getCoreRowModel: getCoreRowModel(),
119
171
  getFilteredRowModel: enableFiltering ? getFilteredRowModel() : undefined,
120
- getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
172
+ // Not applied when the host controls the order — the rows arrive sorted
173
+ // and sorting them again is both wasted work and, for a grouped result,
174
+ // wrong.
175
+ getSortedRowModel: (enableSorting && !controlledSorting) ? getSortedRowModel() : undefined,
176
+ manualSorting: controlledSorting,
121
177
  getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
122
178
  getFacetedRowModel: getFacetedRowModel(),
123
179
  getFacetedUniqueValues: getFacetedUniqueValues(),