@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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Content-based column widths — the pure half.
3
+ *
4
+ * No React, no DOM: this module takes measured text widths in and gives
5
+ * assigned pixel widths out, so the decision it makes is testable without a
6
+ * browser. The DOM side (reading fonts, running canvas measureText, watching
7
+ * the container resize) lives in useColumnWidths.js.
8
+ *
9
+ * THE RULE, in one line: a column never gets less than it needs while some
10
+ * other part of the table is sitting on empty space.
11
+ *
12
+ * Concretely, given a container and a set of columns:
13
+ *
14
+ * 1. Everything fits -> every column gets its natural width, and whatever
15
+ * is left over stays empty. That leftover is the "blank column".
16
+ * 2. It doesn't fit -> there is NO blank column. Every column drops to its
17
+ * floor (the widest single word it must show without breaking mid-word),
18
+ * then the remaining space is handed back in proportion to how much each
19
+ * column actually wanted. A column that never wanted more than its floor
20
+ * -- a number under 100, say -- takes none of it, so all the slack goes
21
+ * to the column that needs it. Columns that end up short of natural wrap.
22
+ * 3. Not even the floors fit -> everyone gets their floor and the table
23
+ * scrolls horizontally.
24
+ *
25
+ * That ordering is what makes case 2 defensible: you can never see a narrow,
26
+ * wrapped column sitting next to blank space, because blank space only exists
27
+ * once every column is already satisfied.
28
+ *
29
+ * Every column comes back with a `reason`, so "why is this column this wide?"
30
+ * has an actual answer rather than a shrug.
31
+ */
32
+
33
+ /** Column got exactly what its content asked for. */
34
+ export var WIDTH_NATURAL = 'natural';
35
+ /** Column was squeezed below its natural width; its text wraps. */
36
+ export var WIDTH_SHRUNK = 'shrunk';
37
+ /** Column is at its floor and the table overflows; horizontal scroll. */
38
+ export var WIDTH_FLOOR = 'floor';
39
+ /** Column was given an explicit width by the caller; nothing was measured. */
40
+ export var WIDTH_PINNED = 'pinned';
41
+
42
+ /**
43
+ * A caller-supplied width for one column, BY POSITION, in PX.
44
+ *
45
+ * Px here even though the public API takes a percentage: this file returns
46
+ * pixel widths, so converting once at the boundary (useColumnWidths, which is
47
+ * the only place that knows the container) keeps every number below in one
48
+ * unit. A percentage this deep would mean two units in the same arithmetic.
49
+ *
50
+ * Positional on purpose, and it is worth saying why, because a key looks like
51
+ * the safer choice and is not.
52
+ *
53
+ * A pivot's columns ARE data: they are the distinct values of the pivot
54
+ * column, so today's `Q3 · Sum` is next quarter's `Q4 · Sum`. Key a width to
55
+ * that and it is lost on an ordinary refresh — nobody edited anything, the
56
+ * layout simply forgot. Position survives that, and only breaks when somebody
57
+ * deliberately restructures the report, where losing a width is a reasonable
58
+ * consequence of a deliberate act.
59
+ *
60
+ * Which is the real point: a width here belongs to the SLOT, not to whatever
61
+ * value lands in it. "The third column has to be wide" is a positional
62
+ * statement.
63
+ *
64
+ * Non-positive and non-finite entries are ignored rather than clamped — they
65
+ * mean the caller has a bug, and inventing a width would hide it.
66
+ */
67
+ /**
68
+ * { index: percent } -> { index: px }, against the width the columns divide.
69
+ *
70
+ * Lives here rather than in the hook so the conversion is testable without a
71
+ * browser, like every other decision in this file. The hook's only job is
72
+ * knowing what `available` is.
73
+ *
74
+ * `available` is the container MINUS the structural columns (expand arrow,
75
+ * checkbox, row actions), which is what makes 100 mean "all the room the
76
+ * columns actually have": two columns at 50 fill the table exactly whether or
77
+ * not there is a checkbox column in front of them. Against the raw container
78
+ * they would overflow by its width — not something anyone typing a percentage
79
+ * is thinking about.
80
+ *
81
+ * Junk is dropped rather than coerced, so a caller's bug stays visible as an
82
+ * unpinned (measured) column rather than as a silently invented width.
83
+ */
84
+ export function resolvePercentWidths(overrides, available) {
85
+ if (!overrides || !(available > 0)) return null;
86
+ var out = {};
87
+ Object.keys(overrides).forEach(function(index) {
88
+ var percent = overrides[index];
89
+ if (typeof percent !== 'number' || !isFinite(percent) || percent <= 0) return;
90
+ out[index] = Math.round((percent / 100) * available);
91
+ });
92
+ return out;
93
+ }
94
+
95
+ function pinnedWidth(overrides, index) {
96
+ if (!overrides) return null;
97
+ var width = overrides[index];
98
+ if (typeof width !== 'number' || !isFinite(width) || width <= 0) return null;
99
+ return Math.ceil(width);
100
+ }
101
+
102
+ /**
103
+ * The longest run of characters with no break opportunity in it.
104
+ *
105
+ * This is a column's floor: text can wrap at spaces for free, so a column
106
+ * only *has* to be as wide as its widest single word. "New York City" can live
107
+ * in the width of "York"; a 60-character URL cannot be narrowed at all, which
108
+ * is why the caller caps this (see maxFloor).
109
+ */
110
+ export function longestToken(text) {
111
+ if (!text) return '';
112
+ var parts = String(text).split(/\s+/);
113
+ var longest = '';
114
+ for (var i = 0; i < parts.length; i++) {
115
+ if (parts[i].length > longest.length) longest = parts[i];
116
+ }
117
+ return longest;
118
+ }
119
+
120
+ function clamp(value, low, high) {
121
+ if (value < low) return low;
122
+ if (value > high) return high;
123
+ return value;
124
+ }
125
+
126
+ /**
127
+ * Turn text into per-column width demands.
128
+ *
129
+ * Deliberately takes `measure` as an argument rather than reaching for a
130
+ * canvas itself — that keeps this file pure, and lets tests substitute a
131
+ * monospace measurer whose numbers can be reasoned about by hand.
132
+ *
133
+ * @param {object} opts
134
+ * @param {Array<object>} opts.columns - Column descriptors (opaque here)
135
+ * @param {Array<object>} opts.rows - Sample rows to measure
136
+ * @param {Function} opts.textOf - (row, column) => displayed string.
137
+ * MUST return what the user actually sees: '₹36,260.80' is far wider than
138
+ * 36260.8, so measuring the raw value would size currency columns wrong.
139
+ * @param {Function} opts.headerOf - (column) => header string
140
+ * @param {Function} opts.keyOf - (column) => stable key
141
+ * @param {Function} opts.measure - (text, font) => px
142
+ * @param {string} opts.bodyFont - CSS font shorthand for cells
143
+ * @param {string} opts.headerFont - CSS font shorthand for headers
144
+ * @param {number} opts.cellPadding - Horizontal chrome around cell text
145
+ * @param {number} opts.headerPadding - Horizontal chrome around header text
146
+ * @param {number} opts.minWidth - No column is ever narrower than this
147
+ * @param {number} opts.maxWidth - Caps natural, so one essay column
148
+ * can't demand 4000px and push everything else off screen
149
+ * @param {number} opts.maxFloor - Caps the floor, so a single
150
+ * unbreakable 200-char token can't force horizontal scroll on its own; past
151
+ * this the browser breaks mid-word (overflow-wrap: anywhere)
152
+ * @param {object} [opts.overrides] - { columnIndex: px } — explicit
153
+ * widths. These columns are NOT measured, and NOT clamped to min/max: an
154
+ * instruction outranks a guess, including the guess about what a sensible
155
+ * minimum is.
156
+ * @returns {Array<{key, natural, floor, pinned?}>}
157
+ */
158
+ export function measureColumnDemand(opts) {
159
+ var columns = opts.columns || [];
160
+ var rows = opts.rows || [];
161
+ var measure = opts.measure;
162
+ var cellPad = opts.cellPadding || 0;
163
+ var headPad = opts.headerPadding || 0;
164
+ var minWidth = opts.minWidth || 0;
165
+ var maxWidth = opts.maxWidth || Infinity;
166
+ var maxFloor = opts.maxFloor || maxWidth;
167
+
168
+ return columns.map(function(column, index) {
169
+ // PINNED COLUMNS ARE NEVER MEASURED. Not measured-then-overridden: the
170
+ // measuring is the expensive part (every sampled row, twice, through the
171
+ // caller's formatter), and its answer would be discarded. natural ===
172
+ // floor is what tells the allocator this column does not negotiate.
173
+ var pinned = pinnedWidth(opts.overrides, index);
174
+ if (pinned !== null) {
175
+ return { key: opts.keyOf(column), natural: pinned, floor: pinned, pinned: true };
176
+ }
177
+
178
+ var header = opts.headerOf(column) || '';
179
+ var natural = measure(header, opts.headerFont) + headPad;
180
+ var floor = measure(longestToken(header), opts.headerFont) + headPad;
181
+
182
+ for (var i = 0; i < rows.length; i++) {
183
+ var text = opts.textOf(rows[i], column);
184
+ if (text === null || text === undefined || text === '') continue;
185
+ text = String(text);
186
+ var full = measure(text, opts.bodyFont) + cellPad;
187
+ if (full > natural) natural = full;
188
+ // Skip the second measure when the text is a single word — then the
189
+ // longest token IS the whole string and we already have its width.
190
+ var token = longestToken(text);
191
+ var tokenWidth = token.length === text.length ? full : measure(token, opts.bodyFont) + cellPad;
192
+ if (tokenWidth > floor) floor = tokenWidth;
193
+ }
194
+
195
+ natural = clamp(Math.ceil(natural), minWidth, maxWidth);
196
+ // The floor can never exceed natural — a column asking for less room than
197
+ // its own minimum is nonsense, and would break the surplus maths below.
198
+ floor = clamp(Math.ceil(floor), minWidth, Math.min(maxFloor, natural));
199
+
200
+ return { key: opts.keyOf(column), natural: natural, floor: floor };
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Hand out the container's width.
206
+ *
207
+ * @param {Array<{key, natural, floor, pinned}>} demands - `pinned` columns are
208
+ * held at their width and excluded from the negotiation entirely.
209
+ * @param {number} containerWidth - Available px. 0/undefined means "unknown",
210
+ * which is treated as unlimited: everyone gets natural and nothing is blank.
211
+ * @returns {{widths: number[], tableWidth: number, blankWidth: number,
212
+ * overflow: boolean, decisions: Array<{key, natural, floor, width, reason}>}}
213
+ */
214
+ export function allocateColumnWidths(demands, containerWidth) {
215
+ demands = demands || [];
216
+ if (!demands.length) {
217
+ return { widths: [], tableWidth: 0, blankWidth: Math.max(0, containerWidth || 0), overflow: false, decisions: [] };
218
+ }
219
+
220
+ // ── Pinned columns come out of the negotiation entirely ──
221
+ //
222
+ // A pinned width is an instruction, so it must not be shrunk in case 2 or
223
+ // dropped to a floor in case 3. Taking them out FIRST and running the
224
+ // existing three cases over what remains is what keeps that true without
225
+ // threading a special case through the surplus maths — the allocator below
226
+ // simply never sees them.
227
+ var pinnedTotal = 0;
228
+ var free = [];
229
+ var freeIndex = [];
230
+ for (var p = 0; p < demands.length; p++) {
231
+ if (demands[p].pinned) { pinnedTotal += demands[p].natural; continue; }
232
+ free.push(demands[p]);
233
+ freeIndex.push(p);
234
+ }
235
+
236
+ if (pinnedTotal > 0) {
237
+ var known = containerWidth > 0;
238
+ var remaining = known ? containerWidth - pinnedTotal : containerWidth;
239
+
240
+ // THE TRAP THIS AVOIDS: `remaining` can now go to zero or negative, and
241
+ // 0 is already this function's word for "container unknown, treat as
242
+ // unlimited". Falling through would hand every free column its natural
243
+ // width — the widest possible table — at exactly the moment there is no
244
+ // room at all. So it is answered here instead of reaching that branch.
245
+ if (known && remaining <= 0) {
246
+ var floored = demands.map(function(d) { return d.pinned ? d.natural : d.floor; });
247
+ return finish(demands, floored, containerWidth, true);
248
+ }
249
+
250
+ var inner = allocateColumnWidths(free, remaining);
251
+ var merged = new Array(demands.length);
252
+ for (var m = 0; m < demands.length; m++) if (demands[m].pinned) merged[m] = demands[m].natural;
253
+ for (var f = 0; f < freeIndex.length; f++) merged[freeIndex[f]] = inner.widths[f];
254
+ return finish(demands, merged, containerWidth, inner.overflow);
255
+ }
256
+
257
+ var naturalTotal = 0;
258
+ var floorTotal = 0;
259
+ for (var i = 0; i < demands.length; i++) {
260
+ naturalTotal += demands[i].natural;
261
+ floorTotal += demands[i].floor;
262
+ }
263
+
264
+ var widths;
265
+ var overflow = false;
266
+
267
+ if (!containerWidth || containerWidth <= 0 || naturalTotal <= containerWidth) {
268
+ // Case 1 — everything fits. This is the ONLY branch that leaves blank space.
269
+ widths = demands.map(function(d) { return d.natural; });
270
+ } else if (floorTotal >= containerWidth) {
271
+ // Case 3 — not even the floors fit. Scroll rather than break words.
272
+ widths = demands.map(function(d) { return d.floor; });
273
+ overflow = true;
274
+ } else {
275
+ // Case 2 — squeeze. Start everyone at their floor, then redistribute the
276
+ // surplus in proportion to unmet demand, so a column that only ever wanted
277
+ // 46px keeps 46px and the wide column absorbs all the slack.
278
+ var surplus = containerWidth - floorTotal;
279
+ var demandTotal = naturalTotal - floorTotal; // > surplus > 0 in this branch
280
+ var used = 0;
281
+ widths = demands.map(function(d) {
282
+ var width = Math.floor(d.floor + ((d.natural - d.floor) / demandTotal) * surplus);
283
+ if (width > d.natural) width = d.natural; // float-rounding guard
284
+ used += width;
285
+ return width;
286
+ });
287
+ // Flooring each share loses a few px; give them to the hungriest column
288
+ // so the table lands exactly on the container edge rather than a pixel shy.
289
+ var remainder = containerWidth - used;
290
+ if (remainder > 0) {
291
+ var hungriest = 0;
292
+ for (var j = 1; j < demands.length; j++) {
293
+ if (demands[j].natural - widths[j] > demands[hungriest].natural - widths[hungriest]) hungriest = j;
294
+ }
295
+ widths[hungriest] += remainder;
296
+ if (widths[hungriest] > demands[hungriest].natural) widths[hungriest] = demands[hungriest].natural;
297
+ }
298
+ }
299
+
300
+ return finish(demands, widths, containerWidth, overflow);
301
+ }
302
+
303
+ /**
304
+ * The shared tail: totals, blank space, and a `reason` per column.
305
+ *
306
+ * One place, so the pinned path and the negotiated path cannot disagree about
307
+ * what a decision record looks like.
308
+ */
309
+ function finish(demands, widths, containerWidth, overflow) {
310
+ var tableWidth = 0;
311
+ for (var k = 0; k < widths.length; k++) tableWidth += widths[k];
312
+
313
+ var decisions = demands.map(function(d, idx) {
314
+ var width = widths[idx];
315
+ var reason = WIDTH_NATURAL;
316
+ // Checked FIRST: a pinned column has natural === floor, so every test
317
+ // below would read it as "got what it asked for" and report `natural`.
318
+ // True, but it hides the one thing worth knowing — that this width was
319
+ // given, not measured, and is the caller's to change.
320
+ if (d.pinned) reason = WIDTH_PINNED;
321
+ else if (overflow && width <= d.floor && d.floor < d.natural) reason = WIDTH_FLOOR;
322
+ else if (width < d.natural) reason = WIDTH_SHRUNK;
323
+ return { key: d.key, natural: d.natural, floor: d.floor, width: width, reason: reason };
324
+ });
325
+
326
+ return {
327
+ widths: widths,
328
+ tableWidth: tableWidth,
329
+ blankWidth: containerWidth > 0 ? Math.max(0, containerWidth - tableWidth) : 0,
330
+ overflow: overflow,
331
+ decisions: decisions
332
+ };
333
+ }
@@ -1,9 +1,23 @@
1
- import React, { useState, useRef, useEffect } from 'react';
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
 
3
4
  /**
4
5
  * Common filter popover shell.
5
6
  * Renders a trigger icon and a dropdown panel that closes on outside click.
6
7
  *
8
+ * PORTALLED TO document.body, and positioned from the trigger's own rect.
9
+ *
10
+ * It used to be `position: absolute; right: 0` inside the header cell, which
11
+ * put it at the mercy of every ancestor: a table scrolls (`overflow: auto`),
12
+ * so the panel was CLIPPED by the table it belongs to. On the first, narrow
13
+ * column a 220px panel anchored to the cell's right edge opened leftward past
14
+ * the table's left edge and had its labels sliced off — a filter list you
15
+ * cannot read is a filter you cannot use.
16
+ *
17
+ * A portal has no ancestors to be clipped by. The cost is that it no longer
18
+ * moves with the page on its own, so it closes on scroll and resize rather
19
+ * than hanging in the wrong place.
20
+ *
7
21
  * @param {object} props
8
22
  * @param {boolean} props.isActive - Whether filter is currently applied
9
23
  * @param {React.ReactNode} props.children - Filter UI content
@@ -11,21 +25,77 @@ import React, { useState, useRef, useEffect } from 'react';
11
25
  */
12
26
  export default function FilterWrapper({ isActive, children, onClear }) {
13
27
  var [open, setOpen] = useState(false);
28
+ var [rect, setRect] = useState(null);
14
29
  var wrapperRef = useRef(null);
30
+ var panelRef = useRef(null);
31
+
32
+ // Where the panel should sit, in VIEWPORT coordinates (position: fixed).
33
+ var place = useCallback(function() {
34
+ var el = wrapperRef.current;
35
+ if (!el) return;
36
+ var r = el.getBoundingClientRect();
37
+ var W = 240; // matches min-width in the stylesheet
38
+ var margin = 8;
39
+ // Right-aligned to the trigger by preference, because that reads as
40
+ // belonging to this column — but never off either edge of the window.
41
+ var left = Math.min(
42
+ Math.max(margin, r.right - W),
43
+ Math.max(margin, window.innerWidth - W - margin)
44
+ );
45
+ setRect({ top: r.bottom + 4, left: left, width: W });
46
+ }, []);
15
47
 
16
- // Close on outside click
17
48
  useEffect(function() {
18
- if (!open) return;
49
+ if (!open) return undefined;
50
+ place();
19
51
 
20
52
  function handleClick(e) {
21
- if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
22
- setOpen(false);
23
- }
53
+ var inTrigger = wrapperRef.current && wrapperRef.current.contains(e.target);
54
+ var inPanel = panelRef.current && panelRef.current.contains(e.target);
55
+ if (!inTrigger && !inPanel) setOpen(false);
24
56
  }
57
+ // A portalled panel does not travel with what it is anchored to, so
58
+ // rather than let it hang somewhere wrong it closes. `true` catches
59
+ // scrolling in the TABLE, not just the window — the scroll event does not
60
+ // bubble, so a listener without capture never hears it.
61
+ function handleScroll() { setOpen(false); }
25
62
 
26
63
  document.addEventListener('mousedown', handleClick);
27
- return function() { document.removeEventListener('mousedown', handleClick); };
28
- }, [open]);
64
+ window.addEventListener('scroll', handleScroll, true);
65
+ window.addEventListener('resize', handleScroll);
66
+ return function() {
67
+ document.removeEventListener('mousedown', handleClick);
68
+ window.removeEventListener('scroll', handleScroll, true);
69
+ window.removeEventListener('resize', handleScroll);
70
+ };
71
+ }, [open, place]);
72
+
73
+ var panel = open && rect ? createPortal(
74
+ <div
75
+ ref={panelRef}
76
+ className="xeplr-table-filter-panel"
77
+ style={{ position: 'fixed', top: rect.top, left: rect.left, width: rect.width }}
78
+ >
79
+ {children}
80
+ <div className="xeplr-table-filter-actions">
81
+ <button
82
+ type="button"
83
+ className="xeplr-table-filter-clear-btn"
84
+ onClick={function() { onClear(); setOpen(false); }}
85
+ >
86
+ Clear
87
+ </button>
88
+ <button
89
+ type="button"
90
+ className="xeplr-table-filter-close-btn"
91
+ onClick={function() { setOpen(false); }}
92
+ >
93
+ Done
94
+ </button>
95
+ </div>
96
+ </div>,
97
+ document.body
98
+ ) : null;
29
99
 
30
100
  return (
31
101
  <div className="xeplr-table-filter-wrapper" ref={wrapperRef}>
@@ -39,28 +109,7 @@ export default function FilterWrapper({ isActive, children, onClear }) {
39
109
  <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
40
110
  </svg>
41
111
  </button>
42
-
43
- {open && (
44
- <div className="xeplr-table-filter-panel">
45
- {children}
46
- <div className="xeplr-table-filter-actions">
47
- <button
48
- type="button"
49
- className="xeplr-table-filter-clear-btn"
50
- onClick={function() { onClear(); setOpen(false); }}
51
- >
52
- Clear
53
- </button>
54
- <button
55
- type="button"
56
- className="xeplr-table-filter-close-btn"
57
- onClick={function() { setOpen(false); }}
58
- >
59
- Done
60
- </button>
61
- </div>
62
- </div>
63
- )}
112
+ {panel}
64
113
  </div>
65
114
  );
66
115
  }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ export { default as RecordModal } from './actions/RecordModal.jsx';
21
21
  export { default as ChildTable } from './actions/ChildTable.jsx';
22
22
  export { default as RecordDetail } from './actions/RecordDetail.jsx';
23
23
  export { CHILD_DISPLAY } from './XeplrTable.jsx';
24
+ export { default as CellZoom } from './CellZoom.jsx';
24
25
 
25
26
  // Change set builder
26
27
  export { buildChangeSet } from './buildChangeSet.js';
@@ -31,6 +32,19 @@ export { resolve as resolveOperator, resolveString, resolveNumber, resolveDate,
31
32
  // Conditional formatting
32
33
  export { resolveCellStyle } from './resolveCellStyle.js';
33
34
 
35
+ // Style vocabulary + layer resolution (theme -> overrides)
36
+ export {
37
+ STYLE_GROUPS, STYLE_FIELDS, BORDER_SIDES, styleField, cssLength,
38
+ flattenStyle, resolveStyle, toCssProperties, resolveCellCss
39
+ } from './tableStyles.js';
40
+
41
+ // Content-based column widths
42
+ export { default as useColumnWidths } from './useColumnWidths.js';
43
+ export {
44
+ measureColumnDemand, allocateColumnWidths, resolvePercentWidths, longestToken,
45
+ WIDTH_NATURAL, WIDTH_SHRUNK, WIDTH_FLOOR
46
+ } from './columnWidths.js';
47
+
34
48
  // Cell renderers (config-driven)
35
49
  export { default as renderers } from './renderers/index.js';
36
50
  export { avatarName, tags, currency, memberChips, twoLine, statusBadge, dateDisplay, link } from './renderers/index.js';