@keenmate/pure-css 1.0.0-rc03 → 1.0.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/src/js/fit.js ADDED
@@ -0,0 +1,890 @@
1
+ /**
2
+ * Pure CSS — Fit (priority-driven container degradation)
3
+ *
4
+ * The one priority-driven degradation engine. Folds EVERY slot in a horizontal
5
+ * container: brand, version, page title, a search box, actions — or, on any flex
6
+ * row (a toolbar, a filter bar, a form's action row), whatever you put in it. It
7
+ * ALSO folds NAV MENU items (the former navbar-collapse.js, now merged here — see
8
+ * the NAV COLLAPSE section below). When the row can't fit all its content, slots
9
+ * degrade one at a time, LOWEST PRIORITY FIRST, each using its declared strategy,
10
+ * until the row fits. When space returns, everything restores.
11
+ *
12
+ * The navbar (`.pc-navbar__inner`) auto-inits; any other container opts in with
13
+ * `pureCss.components.fit.init(el)` (aliased `navFit` for back-compat).
14
+ *
15
+ * Declare participation with attributes on any element inside the container:
16
+ *
17
+ * data-pc-fit="hide" — remove the slot when it must yield.
18
+ * data-pc-fit="steps" — the slot holds ranked variants; show the LARGEST
19
+ * that fits, degrading step 0 → 1 → 2 … → hidden.
20
+ * The A/B/C idea: e.g. full logo → wordmark →
21
+ * monogram, or a search pill → just its icon.
22
+ * data-pc-fit="relocate" — move the slot OUT of the row into a named
23
+ * destination when it must yield, restored on widen.
24
+ * WHERE it goes is a registered sink named by
25
+ * data-pc-fit-target ("sidebar" | "floating-menu" |
26
+ * a custom sink you register). fit only DECIDES;
27
+ * the sink PLACES. `data-pc-fit="sidebar"` is sugar
28
+ * for relocate + target=sidebar.
29
+ *
30
+ * data-pc-fit-priority="N" — degrade order; LOWER degrades first. When omitted,
31
+ * resolves to the nearest ancestor's
32
+ * data-pc-fit-default-priority, else
33
+ * pureCss.config.fit.defaultPriority, else 0.
34
+ * data-pc-fit-step="0" — on a `steps` slot's DIRECT children; 0 = largest
35
+ * / the default. Numbers, ascending = smaller.
36
+ * data-pc-fit-target="sidebar" — (relocate) which sink places the slot.
37
+ * data-pc-fit-target-selector="#sel" — (relocate) destination element for sinks
38
+ * that need one (sidebar: the <ul> to move
39
+ * into; default: first `.pc-sidebar__nav > ul`).
40
+ * data-pc-fit-managed — (relocate) HANDS-OFF: fit fires the event
41
+ * + hides the slot in-row but does NOT move
42
+ * the DOM. A framework wrapper owns placement
43
+ * and re-renders the block from state (fresh
44
+ * data, never a stale moved node). Same effect
45
+ * as calling preventDefault() on the event.
46
+ *
47
+ * Group opt-in / opt-out (so you don't have to tag every child):
48
+ *
49
+ * data-pc-fit-auto — on a CONTAINER: fold ALL its direct children into
50
+ * the fit set. Children with their own data-pc-fit
51
+ * keep it; the rest become implicit `hide` slots at
52
+ * the default priority (so an un-ranked control drops
53
+ * first). Use for a toolbar/filter bar you want to
54
+ * shrink without ranking each button.
55
+ * data-pc-fit-ignore — PIN this element: never a slot, even next to
56
+ * declared siblings or inside a data-pc-fit-auto
57
+ * container. Use for a burger, a notification bell,
58
+ * a profile avatar, a form's submit button.
59
+ * data-pc-fit-default-priority="N" — on a container: the priority implicit /
60
+ * un-ranked descendants inherit (overrides the global
61
+ * config default for that subtree).
62
+ *
63
+ * Example:
64
+ * <div class="pc-app-header">
65
+ * <span data-pc-fit="steps" data-pc-fit-priority="30">
66
+ * <span data-pc-fit-step="0">Pure Admin</span> <!-- widest, default -->
67
+ * <span data-pc-fit-step="1">PA</span> <!-- narrowest -->
68
+ * </span>
69
+ * <span class="pc-app-header__version" data-pc-fit="hide" data-pc-fit-priority="10">v2.9.0</span>
70
+ * </div>
71
+ *
72
+ * Measurement: the row's "needed" width is the sum of the fit container's direct
73
+ * section scrollWidths (+ gaps). scrollWidth reports each section's CONTENT width
74
+ * even when a flex child is squeezed — so a title clipped behind the brand still
75
+ * counts its true width and triggers a degrade before anything overlaps. The
76
+ * algorithm is RESET-THEN-DEGRADE: every pass restores all slots to natural,
77
+ * then degrades to fit. That makes the result a pure function of the width (no
78
+ * hysteresis, no oscillation) and sidesteps the "freed space is absorbed by the
79
+ * flex:1 centre slot, so a restore is undetectable" trap.
80
+ *
81
+ * Nav folding is coordinated in-engine: the header relayout pre-folds every
82
+ * fit-managed nav (relayoutAllNav) before it measures, so it never over-degrades
83
+ * the header to fit items the nav sheds anyway.
84
+ *
85
+ * Relocation events (on the slot element, bubbling):
86
+ * pc:fit-relocate — CustomEvent, cancelable. detail = { action:'out'|'in',
87
+ * target, container }. Fires once per flip. preventDefault()
88
+ * (or data-pc-fit-managed) → fit performs NO DOM move; the
89
+ * listener owns placement.
90
+ *
91
+ * Public API (on window.pureCss.components.fit, alias .navFit):
92
+ * init(container) — wire one fit container (idempotent)
93
+ * initAll(scope) — wire every navbar fit container under scope
94
+ * relayoutAll() — force a re-measure (e.g. after markup changes)
95
+ * registerSink(name, sink) — add a relocation destination. sink =
96
+ * { out(el, ctx) -> mount|false, in(el, ctx) }.
97
+ * Built-ins: 'sidebar', 'floating-menu'.
98
+ */
99
+ (function () {
100
+ 'use strict';
101
+
102
+ var CONTAINER_SELECTOR = '.pc-navbar__inner';
103
+ // Toggled to hide a slot / an inactive step. A class (not inline display) so
104
+ // "show" reverts to the element's natural stylesheet display; `!important` so
105
+ // it beats component rules like `.pc-navbar-search { display: flex }`.
106
+ var HIDDEN_CLASS = 'pc-fit-hidden';
107
+ var containers = [];
108
+
109
+ function stepIndex(el) {
110
+ return parseFloat(el.getAttribute('data-pc-fit-step')) || 0;
111
+ }
112
+
113
+ function isIgnored(el) {
114
+ return el.nodeType === 1 && el.hasAttribute('data-pc-fit-ignore');
115
+ }
116
+
117
+ // The priority a slot without its own data-pc-fit-priority falls back to.
118
+ function configDefaultPriority() {
119
+ var pa = window.pureCss;
120
+ var v = pa && pa.config && pa.config.fit && pa.config.fit.defaultPriority;
121
+ return typeof v === 'number' && !isNaN(v) ? v : 0;
122
+ }
123
+
124
+ // Resolve a slot's priority: its own data-pc-fit-priority wins; else the
125
+ // nearest ancestor (up to and including the container) carrying
126
+ // data-pc-fit-default-priority; else the global config default; else 0.
127
+ function resolvePriority(el, container) {
128
+ if (el.hasAttribute('data-pc-fit-priority')) {
129
+ var own = parseFloat(el.getAttribute('data-pc-fit-priority'));
130
+ if (!isNaN(own)) return own;
131
+ }
132
+ var node = el.parentNode;
133
+ while (node && node.nodeType === 1) {
134
+ if (node.hasAttribute('data-pc-fit-default-priority')) {
135
+ var d = parseFloat(node.getAttribute('data-pc-fit-default-priority'));
136
+ if (!isNaN(d)) return d;
137
+ }
138
+ if (node === container) break;
139
+ node = node.parentNode;
140
+ }
141
+ return configDefaultPriority();
142
+ }
143
+
144
+ // Build one slot descriptor. Declared slots read their strategy/steps from the
145
+ // markup; implicit slots (folded in by a data-pc-fit-auto container) are always
146
+ // a plain `hide`.
147
+ function describeSlot(el, container, implicit) {
148
+ var strategy = 'hide';
149
+ var steps = [];
150
+ var target = null;
151
+ if (!implicit) {
152
+ strategy = el.getAttribute('data-pc-fit') || 'hide';
153
+ // 'sidebar' is sugar for relocate → the built-in 'sidebar' sink.
154
+ if (strategy === 'sidebar') { strategy = 'relocate'; target = 'sidebar'; }
155
+ if (strategy !== 'hide' && strategy !== 'steps' && strategy !== 'relocate') strategy = 'hide';
156
+ if (strategy === 'relocate') {
157
+ target = el.getAttribute('data-pc-fit-target') || target || 'sidebar';
158
+ }
159
+ if (strategy === 'steps') {
160
+ var children = el.children;
161
+ for (var c = 0; c < children.length; c++) {
162
+ if (children[c].hasAttribute('data-pc-fit-step')) steps.push(children[c]);
163
+ }
164
+ steps.sort(function (a, b) { return stepIndex(a) - stepIndex(b); });
165
+ }
166
+ }
167
+ return {
168
+ el: el,
169
+ strategy: strategy,
170
+ target: target, // relocate: name of the destination sink
171
+ priority: resolvePriority(el, container),
172
+ steps: steps,
173
+ domIndex: 0, // assigned after the document-order sort below
174
+ state: 0, // current degradation level
175
+ // maxState: how far a slot can degrade.
176
+ maxState: strategy === 'steps' ? steps.length /* last step + then hidden */ : 1
177
+ };
178
+ }
179
+
180
+ // Collect participating slots under a container. Two sources:
181
+ // 1. DECLARED — any [data-pc-fit] element (its strategy/priority as written).
182
+ // 2. IMPLICIT — the direct children of any [data-pc-fit-auto] container that
183
+ // didn't declare their own data-pc-fit; folded in as `hide` @ default
184
+ // priority so an un-ranked control yields first.
185
+ // data-pc-fit-ignore excludes an element from both. domIndex is assigned in
186
+ // document order so equal-priority ties break "later element first" (trailing
187
+ // decoration yields before leading content).
188
+ function collectSlots(container) {
189
+ var i, k;
190
+
191
+ var declared = [];
192
+ var els = container.querySelectorAll('[data-pc-fit]');
193
+ for (i = 0; i < els.length; i++) {
194
+ if (!isIgnored(els[i])) declared.push(els[i]);
195
+ }
196
+
197
+ // Armed containers: every [data-pc-fit-auto] inside, plus the container
198
+ // itself if it carries the attribute.
199
+ var autoParents = [];
200
+ var autos = container.querySelectorAll('[data-pc-fit-auto]');
201
+ for (i = 0; i < autos.length; i++) autoParents.push(autos[i]);
202
+ if (container.nodeType === 1 && container.hasAttribute('data-pc-fit-auto')) {
203
+ autoParents.push(container);
204
+ }
205
+
206
+ var implicit = [];
207
+ for (i = 0; i < autoParents.length; i++) {
208
+ var kids = autoParents[i].children;
209
+ for (k = 0; k < kids.length; k++) {
210
+ var kid = kids[k];
211
+ if (kid.hasAttribute('data-pc-fit')) continue; // already a declared slot
212
+ if (isIgnored(kid)) continue; // pinned out
213
+ if (implicit.indexOf(kid) === -1) implicit.push(kid);
214
+ }
215
+ }
216
+
217
+ var slots = [];
218
+ for (i = 0; i < declared.length; i++) slots.push(describeSlot(declared[i], container, false));
219
+ for (i = 0; i < implicit.length; i++) slots.push(describeSlot(implicit[i], container, true));
220
+
221
+ slots.sort(function (a, b) {
222
+ var pos = a.el.compareDocumentPosition(b.el);
223
+ if (pos & 4 /* DOCUMENT_POSITION_FOLLOWING */) return -1; // a precedes b
224
+ if (pos & 2 /* DOCUMENT_POSITION_PRECEDING */) return 1;
225
+ return 0;
226
+ });
227
+ for (i = 0; i < slots.length; i++) slots[i].domIndex = i;
228
+
229
+ return slots;
230
+ }
231
+
232
+ // The furthest-along degrade first: pick the lowest-priority slot not yet at
233
+ // its maxState. Tie-break: later DOM position degrades first.
234
+ function nextToDegrade(slots) {
235
+ var best = null;
236
+ for (var i = 0; i < slots.length; i++) {
237
+ var s = slots[i];
238
+ if (s.state >= s.maxState) continue;
239
+ if (!best ||
240
+ s.priority < best.priority ||
241
+ (s.priority === best.priority && s.domIndex > best.domIndex)) {
242
+ best = s;
243
+ }
244
+ }
245
+ return best;
246
+ }
247
+
248
+ function setHidden(el, hidden) {
249
+ if (hidden) el.classList.add(HIDDEN_CLASS);
250
+ else el.classList.remove(HIDDEN_CLASS);
251
+ }
252
+
253
+ // Apply a slot's MEASUREMENT state — class toggles only, no DOM moves.
254
+ // (Sidebar relocation is reconciled once, after the loop settles.)
255
+ function applyMeasureState(slot) {
256
+ var s = slot.state;
257
+ if (slot.strategy === 'steps') {
258
+ for (var i = 0; i < slot.steps.length; i++) {
259
+ setHidden(slot.steps[i], i !== s);
260
+ }
261
+ // Past the last step → the whole slot is hidden.
262
+ setHidden(slot.el, s >= slot.steps.length);
263
+ } else if (slot.strategy === 'hide') {
264
+ setHidden(slot.el, s >= 1);
265
+ } else if (slot.strategy === 'relocate') {
266
+ // Every relayout starts by pulling relocated nodes home (resetAll), so a
267
+ // relocate slot is always in-row during measurement. Hidden here means
268
+ // "won't fit — will move to its sink (or a managed wrapper renders it
269
+ // elsewhere)". reconcileRelocate acts on the settled state afterwards.
270
+ setHidden(slot.el, s >= 1);
271
+ }
272
+ }
273
+
274
+ // --- Relocation: fit decides, a SINK places -----------------------------
275
+ // fit.js only decides a slot must yield; WHERE it goes is a registered sink
276
+ // keyed by data-pc-fit-target. A sink is:
277
+ // out(el, ctx) -> mount | false place `el`; return an opaque mount (passed
278
+ // back to in), or false to refuse (→ acts
279
+ // like `hide`). Do NOT manage `el`'s home —
280
+ // the engine leaves a placeholder and moves
281
+ // `el` back itself.
282
+ // in(el, ctx) tear down the destination wrapper; ctx.mount
283
+ // is what out() returned. The engine has
284
+ // already moved `el` back to its placeholder.
285
+ // Before invoking a sink, fit fires a cancelable `pc:fit-relocate` on the
286
+ // element. A framework (svelte/phoenix) preventDefault()s it — or the slot /
287
+ // container carries data-pc-fit-managed — to own placement itself: it hides
288
+ // in-row (already done) and re-renders the block from state, so restored
289
+ // content is always fresh, never a stale moved node.
290
+ var sinks = {};
291
+ function registerSink(name, sink) { if (name && sink) sinks[name] = sink; }
292
+
293
+ function isManaged(slot, container) {
294
+ return slot.el.hasAttribute('data-pc-fit-managed') ||
295
+ (container.nodeType === 1 && container.hasAttribute('data-pc-fit-managed'));
296
+ }
297
+
298
+ function dispatchRelocate(slot, action, container) {
299
+ var detail = { action: action, target: slot.target, container: container };
300
+ var ev;
301
+ try {
302
+ ev = new CustomEvent('pc:fit-relocate', { bubbles: true, cancelable: true, detail: detail });
303
+ } catch (e) {
304
+ ev = document.createEvent('CustomEvent');
305
+ ev.initCustomEvent('pc:fit-relocate', true, true, detail);
306
+ }
307
+ slot.el.dispatchEvent(ev);
308
+ return ev;
309
+ }
310
+
311
+ // Physically move a slot out through its sink, leaving a placeholder comment
312
+ // at its home so it restores to the exact spot. Returns a record, or null if
313
+ // the sink refused / is unknown (slot then behaves like `hide`).
314
+ function relocateOut(slot, container) {
315
+ var el = slot.el, sink = sinks[slot.target];
316
+ if (!sink) return null;
317
+ var placeholder = document.createComment('pa-fit-slot');
318
+ el.parentNode.insertBefore(placeholder, el);
319
+ setHidden(el, false); // visible in its new home
320
+ var mount = sink.out(el, { container: container, target: slot.target });
321
+ if (mount === false || mount == null) {
322
+ // Refused (e.g. no sidebar present). Undo: pull el back, drop placeholder,
323
+ // hide in row.
324
+ if (el.parentNode !== placeholder.parentNode) placeholder.parentNode.insertBefore(el, placeholder);
325
+ placeholder.parentNode.removeChild(placeholder);
326
+ setHidden(el, true);
327
+ return null;
328
+ }
329
+ return { el: el, placeholder: placeholder, sink: sink, target: slot.target, mount: mount };
330
+ }
331
+
332
+ // Move a relocated node home and tear down its sink wrapper.
333
+ function restoreRec(rec) {
334
+ if (rec.placeholder && rec.placeholder.parentNode) {
335
+ rec.placeholder.parentNode.insertBefore(rec.el, rec.placeholder);
336
+ rec.placeholder.parentNode.removeChild(rec.placeholder);
337
+ }
338
+ try { if (rec.sink && rec.sink.in) rec.sink.in(rec.el, { target: rec.target, mount: rec.mount }); } catch (e) {}
339
+ setHidden(rec.el, false);
340
+ }
341
+
342
+ // Pull every relocated node back home so the next measure sees natural width
343
+ // and collectSlots finds the full set again. This is what makes relocation a
344
+ // pure function of width (restore-on-widen falls out for free).
345
+ function resetAll(entry) {
346
+ if (!entry.relocs || !entry.relocs.length) return;
347
+ var recs = entry.relocs; entry.relocs = [];
348
+ for (var i = 0; i < recs.length; i++) restoreRec(recs[i]);
349
+ }
350
+
351
+ // After the degrade loop settles, relocate slots whose state says "out".
352
+ // Events fire only on a real flip (deduped via el.__paFitOut) so a framework
353
+ // listener isn't spammed every resize frame.
354
+ function reconcileRelocate(entry, container, slots) {
355
+ slots.forEach(function (slot) {
356
+ if (slot.strategy !== 'relocate') return;
357
+ var wantOut = slot.state >= 1;
358
+ var was = slot.el.__paFitOut === true;
359
+ if (wantOut !== was) dispatchRelocateFlip(slot, wantOut, container, entry);
360
+ else if (wantOut && !isManaged(slot, container)) {
361
+ // No user-visible flip, but resetAll pulled it home this pass — put it
362
+ // back out (no event) so a stable narrow width keeps it relocated.
363
+ var rec = relocateOut(slot, container);
364
+ if (rec) entry.relocs.push(rec);
365
+ }
366
+ });
367
+ }
368
+
369
+ function dispatchRelocateFlip(slot, wantOut, container, entry) {
370
+ var ev = dispatchRelocate(slot, wantOut ? 'out' : 'in', container);
371
+ slot.el.__paFitOut = wantOut;
372
+ // Managed / prevented: the framework owns placement. Node stays hidden
373
+ // in-row (applyMeasureState); nothing to move. Restore ('in') is likewise
374
+ // the framework's job.
375
+ if (ev.defaultPrevented || isManaged(slot, container)) return;
376
+ if (wantOut) {
377
+ var rec = relocateOut(slot, container);
378
+ if (rec) entry.relocs.push(rec);
379
+ }
380
+ // Non-managed 'in' already happened in resetAll() at the top of relayout.
381
+ }
382
+
383
+ // --- Built-in sink: sidebar --------------------------------------------
384
+ // Rebuilds the slot as a sidebar list item under the app sidebar's nav <ul>.
385
+ // Target <ul>: data-pc-fit-target-selector (or legacy data-pc-fit-sidebar-target),
386
+ // else the first `.pc-sidebar__nav > ul`.
387
+ registerSink('sidebar', {
388
+ out: function (el, ctx) {
389
+ var sel = el.getAttribute('data-pc-fit-target-selector') || el.getAttribute('data-pc-fit-sidebar-target');
390
+ var ul = sel ? document.querySelector(sel) : document.querySelector('.pc-sidebar__nav > ul');
391
+ if (!ul) return false;
392
+ var li = document.createElement('li');
393
+ li.className = 'pc-sidebar__item pc-fit-relocated';
394
+ li.appendChild(el);
395
+ ul.appendChild(li);
396
+ return { wrapper: li };
397
+ },
398
+ in: function (el, ctx) {
399
+ var li = ctx.mount && ctx.mount.wrapper; // el already moved home by the engine
400
+ if (li && li.parentNode) li.parentNode.removeChild(li);
401
+ }
402
+ });
403
+
404
+ // --- Built-in sink: floating-menu --------------------------------------
405
+ // Self-contained flyout — needs no sidebar, so it works on sidebar-less pages.
406
+ // One trigger (pinned in the container, ignored by fit) + one panel (on body)
407
+ // per container, created lazily and hidden when empty.
408
+ var flyouts = [];
409
+ function flyoutFor(container) {
410
+ for (var i = 0; i < flyouts.length; i++) if (flyouts[i].container === container) return flyouts[i];
411
+ var trigger = document.createElement('button');
412
+ trigger.type = 'button';
413
+ trigger.className = 'pc-fit-flyout__trigger';
414
+ trigger.setAttribute('data-pc-fit-ignore', '');
415
+ trigger.setAttribute('aria-label', 'More');
416
+ trigger.setAttribute('aria-expanded', 'false');
417
+ trigger.style.display = 'none';
418
+ trigger.innerHTML = '<span class="pc-fit-flyout__dots" aria-hidden="true"></span>';
419
+ var panel = document.createElement('div');
420
+ panel.className = 'pc-fit-flyout__panel';
421
+ var f = { container: container, trigger: trigger, panel: panel, count: 0 };
422
+ function reposition() {
423
+ var r = trigger.getBoundingClientRect();
424
+ panel.style.top = (r.bottom + 4) + 'px';
425
+ panel.style.left = 'auto';
426
+ panel.style.right = Math.max(4, window.innerWidth - r.right) + 'px';
427
+ }
428
+ function close() { panel.classList.remove('pc-fit-flyout__panel--open'); trigger.setAttribute('aria-expanded', 'false'); }
429
+ function open() { reposition(); panel.classList.add('pc-fit-flyout__panel--open'); trigger.setAttribute('aria-expanded', 'true'); }
430
+ trigger.addEventListener('click', function (e) {
431
+ e.stopPropagation();
432
+ if (panel.classList.contains('pc-fit-flyout__panel--open')) close(); else open();
433
+ });
434
+ document.addEventListener('click', function (e) {
435
+ if (e.target !== trigger && !trigger.contains(e.target) && !panel.contains(e.target)) close();
436
+ });
437
+ window.addEventListener('resize', function () {
438
+ if (panel.classList.contains('pc-fit-flyout__panel--open')) reposition();
439
+ });
440
+ f.close = close;
441
+ container.appendChild(trigger);
442
+ document.body.appendChild(panel);
443
+ flyouts.push(f);
444
+ return f;
445
+ }
446
+ registerSink('floating-menu', {
447
+ out: function (el, ctx) {
448
+ var f = flyoutFor(ctx.container);
449
+ var item = document.createElement('div');
450
+ item.className = 'pc-fit-flyout__item pc-fit-relocated';
451
+ item.appendChild(el);
452
+ f.panel.appendChild(item);
453
+ f.count++;
454
+ f.trigger.style.display = '';
455
+ return { wrapper: item, flyout: f };
456
+ },
457
+ in: function (el, ctx) {
458
+ var m = ctx.mount || {};
459
+ if (m.wrapper && m.wrapper.parentNode) m.wrapper.parentNode.removeChild(m.wrapper);
460
+ if (m.flyout) {
461
+ m.flyout.count = Math.max(0, m.flyout.count - 1);
462
+ if (m.flyout.count === 0) { m.flyout.trigger.style.display = 'none'; m.flyout.close && m.flyout.close(); }
463
+ }
464
+ }
465
+ });
466
+
467
+ // A section's CONTENT width. For a flex-GROWING section (the centre slot,
468
+ // flex:1) scrollWidth reports the inflated box, not the content — so removing
469
+ // content makes it grow and read *wider*, which would make the engine fight
470
+ // itself and over-degrade. For those, sum the children instead; for fixed
471
+ // sections (flex-grow:0) scrollWidth already equals content.
472
+ function contentWidth(el) {
473
+ if (parseFloat(getComputedStyle(el).flexGrow) > 0) {
474
+ var w = 0, kids = el.children;
475
+ for (var i = 0; i < kids.length; i++) {
476
+ if (getComputedStyle(kids[i]).display !== 'none') w += kids[i].scrollWidth;
477
+ }
478
+ return w;
479
+ }
480
+ return el.scrollWidth;
481
+ }
482
+
483
+ function measure(container) {
484
+ var cs = getComputedStyle(container);
485
+ var gap = parseFloat(cs.columnGap) || parseFloat(cs.gap) || 0;
486
+ var padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
487
+ var avail = container.clientWidth - padX;
488
+ var kids = container.children, need = 0, vis = 0;
489
+ for (var i = 0; i < kids.length; i++) {
490
+ if (getComputedStyle(kids[i]).display === 'none') continue;
491
+ need += contentWidth(kids[i]);
492
+ vis++;
493
+ }
494
+ need += gap * Math.max(0, vis - 1);
495
+ return { avail: avail, need: need };
496
+ }
497
+
498
+ function overflowing(container) {
499
+ var m = measure(container);
500
+ return m.need > m.avail + 1; // 1px tolerance for sub-pixel rounding
501
+ }
502
+
503
+ function relayout(entry) {
504
+ var container = entry.container;
505
+
506
+ // Pull any relocated nodes home FIRST, so we measure natural width and
507
+ // collectSlots sees the full set again (relocation = pure function of width).
508
+ resetAll(entry);
509
+
510
+ var slots = entry.slots = collectSlots(container);
511
+
512
+ // Reset every slot to natural, then degrade until it fits. Deterministic:
513
+ // the settled state is a pure function of the current width.
514
+ slots.forEach(function (s) { s.state = 0; applyMeasureState(s); });
515
+
516
+ // Fold any collapsing nav FIRST (at the natural, widest slot state), so we
517
+ // measure against a nav that's already shed what it can — otherwise we'd
518
+ // over-degrade the header to fit items that fold into the sidebar anyway.
519
+ relayoutAllNav();
520
+
521
+ var guard = 0;
522
+ while (overflowing(container) && guard++ < 100) {
523
+ var s = nextToDegrade(slots);
524
+ if (!s) break; // nothing left to give
525
+ s.state += 1;
526
+ applyMeasureState(s);
527
+ }
528
+
529
+ reconcileRelocate(entry, container, slots);
530
+ }
531
+
532
+ function schedule(entry) {
533
+ if (entry.raf) return;
534
+ entry.raf = (typeof requestAnimationFrame === 'function')
535
+ ? requestAnimationFrame(function () { entry.raf = null; relayout(entry); })
536
+ : (relayout(entry), null);
537
+ }
538
+
539
+ function init(container) {
540
+ if (!container || container.__paFitInit) return;
541
+ // Nothing to manage unless there's a declared slot, an armed sub-container,
542
+ // or the container itself is armed (data-pc-fit-auto with no tagged child).
543
+ var armedSelf = container.nodeType === 1 && container.hasAttribute('data-pc-fit-auto');
544
+ if (!armedSelf && !container.querySelector('[data-pc-fit], [data-pc-fit-auto]')) return;
545
+ container.__paFitInit = true;
546
+
547
+ var entry = { container: container, slots: [], raf: null, relocs: [] };
548
+ containers.push(entry);
549
+
550
+ schedule(entry);
551
+
552
+ if (typeof ResizeObserver !== 'undefined') {
553
+ var ro = new ResizeObserver(function () { schedule(entry); });
554
+ ro.observe(container);
555
+ } else if (window.pureCss && window.pureCss.events) {
556
+ window.pureCss.events.on('viewport:resize', function () { schedule(entry); });
557
+ } else {
558
+ window.addEventListener('resize', function () { schedule(entry); });
559
+ }
560
+ }
561
+
562
+ function initAll(scope) {
563
+ var root = scope || document;
564
+ var els = root.querySelectorAll(CONTAINER_SELECTOR);
565
+ for (var i = 0; i < els.length; i++) init(els[i]);
566
+ }
567
+
568
+ function relayoutAll() {
569
+ containers.forEach(schedule);
570
+ }
571
+
572
+ // ======================================================================
573
+ // NAV COLLAPSE — a nav is a fit container whose ITEMS degrade (Option A:
574
+ // ported from navbar-collapse.js into the one engine). A nav's <li>s fold,
575
+ // lowest-priority first, into a sink chosen by data-pc-fit-nav
576
+ // ("sidebar" | "menu" | "off"), restoring as space returns.
577
+ //
578
+ // <nav class="pc-navmenu" data-pc-fit-nav="sidebar"
579
+ // data-pc-fit-nav-label="Menu"&gt;…
580
+ //
581
+ // Per-<li>: data-pc-fit-nav-priority (lower drops first),
582
+ // data-pc-nav-icon (sidebar icon), data-pc-fit-nav="hide" (drop, don't
583
+ // relocate). Nav config: data-pc-fit-nav-target (sidebar <ul> selector),
584
+ // data-pc-fit-nav-label / -icon, data-pc-fit-nav-more-label (menu).
585
+ //
586
+ // Measurement is NAV-specific — sums the <li> bounding widths vs
587
+ // nav.clientWidth (the nav is overflow:visible so item hover-dropdowns aren't
588
+ // clipped), NOT scrollWidth. This is the key reason it lived in its own file;
589
+ // now it's one engine with a per-container measure.
590
+ // ======================================================================
591
+ var NAV_SELECTOR = '.pc-navmenu[data-pc-fit-nav]';
592
+ var navRelayouts = [];
593
+
594
+ function navDirectChild(parent, tag) {
595
+ for (var c = parent.firstElementChild; c; c = c.nextElementSibling) {
596
+ if (c.tagName && c.tagName.toLowerCase() === tag) return c;
597
+ }
598
+ return null;
599
+ }
600
+ function navEscapeHtml(s) {
601
+ return String(s).replace(/[&<>"']/g, function (c) {
602
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
603
+ });
604
+ }
605
+
606
+ // MENU sink — fold items into a generated "More ▾" nav dropdown (on <body>).
607
+ function navMenuStrategy(nav, ul) {
608
+ var moreLabel = nav.getAttribute('data-pc-fit-nav-more-label') || 'More';
609
+ var moreLi = document.createElement('li');
610
+ moreLi.className = 'pc-navmenu__item pc-navmenu__item--more';
611
+ var moreLink = document.createElement('a');
612
+ moreLink.href = '#';
613
+ moreLink.className = 'pc-navmenu__link';
614
+ moreLink.setAttribute('aria-haspopup', 'true');
615
+ moreLink.setAttribute('aria-expanded', 'false');
616
+ moreLink.innerHTML = navEscapeHtml(moreLabel) +
617
+ ' <span class="pc-navmenu__more-chevron" aria-hidden="true">›</span>';
618
+ moreLi.appendChild(moreLink);
619
+ ul.appendChild(moreLi);
620
+ moreLi.style.display = 'none';
621
+
622
+ var menu = document.createElement('ul');
623
+ menu.className = 'pc-navmenu__dropdown pc-navmenu__more-menu';
624
+ menu.setAttribute('role', 'menu');
625
+ document.body.appendChild(menu);
626
+
627
+ function position() {
628
+ var r = moreLink.getBoundingClientRect();
629
+ menu.style.position = 'fixed';
630
+ menu.style.top = (r.bottom + 4) + 'px';
631
+ menu.style.left = 'auto';
632
+ menu.style.right = (window.innerWidth - r.right) + 'px';
633
+ }
634
+ function isOpen() { return menu.classList.contains('pc-navmenu__more-menu--open'); }
635
+ function openMenu() {
636
+ if (menu.children.length === 0) return;
637
+ position();
638
+ menu.classList.add('pc-navmenu__more-menu--open');
639
+ moreLi.classList.add('is-open');
640
+ moreLink.setAttribute('aria-expanded', 'true');
641
+ window.addEventListener('scroll', position, true);
642
+ window.addEventListener('resize', position);
643
+ setTimeout(function () { document.addEventListener('mousedown', onDocClick); }, 0);
644
+ }
645
+ function closeMenu() {
646
+ menu.classList.remove('pc-navmenu__more-menu--open');
647
+ moreLi.classList.remove('is-open');
648
+ moreLink.setAttribute('aria-expanded', 'false');
649
+ window.removeEventListener('scroll', position, true);
650
+ window.removeEventListener('resize', position);
651
+ document.removeEventListener('mousedown', onDocClick);
652
+ }
653
+ function onDocClick(e) {
654
+ if (menu.contains(e.target) || moreLi.contains(e.target)) return;
655
+ closeMenu();
656
+ }
657
+ moreLink.addEventListener('click', function (e) {
658
+ e.preventDefault(); e.stopPropagation();
659
+ if (isOpen()) closeMenu(); else openMenu();
660
+ });
661
+ menu.addEventListener('click', function (e) {
662
+ if (e.target.closest('a')) setTimeout(closeMenu, 0);
663
+ });
664
+
665
+ return {
666
+ count: function () { return menu.children.length; },
667
+ restore: function (el) { if (el.parentNode === menu) ul.insertBefore(el, moreLi); },
668
+ afterRestore: function () { moreLi.style.display = 'none'; closeMenu(); },
669
+ onFits: function () {},
670
+ onOverflow: function () {},
671
+ collapse: function (el) { moreLi.style.display = ''; menu.appendChild(el); }
672
+ };
673
+ }
674
+
675
+ // SIDEBAR sink — build genuine .pc-sidebar__* markup from each nav item under
676
+ // an injected section heading. Leaf → link item; dropdown parent → collapsible
677
+ // toggle group (chevron, starts closed, parent's own page first). The original
678
+ // nav <li> is kept detached and re-inserted on restore.
679
+ function navSidebarStrategy(nav, ul) {
680
+ var targetSel = nav.getAttribute('data-pc-fit-nav-target');
681
+ var target = targetSel ? document.querySelector(targetSel) : document.querySelector('.pc-sidebar__nav > ul');
682
+ var label = nav.getAttribute('data-pc-fit-nav-label') || 'Menu';
683
+ var defaultIcon = nav.getAttribute('data-pc-fit-nav-icon');
684
+ if (defaultIcon == null) defaultIcon = '•';
685
+
686
+ var section = null, divider = null;
687
+ function ensureSection() {
688
+ if (!target) return null;
689
+ if (!section) {
690
+ section = document.createElement('li');
691
+ section.className = 'pc-sidebar__section';
692
+ section.setAttribute('data-pc-nav-injected', '');
693
+ section.textContent = label;
694
+ }
695
+ if (!divider) {
696
+ divider = document.createElement('li');
697
+ divider.className = 'pc-sidebar__divider';
698
+ divider.setAttribute('data-pc-nav-injected', '');
699
+ divider.setAttribute('aria-hidden', 'true');
700
+ }
701
+ if (section.parentNode !== target) target.insertBefore(section, target.firstChild);
702
+ if (divider.parentNode !== target) target.insertBefore(divider, section.nextSibling);
703
+ return section;
704
+ }
705
+ function removeSection() {
706
+ if (section && section.parentNode) section.parentNode.removeChild(section);
707
+ if (divider && divider.parentNode) divider.parentNode.removeChild(divider);
708
+ }
709
+ function labelOf(a) { return (a ? a.textContent : '').replace(/\s*[›»>]+\s*$/, '').trim(); }
710
+ function iconHtml(icon) { return icon ? '<span class="pc-sidebar__icon">' + navEscapeHtml(icon) + '</span>' : ''; }
711
+ function labelHtml(text) { return '<span class="pc-sidebar__label">' + navEscapeHtml(text) + '</span>'; }
712
+ function buildLinkItem(text, href, icon, active) {
713
+ var li = document.createElement('li');
714
+ li.className = 'pc-sidebar__item';
715
+ var a = document.createElement('a');
716
+ a.className = 'pc-sidebar__link' + (active ? ' pc-sidebar__link--active' : '');
717
+ a.setAttribute('href', href == null ? '#' : href);
718
+ a.innerHTML = iconHtml(icon) + labelHtml(text);
719
+ li.appendChild(a);
720
+ return li;
721
+ }
722
+ function buildSidebarItem(navLi) {
723
+ var link = navDirectChild(navLi, 'a');
724
+ var sub = navDirectChild(navLi, 'ul');
725
+ var icon = navLi.getAttribute('data-pc-nav-icon');
726
+ if (icon == null) icon = defaultIcon;
727
+ var text = labelOf(link);
728
+ var href = link ? link.getAttribute('href') : null;
729
+ var active = navLi.classList.contains('pc-navmenu__item--active');
730
+ var isReal = href && href !== '#' && href.charAt(href.length - 1) !== '#';
731
+ if (!sub) return buildLinkItem(text, href, icon, active);
732
+
733
+ var li = document.createElement('li');
734
+ li.className = 'pc-sidebar__item' + (active ? ' pc-sidebar__item--open' : '');
735
+ var btn = document.createElement('button');
736
+ btn.type = 'button';
737
+ btn.className = 'pc-sidebar__toggle';
738
+ btn.setAttribute('aria-expanded', active ? 'true' : 'false');
739
+ btn.innerHTML = iconHtml(icon) + labelHtml(text) +
740
+ '<span class="pc-sidebar__chevron" aria-hidden="true">›</span>';
741
+ li.appendChild(btn);
742
+ var submenu = document.createElement('ul');
743
+ submenu.className = 'pc-sidebar__submenu' + (active ? ' pc-sidebar__submenu--open' : '');
744
+ if (isReal) submenu.appendChild(buildLinkItem(text, href, icon, active));
745
+ for (var c = sub.firstElementChild; c; c = c.nextElementSibling) {
746
+ if (c.tagName && c.tagName.toLowerCase() === 'li') submenu.appendChild(buildSidebarItem(c));
747
+ }
748
+ li.appendChild(submenu);
749
+ btn.addEventListener('click', function () {
750
+ var open = !li.classList.contains('pc-sidebar__item--open');
751
+ li.classList.toggle('pc-sidebar__item--open', open);
752
+ submenu.classList.toggle('pc-sidebar__submenu--open', open);
753
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
754
+ });
755
+ return li;
756
+ }
757
+
758
+ return {
759
+ count: function () { return target ? target.querySelectorAll('[data-pc-nav-collapsed]').length : 0; },
760
+ restore: function (el) {
761
+ if (el.__paCollapsed) {
762
+ var node = el.__paSidebarNode;
763
+ if (node && node.parentNode) node.parentNode.removeChild(node);
764
+ el.__paSidebarNode = null;
765
+ el.__paCollapsed = false;
766
+ }
767
+ ul.appendChild(el);
768
+ },
769
+ afterRestore: function () {
770
+ if (target && !target.querySelector('[data-pc-nav-collapsed]')) removeSection();
771
+ },
772
+ onFits: function () { removeSection(); },
773
+ onOverflow: function () {},
774
+ collapse: function (el) {
775
+ if (!target || el.__paCollapsed) return;
776
+ ensureSection();
777
+ var node = buildSidebarItem(el);
778
+ node.setAttribute('data-pc-nav-collapsed', '');
779
+ el.__paSidebarNode = node;
780
+ el.__paCollapsed = true;
781
+ if (el.parentNode) el.parentNode.removeChild(el);
782
+ target.insertBefore(node, section.nextSibling);
783
+ }
784
+ };
785
+ }
786
+
787
+ function initNav(nav) {
788
+ if (!nav || nav.__paFitNavInit) return;
789
+ var mode = nav.getAttribute('data-pc-fit-nav') || 'menu';
790
+ if (mode === 'off') return;
791
+ if (mode !== 'menu' && mode !== 'sidebar') mode = 'menu';
792
+ nav.__paFitNavInit = true;
793
+
794
+ var ul = nav.querySelector(':scope > ul');
795
+ if (!ul) return;
796
+
797
+ var items = Array.prototype.slice.call(ul.children).filter(function (el) { return el.nodeType === 1; });
798
+ if (items.length === 0) return;
799
+
800
+ var ordered = items.map(function (el, idx) {
801
+ var raw = parseInt(el.getAttribute('data-pc-fit-nav-priority'), 10);
802
+ var priority = isNaN(raw) ? 0 : raw;
803
+ var hide = el.getAttribute('data-pc-fit-nav') === 'hide';
804
+ return { el: el, priority: priority, domIndex: idx, hide: hide };
805
+ });
806
+ var dropOrder = ordered.slice().sort(function (a, b) {
807
+ if (a.priority !== b.priority) return a.priority - b.priority;
808
+ return b.domIndex - a.domIndex;
809
+ });
810
+
811
+ var strategy = (mode === 'sidebar') ? navSidebarStrategy(nav, ul) : navMenuStrategy(nav, ul);
812
+
813
+ function contentWidth() {
814
+ var cs = getComputedStyle(ul);
815
+ var gap = parseFloat(cs.columnGap) || parseFloat(cs.gap) || 0;
816
+ var total = 0, count = 0, kids = ul.children;
817
+ for (var i = 0; i < kids.length; i++) {
818
+ var k = kids[i];
819
+ if (k.nodeType !== 1) continue;
820
+ if (getComputedStyle(k).display === 'none') continue;
821
+ total += k.getBoundingClientRect().width;
822
+ count++;
823
+ }
824
+ if (count > 1) total += gap * (count - 1);
825
+ return total;
826
+ }
827
+ function overflowing() { return contentWidth() > nav.clientWidth + 1; }
828
+
829
+ function relayout() {
830
+ ordered.slice().sort(function (a, b) { return a.domIndex - b.domIndex; })
831
+ .forEach(function (item) {
832
+ if (item.hide) item.el.style.display = '';
833
+ strategy.restore(item.el);
834
+ });
835
+ strategy.afterRestore();
836
+
837
+ if (!overflowing()) { strategy.onFits(); return; }
838
+
839
+ strategy.onOverflow();
840
+ for (var i = 0; i < dropOrder.length; i++) {
841
+ if (!overflowing()) break;
842
+ if (dropOrder[i].hide) dropOrder[i].el.style.display = 'none';
843
+ else strategy.collapse(dropOrder[i].el);
844
+ }
845
+ }
846
+
847
+ navRelayouts.push(relayout);
848
+ (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : function (f) { f(); })(relayout);
849
+
850
+ if (typeof ResizeObserver !== 'undefined') {
851
+ var ro = new ResizeObserver(function () { relayout(); });
852
+ ro.observe(nav);
853
+ var inner = nav.closest('.pc-navbar__inner') || nav.parentNode;
854
+ if (inner) ro.observe(inner);
855
+ } else if (window.pureCss && window.pureCss.events) {
856
+ window.pureCss.events.on('viewport:resize', relayout);
857
+ } else {
858
+ window.addEventListener('resize', relayout);
859
+ }
860
+ }
861
+
862
+ function initAllNav(scope) {
863
+ var root = scope || document;
864
+ var els = root.querySelectorAll(NAV_SELECTOR);
865
+ for (var i = 0; i < els.length; i++) initNav(els[i]);
866
+ }
867
+ function relayoutAllNav() { navRelayouts.forEach(function (fn) { fn(); }); }
868
+
869
+ // Register the two nav sinks by name so a custom nav target is possible and
870
+ // the fit + nav paths share one registry.
871
+ registerSink('nav-sidebar', { nav: navSidebarStrategy });
872
+ registerSink('nav-menu', { nav: navMenuStrategy });
873
+
874
+ var pa = (window.pureCss = window.pureCss || {});
875
+ var api = {
876
+ init: init, initAll: initAll, relayoutAll: relayoutAll, registerSink: registerSink,
877
+ initNav: initNav, initAllNav: initAllNav, relayoutAllNav: relayoutAllNav
878
+ };
879
+ // `fit` is the canonical name (the engine is container-generic now); `navFit`
880
+ // stays as a back-compat alias for existing callers.
881
+ (pa.components = pa.components || {}).fit = api;
882
+ pa.components.navFit = api;
883
+
884
+ if (document.readyState === 'loading') {
885
+ document.addEventListener('DOMContentLoaded', function () { initAll(); initAllNav(); });
886
+ } else {
887
+ initAll();
888
+ initAllNav();
889
+ }
890
+ })();