@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|