@keenmate/pure-admin-core 2.9.0-rc03 → 2.9.0-rc05
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -13
- package/dist/css/main.css +554 -7
- package/package.json +3 -1
- package/snippets/buttons.html +51 -0
- package/snippets/cards.html +132 -47
- package/snippets/comparison.html +26 -22
- package/snippets/manifest.json +180 -115
- package/snippets/range-group.html +125 -0
- package/snippets/splitter.html +44 -38
- package/snippets/statistics.html +31 -0
- package/src/js/btn-split-auto-absorb.js +327 -0
- package/src/js/command-palette.js +472 -0
- package/src/js/file-selector.js +1275 -0
- package/src/js/internal/logging.js +121 -0
- package/src/js/logic-tree-renderer.js +303 -0
- package/src/js/modal-dialogs.js +460 -0
- package/src/js/overflow.js +371 -0
- package/src/js/pa-stat-fit.js +184 -0
- package/src/js/range-group.js +663 -0
- package/src/js/search-autocomplete-v2.js +907 -0
- package/src/js/search-autocomplete.js +434 -0
- package/src/js/settings-panel.js +245 -0
- package/src/js/split-button.js +141 -0
- package/src/js/splitter.js +1323 -0
- package/src/js/toast-service.js +302 -0
- package/src/js/tooltips-popovers.js +275 -0
- package/src/js/virtual-scroll.js +143 -0
- package/src/js/virtual-textbox.js +803 -0
- package/src/scss/_core.scss +7 -0
- package/src/scss/core-components/_buttons.scss +44 -0
- package/src/scss/core-components/_cards.scss +95 -6
- package/src/scss/core-components/_overflow.scss +50 -0
- package/src/scss/core-components/_range-group.scss +474 -0
- package/src/scss/core-components/_statistics.scss +163 -0
- package/src/scss/variables/_components.scss +41 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Admin — Overflow (progressive-collapse row)
|
|
3
|
+
*
|
|
4
|
+
* Generic progressive-collapse primitive: a horizontal row of children that
|
|
5
|
+
* walks the lowest-priority items into a "more" menu when the row can't fit,
|
|
6
|
+
* and restores them as space comes back. Originally lived as the
|
|
7
|
+
* `pa-card__actions--overflow` variant in `_cards.scss`; promoted to a
|
|
8
|
+
* standalone block (`pa-overflow`) so toolbars, button bars, breadcrumbs,
|
|
9
|
+
* tab rows, chip rows — anything with a flex row of inline-block children —
|
|
10
|
+
* can reuse it without the card scaffolding.
|
|
11
|
+
*
|
|
12
|
+
* Auto-inits on:
|
|
13
|
+
* .pa-overflow — the generic primitive (preferred)
|
|
14
|
+
* .pa-card__actions--overflow — the card-flavoured alias (back-compat)
|
|
15
|
+
*
|
|
16
|
+
* Drop order: lowest `data-pa-actions-priority` (default 0) drops first.
|
|
17
|
+
* Ties broken by DOM order, with the tiebreak direction set on the wrapper:
|
|
18
|
+
*
|
|
19
|
+
* data-pa-actions-overflow-from="end" (default) — rightmost drops first
|
|
20
|
+
* data-pa-actions-overflow-from="start" — leftmost drops first
|
|
21
|
+
*
|
|
22
|
+
* Pin a button so it never collapses by giving it a high priority (e.g. 10).
|
|
23
|
+
*
|
|
24
|
+
* <div class="pa-overflow" data-pa-actions-overflow-from="end">
|
|
25
|
+
* <button class="pa-btn pa-btn--xs">Save</button>
|
|
26
|
+
* <button class="pa-btn pa-btn--xs">Format</button>
|
|
27
|
+
* <button class="pa-btn pa-btn--xs"
|
|
28
|
+
* data-pa-actions-priority="10">Run</button>
|
|
29
|
+
* </div>
|
|
30
|
+
*
|
|
31
|
+
* Algorithm: restore everything to the wrapper, check `scrollWidth >
|
|
32
|
+
* clientWidth`, move items by ascending priority until it fits. Avoids
|
|
33
|
+
* the cached-width math entirely — what the browser thinks fits is the
|
|
34
|
+
* source of truth.
|
|
35
|
+
*
|
|
36
|
+
* Layout contract: the wrapper needs `min-width: 0; overflow: hidden;
|
|
37
|
+
* flex-shrink: 1` so the row actually shrinks below its content (otherwise
|
|
38
|
+
* `scrollWidth > clientWidth` never becomes truthful). `_overflow.scss`
|
|
39
|
+
* supplies that for `.pa-overflow`; the card variant carries equivalent
|
|
40
|
+
* rules in `_cards.scss` under `.pa-card__header`.
|
|
41
|
+
*
|
|
42
|
+
* Opt in to console diagnostics: `window.PA_OVERFLOW_DEBUG = true`
|
|
43
|
+
* before page load. (`window.PA_CARD_ACTIONS_OVERFLOW_DEBUG` still works.)
|
|
44
|
+
*
|
|
45
|
+
* Public API (window.PaOverflow):
|
|
46
|
+
* init(el) — idempotent single-element init
|
|
47
|
+
* initAll(scope) — initialize all uninitialized overflow wrappers under scope
|
|
48
|
+
*
|
|
49
|
+
* `window.PaCardActionsOverflow` is preserved as a back-compat alias.
|
|
50
|
+
*/
|
|
51
|
+
(function () {
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
var INIT_FLAG = '__paOverflowInit';
|
|
55
|
+
|
|
56
|
+
// Selectors we auto-init on. The card-actions modifier stays in the
|
|
57
|
+
// list so existing markup keeps working without having to add the
|
|
58
|
+
// generic class manually.
|
|
59
|
+
var SELECTOR = '.pa-overflow, .pa-card__actions--overflow';
|
|
60
|
+
|
|
61
|
+
function matchesContract(el) {
|
|
62
|
+
return el && el.classList && (
|
|
63
|
+
el.classList.contains('pa-overflow') ||
|
|
64
|
+
el.classList.contains('pa-card__actions--overflow')
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Floating UI (optional) — used to position the overflow menu. Mirrors
|
|
69
|
+
// the helpers `split-button.js` pulls from `window.FloatingUIDOM`. We
|
|
70
|
+
// capture them once at module scope so each instance doesn't repeat
|
|
71
|
+
// the lookup; if the library isn't loaded we transparently fall back
|
|
72
|
+
// to a hand-rolled `getBoundingClientRect` positioner below.
|
|
73
|
+
var FUI = window.FloatingUIDOM || null;
|
|
74
|
+
|
|
75
|
+
function init(root) {
|
|
76
|
+
if (!root || root[INIT_FLAG]) return;
|
|
77
|
+
if (!matchesContract(root)) return;
|
|
78
|
+
root[INIT_FLAG] = true;
|
|
79
|
+
|
|
80
|
+
var items = Array.prototype.slice.call(root.children).filter(function (el) {
|
|
81
|
+
return el.nodeType === 1;
|
|
82
|
+
});
|
|
83
|
+
if (items.length === 0) return;
|
|
84
|
+
|
|
85
|
+
var ordered = items.map(function (el, idx) {
|
|
86
|
+
var p = parseInt(el.getAttribute('data-pa-actions-priority'), 10);
|
|
87
|
+
return { el: el, priority: isNaN(p) ? 0 : p, domIndex: idx };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Drop order recomputed on every relayout via `buildDropOrder()` —
|
|
91
|
+
// cheap (one sort over N items) and means the wrapper's
|
|
92
|
+
// `data-pa-actions-overflow-from` attribute can be flipped at
|
|
93
|
+
// runtime (a MutationObserver below also forces a re-layout when
|
|
94
|
+
// that attribute changes).
|
|
95
|
+
var dropOrder = [];
|
|
96
|
+
function buildDropOrder() {
|
|
97
|
+
var fromAttr = root.getAttribute('data-pa-actions-overflow-from');
|
|
98
|
+
var dropFromStart = fromAttr === 'start';
|
|
99
|
+
dropOrder = ordered.slice().sort(function (a, b) {
|
|
100
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
101
|
+
return dropFromStart ? (a.domIndex - b.domIndex) : (b.domIndex - a.domIndex);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
buildDropOrder();
|
|
105
|
+
|
|
106
|
+
// Opt-in diagnostics — set `window.PA_OVERFLOW_DEBUG = true` (or the
|
|
107
|
+
// pre-rename `PA_CARD_ACTIONS_OVERFLOW_DEBUG`) before page load to
|
|
108
|
+
// see init / relayout / move-to-menu traces.
|
|
109
|
+
var DEBUG = window.PA_OVERFLOW_DEBUG === true || window.PA_CARD_ACTIONS_OVERFLOW_DEBUG === true;
|
|
110
|
+
var instanceLabel = '[pa-overflow#' + (root.id || ordered.map(function (i) { return i.el.tagName; }).join('-')).slice(0, 40) + ']';
|
|
111
|
+
function log() {
|
|
112
|
+
if (!DEBUG) return;
|
|
113
|
+
var args = Array.prototype.slice.call(arguments);
|
|
114
|
+
console.log.apply(console, [instanceLabel].concat(args));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Trigger button — the "..." affordance. Carries both the generic
|
|
118
|
+
// and the card-flavoured class so any existing SCSS targeting the
|
|
119
|
+
// old `.pa-card__actions-overflow-trigger` selector keeps working
|
|
120
|
+
// (themes / consumer overrides) without forcing a coordinated update.
|
|
121
|
+
var trigger = document.createElement('button');
|
|
122
|
+
trigger.type = 'button';
|
|
123
|
+
trigger.className = 'pa-btn pa-btn--xs pa-btn--ghost pa-overflow__trigger pa-card__actions-overflow-trigger';
|
|
124
|
+
trigger.setAttribute('aria-label', 'More actions');
|
|
125
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
126
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
127
|
+
trigger.innerHTML = '<i class="fa-solid fa-ellipsis-vertical" aria-hidden="true"></i>';
|
|
128
|
+
trigger.style.display = 'none';
|
|
129
|
+
root.appendChild(trigger);
|
|
130
|
+
|
|
131
|
+
// Menu reuses pa-btn-split__menu — same container styling and
|
|
132
|
+
// `--open` toggle behaviour as the standard split button, no
|
|
133
|
+
// parallel styling to maintain. Lives on <body> so card / wrapper
|
|
134
|
+
// overflow:hidden can't clip it; positioned via Floating UI.
|
|
135
|
+
var menu = document.createElement('div');
|
|
136
|
+
menu.className = 'pa-btn-split__menu';
|
|
137
|
+
menu.setAttribute('role', 'menu');
|
|
138
|
+
document.body.appendChild(menu);
|
|
139
|
+
var menuInner = document.createElement('div');
|
|
140
|
+
menuInner.className = 'pa-btn-split__menu-inner';
|
|
141
|
+
menu.appendChild(menuInner);
|
|
142
|
+
|
|
143
|
+
log('init', { itemCount: ordered.length, priorities: ordered.map(function (i) { return i.priority; }) });
|
|
144
|
+
|
|
145
|
+
// When we move an item into the menu we replace its classList with
|
|
146
|
+
// `pa-btn-split__item` so it adopts the standard split-button menu
|
|
147
|
+
// row styling; the original classList is stashed on the element
|
|
148
|
+
// itself and restored on the way back.
|
|
149
|
+
function moveToMenu(el) {
|
|
150
|
+
if (el.__paOverflowOrigClass == null) {
|
|
151
|
+
el.__paOverflowOrigClass = el.className;
|
|
152
|
+
}
|
|
153
|
+
el.className = 'pa-btn-split__item';
|
|
154
|
+
menuInner.appendChild(el);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function moveToRoot(el) {
|
|
158
|
+
if (el.__paOverflowOrigClass != null) {
|
|
159
|
+
el.className = el.__paOverflowOrigClass;
|
|
160
|
+
}
|
|
161
|
+
// insertBefore moves the node if it's already attached, so we
|
|
162
|
+
// always call it — that keeps the items in original DOM order
|
|
163
|
+
// even after a drop-from-start cycle reshuffled them.
|
|
164
|
+
root.insertBefore(el, trigger);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function relayout() {
|
|
168
|
+
log('relayout entered');
|
|
169
|
+
|
|
170
|
+
// Step 1 — bring everything back to root in DOM order, hide trigger.
|
|
171
|
+
ordered.slice().sort(function (a, b) {
|
|
172
|
+
return a.domIndex - b.domIndex;
|
|
173
|
+
}).forEach(function (item) {
|
|
174
|
+
moveToRoot(item.el);
|
|
175
|
+
});
|
|
176
|
+
trigger.style.display = 'none';
|
|
177
|
+
|
|
178
|
+
// Step 2 — does everything fit? `scrollWidth > clientWidth` is the
|
|
179
|
+
// truthful "browser thinks this overflows" signal; requires the
|
|
180
|
+
// wrapper to have `min-width: 0; overflow: hidden` (set in SCSS)
|
|
181
|
+
// so the wrapper actually shrinks below its content when there's
|
|
182
|
+
// not enough room.
|
|
183
|
+
var clientW = root.clientWidth;
|
|
184
|
+
var scrollW = root.scrollWidth;
|
|
185
|
+
var parent = root.parentNode;
|
|
186
|
+
var parentW = parent ? parent.clientWidth : null;
|
|
187
|
+
var rootComputedStyle = getComputedStyle(root);
|
|
188
|
+
log('relayout step 2', {
|
|
189
|
+
clientW: clientW,
|
|
190
|
+
scrollW: scrollW,
|
|
191
|
+
fitsAll: scrollW <= clientW,
|
|
192
|
+
parentClientW: parentW,
|
|
193
|
+
rootMinWidth: rootComputedStyle.minWidth,
|
|
194
|
+
rootOverflow: rootComputedStyle.overflow,
|
|
195
|
+
rootFlex: rootComputedStyle.flex,
|
|
196
|
+
rootDisplay: rootComputedStyle.display
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (scrollW <= clientW) {
|
|
200
|
+
log('all fits, hiding trigger');
|
|
201
|
+
if (menu.classList.contains('pa-btn-split__menu--open')) closeMenu();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Step 3 — needs to overflow. Show trigger; walk drop order and
|
|
206
|
+
// move items into the menu one at a time, re-checking after each
|
|
207
|
+
// move. Stop as soon as `scrollWidth <= clientWidth`.
|
|
208
|
+
trigger.style.display = '';
|
|
209
|
+
|
|
210
|
+
for (var i = 0; i < dropOrder.length; i++) {
|
|
211
|
+
if (root.scrollWidth <= root.clientWidth) break;
|
|
212
|
+
var el = dropOrder[i].el;
|
|
213
|
+
moveToMenu(el);
|
|
214
|
+
log('moved to menu', el, { remainingScroll: root.scrollWidth, clientW: root.clientWidth });
|
|
215
|
+
}
|
|
216
|
+
log('relayout done', { inMenu: menuInner.children.length, inRoot: ordered.length - menuInner.children.length });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Floating UI's autoUpdate returns a cleanup fn — we hold it while
|
|
220
|
+
// open so closeMenu() can stop watching scroll/resize/IntersectionObserver
|
|
221
|
+
// for free, instead of unhooking listeners by hand.
|
|
222
|
+
var stopAutoUpdate = null;
|
|
223
|
+
|
|
224
|
+
function openMenu() {
|
|
225
|
+
if (menuInner.children.length === 0) return; // nothing to show
|
|
226
|
+
menu.classList.add('pa-btn-split__menu--open');
|
|
227
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
228
|
+
|
|
229
|
+
if (FUI && FUI.computePosition && FUI.autoUpdate) {
|
|
230
|
+
// autoUpdate fires the callback on init AND whenever scroll
|
|
231
|
+
// ancestors, the viewport, or the reference itself move —
|
|
232
|
+
// handles flip / shift / off-screen retraction without our
|
|
233
|
+
// own scroll/resize listeners.
|
|
234
|
+
stopAutoUpdate = FUI.autoUpdate(trigger, menu, function () {
|
|
235
|
+
// Trigger gone (display:none on it or any ancestor —
|
|
236
|
+
// e.g. the splitter rail-minimize hides all buttons in
|
|
237
|
+
// a minimized pane) → close the menu so it doesn't
|
|
238
|
+
// hang in the middle of the page detached from any
|
|
239
|
+
// anchor.
|
|
240
|
+
if (trigger.offsetParent === null) {
|
|
241
|
+
closeMenu();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
var rect = trigger.getBoundingClientRect();
|
|
245
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
246
|
+
closeMenu();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
FUI.computePosition(trigger, menu, {
|
|
250
|
+
placement: 'bottom-end',
|
|
251
|
+
strategy: 'fixed',
|
|
252
|
+
middleware: [
|
|
253
|
+
FUI.offset(4),
|
|
254
|
+
FUI.flip(),
|
|
255
|
+
FUI.shift({ padding: 8 })
|
|
256
|
+
]
|
|
257
|
+
}).then(function (pos) {
|
|
258
|
+
Object.assign(menu.style, {
|
|
259
|
+
position: 'fixed',
|
|
260
|
+
left: pos.x + 'px',
|
|
261
|
+
top: pos.y + 'px',
|
|
262
|
+
right: 'auto'
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
positionMenuFallback();
|
|
268
|
+
window.addEventListener('scroll', positionMenuFallback, true);
|
|
269
|
+
window.addEventListener('resize', positionMenuFallback);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setTimeout(function () {
|
|
273
|
+
document.addEventListener('mousedown', onDocClick);
|
|
274
|
+
}, 0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function closeMenu() {
|
|
278
|
+
menu.classList.remove('pa-btn-split__menu--open');
|
|
279
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
280
|
+
if (stopAutoUpdate) {
|
|
281
|
+
stopAutoUpdate();
|
|
282
|
+
stopAutoUpdate = null;
|
|
283
|
+
}
|
|
284
|
+
window.removeEventListener('scroll', positionMenuFallback, true);
|
|
285
|
+
window.removeEventListener('resize', positionMenuFallback);
|
|
286
|
+
document.removeEventListener('mousedown', onDocClick);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function positionMenuFallback() {
|
|
290
|
+
// Used only if Floating UI isn't loaded — no flip, no shift,
|
|
291
|
+
// no scroll-into-corner handling.
|
|
292
|
+
var rect = trigger.getBoundingClientRect();
|
|
293
|
+
menu.style.position = 'fixed';
|
|
294
|
+
menu.style.top = (rect.bottom + 4) + 'px';
|
|
295
|
+
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
|
296
|
+
menu.style.left = 'auto';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function onDocClick(e) {
|
|
300
|
+
if (menu.contains(e.target) || trigger.contains(e.target)) return;
|
|
301
|
+
closeMenu();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
trigger.addEventListener('click', function (e) {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
if (menu.classList.contains('pa-btn-split__menu--open')) closeMenu();
|
|
307
|
+
else openMenu();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
menu.addEventListener('click', function (e) {
|
|
311
|
+
// Any click on a menu row defers a close — the row's original
|
|
312
|
+
// click handler fires first because we don't preventDefault.
|
|
313
|
+
if (!e.target.closest('.pa-btn-split__item')) return;
|
|
314
|
+
setTimeout(closeMenu, 0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// First paint — wait for layout, then run relayout.
|
|
318
|
+
requestAnimationFrame(relayout);
|
|
319
|
+
|
|
320
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
321
|
+
var ro = new ResizeObserver(function (entries) {
|
|
322
|
+
log('ResizeObserver', entries.map(function (e) {
|
|
323
|
+
return { target: e.target === root ? 'root' : 'parent', width: e.contentRect.width };
|
|
324
|
+
}));
|
|
325
|
+
// Any card resize closes the menu. UX is cleaner than
|
|
326
|
+
// dragging the menu around mid-resize; the user can reopen
|
|
327
|
+
// once they've settled the layout.
|
|
328
|
+
if (menu.classList.contains('pa-btn-split__menu--open')) {
|
|
329
|
+
closeMenu();
|
|
330
|
+
}
|
|
331
|
+
relayout();
|
|
332
|
+
});
|
|
333
|
+
ro.observe(root);
|
|
334
|
+
// Also observe the parent header — when items are in the menu, the
|
|
335
|
+
// wrapper sizes to its (smaller) content and doesn't fire its own
|
|
336
|
+
// size change when the parent grows. Watching the parent gives us
|
|
337
|
+
// a signal to attempt restoring items.
|
|
338
|
+
if (root.parentNode) ro.observe(root.parentNode);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// React to `data-pa-actions-overflow-from` flips so consumers can
|
|
342
|
+
// change drop direction at runtime (eg. a settings toggle) without
|
|
343
|
+
// re-initializing the wrapper.
|
|
344
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
345
|
+
var mo = new MutationObserver(function () {
|
|
346
|
+
buildDropOrder();
|
|
347
|
+
relayout();
|
|
348
|
+
});
|
|
349
|
+
mo.observe(root, { attributes: true, attributeFilter: ['data-pa-actions-overflow-from'] });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function initAll(scope) {
|
|
354
|
+
var nodes = (scope || document).querySelectorAll(SELECTOR);
|
|
355
|
+
for (var i = 0; i < nodes.length; i++) init(nodes[i]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
var api = { init: init, initAll: initAll };
|
|
359
|
+
window.PaOverflow = api;
|
|
360
|
+
// Back-compat: `card-actions-overflow.js` exported this global; keep
|
|
361
|
+
// pointing at the same { init, initAll } pair so existing consumers
|
|
362
|
+
// calling `window.PaCardActionsOverflow.initAll(scope)` (after
|
|
363
|
+
// injecting dynamic markup) keep working.
|
|
364
|
+
window.PaCardActionsOverflow = api;
|
|
365
|
+
|
|
366
|
+
if (document.readyState === 'loading') {
|
|
367
|
+
document.addEventListener('DOMContentLoaded', function () { initAll(); });
|
|
368
|
+
} else {
|
|
369
|
+
initAll();
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
pa-stat-fit.js — fit-to-box for pa-stat--square[data-pa-stat-fit]
|
|
3
|
+
|
|
4
|
+
Opt-in companion to the SCSS "fit + progressive disclosure" mode. For every
|
|
5
|
+
.pa-stat--square[data-pa-stat-fit] it:
|
|
6
|
+
|
|
7
|
+
1. Wraps the authored .pa-stat__number (+ optional .pa-stat__symbol, in
|
|
8
|
+
their authored order so prefix "$847K" and suffix "87%" both work) into
|
|
9
|
+
a .pa-stat__slot > .pa-stat__group structure. Idempotent.
|
|
10
|
+
2. Sizes .pa-stat__group so it fills .pa-stat__slot at the largest font
|
|
11
|
+
that still fits — never overflowing, independent of character count
|
|
12
|
+
(which a pure-CSS `cqi` clamp can't guarantee). The symbol scales with
|
|
13
|
+
the number because the SCSS sizes it in `em`.
|
|
14
|
+
3. Re-fits whenever the slot changes size — including when the CSS
|
|
15
|
+
@container ladder reveals/hides the label / change / context rows
|
|
16
|
+
(which grows or shrinks the slot).
|
|
17
|
+
|
|
18
|
+
The progressive disclosure itself (which rows show at which size) is pure
|
|
19
|
+
CSS — see core-components/_statistics.scss. This file only does the one
|
|
20
|
+
thing CSS can't: fit the primary number to the box.
|
|
21
|
+
|
|
22
|
+
Public API (window.PaStatFit):
|
|
23
|
+
init(root) — wrap + observe every fit tile under `root` (default document)
|
|
24
|
+
refresh(el) — re-run fit on one tile (or all, if omitted); call after you
|
|
25
|
+
change the number's text content programmatically
|
|
26
|
+
========================================================================== */
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
var BASE = 200; // measure the group at a known font-size, then scale linearly
|
|
31
|
+
var SAFETY = 0.92; // leave a hair of breathing room so glyphs never kiss the edge
|
|
32
|
+
// The number is the HERO; the metadata is subordinate. Its column font-size is set
|
|
33
|
+
// to this fraction of the TILE HEIGHT, so the label / trend / context stay small and
|
|
34
|
+
// — crucially — CONSISTENT across a row of same-height tiles. (Basing it on the fitted
|
|
35
|
+
// number size looked right on one tile but drifted between tiles: a wide "847K" fits
|
|
36
|
+
// at a different size than "87", so the meta came out bigger on some tiles than others.
|
|
37
|
+
// Tile height is uniform across the row, so the meta matches while still scaling if the
|
|
38
|
+
// tile itself grows/shrinks.) This is THE hierarchy knob — lower = tinier meta. The
|
|
39
|
+
// rows are em-relative in the SCSS, so this one value drives all three.
|
|
40
|
+
var META_RATIO = 0.10; // ~14px label on a 140px tile (was 0.07 ≈ 10px, too small to read)
|
|
41
|
+
var META_MIN_PX = 12; // …but never below this: on a short tile the proportional size
|
|
42
|
+
// would go illegible. Floors the __label; __change/__context are
|
|
43
|
+
// 0.9em/0.8em of it, so the smallest row bottoms out ~9.6px.
|
|
44
|
+
var HORIZONTAL_MIN_REM = 32; // keep in sync with $stat-square-fit-horizontal-min-w
|
|
45
|
+
var observed = []; // [{ tile, slot }] for refresh()
|
|
46
|
+
|
|
47
|
+
function rootFontPx() {
|
|
48
|
+
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Switch a tile to the horizontal "banner" layout once it's wide enough.
|
|
52
|
+
// Done in JS (not a @container query) because the tile is its own size
|
|
53
|
+
// container, and a container query can't restyle the container element itself.
|
|
54
|
+
function applyMode(tile) {
|
|
55
|
+
var wide = tile.clientWidth >= HORIZONTAL_MIN_REM * rootFontPx();
|
|
56
|
+
tile.classList.toggle('pa-stat--fit-wide', wide);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Wrap the authored-flat children:
|
|
60
|
+
// __number + __symbol (in DOM order) → __slot > __group (the fit target)
|
|
61
|
+
// __label / __change / __context → __meta (the metadata column)
|
|
62
|
+
// so the metadata can move beside the number in the horizontal banner layout.
|
|
63
|
+
// Returns the slot element, or null if there's no number to fit.
|
|
64
|
+
function ensureStructure(tile) {
|
|
65
|
+
var existing = tile.querySelector(':scope > .pa-stat__slot');
|
|
66
|
+
if (existing) return existing;
|
|
67
|
+
|
|
68
|
+
var numberParts = [];
|
|
69
|
+
var metaParts = [];
|
|
70
|
+
Array.prototype.forEach.call(tile.children, function (child) {
|
|
71
|
+
if (child.classList.contains('pa-stat__number') ||
|
|
72
|
+
child.classList.contains('pa-stat__symbol')) {
|
|
73
|
+
numberParts.push(child);
|
|
74
|
+
} else if (child.classList.contains('pa-stat__label') ||
|
|
75
|
+
child.classList.contains('pa-stat__change') ||
|
|
76
|
+
child.classList.contains('pa-stat__context')) {
|
|
77
|
+
metaParts.push(child);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
if (!numberParts.length) return null;
|
|
81
|
+
|
|
82
|
+
var group = document.createElement('span');
|
|
83
|
+
group.className = 'pa-stat__group';
|
|
84
|
+
var slot = document.createElement('div');
|
|
85
|
+
slot.className = 'pa-stat__slot';
|
|
86
|
+
|
|
87
|
+
// Drop the slot where the first number part lived, then move the parts into
|
|
88
|
+
// the group (appendChild relocates them, preserving authored order).
|
|
89
|
+
tile.insertBefore(slot, numberParts[0]);
|
|
90
|
+
numberParts.forEach(function (part) { group.appendChild(part); });
|
|
91
|
+
slot.appendChild(group);
|
|
92
|
+
|
|
93
|
+
// Wrap the metadata rows into a column after the slot.
|
|
94
|
+
if (metaParts.length) {
|
|
95
|
+
var meta = document.createElement('div');
|
|
96
|
+
meta.className = 'pa-stat__meta';
|
|
97
|
+
tile.insertBefore(meta, metaParts[0]);
|
|
98
|
+
metaParts.forEach(function (part) { meta.appendChild(part); });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return slot;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Scale .pa-stat__group to fill its slot without overflowing either axis.
|
|
105
|
+
function fitSlot(slot) {
|
|
106
|
+
if (!slot) return;
|
|
107
|
+
var group = slot.querySelector('.pa-stat__group');
|
|
108
|
+
if (!group) return;
|
|
109
|
+
|
|
110
|
+
group.style.fontSize = BASE + 'px';
|
|
111
|
+
var gw = group.offsetWidth, gh = group.offsetHeight;
|
|
112
|
+
var sw = slot.clientWidth, sh = slot.clientHeight;
|
|
113
|
+
if (!gw || !gh || !sw || !sh) return;
|
|
114
|
+
|
|
115
|
+
var scale = Math.min(sw / gw, sh / gh) * SAFETY;
|
|
116
|
+
group.style.fontSize = (BASE * scale) + 'px';
|
|
117
|
+
|
|
118
|
+
// Size the metadata column as a small fraction of the TILE HEIGHT so it stays
|
|
119
|
+
// subordinate AND consistent across same-height tiles (not coupled to the per-tile
|
|
120
|
+
// number size). The slot's parent is the tile; the meta column is its sibling.
|
|
121
|
+
// label/change/context are em-relative, so this one set cascades to all three.
|
|
122
|
+
var tile = slot.parentNode;
|
|
123
|
+
var meta = tile && tile.querySelector(':scope > .pa-stat__meta');
|
|
124
|
+
if (meta && tile.clientHeight) {
|
|
125
|
+
meta.style.fontSize = Math.max(META_MIN_PX, tile.clientHeight * META_RATIO) + 'px';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ResizeObserver fires when a watched box changes. We watch:
|
|
130
|
+
// • the TILE → toggle the horizontal/vertical layout class by its width
|
|
131
|
+
// • the SLOT → refit the number (also catches the @container disclosure
|
|
132
|
+
// ladder growing/shrinking the slot, which doesn't resize the tile)
|
|
133
|
+
var ro = typeof ResizeObserver !== 'undefined'
|
|
134
|
+
? new ResizeObserver(function (entries) {
|
|
135
|
+
entries.forEach(function (entry) {
|
|
136
|
+
var el = entry.target;
|
|
137
|
+
if (el.classList.contains('pa-stat__slot')) {
|
|
138
|
+
fitSlot(el);
|
|
139
|
+
} else {
|
|
140
|
+
applyMode(el);
|
|
141
|
+
fitSlot(el.querySelector(':scope > .pa-stat__slot'));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
})
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
function setupTile(tile) {
|
|
148
|
+
if (tile.__paStatFit) return;
|
|
149
|
+
var slot = ensureStructure(tile);
|
|
150
|
+
if (!slot) return;
|
|
151
|
+
tile.__paStatFit = true;
|
|
152
|
+
observed.push({ tile: tile, slot: slot });
|
|
153
|
+
applyMode(tile); // set initial layout before first fit
|
|
154
|
+
if (ro) { ro.observe(tile); ro.observe(slot); }
|
|
155
|
+
fitSlot(slot); // initial pass (also covers no-ResizeObserver fallback)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function init(root) {
|
|
159
|
+
(root || document)
|
|
160
|
+
.querySelectorAll('.pa-stat--square[data-pa-stat-fit]')
|
|
161
|
+
.forEach(setupTile);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Re-fit after the number text changes (font-size needs recomputing even
|
|
165
|
+
// though the slot box didn't move, so ResizeObserver wouldn't fire).
|
|
166
|
+
function refresh(el) {
|
|
167
|
+
if (el) {
|
|
168
|
+
var slot = el.classList && el.classList.contains('pa-stat__slot')
|
|
169
|
+
? el
|
|
170
|
+
: el.querySelector('.pa-stat__slot');
|
|
171
|
+
fitSlot(slot);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
observed.forEach(function (rec) { fitSlot(rec.slot); });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (document.readyState === 'loading') {
|
|
178
|
+
document.addEventListener('DOMContentLoaded', function () { init(); });
|
|
179
|
+
} else {
|
|
180
|
+
init();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
window.PaStatFit = { init: init, refresh: refresh };
|
|
184
|
+
})();
|