@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.
Files changed (35) hide show
  1. package/README.md +55 -13
  2. package/dist/css/main.css +554 -7
  3. package/package.json +3 -1
  4. package/snippets/buttons.html +51 -0
  5. package/snippets/cards.html +132 -47
  6. package/snippets/comparison.html +26 -22
  7. package/snippets/manifest.json +180 -115
  8. package/snippets/range-group.html +125 -0
  9. package/snippets/splitter.html +44 -38
  10. package/snippets/statistics.html +31 -0
  11. package/src/js/btn-split-auto-absorb.js +327 -0
  12. package/src/js/command-palette.js +472 -0
  13. package/src/js/file-selector.js +1275 -0
  14. package/src/js/internal/logging.js +121 -0
  15. package/src/js/logic-tree-renderer.js +303 -0
  16. package/src/js/modal-dialogs.js +460 -0
  17. package/src/js/overflow.js +371 -0
  18. package/src/js/pa-stat-fit.js +184 -0
  19. package/src/js/range-group.js +663 -0
  20. package/src/js/search-autocomplete-v2.js +907 -0
  21. package/src/js/search-autocomplete.js +434 -0
  22. package/src/js/settings-panel.js +245 -0
  23. package/src/js/split-button.js +141 -0
  24. package/src/js/splitter.js +1323 -0
  25. package/src/js/toast-service.js +302 -0
  26. package/src/js/tooltips-popovers.js +275 -0
  27. package/src/js/virtual-scroll.js +143 -0
  28. package/src/js/virtual-textbox.js +803 -0
  29. package/src/scss/_core.scss +7 -0
  30. package/src/scss/core-components/_buttons.scss +44 -0
  31. package/src/scss/core-components/_cards.scss +95 -6
  32. package/src/scss/core-components/_overflow.scss +50 -0
  33. package/src/scss/core-components/_range-group.scss +474 -0
  34. package/src/scss/core-components/_statistics.scss +163 -0
  35. package/src/scss/variables/_components.scss +41 -2
@@ -0,0 +1,1323 @@
1
+ /**
2
+ * Pure Admin Splitter
3
+ * Resizable container with two or more panes. Auto-initializes on
4
+ * [data-pa-splitter]. Panes and gutters alternate (pane, gutter, pane,
5
+ * gutter, …, pane); each pane carries its own sizing attributes.
6
+ *
7
+ * --- Attributes on root ---
8
+ * data-pa-splitter (marker; required)
9
+ * data-pa-splitter-id="key" (enables localStorage persistence)
10
+ * data-pa-splitter-step="10" (keyboard step in px; default 10)
11
+ * data-pa-splitter-rail-size="40" (rail width in px; default reads
12
+ * --pa-splitter-rail-size from
13
+ * getComputedStyle, then 40 literal)
14
+ * data-pa-splitter-minimize-threshold="0.40"
15
+ * (drag-to-minimize snap ratio of the
16
+ * drag-start size, floored at rail × 1.5)
17
+ *
18
+ * --- Accordion mode (auto) ---
19
+ * When the container is narrower than the sum of all pane mins + gutters
20
+ * + gaps + padding AND there are 2+ minimizable panes, the splitter
21
+ * switches into single-pane-expanded mode: restoring one minimizable pane
22
+ * auto-rails the others. Engages/disengages automatically as the
23
+ * container resizes. Adds the class `pa-splitter--accordion` to the root
24
+ * as a styling hook.
25
+ *
26
+ * --- Attributes on each .pa-splitter__pane ---
27
+ * data-pa-splitter-size="200px" | "30%" (initial size; unspecified panes
28
+ * share leftover equally — or the
29
+ * last pane absorbs if all sized)
30
+ * data-pa-splitter-min="150px" | "10%"
31
+ * data-pa-splitter-max="400px" | "50%"
32
+ * data-pa-splitter-minimize (marker; only honoured on the
33
+ * first and last panes — they
34
+ * roll up against the closest
35
+ * container edge)
36
+ *
37
+ * --- Drag-from-rail asymmetry ---
38
+ * When the gutter's primary neighbour is already railed, drag direction
39
+ * matters: dragging OUTWARD (the direction that would grow the pane)
40
+ * releases the rail and follows the cursor; dragging INWARD (into the
41
+ * rail, would shrink the pane further) is inert — the rail stays put and
42
+ * the gutter doesn't move. Tap-without-drag on the gutter does nothing.
43
+ * Restore gestures: rail-body click, dblclick on the gutter, focus the
44
+ * gutter and press Enter/Space, click a [data-pa-splitter-toggle], or
45
+ * drag the gutter outward past the snap threshold.
46
+ *
47
+ * --- Events (CustomEvent, bubbles from the pane) ---
48
+ * pa-splitter:resize detail: { index, pane, size } per pane on every applySizes
49
+ * pa-splitter:collapse detail: { index, pane } when a pane is railed
50
+ * pa-splitter:expand detail: { index, pane } when a pane is restored
51
+ * Resize fires unconditionally per pane during drag — debounce in the listener.
52
+ * Init does NOT fire collapse for panes that started rail'd from saved state.
53
+ *
54
+ * Public API (window.PaSplitter):
55
+ * init(el) - initialize a single splitter element (idempotent)
56
+ * initAll(root?) - initialize all uninitialized splitters under root (default: document)
57
+ */
58
+ (function () {
59
+ 'use strict';
60
+
61
+ var STORAGE_PREFIX = 'pa-splitter:';
62
+ var INIT_FLAG = '__paSplitterInit';
63
+
64
+ function parseSize(raw, totalPx) {
65
+ if (raw == null || raw === '') return null;
66
+ var s = String(raw).trim();
67
+ if (s.endsWith('%')) {
68
+ var pct = parseFloat(s);
69
+ if (isNaN(pct)) return null;
70
+ return (pct / 100) * totalPx;
71
+ }
72
+ if (s.endsWith('px')) {
73
+ var px = parseFloat(s);
74
+ return isNaN(px) ? null : px;
75
+ }
76
+ // Bare number = px
77
+ var n = parseFloat(s);
78
+ if (!isNaN(n) && /^-?\d*\.?\d+$/.test(s)) return n;
79
+ console.warn('[pa-splitter] unsupported size unit:', raw, '(use px or %)');
80
+ return null;
81
+ }
82
+
83
+ function clamp(value, min, max) {
84
+ if (value < min) return min;
85
+ if (value > max) return max;
86
+ return value;
87
+ }
88
+
89
+ // Rail size resolution: per-instance attribute wins, then the
90
+ // --pa-splitter-rail-size CSS variable (themes / inline-style overrides),
91
+ // then a hardcoded 40px floor. Without the CSS-var path the SCSS variable
92
+ // $splitter-rail-size was effectively dead — themes could rename it and
93
+ // nothing happened at the JS layer.
94
+ //
95
+ // Note on rem/em: the CSS variable emit is `4rem` (per SCSS source), and
96
+ // `getComputedStyle` returns the variable's value un-resolved — i.e. the
97
+ // raw string "4rem", not the px equivalent. parseFloat("4rem") returns 4,
98
+ // which would snap panes to 4px-wide strips instead of 40px rails. So we
99
+ // probe with a hidden element: set its width to the CSS variable, read
100
+ // offsetWidth, and let the browser do the unit resolution.
101
+ function readRailSize(root) {
102
+ var attr = root.getAttribute('data-pa-splitter-rail-size');
103
+ if (attr != null && attr !== '') {
104
+ var px = parseInt(attr, 10);
105
+ if (!isNaN(px) && px > 0) return px;
106
+ }
107
+ try {
108
+ var probe = root.ownerDocument.createElement('div');
109
+ probe.style.cssText = 'position:absolute;visibility:hidden;height:0;width:var(--pa-splitter-rail-size, 40px);';
110
+ root.appendChild(probe);
111
+ var resolved = probe.offsetWidth;
112
+ root.removeChild(probe);
113
+ if (resolved > 0) return resolved;
114
+ } catch (err) { /* fall through to default */ }
115
+ return 40;
116
+ }
117
+
118
+ function readStorage(id) {
119
+ if (!id) return null;
120
+ try {
121
+ var raw = localStorage.getItem(STORAGE_PREFIX + id);
122
+ if (!raw) return null;
123
+ var parsed = JSON.parse(raw);
124
+ if (typeof parsed !== 'object' || parsed === null) return null;
125
+ return parsed;
126
+ } catch (e) {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function writeStorage(id, state) {
132
+ if (!id) return;
133
+ try {
134
+ localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(state));
135
+ } catch (e) {
136
+ // quota / privacy mode — silently skip
137
+ }
138
+ }
139
+
140
+ function init(root) {
141
+ if (!root || root[INIT_FLAG]) return;
142
+ initNPane(root);
143
+ }
144
+
145
+ // ====================================================================
146
+ // Per-pane state lives in parallel arrays (sizes[i], mins[i], …) so the
147
+ // hot drag loop avoids object churn.
148
+ //
149
+ // Drag model: REBALANCE (not boundary-coupled). Each gutter `g` owns
150
+ // resizing of its PRIMARY neighbour (edge-closer side; LTR/RTL
151
+ // tiebreaker on middle/even ties). Dragging changes only the primary's
152
+ // size; the matching opposite delta is distributed across all other
153
+ // non-minimized panes proportionally to their current sizes. Rail panes
154
+ // (isMin[i] === true) stay pinned at railSize and don't participate.
155
+ //
156
+ // Rationale: in a mixed layout like AcBcCeDeEc (A,B,E rail; C,D
157
+ // expanded), the user grabbing the A|B gutter expects to grow A. A
158
+ // boundary-coupled model would try to shrink B for slack — but B is
159
+ // already railed, so the drag locks. The rebalance model pulls slack
160
+ // from C and D instead, matching user intent.
161
+ //
162
+ // Minimize is honoured on any pane with data-pa-splitter-minimize.
163
+
164
+ function initNPane(root) {
165
+ var isVertical = root.classList.contains('pa-splitter--vertical');
166
+ var clientAxis = isVertical ? 'clientHeight' : 'clientWidth';
167
+ var clientCoord = isVertical ? 'clientY' : 'clientX';
168
+
169
+ var panes = Array.prototype.slice.call(root.querySelectorAll(':scope > .pa-splitter__pane'));
170
+ var gutters = Array.prototype.slice.call(root.querySelectorAll(':scope > .pa-splitter__gutter'));
171
+
172
+ if (panes.length < 2 || gutters.length !== panes.length - 1) {
173
+ console.warn('[pa-splitter] N-pane mode needs N panes + N-1 gutters, skipping', root, {
174
+ panes: panes.length, gutters: gutters.length
175
+ });
176
+ return;
177
+ }
178
+
179
+ // Verify alternating DOM order (pane, gutter, pane, gutter, …, pane).
180
+ // Children matching neither selector are tolerated (a stray comment
181
+ // node or whitespace won't trip this) but anything inserted between
182
+ // pane and gutter would break drag math.
183
+ var structural = Array.prototype.slice.call(root.children).filter(function (el) {
184
+ return el.classList && (el.classList.contains('pa-splitter__pane') || el.classList.contains('pa-splitter__gutter'));
185
+ });
186
+ for (var si = 0; si < structural.length; si++) {
187
+ var expected = si % 2 === 0 ? 'pa-splitter__pane' : 'pa-splitter__gutter';
188
+ if (!structural[si].classList.contains(expected)) {
189
+ console.warn('[pa-splitter] panes and gutters must alternate (pane, gutter, pane, …), skipping', root);
190
+ return;
191
+ }
192
+ }
193
+
194
+ root[INIT_FLAG] = true;
195
+
196
+ var id = root.getAttribute('data-pa-splitter-id') || null;
197
+ var stepPx = parseInt(root.getAttribute('data-pa-splitter-step'), 10) || 10;
198
+ var railSizePx = readRailSize(root);
199
+ var minimizeThresholdRatio = parseFloat(root.getAttribute('data-pa-splitter-minimize-threshold'));
200
+ if (isNaN(minimizeThresholdRatio) || minimizeThresholdRatio <= 0 || minimizeThresholdRatio >= 1) {
201
+ minimizeThresholdRatio = 0.40;
202
+ }
203
+
204
+ var label = '[pa-splitter:' + (id || 'anon') + ']';
205
+ function log() {
206
+ console.log.apply(console, [label].concat(Array.prototype.slice.call(arguments)));
207
+ }
208
+
209
+ var N = panes.length;
210
+ var sizes = new Array(N);
211
+ var mins = new Array(N);
212
+ var maxes = new Array(N);
213
+ var canMin = new Array(N);
214
+ var minSide = new Array(N);
215
+ var lastNonZero = new Array(N);
216
+ var isMin = new Array(N);
217
+
218
+ // Per-pane orientation class — defensive against nested splitters
219
+ // with mixed orientations. Downstream CSS that only wants to act on
220
+ // panes inside a horizontal (or vertical) splitter can key off the
221
+ // class on the pane itself instead of walking up to find an
222
+ // orientation modifier (which, with nesting, returns the outer
223
+ // splitter's orientation for inner panes).
224
+ var paneOrientationClass = isVertical ? 'pa-splitter__pane--vertical' : 'pa-splitter__pane--horizontal';
225
+
226
+ // Read per-pane attributes. Sizes are resolved later (against total).
227
+ var sizeRaws = new Array(N);
228
+ var minRaws = new Array(N);
229
+ var maxRaws = new Array(N);
230
+ for (var i = 0; i < N; i++) {
231
+ sizeRaws[i] = panes[i].getAttribute('data-pa-splitter-size');
232
+ minRaws[i] = panes[i].getAttribute('data-pa-splitter-min');
233
+ maxRaws[i] = panes[i].getAttribute('data-pa-splitter-max');
234
+ // Any pane with the minimize marker can collapse to rail.
235
+ // Start / end panes dock against the container edge; middle
236
+ // panes shrink in place, with the released slack split between
237
+ // both neighbours (and on restore, taken back from both).
238
+ var hasMinAttr = panes[i].hasAttribute('data-pa-splitter-minimize');
239
+ canMin[i] = hasMinAttr;
240
+ minSide[i] = canMin[i] ? (i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle')) : null;
241
+ isMin[i] = false;
242
+ lastNonZero[i] = 0;
243
+ // Flex setup: every pane is fully JS-controlled, no fill via flex-grow.
244
+ panes[i].style.flex = '0 0 auto';
245
+ panes[i].classList.add(paneOrientationClass);
246
+ }
247
+
248
+ // CustomEvent dispatch helpers. Events bubble from the pane element
249
+ // so consumers can listen on the splitter root (or higher) with a
250
+ // single handler. resize fires per pane per applySizes — unfiltered;
251
+ // debounce in the listener if needed. collapse/expand fire only on
252
+ // user-initiated transitions (toggle, dblclick, drag snap, rail
253
+ // click) — NOT on init from saved state.
254
+ function fireResize(i) {
255
+ panes[i].dispatchEvent(new CustomEvent('pa-splitter:resize', {
256
+ bubbles: true,
257
+ detail: { index: i, pane: panes[i], size: sizes[i] }
258
+ }));
259
+ }
260
+ function fireCollapse(i) {
261
+ panes[i].dispatchEvent(new CustomEvent('pa-splitter:collapse', {
262
+ bubbles: true,
263
+ detail: { index: i, pane: panes[i] }
264
+ }));
265
+ }
266
+ function fireExpand(i) {
267
+ panes[i].dispatchEvent(new CustomEvent('pa-splitter:expand', {
268
+ bubbles: true,
269
+ detail: { index: i, pane: panes[i] }
270
+ }));
271
+ }
272
+
273
+ function gapPx() {
274
+ var cs = getComputedStyle(root);
275
+ var raw = isVertical ? cs.rowGap : cs.columnGap;
276
+ var px = parseFloat(raw);
277
+ return isNaN(px) ? 0 : px;
278
+ }
279
+
280
+ function paddingPx() {
281
+ // `clientWidth` / `clientHeight` include padding but flex children
282
+ // are placed inside the content area only. If we don't subtract
283
+ // padding here, the panes' total flex-basis overflows the content
284
+ // area by 2 × padding and the last pane gets clipped by the
285
+ // splitter's `overflow: hidden`.
286
+ var cs = getComputedStyle(root);
287
+ var start = isVertical ? cs.paddingTop : cs.paddingLeft;
288
+ var end = isVertical ? cs.paddingBottom : cs.paddingRight;
289
+ return (parseFloat(start) || 0) + (parseFloat(end) || 0);
290
+ }
291
+
292
+ function gutterTotal() {
293
+ // Sum across all gutters — they may not all be the same size if a
294
+ // theme overrides one — but in practice they're uniform.
295
+ var t = 0;
296
+ for (var g = 0; g < gutters.length; g++) t += gutters[g][clientAxis];
297
+ return t;
298
+ }
299
+
300
+ function totalAvailable() {
301
+ // Flex `gap` lands between every adjacent child. With panes and
302
+ // gutters alternating (pane, gutter, pane, …) there are 2(N-1)
303
+ // such boundaries.
304
+ var gapCount = 2 * (N - 1);
305
+ return root[clientAxis] - paddingPx() - gutterTotal() - (gapCount * gapPx());
306
+ }
307
+
308
+ function resolveConstraints() {
309
+ var rootSize = root[clientAxis];
310
+ var total = totalAvailable();
311
+ for (var i = 0; i < N; i++) {
312
+ var mn = parseSize(minRaws[i], rootSize);
313
+ var mx = parseSize(maxRaws[i], rootSize);
314
+ if (mn == null) mn = 0;
315
+ if (mx == null) mx = total;
316
+ if (mx > total) mx = total;
317
+ if (mn > mx) mn = mx;
318
+ mins[i] = mn;
319
+ maxes[i] = mx;
320
+ }
321
+ return total;
322
+ }
323
+
324
+ function clampToConstraints(arr, total, pinned) {
325
+ // Clamp each pane to [min, max] and redistribute any shortfall/excess
326
+ // to unclamped neighbours proportionally. Iterates up to N times to
327
+ // converge — each pass may free up new slack as panes hit walls.
328
+ // `pinned[i] === true` excludes pane i from the flexable pool, which
329
+ // is how minimized (rail) panes stay at rail across container resizes
330
+ // — without this, a growing container would let them flex above
331
+ // railSize because their `max - railSize` headroom looks fine.
332
+ pinned = pinned || [];
333
+ for (var pass = 0; pass < N + 1; pass++) {
334
+ var sum = 0;
335
+ for (var i = 0; i < N; i++) sum += arr[i];
336
+ var diff = total - sum;
337
+ if (Math.abs(diff) < 0.5) break;
338
+ var flexable = [];
339
+ var flexableWeight = 0;
340
+ for (var j = 0; j < N; j++) {
341
+ if (pinned[j]) continue;
342
+ var headroom = diff > 0 ? (maxes[j] - arr[j]) : (arr[j] - mins[j]);
343
+ if (headroom > 0.5) {
344
+ flexable.push(j);
345
+ flexableWeight += arr[j] > 0 ? arr[j] : 1;
346
+ }
347
+ }
348
+ if (flexable.length === 0) break;
349
+ for (var k = 0; k < flexable.length; k++) {
350
+ var idx = flexable[k];
351
+ var share = diff * ((arr[idx] > 0 ? arr[idx] : 1) / flexableWeight);
352
+ var next = arr[idx] + share;
353
+ if (next < mins[idx]) next = mins[idx];
354
+ if (next > maxes[idx]) next = maxes[idx];
355
+ arr[idx] = next;
356
+ }
357
+ }
358
+ return arr;
359
+ }
360
+
361
+ function applySizes(opts) {
362
+ opts = opts || {};
363
+ var anyMin = false;
364
+ var lastNonZeroChanged = [];
365
+ var classBefore = panes.map(function (p) { return p.classList.contains('pa-splitter__pane--minimized'); });
366
+ for (var i = 0; i < N; i++) {
367
+ panes[i].style.flexBasis = sizes[i] + 'px';
368
+ panes[i].classList.toggle('pa-splitter__pane--minimized', isMin[i]);
369
+ if (isMin[i]) anyMin = true;
370
+ // Only persist sizes at or above mins[i] as "lastNonZero".
371
+ // During a drag-from-rail, sizes[i] may sit at the rail
372
+ // floor (well below mins[i]) for the whole drag if the
373
+ // boundary couldn't move — overwriting lastNonZero with
374
+ // that value would silently destroy the pane's remembered
375
+ // expanded size, causing future restores to fall back to
376
+ // bare mins instead of the original expanded width.
377
+ if (sizes[i] >= mins[i] && !isMin[i]) {
378
+ if (lastNonZero[i] !== sizes[i]) lastNonZeroChanged.push({ i: i, from: lastNonZero[i], to: sizes[i] });
379
+ lastNonZero[i] = sizes[i];
380
+ }
381
+ fireResize(i);
382
+ }
383
+ root.classList.toggle('pa-splitter--minimized', anyMin);
384
+ // Only log applySizes when a pane's minimized class actually
385
+ // flipped — that's the moment the card visually re-renders.
386
+ // Suppresses the per-frame flood while still surfacing every
387
+ // rail/expanded transition.
388
+ var anyClassChanged = false;
389
+ var perPane = [];
390
+ for (var k = 0; k < N; k++) {
391
+ var nowMin = panes[k].classList.contains('pa-splitter__pane--minimized');
392
+ if (classBefore[k] !== nowMin) anyClassChanged = true;
393
+ perPane.push({
394
+ i: k,
395
+ size: Math.round(sizes[k]),
396
+ isMin: isMin[k],
397
+ classDOM: nowMin,
398
+ classChanged: classBefore[k] !== nowMin
399
+ });
400
+ }
401
+ if (anyClassChanged) {
402
+ log('applySizes (class flip)', perPane, 'persist=' + (opts.persist !== false));
403
+ }
404
+
405
+ // Update each gutter's ARIA (uses left-pane size as the value).
406
+ for (var g = 0; g < gutters.length; g++) {
407
+ gutters[g].setAttribute('aria-valuenow', String(Math.round(sizes[g])));
408
+ gutters[g].setAttribute('aria-valuemin', String(Math.round(mins[g])));
409
+ gutters[g].setAttribute('aria-valuemax', String(Math.round(maxes[g])));
410
+ }
411
+
412
+ if (opts.persist !== false && id) {
413
+ writeStorage(id, { v: 2, sizes: sizes.slice(), lasts: lastNonZero.slice(), minimized: isMin.slice() });
414
+ }
415
+ }
416
+
417
+ function setupGutters() {
418
+ for (var g = 0; g < gutters.length; g++) {
419
+ var gut = gutters[g];
420
+ gut.setAttribute('role', gut.getAttribute('role') || 'separator');
421
+ gut.setAttribute('aria-orientation', isVertical ? 'horizontal' : 'vertical');
422
+ if (!gut.hasAttribute('tabindex')) gut.setAttribute('tabindex', '0');
423
+ bindGutter(gut, g);
424
+ }
425
+ }
426
+
427
+ function bindGutter(gut, g) {
428
+ // Rebalance-on-drag model: each gutter `g` resizes its PRIMARY
429
+ // neighbour only (primary = edge-closer side; LTR/RTL tiebreaker
430
+ // on ties). The slack — the matching opposite delta — is
431
+ // absorbed by *all other non-minimized panes* proportionally
432
+ // to their current sizes. The adjacent non-primary pane is
433
+ // treated like any other absorber: rail panes stay railed, the
434
+ // rest share the load. This is intentionally different from the
435
+ // classic boundary-coupled splitter (left and right neighbours
436
+ // trade space 1:1); in mixed rail/expanded layouts the boundary
437
+ // model produced a stuck-at-rail experience where dragging A|B
438
+ // couldn't grow A while B was collapsed.
439
+ var dragStartCoord = 0;
440
+ var dragStartPrimary = 0;
441
+ var activePointerId = null;
442
+ var primaryIdx = -1;
443
+ // +1 if primary is the left neighbour (g): cursor moving right
444
+ // grows the primary; -1 if primary is the right neighbour (g+1):
445
+ // cursor moving right shrinks the primary (gutter trails cursor
446
+ // toward primary's start edge).
447
+ var primarySign = 1;
448
+ // Read-only after pointerdown — see "startedMin" / "canSnap"
449
+ // comments in earlier versions. Promoting these mid-drag would
450
+ // permanently disable the snap-in gate and block re-snap.
451
+ var primaryStartedMin = false;
452
+ // Transient "rail → expanded" transition state. Set when the
453
+ // user drags a mid-drag-snapped pane back out past the
454
+ // threshold; while true the floor stays at railSizePx so the
455
+ // pane grows smoothly out of rail instead of jumping to mins.
456
+ var primaryInEscape = false;
457
+ // "Is snap-into-rail allowed right now". Starts as
458
+ // !primaryStartedMin (drag-from-expanded can snap immediately;
459
+ // drag-from-rail needs to first commit to expanded by crossing
460
+ // mins[primary] outward).
461
+ var primaryCanSnap = false;
462
+ // Largest size the primary has reached during this drag. Anchors
463
+ // the snap-threshold formula so a drag-from-rail user who has
464
+ // expanded the pane to e.g. 300 gets a meaningful re-snap
465
+ // threshold instead of the rail*1.5 = 60 floor.
466
+ var primaryMaxReached = 0;
467
+ // Distinguish tap from drag at pointerup (set true when the
468
+ // pointer crosses a small jitter threshold, even if the user
469
+ // dragged out and back to the same size).
470
+ var everMoved = false;
471
+ var TAP_PX = 2;
472
+ // Per-frame `move` logs are throttled to size-bucket transitions
473
+ // (0–25 % / 25–50 % / 50–75 % / 75–100 % of total). State
474
+ // transitions (SNAP, DRAG-OUT, CLAMP-TO-MIN, etc.) always log.
475
+ var lastBucket = -1;
476
+
477
+ function onPointerDown(e) {
478
+ if (e.button != null && e.button !== 0) return;
479
+ // Defensive sweep: clear --active from every gutter before
480
+ // committing this one. Covers a stale class from a previous
481
+ // pointerup that didn't fire cleanly (rare).
482
+ for (var gi = 0; gi < gutters.length; gi++) {
483
+ gutters[gi].classList.remove('pa-splitter__gutter--active');
484
+ }
485
+ primaryIdx = primaryNeighbour(g);
486
+ primarySign = (primaryIdx === g) ? 1 : -1;
487
+ // Snapshot rail state at drag start. Crucially DO NOT clear
488
+ // isMin here — the user might be dragging INTO the rail (no
489
+ // expand intent), and pre-clearing would visually flash the
490
+ // card out of its rail state before we know which direction
491
+ // they're going. Rail release is deferred to onPointerMove,
492
+ // where it gates on outward direction (primarySign * delta
493
+ // > 0). Tap-without-drag is therefore inert on the gutter.
494
+ primaryStartedMin = isMin[primaryIdx];
495
+ primaryInEscape = false;
496
+ primaryCanSnap = !primaryStartedMin;
497
+ everMoved = false;
498
+ activePointerId = e.pointerId;
499
+ dragStartCoord = e[clientCoord];
500
+ dragStartPrimary = sizes[primaryIdx];
501
+ primaryMaxReached = dragStartPrimary;
502
+ lastBucket = -1;
503
+ log('pointerdown g=' + g, {
504
+ primaryIdx: primaryIdx,
505
+ primarySign: primarySign,
506
+ primaryStartedMin: primaryStartedMin,
507
+ dragStartPrimary: dragStartPrimary,
508
+ mins_primary: mins[primaryIdx],
509
+ railSizePx: railSizePx,
510
+ lastNonZero_primary: lastNonZero[primaryIdx]
511
+ });
512
+ try { gut.setPointerCapture(e.pointerId); } catch (err) { /* iOS */ }
513
+ root.classList.add('pa-splitter--dragging');
514
+ gut.classList.add('pa-splitter__gutter--active');
515
+ gut.addEventListener('pointermove', onPointerMove);
516
+ gut.addEventListener('pointerup', onPointerUp);
517
+ gut.addEventListener('pointercancel', onPointerUp);
518
+ e.preventDefault();
519
+ }
520
+
521
+ // rAF-throttle the move handler so each frame coalesces all
522
+ // pending pointermove events into a single applySizes. Without
523
+ // this, a 120 Hz trackpad fires move at ~8 ms and every event
524
+ // triggers flex-basis writes + reflow — measurable jank on a
525
+ // pane containing a chart canvas or iframe.
526
+ var pendingMove = null;
527
+ var rafScheduled = false;
528
+ function snapThreshold(anchor) {
529
+ // Rebase on the drag-start (or max-reached) size of the
530
+ // primary so the threshold is meaningful even when min is 0.
531
+ // At ratio=0.4 the user has to drag below 40% of the anchor
532
+ // to commit. Floored at rail × 1.5 so a pane already near
533
+ // rail doesn't insta-snap on first move.
534
+ return Math.max(railSizePx * 1.5, railSizePx + (anchor - railSizePx) * minimizeThresholdRatio);
535
+ }
536
+ function processMove() {
537
+ rafScheduled = false;
538
+ if (pendingMove == null) return;
539
+ var coord = pendingMove;
540
+ pendingMove = null;
541
+ var delta = coord - dragStartCoord;
542
+ var newPrimary = dragStartPrimary + (primarySign * delta);
543
+
544
+ // SNAP-INTO-RAIL: only fires on the primary, and only after
545
+ // it has committed to expanded (primaryCanSnap = true,
546
+ // which is set to true at pointerdown for drag-from-expanded
547
+ // and flipped on the first crossing of mins[primary] for
548
+ // drag-from-rail). Threshold anchored on maxReached.
549
+ if (canMin[primaryIdx] && primaryCanSnap) {
550
+ if (newPrimary < snapThreshold(primaryMaxReached) && nonMinCount() > 1) {
551
+ log('move g=' + g + ' SNAP-INTO-RAIL primary i=' + primaryIdx,
552
+ { newPrimary: newPrimary, threshold: snapThreshold(primaryMaxReached), maxReached: primaryMaxReached });
553
+ // Compute absorbers BEFORE flipping isMin so the
554
+ // walk doesn't treat the primary as a rail wall.
555
+ var snapAbsorbers = computeAbsorbers(g, primaryIdx);
556
+ isMin[primaryIdx] = true;
557
+ sizes[primaryIdx] = railSizePx;
558
+ // Redistribute the freed slack across the contiguous
559
+ // non-rail block on the secondary side only.
560
+ var totalSnap = totalAvailable();
561
+ clampToConstraints(sizes, totalSnap, pinnedForAbsorbers(snapAbsorbers));
562
+ applySizes({ persist: false });
563
+ fireCollapse(primaryIdx);
564
+ return;
565
+ }
566
+ }
567
+
568
+ // DRAG-OUT-OF-RAIL: primary snapped mid-drag and user has
569
+ // now dragged back past the threshold — release the rail
570
+ // commitment. Sets primaryInEscape so the floor stays at
571
+ // railSizePx for the transition out (auto-cleared once
572
+ // newPrimary reaches mins[primary]).
573
+ if (isMin[primaryIdx] && newPrimary >= snapThreshold(primaryMaxReached)) {
574
+ log('move g=' + g + ' DRAG-OUT-OF-RAIL primary i=' + primaryIdx,
575
+ { newPrimary: newPrimary, threshold: snapThreshold(primaryMaxReached) });
576
+ isMin[primaryIdx] = false;
577
+ primaryInEscape = true;
578
+ fireExpand(primaryIdx);
579
+ }
580
+
581
+ // If primary is still railed (cursor in the snap zone), the
582
+ // drag is a no-op for this frame — primary stays at
583
+ // railSizePx, layout is already correct from the SNAP-INTO-
584
+ // RAIL action. Skipping the floor/max/rebalance below is
585
+ // essential: otherwise the floor would clamp newPrimary up
586
+ // to mins[primary] and re-introduce a "minimized AND at
587
+ // min-width" state (the bug visible as the Inspector
588
+ // showing rail icon but at 180px instead of 40px width).
589
+ if (isMin[primaryIdx]) return;
590
+
591
+ // Floor / max for primary. Floor is railSizePx (not mins)
592
+ // when the primary is still in its rail→expanded transition;
593
+ // otherwise it's mins[primary]. The real mins clamp is
594
+ // applied on pointerup if the final size landed below it.
595
+ var floor = ((primaryStartedMin && !primaryCanSnap) || primaryInEscape) ? railSizePx : mins[primaryIdx];
596
+ if (newPrimary < floor) newPrimary = floor;
597
+ if (newPrimary > maxes[primaryIdx]) newPrimary = maxes[primaryIdx];
598
+
599
+ if (primaryInEscape && newPrimary >= mins[primaryIdx]) primaryInEscape = false;
600
+ if (!primaryCanSnap && newPrimary >= mins[primaryIdx]) primaryCanSnap = true;
601
+ if (newPrimary > primaryMaxReached) primaryMaxReached = newPrimary;
602
+
603
+ // Apply primary's new size, then let clampToConstraints
604
+ // distribute the opposite delta across the contiguous
605
+ // non-rail block on the secondary side ONLY. Everything
606
+ // else (rails, primary, panes across a rail wall) stays
607
+ // pinned. Primary stays at newPrimary unless absorbers
608
+ // can't yield enough — see post-check below.
609
+ sizes[primaryIdx] = newPrimary;
610
+ var total = totalAvailable();
611
+ var absorbers = computeAbsorbers(g, primaryIdx);
612
+ clampToConstraints(sizes, total, pinnedForAbsorbers(absorbers));
613
+
614
+ // Post-check: if absorbers couldn't absorb fully (all at
615
+ // their mins), the sum overshoots total. Pull primary back
616
+ // by the overshoot so the layout fits.
617
+ var sum = 0;
618
+ for (var ck = 0; ck < N; ck++) sum += sizes[ck];
619
+ if (Math.abs(sum - total) > 0.5) {
620
+ sizes[primaryIdx] -= (sum - total);
621
+ if (sizes[primaryIdx] < floor) sizes[primaryIdx] = floor;
622
+ if (sizes[primaryIdx] > maxes[primaryIdx]) sizes[primaryIdx] = maxes[primaryIdx];
623
+ }
624
+
625
+ var bucket = total > 0 ? Math.floor(sizes[primaryIdx] / total * 4) : -1;
626
+ if (bucket !== lastBucket) {
627
+ lastBucket = bucket;
628
+ log('move g=' + g + ' bucket=' + bucket + '/4',
629
+ 'primary=i' + primaryIdx + ':' + Math.round(sizes[primaryIdx]),
630
+ 'sign=' + primarySign,
631
+ 'floor=' + floor,
632
+ 'startedMin=' + primaryStartedMin,
633
+ 'canSnap=' + primaryCanSnap,
634
+ 'maxReached=' + Math.round(primaryMaxReached),
635
+ 'inEscape=' + primaryInEscape,
636
+ 'isMin=' + isMin[primaryIdx]);
637
+ }
638
+ applySizes({ persist: false });
639
+ }
640
+
641
+ function onPointerMove(e) {
642
+ if (e.pointerId !== activePointerId) return;
643
+ var delta = e[clientCoord] - dragStartCoord;
644
+ if (!everMoved && Math.abs(delta) >= TAP_PX) {
645
+ everMoved = true;
646
+ }
647
+ // Asymmetric drag against a primary that started rail'd:
648
+ // outward (primarySign * delta > 0, would grow primary) →
649
+ // release the rail, fire expand, fall through to drag math.
650
+ // inward (primarySign * delta <= 0, would shrink further) →
651
+ // no-op for the frame. Pane stays rail'd, gutter doesn't
652
+ // move, no applySizes call. Stay inert until either the
653
+ // direction flips outward or the user releases.
654
+ if (primaryStartedMin && isMin[primaryIdx]) {
655
+ if (primarySign * delta <= 0) return;
656
+ if (Math.abs(delta) < TAP_PX) return;
657
+ log('move g=' + g + ' DRAG-OUT-OF-RAIL-AT-START primary i=' + primaryIdx);
658
+ isMin[primaryIdx] = false;
659
+ primaryInEscape = true;
660
+ fireExpand(primaryIdx);
661
+ }
662
+ pendingMove = e[clientCoord];
663
+ if (rafScheduled) return;
664
+ rafScheduled = true;
665
+ requestAnimationFrame(processMove);
666
+ }
667
+
668
+ function onPointerUp(e) {
669
+ if (e.pointerId !== activePointerId) return;
670
+ // Flush any rAF-pending move so the final cursor position is
671
+ // reflected in sizes before end-of-drag decisions. Without
672
+ // this, a last-millisecond move scheduled a rAF that fires
673
+ // AFTER pointerup, overwriting pointerup's decisions.
674
+ if (rafScheduled && pendingMove != null) {
675
+ log('pointerup g=' + g + ' flushing pending move');
676
+ processMove();
677
+ }
678
+ rafScheduled = false;
679
+ pendingMove = null;
680
+ try { gut.releasePointerCapture(e.pointerId); } catch (err) { /* */ }
681
+ activePointerId = null;
682
+ root.classList.remove('pa-splitter--dragging');
683
+ gut.classList.remove('pa-splitter__gutter--active');
684
+ gut.removeEventListener('pointermove', onPointerMove);
685
+ gut.removeEventListener('pointerup', onPointerUp);
686
+ gut.removeEventListener('pointercancel', onPointerUp);
687
+ log('pointerup g=' + g, {
688
+ everMoved: everMoved,
689
+ primaryIdx: primaryIdx,
690
+ primaryStartedMin: primaryStartedMin,
691
+ sizes_primary: sizes[primaryIdx],
692
+ mins_primary: mins[primaryIdx],
693
+ isMin_primary: isMin[primaryIdx]
694
+ });
695
+
696
+ // Tap-without-drag on the gutter is intentionally inert.
697
+ // Restore gestures: rail-body click (handler below), dblclick
698
+ // on the gutter, focus + Enter/Space, toggle button, or drag
699
+ // the gutter outward past the snap threshold.
700
+
701
+ // RAIL-STAYED: primary started rail, the user dragged
702
+ // outward enough to release isMin (set in onPointerMove),
703
+ // but the size never grew meaningfully above rail (absorbers
704
+ // had no headroom, or the user dragged back into the snap
705
+ // zone before release). Restore isMin so the visual state
706
+ // matches and fire collapse so consumers see the round-trip.
707
+ var anyChange = false;
708
+ if (primaryStartedMin && Math.abs(sizes[primaryIdx] - railSizePx) < 1 && !isMin[primaryIdx]) {
709
+ log('pointerup g=' + g + ' RAIL-STAYED primary i=' + primaryIdx);
710
+ isMin[primaryIdx] = true;
711
+ anyChange = true;
712
+ fireCollapse(primaryIdx);
713
+ }
714
+
715
+ // CLAMP-TO-MIN: any non-rail pane sitting below its mins
716
+ // (primary that got pulled back during absorber starvation,
717
+ // or absorbers themselves) gets bumped up; remainder
718
+ // redistributed via clampToConstraints across all non-rail
719
+ // panes proportionally.
720
+ var anyBelowMin = false;
721
+ for (var bj = 0; bj < N; bj++) {
722
+ if (!isMin[bj] && sizes[bj] < mins[bj] - 0.5) { anyBelowMin = true; break; }
723
+ }
724
+ if (anyBelowMin) {
725
+ log('pointerup g=' + g + ' CLAMP-TO-MIN');
726
+ for (var bk = 0; bk < N; bk++) {
727
+ if (!isMin[bk] && sizes[bk] < mins[bk]) sizes[bk] = mins[bk];
728
+ }
729
+ var totalPU = totalAvailable();
730
+ clampToConstraints(sizes, totalPU, isMin);
731
+ applySizes({ persist: false });
732
+ } else if (anyChange) {
733
+ applySizes({ persist: false });
734
+ }
735
+ primaryStartedMin = false;
736
+ primaryInEscape = false;
737
+ primaryCanSnap = false;
738
+ primaryMaxReached = 0;
739
+ primaryIdx = -1;
740
+ if (id) writeStorage(id, { v: 2, sizes: sizes.slice(), lasts: lastNonZero.slice(), minimized: isMin.slice() });
741
+ }
742
+
743
+ gut.addEventListener('pointerdown', onPointerDown);
744
+
745
+ gut.addEventListener('dblclick', function (e) {
746
+ e.preventDefault();
747
+ // Double-click toggles the primary neighbour, falling back
748
+ // to the other neighbour if the primary can't minimize.
749
+ var primary = primaryNeighbour(g);
750
+ var other = primary === g ? g + 1 : g;
751
+ log('dblclick g=' + g, { primary: primary, other: other, canMin_primary: canMin[primary], canMin_other: canMin[other] });
752
+ if (canMin[primary]) togglePane(primary);
753
+ else if (canMin[other]) togglePane(other);
754
+ });
755
+
756
+ gut.addEventListener('keydown', function (e) {
757
+ var handled = false;
758
+ var step = stepPx;
759
+ switch (e.key) {
760
+ case 'ArrowLeft':
761
+ case 'ArrowUp':
762
+ shiftPrimary(g, -step);
763
+ handled = true;
764
+ break;
765
+ case 'ArrowRight':
766
+ case 'ArrowDown':
767
+ shiftPrimary(g, step);
768
+ handled = true;
769
+ break;
770
+ case 'Home':
771
+ setPrimaryTo(g, mins[primaryNeighbour(g)]);
772
+ handled = true;
773
+ break;
774
+ case 'End':
775
+ setPrimaryTo(g, maxes[primaryNeighbour(g)]);
776
+ handled = true;
777
+ break;
778
+ case 'Enter':
779
+ case ' ':
780
+ var primaryK = primaryNeighbour(g);
781
+ var otherK = primaryK === g ? g + 1 : g;
782
+ if (canMin[primaryK]) togglePane(primaryK);
783
+ else if (canMin[otherK]) togglePane(otherK);
784
+ handled = true;
785
+ break;
786
+ }
787
+ if (handled) e.preventDefault();
788
+ });
789
+ }
790
+
791
+ // Keyboard equivalent of a drag: change the primary neighbour by
792
+ // `gutterDelta` px (sign convention: positive moves the gutter in
793
+ // the "right/down" direction, so primary on the left grows and
794
+ // primary on the right shrinks). Mirrors the rebalance model used
795
+ // by drag — slack absorbed by all non-rail non-primary panes.
796
+ function shiftPrimary(g, gutterDelta) {
797
+ var primary = primaryNeighbour(g);
798
+ if (isMin[primary]) return;
799
+ var sign = (primary === g) ? 1 : -1;
800
+ setPrimaryTo(g, sizes[primary] + sign * gutterDelta);
801
+ }
802
+
803
+ function setPrimaryTo(g, newSize) {
804
+ var primary = primaryNeighbour(g);
805
+ if (isMin[primary]) return;
806
+ var target = newSize;
807
+ if (target < mins[primary]) target = mins[primary];
808
+ if (target > maxes[primary]) target = maxes[primary];
809
+ sizes[primary] = target;
810
+ var total = totalAvailable();
811
+ var absorbers = computeAbsorbers(g, primary);
812
+ clampToConstraints(sizes, total, pinnedForAbsorbers(absorbers));
813
+ var sum = 0;
814
+ for (var i = 0; i < N; i++) sum += sizes[i];
815
+ if (Math.abs(sum - total) > 0.5) {
816
+ sizes[primary] -= (sum - total);
817
+ if (sizes[primary] < mins[primary]) sizes[primary] = mins[primary];
818
+ if (sizes[primary] > maxes[primary]) sizes[primary] = maxes[primary];
819
+ }
820
+ applySizes();
821
+ }
822
+
823
+ // "At least one expanded pane" invariant. Without it, restore-from-rail
824
+ // math degenerates (all panes at rail → available headroom from other
825
+ // panes is zero or negative → restore can't pull enough room for the
826
+ // pane being restored, layout overflows). Toggle button / dblclick /
827
+ // drag-to-rail all gate on this.
828
+ function nonMinCount() {
829
+ var c = 0;
830
+ for (var k = 0; k < N; k++) if (!isMin[k]) c++;
831
+ return c;
832
+ }
833
+
834
+ // Absorbers for a drag on gutter `g` with the given `primary`
835
+ // neighbour. Two modes:
836
+ //
837
+ // 1. CLASSIC (no rail wall between primary and the immediate
838
+ // secondary neighbour): just the immediate adjacent non-rail
839
+ // pane absorbs. Equivalent to a standard boundary-coupled
840
+ // splitter — panes farther away on the same side don't shift.
841
+ //
842
+ // 2. TUNNEL (immediate secondary is rail): skip the rail(s),
843
+ // then collect the contiguous non-rail block beyond. Lets
844
+ // slack punch through a rail wall to the next "section",
845
+ // which is the only way drag-from-rail can grow the primary
846
+ // when its immediate neighbour is also rail (e.g. AcBcCeDeEc
847
+ // dragging A|B — slack has to come from C/D past the B wall).
848
+ //
849
+ // In all-expanded layouts mode 1 fires and only the adjacent pane
850
+ // changes, which matches user intuition: dragging D|E in `abcde`
851
+ // shouldn't ripple A and B around.
852
+ function computeAbsorbers(g, primary) {
853
+ var absorbers = [];
854
+ var sawRail = false;
855
+ if (primary === g) {
856
+ // Secondary side = right of gutter; walk from g+1 forward.
857
+ for (var i = g + 1; i < N; i++) {
858
+ if (isMin[i]) {
859
+ if (absorbers.length > 0) break; // end of tunneled block
860
+ sawRail = true;
861
+ continue;
862
+ }
863
+ absorbers.push(i);
864
+ if (!sawRail) break; // CLASSIC: only the immediate neighbour
865
+ }
866
+ } else {
867
+ // Secondary side = left of gutter; walk from g backward.
868
+ for (var j = g; j >= 0; j--) {
869
+ if (isMin[j]) {
870
+ if (absorbers.length > 0) break;
871
+ sawRail = true;
872
+ continue;
873
+ }
874
+ absorbers.push(j);
875
+ if (!sawRail) break;
876
+ }
877
+ }
878
+ return absorbers;
879
+ }
880
+
881
+ // Build a `pinned` array for clampToConstraints that lets ONLY the
882
+ // listed absorbers flex; everything else (rail panes, primary,
883
+ // panes on the far side of a rail wall) stays put.
884
+ function pinnedForAbsorbers(absorbers) {
885
+ var pinned = new Array(N);
886
+ for (var i = 0; i < N; i++) pinned[i] = true;
887
+ for (var k = 0; k < absorbers.length; k++) pinned[absorbers[k]] = false;
888
+ return pinned;
889
+ }
890
+
891
+ // Primary-neighbour heuristic for gutter `g`: pick the side closer to
892
+ // its container edge (so a gutter near the right edge picks pane g+1,
893
+ // a gutter near the left edge picks pane g). When the two neighbours
894
+ // are equidistant from their respective edges — exact-middle gutter on
895
+ // an even-N splitter, or the lone gutter on a 2-pane splitter —
896
+ // LTR/RTL is the tiebreaker (LTR → left, RTL → right). Used by
897
+ // dblclick and keyboard toggle (`Enter` / `Space`) to decide which
898
+ // neighbour collapses when both are candidates.
899
+ function primaryNeighbour(g) {
900
+ var leftDist = g; // pane g → left edge
901
+ var rightDist = N - 2 - g; // pane g+1 → right edge
902
+ if (leftDist < rightDist) return g;
903
+ if (rightDist < leftDist) return g + 1;
904
+ return getComputedStyle(root).direction === 'rtl' ? (g + 1) : g;
905
+ }
906
+
907
+ function minimizePane(i) {
908
+ if (!canMin[i] || isMin[i]) {
909
+ log('minimizePane i=' + i + ' NOOP', { canMin: canMin[i], isMin: isMin[i] });
910
+ return;
911
+ }
912
+ if (nonMinCount() <= 1) {
913
+ log('minimizePane i=' + i + ' BLOCKED (would leave zero non-min)');
914
+ return;
915
+ }
916
+ var slack = sizes[i] - railSizePx;
917
+ log('minimizePane i=' + i, {
918
+ sizes_i: sizes[i],
919
+ slack: slack,
920
+ position: i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle'),
921
+ lastNonZero_i_before: lastNonZero[i]
922
+ });
923
+ isMin[i] = true;
924
+ sizes[i] = railSizePx;
925
+ // Distribute slack across ALL non-minimized panes proportionally
926
+ // to their current sizes. clampToConstraints walks the sum, picks
927
+ // panes with growth headroom (max - size), and redistributes the
928
+ // delta to them — respecting per-pane maxes and converging in
929
+ // up to N passes. Pinning all currently-minimized panes (via
930
+ // isMin) keeps railed panes locked at rail width.
931
+ var total = totalAvailable();
932
+ clampToConstraints(sizes, total, isMin);
933
+ applySizes();
934
+ fireCollapse(i);
935
+ }
936
+
937
+ function restorePane(i) {
938
+ if (!isMin[i]) {
939
+ log('restorePane i=' + i + ' NOOP (not minimized)');
940
+ return;
941
+ }
942
+ // ACCORDION SWEEP: when accordion mode is engaged, restoring a
943
+ // pane auto-rails any other currently-expanded minimizable
944
+ // panes so exactly one stays open. `lastNonZero` for the
945
+ // panes being railed was already captured on their last
946
+ // applySizes while they were expanded — no extra bookkeeping
947
+ // needed for a future restore to recover their size.
948
+ if (accordionActive) {
949
+ for (var ai = 0; ai < N; ai++) {
950
+ if (ai !== i && canMin[ai] && !isMin[ai]) {
951
+ log('restorePane accordion-rail i=' + ai + ' (sweep for restore of i=' + i + ')');
952
+ isMin[ai] = true;
953
+ sizes[ai] = railSizePx;
954
+ fireCollapse(ai);
955
+ }
956
+ }
957
+ }
958
+ isMin[i] = false;
959
+ // Target: remembered "expanded" size, but never below mins[i].
960
+ // Compute the mins floor first so `deficit` below reflects the
961
+ // actual restored amount (the old order silently bumped target
962
+ // after deficit was captured, making the log say `deficit: 0`
963
+ // for a 120 px restore).
964
+ var target = lastNonZero[i] > 0 ? lastNonZero[i] : Math.max(mins[i], railSizePx * 4);
965
+ if (target < mins[i]) target = mins[i];
966
+ var total = totalAvailable();
967
+ // Two sources of room for the restore:
968
+ // 1. Headroom from other non-minimized panes (sizes - mins).
969
+ // 2. Empty space currently in the container (total - sum) —
970
+ // a prior minimize can leave a gap when the only available
971
+ // absorbers hit their `max` cap before consuming all the
972
+ // slack. Restoring should reclaim that gap first; without
973
+ // this the restored pane only sees source #1 and gets
974
+ // stuck at mins even though there's plenty of empty layout
975
+ // space waiting.
976
+ var currentSum = 0;
977
+ var fromOthers = 0;
978
+ for (var j = 0; j < N; j++) {
979
+ currentSum += sizes[j];
980
+ if (j !== i && !isMin[j]) fromOthers += sizes[j] - mins[j];
981
+ }
982
+ var emptySpace = total - currentSum;
983
+ if (emptySpace < 0) emptySpace = 0;
984
+ var available = fromOthers + emptySpace;
985
+ var deficit = target - sizes[i];
986
+ if (deficit > available) {
987
+ target = sizes[i] + available;
988
+ deficit = available;
989
+ }
990
+ log('restorePane i=' + i, {
991
+ lastNonZero_i: lastNonZero[i],
992
+ position: i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle'),
993
+ deficit: deficit,
994
+ target: target,
995
+ available: available,
996
+ fromOthers: fromOthers,
997
+ emptySpace: emptySpace
998
+ });
999
+ sizes[i] = target;
1000
+ // Only redistribute on OVERSHOOT (sum > total). If the restored
1001
+ // pane plus the unchanged others already fits, leave any
1002
+ // remaining gap alone — don't grow neighbours to fill it.
1003
+ // Without this, restoring pane i with a leftover gap (e.g. 5
1004
+ // panes where pane 0 was capped at its max during minimization)
1005
+ // would make clampToConstraints "fill" the gap by inflating
1006
+ // the only flexable non-rail pane, producing the jump UX where
1007
+ // a previously-restored pane suddenly grows when another pane
1008
+ // is restored. Visible empty space is the lesser evil here:
1009
+ // it goes away naturally as the user restores more panes.
1010
+ //
1011
+ // Accordion mode is the explicit opposite — only one pane is
1012
+ // expanded so a gap to its right is just dead space. Fill it.
1013
+ var newSum = 0;
1014
+ for (var ns = 0; ns < N; ns++) newSum += sizes[ns];
1015
+ if (newSum > total + 0.5) {
1016
+ var pinned = isMin.slice();
1017
+ pinned[i] = true;
1018
+ clampToConstraints(sizes, total, pinned);
1019
+ } else if (accordionActive) {
1020
+ expandNonMinToFill();
1021
+ }
1022
+ applySizes();
1023
+ fireExpand(i);
1024
+ }
1025
+
1026
+ function togglePane(i) {
1027
+ log('togglePane i=' + i, { currentlyMin: isMin[i] });
1028
+ if (isMin[i]) restorePane(i);
1029
+ else minimizePane(i);
1030
+ }
1031
+
1032
+ // ---- Accordion mode (auto, viewport-driven) ----
1033
+ // Engaged when container width is below the sum of all panes'
1034
+ // mins + gutters + gaps + padding AND there are 2+ minimizable
1035
+ // panes (otherwise there's nothing to switch between). While
1036
+ // active, restoring one pane auto-rails the others — see the
1037
+ // ACCORDION SWEEP block in restorePane().
1038
+ var accordionActive = false;
1039
+
1040
+ function requiredForAllExpanded() {
1041
+ // mins[] are in px (resolved by resolveConstraints against the
1042
+ // current container). gutterTotal/gapPx/paddingPx mirror
1043
+ // totalAvailable()'s deductions so the comparison is apples-
1044
+ // to-apples with root[clientAxis].
1045
+ var sum = 0;
1046
+ for (var i = 0; i < N; i++) sum += mins[i];
1047
+ var gapCount = 2 * (N - 1);
1048
+ return sum + gutterTotal() + (gapCount * gapPx()) + paddingPx();
1049
+ }
1050
+
1051
+ function shouldBeAccordion() {
1052
+ var minimizableCount = 0;
1053
+ for (var i = 0; i < N; i++) if (canMin[i]) minimizableCount++;
1054
+ if (minimizableCount < 2) return false;
1055
+ return root[clientAxis] < requiredForAllExpanded();
1056
+ }
1057
+
1058
+ function enterAccordion() {
1059
+ if (accordionActive) return;
1060
+ accordionActive = true;
1061
+ root.classList.add('pa-splitter--accordion');
1062
+ // Pick what to keep expanded. Priority:
1063
+ // 1. A non-minimizable pane (it has to stay expanded anyway)
1064
+ // 2. The first currently-expanded minimizable pane (user's
1065
+ // current focus survives the transition)
1066
+ // 3. Pane 0 as fallback
1067
+ var keepIdx = -1;
1068
+ for (var i = 0; i < N; i++) {
1069
+ if (!canMin[i]) { keepIdx = i; break; }
1070
+ }
1071
+ if (keepIdx === -1) {
1072
+ for (var j = 0; j < N; j++) {
1073
+ if (canMin[j] && !isMin[j]) { keepIdx = j; break; }
1074
+ }
1075
+ }
1076
+ if (keepIdx === -1) keepIdx = 0;
1077
+ log('enterAccordion keepIdx=' + keepIdx);
1078
+ for (var k = 0; k < N; k++) {
1079
+ if (k !== keepIdx && canMin[k] && !isMin[k]) {
1080
+ isMin[k] = true;
1081
+ sizes[k] = railSizePx;
1082
+ fireCollapse(k);
1083
+ }
1084
+ }
1085
+ expandNonMinToFill();
1086
+ applySizes();
1087
+ }
1088
+
1089
+ // In accordion mode the expanded pane(s) consume all remaining
1090
+ // space, IGNORING their declared `max` constraints. Rationale:
1091
+ // `max` is a "share fairly with siblings" ceiling that stops
1092
+ // making sense once the siblings are all railed — strictly
1093
+ // honouring it just leaves a visible gap to the right of the
1094
+ // expanded pane. Mins are still respected.
1095
+ function expandNonMinToFill() {
1096
+ var total = totalAvailable();
1097
+ var sum = 0;
1098
+ var flexable = [];
1099
+ var flexableWeight = 0;
1100
+ for (var i = 0; i < N; i++) {
1101
+ sum += sizes[i];
1102
+ if (!isMin[i]) {
1103
+ flexable.push(i);
1104
+ flexableWeight += sizes[i] > 0 ? sizes[i] : 1;
1105
+ }
1106
+ }
1107
+ var diff = total - sum;
1108
+ if (flexable.length === 0 || Math.abs(diff) < 0.5) return;
1109
+ for (var k = 0; k < flexable.length; k++) {
1110
+ var idx = flexable[k];
1111
+ var weight = (sizes[idx] > 0 ? sizes[idx] : 1) / flexableWeight;
1112
+ var next = sizes[idx] + diff * weight;
1113
+ if (next < mins[idx]) next = mins[idx];
1114
+ sizes[idx] = next;
1115
+ }
1116
+ }
1117
+
1118
+ function exitAccordion() {
1119
+ if (!accordionActive) return;
1120
+ accordionActive = false;
1121
+ root.classList.remove('pa-splitter--accordion');
1122
+ log('exitAccordion');
1123
+ // Don't auto-restore panes. The user railed them implicitly
1124
+ // by going narrow; restoring them on widen would surprise
1125
+ // anyone who narrowed/widened during a single session. They
1126
+ // click a rail when they want it back.
1127
+ }
1128
+
1129
+ // ---- Initial size resolution ----
1130
+ var initialTotal = resolveConstraints();
1131
+ log('init total available', initialTotal);
1132
+ var saved = readStorage(id);
1133
+ log('storage read', saved);
1134
+
1135
+ var startupMinimized = new Array(N);
1136
+ // Storage merge is forgiving: matching-index entries are trusted,
1137
+ // missing slots fall through to attribute resolution, extra slots are
1138
+ // ignored. Survives markup drift (consumer adds / removes a pane
1139
+ // without nuking everyone else's saved layout).
1140
+ var savedSizes = null, savedLasts = null, savedMin = null;
1141
+ if (saved && saved.v === 2 && Array.isArray(saved.sizes)) {
1142
+ savedSizes = saved.sizes;
1143
+ savedLasts = Array.isArray(saved.lasts) ? saved.lasts : null;
1144
+ savedMin = Array.isArray(saved.minimized) ? saved.minimized : null;
1145
+ }
1146
+
1147
+ // Pass 1: assign explicit sizes from attributes, collect unspecified.
1148
+ var rootSizeRef = root[clientAxis];
1149
+ var explicitSum = 0;
1150
+ var unspecified = [];
1151
+ for (var p = 0; p < N; p++) {
1152
+ var v = parseSize(sizeRaws[p], rootSizeRef);
1153
+ if (v == null) {
1154
+ sizes[p] = 0;
1155
+ unspecified.push(p);
1156
+ } else {
1157
+ sizes[p] = v;
1158
+ explicitSum += v;
1159
+ }
1160
+ startupMinimized[p] = false;
1161
+ }
1162
+ // Pass 2: distribute leftover across unspecified panes.
1163
+ var leftover = initialTotal - explicitSum;
1164
+ if (unspecified.length > 0) {
1165
+ var share = leftover / unspecified.length;
1166
+ for (var u = 0; u < unspecified.length; u++) sizes[unspecified[u]] = Math.max(0, share);
1167
+ } else if (Math.abs(leftover) > 0.5) {
1168
+ // All panes sized but they don't sum to total — give the
1169
+ // delta to the last pane (matches the "content absorbs" UX
1170
+ // most admin layouts expect).
1171
+ sizes[N - 1] += leftover;
1172
+ }
1173
+ for (var z = 0; z < N; z++) lastNonZero[z] = sizes[z];
1174
+
1175
+ // Pass 3: overlay saved state on matching-index slots, leaving the
1176
+ // rest at their attribute-derived defaults.
1177
+ // savedLasts[s] is filtered: values at or below the pane's `mins[s]`
1178
+ // are useless for restore (would just bump to mins anyway) and
1179
+ // typically reflect a stale buggy state where the pane's rail size
1180
+ // got persisted as its "last expanded size". Falling back to the
1181
+ // attribute-derived default (set in pass 2) gives a sensible restore.
1182
+ if (savedSizes) {
1183
+ for (var s = 0; s < Math.min(N, savedSizes.length); s++) {
1184
+ if (typeof savedSizes[s] === 'number') sizes[s] = savedSizes[s];
1185
+ if (savedLasts && typeof savedLasts[s] === 'number' && savedLasts[s] > mins[s]) lastNonZero[s] = savedLasts[s];
1186
+ if (savedMin && savedMin[s]) startupMinimized[s] = !!canMin[s];
1187
+ }
1188
+ }
1189
+
1190
+ setupGutters();
1191
+
1192
+ // Defer the first paint to rAF so a container that's 0-sized at
1193
+ // DOMContentLoaded (hidden parent, modal not yet open) gets a real
1194
+ // measurement before we clamp.
1195
+ requestAnimationFrame(function () {
1196
+ var total = resolveConstraints();
1197
+ log('rAF apply, total=', total, 'sizes=', sizes.slice(), 'startupMin=', startupMinimized);
1198
+ // Enforce the "at least one expanded pane" invariant on saved state.
1199
+ // A blob from before this rule (or from a corrupted localStorage)
1200
+ // could claim every pane is minimized; we keep the first one open
1201
+ // so the user has something to drag from.
1202
+ var allMin = true;
1203
+ for (var sm = 0; sm < N; sm++) if (!startupMinimized[sm]) { allMin = false; break; }
1204
+ if (allMin) {
1205
+ log('rAF apply: saved state had all panes minimized — keeping pane 0 expanded');
1206
+ startupMinimized[0] = false;
1207
+ }
1208
+ // Pin minimized panes to rail BEFORE clamping so the redistribute
1209
+ // pass leaves them alone — clampToConstraints would otherwise
1210
+ // happily flex a rail pane upward if the container has slack.
1211
+ for (var m = 0; m < N; m++) {
1212
+ if (startupMinimized[m]) {
1213
+ isMin[m] = true;
1214
+ sizes[m] = railSizePx;
1215
+ }
1216
+ }
1217
+ clampToConstraints(sizes, total, isMin);
1218
+ applySizes({ persist: false });
1219
+ // If the startup viewport is already too narrow to fit all
1220
+ // panes at their mins, engage accordion immediately so the
1221
+ // user never sees the cramped all-rails-except-one-natural
1222
+ // state.
1223
+ if (shouldBeAccordion()) enterAccordion();
1224
+ });
1225
+
1226
+ // ---- Toggle delegation ----
1227
+ root.addEventListener('click', function (e) {
1228
+ var toggle = e.target.closest && e.target.closest('[data-pa-splitter-toggle]');
1229
+ if (!toggle || toggle.closest('[data-pa-splitter]') !== root) return;
1230
+ // Find which pane the toggle lives in.
1231
+ var pane = toggle.closest('.pa-splitter__pane');
1232
+ if (!pane) return;
1233
+ var idx = panes.indexOf(pane);
1234
+ if (idx < 0) return;
1235
+ if (!canMin[idx]) return;
1236
+ log('toggle-button click i=' + idx);
1237
+ e.preventDefault();
1238
+ e.stopPropagation();
1239
+ togglePane(idx);
1240
+ });
1241
+
1242
+ // ---- Click rail to restore ----
1243
+ for (var rp = 0; rp < N; rp++) {
1244
+ (function (idx) {
1245
+ panes[idx].addEventListener('click', function () {
1246
+ log('rail click i=' + idx, { isMin: isMin[idx] });
1247
+ if (isMin[idx]) restorePane(idx);
1248
+ });
1249
+ })(rp);
1250
+ }
1251
+
1252
+ // ---- Container resize ----
1253
+ if (typeof ResizeObserver !== 'undefined') {
1254
+ var lastTotal = initialTotal;
1255
+ var ro = new ResizeObserver(function () {
1256
+ var total = resolveConstraints();
1257
+ if (Math.abs(total - lastTotal) < 0.5) return;
1258
+ if (lastTotal <= 0) { lastTotal = total; return; }
1259
+ log('ResizeObserver fire', {
1260
+ oldTotal: lastTotal,
1261
+ newTotal: total,
1262
+ sizesBefore: sizes.slice(),
1263
+ isMin: isMin.slice()
1264
+ });
1265
+ // Accordion mode toggles BEFORE the scale pass. Entering
1266
+ // accordion rails most panes (large layout change); exiting
1267
+ // is a flag-only flip and falls through to the normal
1268
+ // scale so the remaining expanded panes reflow into the
1269
+ // new width.
1270
+ var wantAccordion = shouldBeAccordion();
1271
+ if (wantAccordion && !accordionActive) {
1272
+ enterAccordion();
1273
+ lastTotal = total;
1274
+ return;
1275
+ }
1276
+ if (!wantAccordion && accordionActive) {
1277
+ exitAccordion();
1278
+ }
1279
+ // Scale only the non-minimized pool. Minimized panes hold at
1280
+ // railSize and don't participate — their fraction of the
1281
+ // container intentionally drifts as the container grows.
1282
+ var oldNonMinSum = 0;
1283
+ var railBudget = 0;
1284
+ for (var i = 0; i < N; i++) {
1285
+ if (isMin[i]) { railBudget += sizes[i]; }
1286
+ else { oldNonMinSum += sizes[i]; }
1287
+ }
1288
+ var newNonMinTotal = total - railBudget;
1289
+ if (oldNonMinSum > 0 && newNonMinTotal > 0) {
1290
+ var scale = newNonMinTotal / oldNonMinSum;
1291
+ for (var j = 0; j < N; j++) {
1292
+ if (!isMin[j]) sizes[j] *= scale;
1293
+ }
1294
+ }
1295
+ // In accordion mode the expanded pane(s) ignore max and
1296
+ // consume all remaining space — clampToConstraints would
1297
+ // re-clip to max and leave a gap.
1298
+ if (accordionActive) {
1299
+ expandNonMinToFill();
1300
+ } else {
1301
+ clampToConstraints(sizes, total, isMin);
1302
+ }
1303
+ applySizes({ persist: false });
1304
+ lastTotal = total;
1305
+ });
1306
+ ro.observe(root);
1307
+ }
1308
+ }
1309
+
1310
+ function initAll(root) {
1311
+ var scope = root || document;
1312
+ var nodes = scope.querySelectorAll('[data-pa-splitter]');
1313
+ for (var i = 0; i < nodes.length; i++) init(nodes[i]);
1314
+ }
1315
+
1316
+ window.PaSplitter = { init: init, initAll: initAll };
1317
+
1318
+ if (document.readyState === 'loading') {
1319
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
1320
+ } else {
1321
+ initAll();
1322
+ }
1323
+ })();