reviewzy 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/dashboard/mount.ts +20 -3
- package/src/dashboard/static/components.css +1196 -451
- package/src/dashboard/static/cursor-rules.js +30 -34
- package/src/dashboard/static/cursor.css +118 -89
- package/src/dashboard/static/cursor.js +1636 -245
- package/src/dashboard/static/dashboard.css +275 -179
- package/src/dashboard/static/dashboard.js +330 -185
- package/src/dashboard/static/tokens.css +240 -54
- package/src/dashboard/static/ui.js +666 -171
- package/src/dashboard/views.tsx +1138 -841
- package/src/dashboard/word-diff.ts +18 -25
- package/src/db/migrations.ts +5 -6
- package/src/db/queries.ts +37 -0
- package/src/mcp/file-entries.ts +1 -1
- package/src/mcp/list-entries.ts +11 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* controllers. Every component that needs script, in one module.
|
|
2
2
|
Load as `<script type="module" src="ui.js"></script>`. It wires itself on load
|
|
3
3
|
against whatever markup is present and does nothing where a component is absent,
|
|
4
4
|
so one script tag is correct for every page in the system.
|
|
@@ -36,6 +36,50 @@ const once = (el, key) => {
|
|
|
36
36
|
const animMs = el => (parseFloat(getComputedStyle(el).animationDuration) || 0) * 1000 + 20;
|
|
37
37
|
const transMs = el => Math.max(...getComputedStyle(el).transitionDuration.split(',').map(parseFloat)) * 1000;
|
|
38
38
|
|
|
39
|
+
// the control a row-level click must not answer for, because its own handler already will
|
|
40
|
+
const isControl = t => t.closest?.('button, a, input, select, textarea, label');
|
|
41
|
+
|
|
42
|
+
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
43
|
+
|
|
44
|
+
// keeps a row inside its own scroll box without scrollIntoView, which would also scroll
|
|
45
|
+
// the page when the box sits near a viewport edge
|
|
46
|
+
const scrollIntoBox = (box, top, bottom) => {
|
|
47
|
+
if (top < box.scrollTop) box.scrollTop = top;
|
|
48
|
+
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/* The query rule both filtering lists run on: every word has to land. The tokenizer is
|
|
52
|
+
its own step because the palette's empty message names the fix from the COUNT:
|
|
53
|
+
another word for a one-word query, fewer for the rest. */
|
|
54
|
+
const queryWords = typed => typed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
55
|
+
const matchesQuery = (hay, words) => words.every(w => hay.includes(w));
|
|
56
|
+
|
|
57
|
+
/* The exit every overlay in this file runs: the `.closing` costume, held for `clock`,
|
|
58
|
+
then hidden with the costume dropped on the same frame. `clock` is a function because
|
|
59
|
+
the duration belongs to the costume, so it can only be read once the class is on: a
|
|
60
|
+
modal panel's own exit is declared at `.modal-backdrop.closing .modal`, which needs
|
|
61
|
+
the class on the BACKDROP too. The timer comes back to the caller, since a reopen
|
|
62
|
+
mid-exit has to clear it. */
|
|
63
|
+
const runExit = (el, panel, clock) => {
|
|
64
|
+
el.classList.add('closing');
|
|
65
|
+
panel?.classList.add('closing');
|
|
66
|
+
return setTimeout(() => {
|
|
67
|
+
el.hidden = true;
|
|
68
|
+
el.classList.remove('closing');
|
|
69
|
+
panel?.classList.remove('closing');
|
|
70
|
+
}, clock());
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// a press inside a controller belongs to that controller, so only the others close.
|
|
74
|
+
// Controllers are dropped when their root leaves the DOM: a re-render that replaces
|
|
75
|
+
// a tip or a select leaves a dead entry here, and every outside press then walks it.
|
|
76
|
+
const closeOnOutside = controllers => document.addEventListener('pointerdown', e => {
|
|
77
|
+
for (let i = controllers.length - 1; i >= 0; i--) {
|
|
78
|
+
if (!controllers[i].root.isConnected) { controllers.splice(i, 1); continue; }
|
|
79
|
+
if (!controllers[i].root.contains(e.target)) controllers[i].close();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
39
83
|
/* ====================== sliding inks (tabs, navbar) ====================== */
|
|
40
84
|
|
|
41
85
|
/* First placement lands in place: with the transform transition live, an ink
|
|
@@ -88,12 +132,16 @@ function placeFrom(wrapEl, inkEl, activeSel) {
|
|
|
88
132
|
a page writes the wrap and gets the whole component. Where a marker sits is
|
|
89
133
|
part of it: these are positioned with no z-index, so paint order is document
|
|
90
134
|
order, and one that paints UNDER its siblings goes first. Existing markup is
|
|
91
|
-
reused, so a page that still writes one never gets a second.
|
|
92
|
-
|
|
93
|
-
|
|
135
|
+
reused, so a page that still writes one never gets a second. `svg` builds in
|
|
136
|
+
the SVG namespace, where `className` is read-only, so the class is an
|
|
137
|
+
attribute for both. */
|
|
138
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
139
|
+
function ownChild(parent, cls, { tag = 'div', first = false, svg = false } = {}) {
|
|
140
|
+
const sel = `:scope > .${cls.trim().split(/\s+/).join('.')}`;
|
|
141
|
+
let el = parent.querySelector(sel);
|
|
94
142
|
if (!el) {
|
|
95
|
-
el = document.createElement(tag);
|
|
96
|
-
el.
|
|
143
|
+
el = svg ? document.createElementNS(SVG_NS, tag) : document.createElement(tag);
|
|
144
|
+
el.setAttribute('class', cls);
|
|
97
145
|
el.setAttribute('aria-hidden', 'true');
|
|
98
146
|
if (first) parent.prepend(el); else parent.append(el);
|
|
99
147
|
}
|
|
@@ -128,6 +176,17 @@ function ownLabel(parent, cls) {
|
|
|
128
176
|
return el;
|
|
129
177
|
}
|
|
130
178
|
|
|
179
|
+
// a wrapper is not a child: the component's own node has to end up inside it
|
|
180
|
+
function ownWrap(el, cls) {
|
|
181
|
+
const have = el.parentElement;
|
|
182
|
+
if (have && cls.trim().split(/\s+/).every(t => have.classList.contains(t))) return have;
|
|
183
|
+
const wrap = document.createElement('div');
|
|
184
|
+
wrap.className = cls;
|
|
185
|
+
el.parentNode.insertBefore(wrap, el);
|
|
186
|
+
wrap.appendChild(el);
|
|
187
|
+
return wrap;
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
// a resize or a scroll moves the ink without animating the slide: the indicator is
|
|
132
191
|
// following its tab, not travelling to a new one
|
|
133
192
|
function replace(inkEl, run) {
|
|
@@ -146,6 +205,8 @@ function replace(inkEl, run) {
|
|
|
146
205
|
and the control just moves its own marker, which is what a segmented control
|
|
147
206
|
with no panels needs. Keying only on `data-tabs` is why every page carrying a
|
|
148
207
|
panel-less boxed control had a hand-written copy of this. */
|
|
208
|
+
const TAB_WRAP = '.tabs-wrap, .tabs-boxed-wrap';
|
|
209
|
+
|
|
149
210
|
export function initTabs() {
|
|
150
211
|
/* examples/dashboard.html is what this cost: its wrap held no pill, so the
|
|
151
212
|
segments cross-faded their own backgrounds instead. The two read identical
|
|
@@ -153,21 +214,34 @@ export function initTabs() {
|
|
|
153
214
|
click, the sheet's pill at translate 24.55 on its way to 42.42 against the
|
|
154
215
|
dashboard's nothing. The boxed pill paints under its segments, so it goes
|
|
155
216
|
first. */
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
217
|
+
/* The underline ink rides the TAB ROW, never the wrap: a wrap may hold its
|
|
218
|
+
panels too (the sheet's specimen nests them), and the row is the box the
|
|
219
|
+
ink underlines whatever else the wrap contains. The boxed pill's track is
|
|
220
|
+
the wrap itself, so the two branch here. */
|
|
221
|
+
const inkFor = wrap => {
|
|
222
|
+
if (!wrap) return null;
|
|
223
|
+
if (wrap.classList.contains('tabs-boxed-wrap')) return ownChild(wrap, 'tabs-boxed-ink', { first: true });
|
|
224
|
+
// no row, no ink: an ink with no rail resolves against an ancestor the
|
|
225
|
+
// component does not own
|
|
226
|
+
const tabsRow = wrap.querySelector(':scope > .tabs');
|
|
227
|
+
if (!tabsRow) return null;
|
|
228
|
+
// the rail is hoisted, never queried inline: the builder args are parsed with
|
|
229
|
+
// a flat scan that closes at the first `)`
|
|
230
|
+
return ownChild(tabsRow, 'tab-ink');
|
|
231
|
+
};
|
|
232
|
+
const wraps = () => document.querySelectorAll(TAB_WRAP);
|
|
160
233
|
const placeWrap = wrap => {
|
|
161
234
|
const ink = inkFor(wrap);
|
|
162
235
|
if (!ink) return;
|
|
163
236
|
const boxed = ink.classList.contains('tabs-boxed-ink');
|
|
164
|
-
|
|
237
|
+
// the rail is the ink's own parent, so geometry and paint cannot disagree
|
|
238
|
+
placeFrom(ink.parentElement, ink, boxed ? '.tab-box.active' : '.tab.active');
|
|
165
239
|
if (boxed) wrap.classList.add('has-ink');
|
|
166
240
|
};
|
|
167
241
|
|
|
168
242
|
document.querySelectorAll('.tab, .tab-box').forEach(btn => {
|
|
169
243
|
if (!once(btn, 'uiTab')) return;
|
|
170
|
-
const wrap = btn.closest(
|
|
244
|
+
const wrap = btn.closest(TAB_WRAP);
|
|
171
245
|
const kind = btn.classList.contains('tab-box') ? '.tab-box' : '.tab';
|
|
172
246
|
btn.addEventListener('click', () => {
|
|
173
247
|
const group = btn.dataset.tabs;
|
|
@@ -185,7 +259,7 @@ export function initTabs() {
|
|
|
185
259
|
if (panel) panel.classList.add('active');
|
|
186
260
|
|
|
187
261
|
// a data-tabs group can span wraps, so every wrap holding a peer re-places
|
|
188
|
-
new Set(peers.map(b => b.closest(
|
|
262
|
+
new Set(peers.map(b => b.closest(TAB_WRAP)).filter(Boolean))
|
|
189
263
|
.forEach(placeWrap);
|
|
190
264
|
});
|
|
191
265
|
});
|
|
@@ -285,18 +359,28 @@ export function initDrawer() {
|
|
|
285
359
|
const hamburger = document.getElementById('hamburger-btn');
|
|
286
360
|
if (!sidebar) return;
|
|
287
361
|
|
|
362
|
+
/* Under 768 the column is the off-canvas drawer, and a transform moves paint, never focus:
|
|
363
|
+
without `inert` its rows stay in the tab order behind a closed drawer. Above 768 the same
|
|
364
|
+
column is in flow and always reachable, so the flag follows the query as well as the state. */
|
|
365
|
+
const narrow = matchMedia('(max-width: 768px)');
|
|
366
|
+
const setInert = () => { sidebar.inert = narrow.matches && !sidebar.classList.contains('open'); };
|
|
288
367
|
const setOpen = open => {
|
|
289
368
|
sidebar.classList.toggle('open', open);
|
|
290
369
|
overlay?.classList.toggle('open', open);
|
|
291
370
|
hamburger?.setAttribute('aria-expanded', String(open));
|
|
371
|
+
const held = sidebar.contains(document.activeElement);
|
|
372
|
+
setInert();
|
|
373
|
+
if (!open && held) hamburger?.focus();
|
|
292
374
|
};
|
|
375
|
+
narrow.addEventListener('change', setInert);
|
|
376
|
+
setInert();
|
|
293
377
|
if (hamburger && once(hamburger, 'uiDrawer')) hamburger.addEventListener('click', () => setOpen(true));
|
|
294
378
|
if (overlay && once(overlay, 'uiDrawer')) overlay.addEventListener('click', () => setOpen(false));
|
|
295
379
|
if (once(sidebar, 'uiDrawerRows')) {
|
|
296
380
|
document.querySelectorAll('.nav-item, .navbar-link').forEach(item => {
|
|
297
381
|
item.addEventListener('click', () => { if (innerWidth <= 768) setOpen(false); });
|
|
298
382
|
});
|
|
299
|
-
addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
|
|
383
|
+
addEventListener('keydown', e => { if (e.key === 'Escape' && sidebar.classList.contains('open')) setOpen(false); });
|
|
300
384
|
}
|
|
301
385
|
}
|
|
302
386
|
|
|
@@ -305,14 +389,12 @@ export function initDrawer() {
|
|
|
305
389
|
/* A `.toggle` is a real button carrying `aria-pressed`, so the state and the
|
|
306
390
|
semantics move together and never disagree.
|
|
307
391
|
|
|
308
|
-
THE ROW IS THE TARGET
|
|
392
|
+
THE ROW IS THE TARGET: a coarse pointer gets its 44px on
|
|
309
393
|
the row and the control keeps its proportions, so the row has to be clickable
|
|
310
394
|
or that floor buys nothing. The guard skips a click that landed on another
|
|
311
395
|
control, so the control's own handler runs once and the row cannot double-toggle
|
|
312
396
|
it. Every page carrying a settings panel had written this by hand. */
|
|
313
397
|
export function initToggles() {
|
|
314
|
-
const isControl = t => t.closest?.('button, a, input, select, textarea, label');
|
|
315
|
-
|
|
316
398
|
document.querySelectorAll('.toggle').forEach(t => {
|
|
317
399
|
if (t.disabled || !once(t, 'uiToggle')) return;
|
|
318
400
|
t.addEventListener('click', () => {
|
|
@@ -366,6 +448,10 @@ export function initSelect() {
|
|
|
366
448
|
rendered yet) would leave this in its dead zone and throw at the first
|
|
367
449
|
chip click. */
|
|
368
450
|
const dead = () => trigger.disabled || trigger.getAttribute('aria-disabled') === 'true';
|
|
451
|
+
// the same dead zone: a chip's remove announces, and it is wired at construction
|
|
452
|
+
const announce = () => root.dispatchEvent(
|
|
453
|
+
new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
454
|
+
const selectOnly = opt => options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
|
|
369
455
|
|
|
370
456
|
// one value surface per shape: the shipped .select-value, an <input>
|
|
371
457
|
// trigger's value (combobox), or the chips in a multi-select trigger
|
|
@@ -373,7 +459,7 @@ export function initSelect() {
|
|
|
373
459
|
read: () => valueEl.textContent.trim(),
|
|
374
460
|
value: () => valueEl.textContent.trim(),
|
|
375
461
|
pick(opt) {
|
|
376
|
-
|
|
462
|
+
selectOnly(opt);
|
|
377
463
|
valueEl.textContent = opt.textContent.trim();
|
|
378
464
|
return true;
|
|
379
465
|
},
|
|
@@ -389,7 +475,7 @@ export function initSelect() {
|
|
|
389
475
|
// query a blur left behind cannot pin the panel to one match
|
|
390
476
|
onOpen: () => { trigger.value = committed; showAll(); },
|
|
391
477
|
pick(opt) {
|
|
392
|
-
|
|
478
|
+
selectOnly(opt);
|
|
393
479
|
committed = opt.textContent.trim();
|
|
394
480
|
trigger.value = committed;
|
|
395
481
|
return true;
|
|
@@ -403,8 +489,6 @@ export function initSelect() {
|
|
|
403
489
|
const picked = () => options.filter(o => o.getAttribute('aria-selected') === 'true')
|
|
404
490
|
.map(o => o.textContent.trim()).join(', ');
|
|
405
491
|
const sync = () => { root.dataset.value = picked(); };
|
|
406
|
-
const announce = () => root.dispatchEvent(
|
|
407
|
-
new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
408
492
|
const removeChip = chip => {
|
|
409
493
|
if (dead()) return;
|
|
410
494
|
const label = chip.querySelector('span')?.textContent ?? '';
|
|
@@ -470,6 +554,31 @@ export function initSelect() {
|
|
|
470
554
|
trigger.setAttribute('aria-controls', menu.id);
|
|
471
555
|
options.forEach((o, j) => { o.id ||= `select-${i}-opt-${j}`; });
|
|
472
556
|
|
|
557
|
+
// the width floor: the trigger's text is only the selected value, so left
|
|
558
|
+
// to itself a select in a flex row sizes to it and the open panel outgrows
|
|
559
|
+
// the shut state. Measured after the row reaches the document (a detached
|
|
560
|
+
// tree has no layout, so offsetWidth reads 0) and again through an explicit
|
|
561
|
+
// fonts.load, since fonts.ready can resolve before a lazy face even starts.
|
|
562
|
+
// Pinned at the panel's corner so its box never extends the scroller's
|
|
563
|
+
// overflow. The floor is the widest option plus the menu's gutter and border.
|
|
564
|
+
const sizer = document.createElement('span');
|
|
565
|
+
sizer.className = 'menu-item';
|
|
566
|
+
sizer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;top:0;left:0;';
|
|
567
|
+
menu.appendChild(sizer);
|
|
568
|
+
const fitLongest = () => {
|
|
569
|
+
if (!root.isConnected) return;
|
|
570
|
+
let widest = 0;
|
|
571
|
+
options.forEach(opt => {
|
|
572
|
+
sizer.textContent = opt.textContent;
|
|
573
|
+
widest = Math.max(widest, sizer.offsetWidth);
|
|
574
|
+
});
|
|
575
|
+
root.style.minWidth = (widest + 10) + 'px';
|
|
576
|
+
};
|
|
577
|
+
requestAnimationFrame(fitLongest);
|
|
578
|
+
document.fonts.ready.then(() => {
|
|
579
|
+
document.fonts.load(getComputedStyle(sizer).font).then(fitLongest);
|
|
580
|
+
});
|
|
581
|
+
|
|
473
582
|
const visible = () => options.filter(o => !o.hidden);
|
|
474
583
|
const selectedVisible = () => {
|
|
475
584
|
const vis = visible();
|
|
@@ -480,19 +589,18 @@ export function initSelect() {
|
|
|
480
589
|
// the value surface exists from load, or a consumer reading it before the
|
|
481
590
|
// first pick disagrees with what aria-selected already announces
|
|
482
591
|
root.dataset.value = adapter.read();
|
|
592
|
+
// the surface is written before the event, or a consumer reading it from the
|
|
593
|
+
// change handler still sees the previous pick
|
|
594
|
+
const commit = () => { root.dataset.value = adapter.value(); announce(); };
|
|
483
595
|
let closingTimer = 0;
|
|
484
596
|
const isOpen = () => root.classList.contains('open');
|
|
485
597
|
// read off --fold, the move itself, so a token change or reduced motion
|
|
486
598
|
// retimes the hold without touching this number
|
|
487
599
|
const exitMs = () => transMs(root);
|
|
488
600
|
|
|
489
|
-
// keeps the arrow-key row inside the scroll box without scrollIntoView,
|
|
490
|
-
// which would also scroll the page when the panel sits near an edge
|
|
491
601
|
const revealFocused = opt => {
|
|
492
602
|
const top = opt.offsetTop;
|
|
493
|
-
|
|
494
|
-
if (top < menu.scrollTop) menu.scrollTop = top;
|
|
495
|
-
else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
|
|
603
|
+
scrollIntoBox(menu, top, top + opt.offsetHeight);
|
|
496
604
|
};
|
|
497
605
|
|
|
498
606
|
const markFocus = idx => {
|
|
@@ -542,17 +650,13 @@ export function initSelect() {
|
|
|
542
650
|
const pick = opt => {
|
|
543
651
|
if (!opt) return;
|
|
544
652
|
if (adapter.pick(opt)) close({ focus: true });
|
|
545
|
-
|
|
546
|
-
root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
653
|
+
commit();
|
|
547
654
|
};
|
|
548
655
|
|
|
549
656
|
const filter = () => {
|
|
550
|
-
const
|
|
551
|
-
// every word has to land, the same rule the palette filters its rows by
|
|
552
|
-
const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
657
|
+
const words = queryWords(trigger.value.trim());
|
|
553
658
|
options.forEach(opt => {
|
|
554
|
-
|
|
555
|
-
opt.hidden = !words.every(w => hay.includes(w));
|
|
659
|
+
opt.hidden = !matchesQuery(opt.textContent.trim().toLowerCase(), words);
|
|
556
660
|
});
|
|
557
661
|
if (empty) empty.hidden = visible().length > 0;
|
|
558
662
|
markFocus(selectedVisible());
|
|
@@ -600,18 +704,13 @@ export function initSelect() {
|
|
|
600
704
|
e.stopPropagation();
|
|
601
705
|
if (dead()) return;
|
|
602
706
|
adapter.clear();
|
|
603
|
-
|
|
604
|
-
root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
707
|
+
commit();
|
|
605
708
|
});
|
|
606
709
|
|
|
607
710
|
selectControllers.push({ root, close });
|
|
608
711
|
});
|
|
609
712
|
|
|
610
|
-
if (once(document.body, 'uiSelectOutside'))
|
|
611
|
-
document.addEventListener('pointerdown', e => {
|
|
612
|
-
selectControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
|
|
613
|
-
});
|
|
614
|
-
}
|
|
713
|
+
if (once(document.body, 'uiSelectOutside')) closeOnOutside(selectControllers);
|
|
615
714
|
}
|
|
616
715
|
|
|
617
716
|
/* ============================= palette ============================ */
|
|
@@ -620,6 +719,66 @@ export function initSelect() {
|
|
|
620
719
|
listbox, so the arrow-key row is named by aria-activedescendant. The controller
|
|
621
720
|
owns nothing a command does: it dispatches `run` on the backdrop with the row's
|
|
622
721
|
data-command. Ctrl/Cmd+K opens it whether or not a trigger button exists. */
|
|
722
|
+
const VISIBLE_ROW = '.palette-row:not([hidden])';
|
|
723
|
+
|
|
724
|
+
export function initSlider() {
|
|
725
|
+
document.querySelectorAll('.slider').forEach(root => {
|
|
726
|
+
if (!once(root, 'uiSlider')) return;
|
|
727
|
+
const track = root.querySelector('.slider-track');
|
|
728
|
+
const fill = root.querySelector('.slider-fill');
|
|
729
|
+
if (!track || !fill) return; // the controller never throws on half-built markup
|
|
730
|
+
const min = parseFloat(root.getAttribute('aria-valuemin') ?? '0');
|
|
731
|
+
const max = parseFloat(root.getAttribute('aria-valuemax') ?? '100');
|
|
732
|
+
let val = parseFloat(root.getAttribute('aria-valuenow') ?? String(min));
|
|
733
|
+
const commit = () => {
|
|
734
|
+
root.setAttribute('aria-valuenow', String(val));
|
|
735
|
+
fill.style.width = ((val - min) / (max - min)) * 100 + '%';
|
|
736
|
+
};
|
|
737
|
+
const setVal = (v, fire) => {
|
|
738
|
+
val = Math.min(max, Math.max(min, v));
|
|
739
|
+
commit();
|
|
740
|
+
if (fire) root.dispatchEvent(new Event('input', { bubbles: true }));
|
|
741
|
+
};
|
|
742
|
+
const valFromX = clientX => {
|
|
743
|
+
const r = track.getBoundingClientRect();
|
|
744
|
+
return min + ((clientX - r.left) / r.width) * (max - min);
|
|
745
|
+
};
|
|
746
|
+
root.addEventListener('pointerdown', e => {
|
|
747
|
+
e.preventDefault();
|
|
748
|
+
root.focus();
|
|
749
|
+
setVal(valFromX(e.clientX), true);
|
|
750
|
+
// a synthetic event carries no active pointer and the capture throws,
|
|
751
|
+
// which would also bail the ring through its error listener
|
|
752
|
+
try { root.setPointerCapture(e.pointerId); } catch {}
|
|
753
|
+
root.dataset.dragging = '1';
|
|
754
|
+
});
|
|
755
|
+
root.addEventListener('pointermove', e => {
|
|
756
|
+
if (root.dataset.dragging) setVal(valFromX(e.clientX), true);
|
|
757
|
+
});
|
|
758
|
+
const end = e => {
|
|
759
|
+
if (!root.dataset.dragging) return;
|
|
760
|
+
delete root.dataset.dragging;
|
|
761
|
+
try { if (root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId); } catch {}
|
|
762
|
+
};
|
|
763
|
+
root.addEventListener('pointerup', end);
|
|
764
|
+
root.addEventListener('pointercancel', end);
|
|
765
|
+
root.addEventListener('keydown', e => {
|
|
766
|
+
const step =
|
|
767
|
+
e.key === 'ArrowUp' || e.key === 'ArrowRight' ? 1 :
|
|
768
|
+
e.key === 'ArrowDown' || e.key === 'ArrowLeft' ? -1 :
|
|
769
|
+
e.key === 'PageUp' ? 10 :
|
|
770
|
+
e.key === 'PageDown' ? -10 :
|
|
771
|
+
e.key === 'Home' ? min - val :
|
|
772
|
+
e.key === 'End' ? max - val : 0;
|
|
773
|
+
if (!step) return;
|
|
774
|
+
e.preventDefault();
|
|
775
|
+
setVal(val + step, true);
|
|
776
|
+
});
|
|
777
|
+
Object.defineProperty(root, 'value', { get: () => String(val) });
|
|
778
|
+
commit();
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
623
782
|
export function initPalette() {
|
|
624
783
|
// the sheet's own catalogue anchor is named `palette`, so an id lookup finds
|
|
625
784
|
// the section that documents the component instead of the live palette
|
|
@@ -652,18 +811,12 @@ export function initPalette() {
|
|
|
652
811
|
// .closing counts as closed: a second Escape mid-exit must not restart the
|
|
653
812
|
// exit, and a reopen mid-exit must not return early
|
|
654
813
|
const isOpen = () => !backdrop.hidden && !backdrop.classList.contains('closing');
|
|
655
|
-
const exitMs = () => animMs(panel);
|
|
656
814
|
|
|
657
|
-
//
|
|
658
|
-
// would scroll the page under the palette as well. A row that opens its
|
|
659
|
-
// group scrolls the group in, or Home lands on a cut-off label.
|
|
815
|
+
// a row that opens its group scrolls the group in, or Home lands on a cut-off label
|
|
660
816
|
const reveal = row => {
|
|
661
817
|
const group = row.parentElement;
|
|
662
|
-
const opensGroup = row === group.querySelector(
|
|
663
|
-
|
|
664
|
-
const bottom = row.offsetTop + row.offsetHeight;
|
|
665
|
-
if (top < list.scrollTop) list.scrollTop = top;
|
|
666
|
-
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
|
|
818
|
+
const opensGroup = row === group.querySelector(VISIBLE_ROW);
|
|
819
|
+
scrollIntoBox(list, (opensGroup ? group : row).offsetTop, row.offsetTop + row.offsetHeight);
|
|
667
820
|
};
|
|
668
821
|
|
|
669
822
|
const markActive = i => {
|
|
@@ -679,16 +832,14 @@ export function initPalette() {
|
|
|
679
832
|
|
|
680
833
|
const filter = () => {
|
|
681
834
|
const typed = input.value.trim();
|
|
682
|
-
|
|
683
|
-
// for a one-word query, fewer for the rest
|
|
684
|
-
const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
835
|
+
const words = queryWords(typed);
|
|
685
836
|
shown = [];
|
|
686
837
|
rows.forEach(row => {
|
|
687
|
-
const hit =
|
|
838
|
+
const hit = matchesQuery(row.dataset.hay, words);
|
|
688
839
|
row.hidden = !hit;
|
|
689
840
|
if (hit) shown.push(row);
|
|
690
841
|
});
|
|
691
|
-
groups.forEach(g => { g.hidden = !g.querySelector(
|
|
842
|
+
groups.forEach(g => { g.hidden = !g.querySelector(VISIBLE_ROW); });
|
|
692
843
|
const none = !shown.length;
|
|
693
844
|
list.hidden = none;
|
|
694
845
|
if (empty) empty.hidden = !none;
|
|
@@ -723,14 +874,8 @@ export function initPalette() {
|
|
|
723
874
|
|
|
724
875
|
const close = () => {
|
|
725
876
|
if (!isOpen()) return;
|
|
726
|
-
backdrop.classList.add('closing');
|
|
727
|
-
panel.classList.add('closing');
|
|
728
877
|
clearTimeout(closeTimer);
|
|
729
|
-
closeTimer =
|
|
730
|
-
backdrop.hidden = true;
|
|
731
|
-
backdrop.classList.remove('closing');
|
|
732
|
-
panel.classList.remove('closing');
|
|
733
|
-
}, exitMs());
|
|
878
|
+
closeTimer = runExit(backdrop, panel, () => animMs(panel));
|
|
734
879
|
// focus goes back where it came from, or to the control that names the palette
|
|
735
880
|
const back = lastFocus && lastFocus.isConnected && lastFocus !== document.body ? lastFocus : trigger;
|
|
736
881
|
back?.focus();
|
|
@@ -792,7 +937,6 @@ export function initTooltip() {
|
|
|
792
937
|
|
|
793
938
|
let hovering = false, focused = false, hideTimer = 0, hoverTimer = 0;
|
|
794
939
|
const isOpen = () => !tip.hidden;
|
|
795
|
-
const exitMs = () => animMs(tip);
|
|
796
940
|
|
|
797
941
|
const place = () => {
|
|
798
942
|
const r = root.getBoundingClientRect();
|
|
@@ -814,12 +958,8 @@ export function initTooltip() {
|
|
|
814
958
|
};
|
|
815
959
|
const close = () => {
|
|
816
960
|
if (!isOpen()) return;
|
|
817
|
-
tip.classList.add('closing');
|
|
818
961
|
clearTimeout(hideTimer);
|
|
819
|
-
hideTimer =
|
|
820
|
-
tip.hidden = true;
|
|
821
|
-
tip.classList.remove('closing');
|
|
822
|
-
}, exitMs());
|
|
962
|
+
hideTimer = runExit(tip, null, () => animMs(tip));
|
|
823
963
|
};
|
|
824
964
|
const maybeClose = () => { if (!hovering && !focused) close(); };
|
|
825
965
|
|
|
@@ -838,7 +978,12 @@ export function initTooltip() {
|
|
|
838
978
|
});
|
|
839
979
|
// touch has no hover: the first contact shows it
|
|
840
980
|
trigger.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') open(); });
|
|
841
|
-
trigger.addEventListener('focus', () => {
|
|
981
|
+
trigger.addEventListener('focus', () => {
|
|
982
|
+
// click focuses the trigger too, and a pointer focus must not count as the
|
|
983
|
+
// show condition: :focus-visible separates keyboard focus from pointer focus
|
|
984
|
+
focused = trigger.matches(':focus-visible');
|
|
985
|
+
if (focused) open();
|
|
986
|
+
});
|
|
842
987
|
trigger.addEventListener('blur', () => { focused = false; maybeClose(); });
|
|
843
988
|
trigger.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
|
844
989
|
|
|
@@ -846,9 +991,7 @@ export function initTooltip() {
|
|
|
846
991
|
});
|
|
847
992
|
|
|
848
993
|
if (once(document.body, 'uiTooltipGlobal')) {
|
|
849
|
-
|
|
850
|
-
tooltipControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
|
|
851
|
-
});
|
|
994
|
+
closeOnOutside(tooltipControllers);
|
|
852
995
|
// an open tooltip re-places on resize; it is anchored to its wrapper,
|
|
853
996
|
// so scrolling carries it with the page
|
|
854
997
|
addEventListener('resize', () => {
|
|
@@ -938,14 +1081,8 @@ export function initModal() {
|
|
|
938
1081
|
if (backdrop.hidden || backdrop.classList.contains('closing')) return;
|
|
939
1082
|
const s = state.get(backdrop) || {};
|
|
940
1083
|
const panel = backdrop.querySelector('.modal');
|
|
941
|
-
backdrop.classList.add('closing');
|
|
942
|
-
panel?.classList.add('closing');
|
|
943
1084
|
clearTimeout(s.timer);
|
|
944
|
-
s.timer =
|
|
945
|
-
backdrop.hidden = true;
|
|
946
|
-
backdrop.classList.remove('closing');
|
|
947
|
-
panel?.classList.remove('closing');
|
|
948
|
-
}, panel ? animMs(panel) : 180);
|
|
1085
|
+
s.timer = runExit(backdrop, panel, () => (panel ? animMs(panel) : 180));
|
|
949
1086
|
s.inert?.forEach(el => { el.inert = false; });
|
|
950
1087
|
s.inert = null;
|
|
951
1088
|
state.set(backdrop, s);
|
|
@@ -1003,7 +1140,7 @@ export function showToast(type, title, msg) {
|
|
|
1003
1140
|
</button>`;
|
|
1004
1141
|
t.querySelector('.toast-title').textContent = title;
|
|
1005
1142
|
t.querySelector('.toast-msg').textContent = msg;
|
|
1006
|
-
t.querySelector('.close')
|
|
1143
|
+
wireDismiss(t.querySelector('.close'));
|
|
1007
1144
|
stack.appendChild(t);
|
|
1008
1145
|
setTimeout(() => removeToast(t), 5000);
|
|
1009
1146
|
return t;
|
|
@@ -1015,26 +1152,29 @@ export function removeToast(t) {
|
|
|
1015
1152
|
setTimeout(() => t.remove(), 110); // --dur-out-near, the toast's own exit
|
|
1016
1153
|
}
|
|
1017
1154
|
|
|
1018
|
-
/*
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
});
|
|
1155
|
+
/* `.close` is the one remove, for a toast and for the page-level banner alike. A toast
|
|
1156
|
+
this file builds is wired here at creation, because nothing re-runs init() after it:
|
|
1157
|
+
the sweep below only ever sees the controls already sitting in the markup. */
|
|
1158
|
+
const wireDismiss = btn => {
|
|
1159
|
+
if (!once(btn, 'uiDismiss')) return;
|
|
1160
|
+
btn.addEventListener('click', () => {
|
|
1161
|
+
const toast = btn.closest('.toast');
|
|
1162
|
+
if (toast) return removeToast(toast);
|
|
1163
|
+
const banner = btn.closest('.banner');
|
|
1164
|
+
if (!banner) return;
|
|
1165
|
+
banner.classList.add('closing');
|
|
1166
|
+
setTimeout(() => banner.remove(), animMs(banner));
|
|
1031
1167
|
});
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
export function initDismiss() {
|
|
1171
|
+
document.querySelectorAll('.close').forEach(wireDismiss);
|
|
1032
1172
|
}
|
|
1033
1173
|
|
|
1034
1174
|
/* ============================== theme ============================= */
|
|
1035
1175
|
|
|
1036
1176
|
/* The saved theme is restored in <head> before the stylesheet loads, so a light
|
|
1037
|
-
visitor never sees a dark flash; that snippet cannot live
|
|
1177
|
+
visitor never sees a dark flash; that snippet is in SKILL.md and cannot live
|
|
1038
1178
|
here, because a module runs too late to beat the first paint. */
|
|
1039
1179
|
export function setTheme(t) {
|
|
1040
1180
|
document.documentElement.dataset.theme = t;
|
|
@@ -1120,11 +1260,61 @@ export function initSticky() {
|
|
|
1120
1260
|
});
|
|
1121
1261
|
}
|
|
1122
1262
|
|
|
1263
|
+
/* Plays each target once, in batches of what arrives together: when it crosses
|
|
1264
|
+
the line 10% above the bottom edge, or when it is on screen and no scroller
|
|
1265
|
+
holding it can lift it any higher. Without the second clause a short element
|
|
1266
|
+
at a page's end, or one low on a page that cannot scroll, stays hidden for
|
|
1267
|
+
good: measured, a 40px chart as the sheet's last element sat at 859-899px of
|
|
1268
|
+
a 900px window at full scroll, under the 810px line. Returns the call that
|
|
1269
|
+
plays a batch from outside, for the reveal's net. */
|
|
1270
|
+
function watchEntrance(targets, play) {
|
|
1271
|
+
const pending = new Set(targets);
|
|
1272
|
+
const onScreen = new Set();
|
|
1273
|
+
const settle = els => {
|
|
1274
|
+
const batch = els.filter(el => pending.delete(el));
|
|
1275
|
+
batch.forEach(el => {
|
|
1276
|
+
onScreen.delete(el);
|
|
1277
|
+
line.unobserve(el);
|
|
1278
|
+
seen.unobserve(el);
|
|
1279
|
+
});
|
|
1280
|
+
if (!pending.size) {
|
|
1281
|
+
removeEventListener('scroll', sweep, true);
|
|
1282
|
+
removeEventListener('resize', sweep);
|
|
1283
|
+
}
|
|
1284
|
+
if (batch.length) play(batch);
|
|
1285
|
+
};
|
|
1286
|
+
function sweep() { settle([...onScreen].filter(cannotRise)); }
|
|
1287
|
+
const line = new IntersectionObserver(
|
|
1288
|
+
entries => settle(entries.filter(e => e.isIntersecting).map(e => e.target)),
|
|
1289
|
+
{ threshold: 0, rootMargin: '0px 0px -10% 0px' });
|
|
1290
|
+
const seen = new IntersectionObserver(entries => {
|
|
1291
|
+
entries.forEach(e => e.isIntersecting ? onScreen.add(e.target) : onScreen.delete(e.target));
|
|
1292
|
+
sweep();
|
|
1293
|
+
}, { threshold: 0 });
|
|
1294
|
+
addEventListener('scroll', sweep, { capture: true, passive: true });
|
|
1295
|
+
addEventListener('resize', sweep);
|
|
1296
|
+
targets.forEach(el => {
|
|
1297
|
+
line.observe(el);
|
|
1298
|
+
seen.observe(el);
|
|
1299
|
+
});
|
|
1300
|
+
return settle;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/* every scroller holding the element already sits at its end */
|
|
1304
|
+
const cannotRise = el => {
|
|
1305
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
1306
|
+
const overflowY = getComputedStyle(n).overflowY;
|
|
1307
|
+
const scrolls = overflowY === 'auto' || overflowY === 'scroll';
|
|
1308
|
+
if (scrolls && n.scrollTop < n.scrollHeight - n.clientHeight - 1) return false;
|
|
1309
|
+
}
|
|
1310
|
+
const page = document.scrollingElement;
|
|
1311
|
+
return page.scrollTop >= page.scrollHeight - page.clientHeight - 1;
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1123
1314
|
/* JS adds the hiding class at runtime, so no-JS keeps the content visible.
|
|
1124
1315
|
Reduced motion skips the hide entirely rather than zeroing a duration. */
|
|
1125
1316
|
export function initReveal() {
|
|
1126
|
-
|
|
1127
|
-
if (reduced || !('IntersectionObserver' in window)) return;
|
|
1317
|
+
if (reducedMotion() || !('IntersectionObserver' in window)) return;
|
|
1128
1318
|
|
|
1129
1319
|
const targets = [...document.querySelectorAll('[data-reveal]')].filter(el => once(el, 'uiReveal'));
|
|
1130
1320
|
if (!targets.length) return;
|
|
@@ -1138,20 +1328,14 @@ export function initReveal() {
|
|
|
1138
1328
|
const land = els => els.forEach((el, i) => {
|
|
1139
1329
|
el.style.transitionDelay = Math.min(i, 5) * 40 + 'ms';
|
|
1140
1330
|
el.classList.add('in');
|
|
1141
|
-
io.unobserve(el);
|
|
1142
1331
|
});
|
|
1143
|
-
|
|
1144
|
-
const io = new IntersectionObserver(
|
|
1145
|
-
entries => land(entries.filter(e => e.isIntersecting).map(e => e.target)),
|
|
1146
|
-
{ threshold: 0, rootMargin: '0px 0px -10% 0px' });
|
|
1147
|
-
targets.forEach(el => io.observe(el));
|
|
1332
|
+
const settle = watchEntrance(targets, land);
|
|
1148
1333
|
|
|
1149
1334
|
/* The net covers an observer that never fired, so it reveals only what is ON
|
|
1150
1335
|
SCREEN when it lands. Revealing every target instead settles the whole page
|
|
1151
1336
|
before the reader reaches any of it: measured on the rendered sheet, 44 of
|
|
1152
1337
|
46 sections were off screen at 1500ms, so no section could ever animate. */
|
|
1153
|
-
setTimeout(() =>
|
|
1154
|
-
if (el.classList.contains('in')) return false;
|
|
1338
|
+
setTimeout(() => settle(targets.filter(el => {
|
|
1155
1339
|
const r = el.getBoundingClientRect();
|
|
1156
1340
|
return r.top < innerHeight && r.bottom > 0;
|
|
1157
1341
|
})), 1500);
|
|
@@ -1181,7 +1365,7 @@ function initCardButtons() {
|
|
|
1181
1365
|
});
|
|
1182
1366
|
}
|
|
1183
1367
|
|
|
1184
|
-
/* A legend built from buttons is a control, and
|
|
1368
|
+
/* A legend built from buttons is a control, and `charts.md` had described this
|
|
1185
1369
|
one for a round before anything wrote it: every key on every page was inert
|
|
1186
1370
|
text beside a swatch. The toggle names its slot with the same
|
|
1187
1371
|
`chart-series-N` class the mark carries, so the pair cannot drift, and it
|
|
@@ -1202,8 +1386,56 @@ function initLegend() {
|
|
|
1202
1386
|
});
|
|
1203
1387
|
}
|
|
1204
1388
|
|
|
1389
|
+
/* A line's area and its end point are the line's own geometry, so a page writes
|
|
1390
|
+
the polyline and this derives the other two: the flat tint down to the plot's
|
|
1391
|
+
floor, under the line, and the round mark on its last point, over it. Both are
|
|
1392
|
+
read off the `points` attribute rather than kept, so a page that redraws a line
|
|
1393
|
+
rewrites that one attribute and the pair follows; an emptied or removed line
|
|
1394
|
+
takes its pair with it, or a tint would outlive the line it stood under. A write through
|
|
1395
|
+
the `SVGPointList` API fires no mutation, measured in Chrome, so it redraws
|
|
1396
|
+
nothing. A sparkline takes the same pair. A `<path>` line has no point list to
|
|
1397
|
+
read, and gets neither. */
|
|
1398
|
+
let lineMarks = null;
|
|
1399
|
+
function drawLineMarks(line) {
|
|
1400
|
+
const n = line.points?.numberOfItems;
|
|
1401
|
+
const box = line.ownerSVGElement?.viewBox.baseVal;
|
|
1402
|
+
const parent = line.parentNode;
|
|
1403
|
+
if (!parent) return;
|
|
1404
|
+
if (!n || !box?.height) {
|
|
1405
|
+
parent.querySelectorAll(':scope > .chart-area, :scope > .chart-point.chart-end').forEach(m => m.remove());
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const pts = Array.from({ length: n }, (_, i) => line.points.getItem(i));
|
|
1409
|
+
const floor = box.y + box.height;
|
|
1410
|
+
const first = pts[0];
|
|
1411
|
+
const last = pts[n - 1];
|
|
1412
|
+
const area = ownChild(parent, 'chart-area', { tag: 'polygon', svg: true, first: true });
|
|
1413
|
+
area.setAttribute('points',
|
|
1414
|
+
[...pts.map(p => `${p.x},${p.y}`), `${last.x},${floor}`, `${first.x},${floor}`].join(' '));
|
|
1415
|
+
const end = ownChild(parent, 'chart-point chart-end', { tag: 'line', svg: true });
|
|
1416
|
+
end.setAttribute('x1', last.x);
|
|
1417
|
+
end.setAttribute('y1', last.y);
|
|
1418
|
+
end.setAttribute('x2', last.x);
|
|
1419
|
+
end.setAttribute('y2', last.y);
|
|
1420
|
+
}
|
|
1421
|
+
function initLineMarks() {
|
|
1422
|
+
document.querySelectorAll('.chart-series > .chart-line, .stat-spark svg > polyline').forEach(line => {
|
|
1423
|
+
if (!once(line, 'uiLineMarks')) return;
|
|
1424
|
+
lineMarks ||= new MutationObserver(records => records.forEach(r => {
|
|
1425
|
+
if (r.type === 'attributes') return drawLineMarks(r.target);
|
|
1426
|
+
if (![...r.removedNodes].some(n => n.matches?.('.chart-line, polyline'))) return;
|
|
1427
|
+
if (r.target.querySelector(':scope > .chart-line, :scope > polyline')) return;
|
|
1428
|
+
r.target.querySelectorAll(':scope > .chart-area, :scope > .chart-point.chart-end').forEach(m => m.remove());
|
|
1429
|
+
}));
|
|
1430
|
+
drawLineMarks(line);
|
|
1431
|
+
lineMarks.observe(line, { attributes: true, attributeFilter: ['points'] });
|
|
1432
|
+
lineMarks.observe(line.parentNode, { childList: true });
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1205
1436
|
export function initChart() {
|
|
1206
1437
|
initLegend();
|
|
1438
|
+
initLineMarks();
|
|
1207
1439
|
const charts = [...document.querySelectorAll('.chart')].filter(el => once(el, 'uiChart'));
|
|
1208
1440
|
if (!charts.length) return;
|
|
1209
1441
|
|
|
@@ -1214,15 +1446,7 @@ export function initChart() {
|
|
|
1214
1446
|
charts.forEach(el => el.classList.add('is-drawn'));
|
|
1215
1447
|
return;
|
|
1216
1448
|
}
|
|
1217
|
-
|
|
1218
|
-
entries.forEach(e => {
|
|
1219
|
-
if (e.isIntersecting) {
|
|
1220
|
-
e.target.classList.add('is-drawn');
|
|
1221
|
-
io.unobserve(e.target);
|
|
1222
|
-
}
|
|
1223
|
-
});
|
|
1224
|
-
}, { threshold: 0, rootMargin: '0px 0px -10% 0px' });
|
|
1225
|
-
charts.forEach(el => io.observe(el));
|
|
1449
|
+
watchEntrance(charts, els => els.forEach(el => el.classList.add('is-drawn')));
|
|
1226
1450
|
}
|
|
1227
1451
|
|
|
1228
1452
|
/* ========================== popovers and menus ========================== */
|
|
@@ -1240,38 +1464,126 @@ export function initChart() {
|
|
|
1240
1464
|
const popoverPairs = [];
|
|
1241
1465
|
|
|
1242
1466
|
export function initPopovers() {
|
|
1243
|
-
const isControl = t => t.closest?.('button, a, input, select, textarea, label');
|
|
1244
1467
|
const close = (menu, btn) => {
|
|
1245
1468
|
if (menu.hidden || menu.classList.contains('closing')) return;
|
|
1246
|
-
menu.classList.add('closing');
|
|
1247
1469
|
btn?.setAttribute('aria-expanded', 'false');
|
|
1248
|
-
|
|
1470
|
+
menu.querySelectorAll('.menu-item.focus').forEach(r => r.classList.remove('focus'));
|
|
1471
|
+
menu.uiPopoverExit = runExit(menu, null, () => animMs(menu));
|
|
1249
1472
|
};
|
|
1250
1473
|
|
|
1251
1474
|
document.querySelectorAll('[aria-controls][aria-haspopup]').forEach(btn => {
|
|
1252
1475
|
const menu = document.getElementById(btn.getAttribute('aria-controls'));
|
|
1253
1476
|
if (!menu || !menu.classList.contains('popover') || !once(btn, 'uiPopover')) return;
|
|
1254
1477
|
popoverPairs.push({ btn, menu, close });
|
|
1478
|
+
|
|
1479
|
+
const show = () => {
|
|
1480
|
+
clearTimeout(menu.uiPopoverExit);
|
|
1481
|
+
menu.classList.remove('closing');
|
|
1482
|
+
menu.hidden = false;
|
|
1483
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1255
1486
|
// a checkbox row has to survive several picks, so an input inside the
|
|
1256
1487
|
// item keeps the menu open; a button row is an action, and an action
|
|
1257
1488
|
// row that opens a modal leaves a menu open behind the dialog, whose
|
|
1258
1489
|
// Escape handler then eats the dialog's
|
|
1259
1490
|
menu.addEventListener('click', e => {
|
|
1260
1491
|
const item = e.target.closest?.('.menu-item');
|
|
1261
|
-
if (!item || isControl(e.target)) return;
|
|
1262
|
-
close(menu, btn);
|
|
1263
|
-
btn.focus();
|
|
1264
|
-
});
|
|
1265
|
-
menu.addEventListener('click', e => {
|
|
1266
|
-
if (!e.target.closest?.('button.menu-item')) return;
|
|
1492
|
+
if (!item || (isControl(e.target) && !e.target.closest?.('button.menu-item'))) return;
|
|
1267
1493
|
close(menu, btn);
|
|
1268
1494
|
btn.focus();
|
|
1269
1495
|
});
|
|
1270
1496
|
btn.addEventListener('click', () => {
|
|
1271
1497
|
if (!menu.hidden) return close(menu, btn);
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1498
|
+
show();
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// the keyboard model and the row focus belong to the menu costume alone;
|
|
1502
|
+
// a bare popover keeps the base open / outside-click / Escape / exit
|
|
1503
|
+
if (!menu.classList.contains('popover-menu')) return;
|
|
1504
|
+
|
|
1505
|
+
const rows = [...menu.querySelectorAll('.menu-item')];
|
|
1506
|
+
// the menu-button pattern: the button is the one Tab stop, rows are focusable
|
|
1507
|
+
// by script only, and a checkbox row's input is never a second target; the
|
|
1508
|
+
// box stays the state and the row's aria-checked mirrors it
|
|
1509
|
+
rows.forEach(row => {
|
|
1510
|
+
row.tabIndex = -1;
|
|
1511
|
+
const input = row.querySelector('input');
|
|
1512
|
+
if (input) {
|
|
1513
|
+
input.tabIndex = -1;
|
|
1514
|
+
// the menuitemcheckbox row is the one checkbox a screen reader hears;
|
|
1515
|
+
// the input stays the state and is never announced or Tab-stopped
|
|
1516
|
+
input.setAttribute('aria-hidden', 'true');
|
|
1517
|
+
const sync = () => row.setAttribute('aria-checked', String(input.checked));
|
|
1518
|
+
sync();
|
|
1519
|
+
input.addEventListener('change', sync);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
let focusIdx = 0;
|
|
1523
|
+
const isOpen = () => !menu.hidden && !menu.classList.contains('closing');
|
|
1524
|
+
const activeIndex = () => {
|
|
1525
|
+
const at = rows.findIndex(r => r.classList.contains('active'));
|
|
1526
|
+
return at >= 0 ? at : 0;
|
|
1527
|
+
};
|
|
1528
|
+
const focusRow = idx => {
|
|
1529
|
+
if (!rows.length) return;
|
|
1530
|
+
focusIdx = ((idx % rows.length) + rows.length) % rows.length;
|
|
1531
|
+
rows[focusIdx].focus();
|
|
1532
|
+
};
|
|
1533
|
+
// the .focus mark follows the real focus wherever it lands, and the row is
|
|
1534
|
+
// the only thing that ever holds focus: a pointer click focuses the checkbox
|
|
1535
|
+
// input, which is aria-hidden, so focus moves to its row instead
|
|
1536
|
+
menu.addEventListener('focusin', e => {
|
|
1537
|
+
const row = e.target.closest?.('.menu-item');
|
|
1538
|
+
if (!row) return;
|
|
1539
|
+
if (e.target.matches?.('input')) { row.focus(); return; }
|
|
1540
|
+
const pos = rows.indexOf(row);
|
|
1541
|
+
if (pos >= 0) {
|
|
1542
|
+
focusIdx = pos;
|
|
1543
|
+
rows.forEach(r => r.classList.toggle('focus', r === rows[pos]));
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
// Enter/Space fire the row's own click, so a consumer's listener on it runs;
|
|
1547
|
+
// the menu's click listener above then closes it. A checkbox row toggles its
|
|
1548
|
+
// box and stays open.
|
|
1549
|
+
const activate = () => {
|
|
1550
|
+
const row = rows[focusIdx];
|
|
1551
|
+
if (!row) return;
|
|
1552
|
+
const input = row.querySelector('input');
|
|
1553
|
+
if (input) { input.click(); return; }
|
|
1554
|
+
row.click();
|
|
1555
|
+
};
|
|
1556
|
+
const open = (toLast = false) => {
|
|
1557
|
+
if (isOpen()) return;
|
|
1558
|
+
show();
|
|
1559
|
+
focusRow(toLast ? rows.length - 1 : activeIndex());
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
// focus is on the button: closed keys open, and a pointer-opened menu moves
|
|
1563
|
+
// focus into the rows on the first arrow
|
|
1564
|
+
btn.addEventListener('keydown', e => {
|
|
1565
|
+
switch (e.key) {
|
|
1566
|
+
case 'ArrowDown': e.preventDefault(); isOpen() ? focusRow(activeIndex()) : open(false); break;
|
|
1567
|
+
case 'ArrowUp': e.preventDefault(); isOpen() ? focusRow(rows.length - 1) : open(true); break;
|
|
1568
|
+
case 'Home': if (isOpen()) { e.preventDefault(); focusRow(0); } break;
|
|
1569
|
+
case 'End': if (isOpen()) { e.preventDefault(); focusRow(rows.length - 1); } break;
|
|
1570
|
+
case 'Enter':
|
|
1571
|
+
case ' ': e.preventDefault(); isOpen() ? close(menu, btn) : open(false); break;
|
|
1572
|
+
case 'Tab': close(menu, btn); break;
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
// focus is on a row: the arrows walk it, Enter/Space activate, Escape is the
|
|
1576
|
+
// global handler's, and Tab closes while the default moves focus on
|
|
1577
|
+
menu.addEventListener('keydown', e => {
|
|
1578
|
+
switch (e.key) {
|
|
1579
|
+
case 'ArrowDown': e.preventDefault(); focusRow(focusIdx + 1); break;
|
|
1580
|
+
case 'ArrowUp': e.preventDefault(); focusRow(focusIdx - 1); break;
|
|
1581
|
+
case 'Home': e.preventDefault(); focusRow(0); break;
|
|
1582
|
+
case 'End': e.preventDefault(); focusRow(rows.length - 1); break;
|
|
1583
|
+
case 'Enter':
|
|
1584
|
+
case ' ': e.preventDefault(); activate(); break;
|
|
1585
|
+
case 'Tab': close(menu, btn); break;
|
|
1586
|
+
}
|
|
1275
1587
|
});
|
|
1276
1588
|
});
|
|
1277
1589
|
if (!once(document.body, 'uiPopoverGlobal')) return;
|
|
@@ -1281,6 +1593,9 @@ export function initPopovers() {
|
|
|
1281
1593
|
openPairs().forEach(({ btn, menu }) => {
|
|
1282
1594
|
if (menu.contains(e.target) || btn.contains(e.target)) return;
|
|
1283
1595
|
close(menu, btn);
|
|
1596
|
+
// focus moves into the rows now, so an outside press must not leave it on
|
|
1597
|
+
// a row the exit is about to hide
|
|
1598
|
+
if (menu.contains(document.activeElement)) btn.focus();
|
|
1284
1599
|
});
|
|
1285
1600
|
});
|
|
1286
1601
|
document.addEventListener('keydown', e => {
|
|
@@ -1301,6 +1616,8 @@ export function initPopovers() {
|
|
|
1301
1616
|
`table:page` {page}
|
|
1302
1617
|
Every one of them bubbles from the `.table-wrap`. Nothing here needs to know
|
|
1303
1618
|
what a row holds, which is why it can wire every table on every page. */
|
|
1619
|
+
const ROW_BOX = 'input[type="checkbox"][data-name]';
|
|
1620
|
+
|
|
1304
1621
|
export function initTables() {
|
|
1305
1622
|
const ariaSort = { asc: 'ascending', desc: 'descending' };
|
|
1306
1623
|
|
|
@@ -1326,7 +1643,7 @@ export function initTables() {
|
|
|
1326
1643
|
});
|
|
1327
1644
|
|
|
1328
1645
|
const selectAll = wrap.querySelector('input[type="checkbox"][data-select-all], thead input[type="checkbox"]');
|
|
1329
|
-
const boxes = () => [...wrap.querySelectorAll(
|
|
1646
|
+
const boxes = () => [...wrap.querySelectorAll(`tbody ${ROW_BOX}`)];
|
|
1330
1647
|
const selected = () => boxes().filter(b => b.checked).map(b => b.dataset.name);
|
|
1331
1648
|
const syncAll = () => {
|
|
1332
1649
|
if (!selectAll) return;
|
|
@@ -1348,7 +1665,7 @@ export function initTables() {
|
|
|
1348
1665
|
announce();
|
|
1349
1666
|
});
|
|
1350
1667
|
wrap.addEventListener('change', e => {
|
|
1351
|
-
const b = e.target.closest?.(
|
|
1668
|
+
const b = e.target.closest?.(`tbody ${ROW_BOX}`);
|
|
1352
1669
|
if (!b) return;
|
|
1353
1670
|
b.closest('tr')?.classList.toggle('row-selected', b.checked);
|
|
1354
1671
|
syncAll();
|
|
@@ -1357,8 +1674,8 @@ export function initTables() {
|
|
|
1357
1674
|
// the row is the target, the same ruling the setting row runs on: a click
|
|
1358
1675
|
// anywhere on a selectable row that missed a control drives its own box
|
|
1359
1676
|
wrap.addEventListener('click', e => {
|
|
1360
|
-
if (e.target
|
|
1361
|
-
e.target.closest('tr')?.querySelector(
|
|
1677
|
+
if (isControl(e.target)) return;
|
|
1678
|
+
e.target.closest('tr')?.querySelector(ROW_BOX)?.click();
|
|
1362
1679
|
});
|
|
1363
1680
|
|
|
1364
1681
|
// a re-rendered body arrives with its own checked state: re-derive rather
|
|
@@ -1438,7 +1755,7 @@ const TYPE_CHAR = 80, TYPE_ERASE = 50, TYPE_HOLD = 5000, TYPE_GAP = 400, TYPE_ST
|
|
|
1438
1755
|
export function initTypedPlaceholder() {
|
|
1439
1756
|
// ambient motion is off under reduced motion, and this one has no reduced form:
|
|
1440
1757
|
// a typed line with the typing removed is just a placeholder the field already has
|
|
1441
|
-
if (
|
|
1758
|
+
if (reducedMotion()) return;
|
|
1442
1759
|
|
|
1443
1760
|
document.querySelectorAll('input[data-typed], textarea[data-typed]').forEach(input => {
|
|
1444
1761
|
if (!once(input, 'uiTyped')) return;
|
|
@@ -1451,10 +1768,7 @@ export function initTypedPlaceholder() {
|
|
|
1451
1768
|
}
|
|
1452
1769
|
if (!Array.isArray(examples) || !examples.length) return;
|
|
1453
1770
|
|
|
1454
|
-
const wrap =
|
|
1455
|
-
wrap.className = 'typed-wrap';
|
|
1456
|
-
input.parentNode.insertBefore(wrap, input);
|
|
1457
|
-
wrap.appendChild(input);
|
|
1771
|
+
const wrap = ownWrap(input, 'typed-wrap');
|
|
1458
1772
|
// a marker this module owns, aria-hidden like the inks: the field's own
|
|
1459
1773
|
// label names it, so the typed examples need no second voice
|
|
1460
1774
|
const overlay = ownChild(wrap, 'typed-overlay');
|
|
@@ -1521,9 +1835,21 @@ export function initTypedPlaceholder() {
|
|
|
1521
1835
|
consumer opts in: this one selector names every component the stylesheet makes
|
|
1522
1836
|
scroll, and the controller writes the cue class onto each. The axis is derived
|
|
1523
1837
|
from what the element actually scrolls, never from a class the author wrote.
|
|
1524
|
-
`no-scroll-fade` on any
|
|
1838
|
+
A consumer scroller opts in with `data-scroll-fade`; `no-scroll-fade` on any
|
|
1839
|
+
scroller is the one opt-out. Floating panels never carry it: the select menu
|
|
1840
|
+
and the palette scroll bare. Off at rest, so a no-JS
|
|
1525
1841
|
page paints no cue. The two has-more classes are the CSS's own gate. */
|
|
1526
|
-
const SCROLL_FADE_SELECTOR = 'pre, .table-wrap, .chart-scroll, .modal-body, .log-view, .pagination-pages, .sidebar-nav, .navbar-nav,
|
|
1842
|
+
const SCROLL_FADE_SELECTOR = 'pre, .table-wrap, .chart-scroll, .modal-body, .log-view, .pagination-pages, .sidebar-nav, .navbar-nav, [data-scroll-fade]';
|
|
1843
|
+
|
|
1844
|
+
/* One resize listener and one observer serve every scroller on the page. A
|
|
1845
|
+
consumer that swaps markup (an htmx partial on every tick) replaces a
|
|
1846
|
+
scroller each time, and a per-element listener plus observation would hold
|
|
1847
|
+
every replaced node in memory for the page's life. */
|
|
1848
|
+
const fadeSyncs = new Map();
|
|
1849
|
+
const fadeObserver = new ResizeObserver(entries => {
|
|
1850
|
+
for (const entry of entries) fadeSyncs.get(entry.target)?.();
|
|
1851
|
+
});
|
|
1852
|
+
let fadeResizeBound = false;
|
|
1527
1853
|
|
|
1528
1854
|
export function initScrollFade() {
|
|
1529
1855
|
document.querySelectorAll(SCROLL_FADE_SELECTOR).forEach(el => {
|
|
@@ -1538,14 +1864,26 @@ export function initScrollFade() {
|
|
|
1538
1864
|
el.classList.toggle('has-more-start', pos > 1);
|
|
1539
1865
|
el.classList.toggle('has-more-end', pos + view < full - 1);
|
|
1540
1866
|
};
|
|
1867
|
+
fadeSyncs.set(el, sync);
|
|
1541
1868
|
el.addEventListener('scroll', sync, { passive: true });
|
|
1542
|
-
addEventListener('resize', sync);
|
|
1543
1869
|
/* The box changes size with no scroll and no window resize: the rail collapse
|
|
1544
1870
|
takes this nav's clientHeight 756 to 722, which left the cue saying nothing
|
|
1545
1871
|
was below with 34px still there, until the next scroll. */
|
|
1546
|
-
|
|
1872
|
+
fadeObserver.observe(el);
|
|
1547
1873
|
sync();
|
|
1548
1874
|
});
|
|
1875
|
+
if (!fadeResizeBound) {
|
|
1876
|
+
addEventListener('resize', () => {
|
|
1877
|
+
for (const [el, sync] of fadeSyncs) if (el.isConnected) sync();
|
|
1878
|
+
});
|
|
1879
|
+
fadeResizeBound = true;
|
|
1880
|
+
}
|
|
1881
|
+
for (const [el, sync] of fadeSyncs) {
|
|
1882
|
+
if (el.isConnected) continue;
|
|
1883
|
+
el.removeEventListener('scroll', sync);
|
|
1884
|
+
fadeObserver.unobserve(el);
|
|
1885
|
+
fadeSyncs.delete(el);
|
|
1886
|
+
}
|
|
1549
1887
|
}
|
|
1550
1888
|
|
|
1551
1889
|
/* ============================ scrollbar =========================== */
|
|
@@ -1560,12 +1898,14 @@ export function initScrollFade() {
|
|
|
1560
1898
|
positioned wrapper around every scrolling component in the system.
|
|
1561
1899
|
|
|
1562
1900
|
It appears on any pointer move inside a scroller and on a scroll, and retires
|
|
1563
|
-
`SCROLLBAR_IDLE` after both stop
|
|
1901
|
+
`SCROLLBAR_IDLE` after both stop. Two things hold it past
|
|
1564
1902
|
that: a drag, and the pointer resting on the thumb itself, or the bar would
|
|
1565
1903
|
retire out from under a pointer on its way to grab it. */
|
|
1566
1904
|
const SCROLLBAR_IDLE = 900;
|
|
1567
1905
|
const SCROLLBAR_MIN = 28; // a thumb shorter than this stops reading as a handle
|
|
1568
1906
|
const SCROLLBAR_GAP = 2; // the scroller's own edge to the thumb's outer edge
|
|
1907
|
+
const SCROLLBAR_LANE = 8; // and on to its inner edge: the 6px thumb plus that gap.
|
|
1908
|
+
// The lane is where the bar is placed, hit-tested and held
|
|
1569
1909
|
const SCROLLBAR_HOLD = 10; // slop around a 6px thumb, so the hold is reachable
|
|
1570
1910
|
const SCROLLBAR_Z = 999; // the bar's own slot in the stacking ladder
|
|
1571
1911
|
const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
|
|
@@ -1575,11 +1915,10 @@ const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
|
|
|
1575
1915
|
export function initScrollbars() {
|
|
1576
1916
|
if (!once(document.documentElement, 'uiScrollbars')) return;
|
|
1577
1917
|
const root = document.scrollingElement || document.documentElement;
|
|
1578
|
-
const bars = {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
document.body.append(bars.y, bars.x);
|
|
1918
|
+
const bars = {
|
|
1919
|
+
y: ownChild(document.body, 'ui-scrollbar ui-scrollbar-y'),
|
|
1920
|
+
x: ownChild(document.body, 'ui-scrollbar ui-scrollbar-x'),
|
|
1921
|
+
};
|
|
1583
1922
|
// the stylesheet hides the platform's bars only once these two exist
|
|
1584
1923
|
document.documentElement.classList.add('has-drawn-scrollbars');
|
|
1585
1924
|
|
|
@@ -1590,15 +1929,23 @@ export function initScrollbars() {
|
|
|
1590
1929
|
const at = (el, axis) => axis === 'y' ? el.scrollTop : el.scrollLeft;
|
|
1591
1930
|
|
|
1592
1931
|
const scrolls = (el, axis) => {
|
|
1932
|
+
/* `no-scrollbar` opts a scroller out of the drawn bar: it keeps scrolling,
|
|
1933
|
+
and the bar keeps riding the page or column it belongs to. A surface
|
|
1934
|
+
that reads as fixed chrome carries it, so it never grows a bar of its
|
|
1935
|
+
own. */
|
|
1936
|
+
if (el.closest('.no-scrollbar')) return false;
|
|
1593
1937
|
if (full(el, axis) <= view(el, axis) + 1) return false;
|
|
1594
1938
|
if (el === root) return true;
|
|
1595
1939
|
const ov = getComputedStyle(el)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
|
1596
1940
|
return ov === 'auto' || ov === 'scroll';
|
|
1597
1941
|
};
|
|
1598
|
-
|
|
1599
|
-
|
|
1942
|
+
/* The INNERMOST scroller ON THIS AXIS under a node, so a table inside a modal
|
|
1943
|
+
wins and a table that scrolls only x leaves the y bar with the page. Asking
|
|
1944
|
+
one question for both axes answered the wrong one: a wide table inside a
|
|
1945
|
+
tall page took BOTH bars, and the page's own y bar went with it. */
|
|
1946
|
+
const scrollerFrom = (node, axis) => {
|
|
1600
1947
|
for (let el = node; el instanceof Element; el = el.parentElement) {
|
|
1601
|
-
if (scrolls(el,
|
|
1948
|
+
if (scrolls(el, axis)) return el;
|
|
1602
1949
|
}
|
|
1603
1950
|
return root;
|
|
1604
1951
|
};
|
|
@@ -1646,8 +1993,8 @@ export function initScrollbars() {
|
|
|
1646
1993
|
};
|
|
1647
1994
|
|
|
1648
1995
|
/* Chrome pinned over the scrollport hides the track under it, and a thumb
|
|
1649
|
-
drawn there marks a position nobody can scroll to.
|
|
1650
|
-
|
|
1996
|
+
drawn there marks a position nobody can scroll to. The covers are DERIVED
|
|
1997
|
+
rather than named: every visible fixed or sticky box
|
|
1651
1998
|
is a candidate, and one counts against this bar only where it overlaps the
|
|
1652
1999
|
bar's own lane and sits below the bar in the stacking ladder.
|
|
1653
2000
|
|
|
@@ -1710,8 +2057,8 @@ export function initScrollbars() {
|
|
|
1710
2057
|
|
|
1711
2058
|
const cover = (el, axis) => {
|
|
1712
2059
|
const box = boxOf(el);
|
|
1713
|
-
const lane = axis === 'y' ? [box.right -
|
|
1714
|
-
: [box.bottom -
|
|
2060
|
+
const lane = axis === 'y' ? [box.right - SCROLLBAR_LANE, box.right - SCROLLBAR_GAP]
|
|
2061
|
+
: [box.bottom - SCROLLBAR_LANE, box.bottom - SCROLLBAR_GAP];
|
|
1715
2062
|
const spans = [];
|
|
1716
2063
|
/* `checkVisibility` is guarded because this runs inside an rAF callback, and a
|
|
1717
2064
|
TypeError there reaches the ring's `window.onerror` bail, which drops the
|
|
@@ -1770,37 +2117,67 @@ export function initScrollbars() {
|
|
|
1770
2117
|
return { padA, track, len, room, max: full(el, axis) - v };
|
|
1771
2118
|
};
|
|
1772
2119
|
|
|
1773
|
-
|
|
2120
|
+
/* ONE OWNER PER AXIS. A page scroll used to hand both bars to the document, so
|
|
2121
|
+
a wide table's x bar lost its scroller in the middle of a wheel: `place`
|
|
2122
|
+
returns before it repositions, and the bar then fades out over four frames
|
|
2123
|
+
wherever it last painted. Measured on the reference sheet, one 240px wheel
|
|
2124
|
+
with the pointer inside the table: the bar held top 545.1 while the table's
|
|
2125
|
+
bottom went 553.1 to 313.1, so it sat 240px low, outside the card, over the
|
|
2126
|
+
section under it. Per axis it keeps its owner and tracks the table instead. */
|
|
2127
|
+
const active = { x: root, y: root };
|
|
1774
2128
|
let hideAt = 0;
|
|
1775
2129
|
let drag = null;
|
|
1776
2130
|
let frame = 0;
|
|
1777
2131
|
let pointer = null;
|
|
2132
|
+
// the thumb's last painted state per scroller and axis, for place()'s re-anchor
|
|
2133
|
+
let anchors = new WeakMap();
|
|
2134
|
+
const anchor = (el, axis) => {
|
|
2135
|
+
let c = anchors.get(el);
|
|
2136
|
+
if (!c) anchors.set(el, c = {});
|
|
2137
|
+
return c[axis] || (c[axis] = {});
|
|
2138
|
+
};
|
|
1778
2139
|
|
|
1779
2140
|
const place = axis => {
|
|
1780
2141
|
const bar = bars[axis];
|
|
1781
|
-
const el = drag ? drag.el : active;
|
|
2142
|
+
const el = drag && drag.axis === axis ? drag.el : active[axis];
|
|
1782
2143
|
if (!el.isConnected || !scrolls(el, axis)) { bar.classList.remove('is-on'); return; }
|
|
1783
2144
|
const box = boxOf(el);
|
|
1784
2145
|
const { padA, track, len, room, max } = metrics(el, axis);
|
|
1785
2146
|
// pinned chrome can take the whole scrollport, and a track with no room for
|
|
1786
2147
|
// a handle is a mark, not a bar
|
|
1787
2148
|
if (room < SCROLLBAR_MIN) { bar.classList.remove('is-on'); return; }
|
|
1788
|
-
|
|
2149
|
+
/* A cover that moves under a live scroll (a stuck header at its section's
|
|
2150
|
+
end) shrinks `padA` one-to-one with the scroll while the progress term
|
|
2151
|
+
follows only at (track - len) / max, so the thumb runs backward under a
|
|
2152
|
+
forward scroll. Re-anchoring the line at the last painted position keeps
|
|
2153
|
+
the thumb continuous through the change and still reaches the track's end
|
|
2154
|
+
exactly at full scroll. With static chrome the re-anchored line IS the
|
|
2155
|
+
standard one, so nothing else changes. */
|
|
2156
|
+
const s = at(el, axis);
|
|
2157
|
+
const a = anchor(el, axis);
|
|
2158
|
+
let run;
|
|
2159
|
+
if (max > 0 && track - len >= 1 && a.at < max) {
|
|
2160
|
+
run = a.run + (s - a.at) * (padA + track - len - a.run) / (max - a.at);
|
|
2161
|
+
} else {
|
|
2162
|
+
run = padA + (max > 0 ? (s / max) * (track - len) : 0);
|
|
2163
|
+
}
|
|
2164
|
+
a.at = s;
|
|
2165
|
+
a.run = run;
|
|
1789
2166
|
if (axis === 'y') {
|
|
1790
2167
|
bar.style.top = `${box.top + run}px`;
|
|
1791
|
-
bar.style.left = `${box.right -
|
|
2168
|
+
bar.style.left = `${box.right - SCROLLBAR_LANE}px`;
|
|
1792
2169
|
bar.style.height = `${len}px`;
|
|
1793
2170
|
} else {
|
|
1794
2171
|
bar.style.left = `${box.left + run}px`;
|
|
1795
|
-
bar.style.top = `${box.bottom -
|
|
2172
|
+
bar.style.top = `${box.bottom - SCROLLBAR_LANE}px`;
|
|
1796
2173
|
bar.style.width = `${len}px`;
|
|
1797
2174
|
}
|
|
1798
2175
|
// the thumb's own box, padded: a bar that retired under a pointer reaching
|
|
1799
2176
|
// for it would take the grab with it
|
|
1800
2177
|
const near = pointer && (axis === 'y'
|
|
1801
|
-
? pointer.x >= box.right -
|
|
2178
|
+
? pointer.x >= box.right - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
|
|
1802
2179
|
&& pointer.y >= box.top + run - SCROLLBAR_HOLD && pointer.y <= box.top + run + len + SCROLLBAR_HOLD
|
|
1803
|
-
: pointer.y >= box.bottom -
|
|
2180
|
+
: pointer.y >= box.bottom - SCROLLBAR_LANE - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
|
|
1804
2181
|
&& pointer.x >= box.left + run - SCROLLBAR_HOLD && pointer.x <= box.left + run + len + SCROLLBAR_HOLD);
|
|
1805
2182
|
const on = drag ? drag.axis === axis : Boolean(near) || performance.now() < hideAt;
|
|
1806
2183
|
const wasOn = bar.classList.contains('is-on');
|
|
@@ -1836,13 +2213,18 @@ export function initScrollbars() {
|
|
|
1836
2213
|
if (drag || performance.now() < hideAt) sync();
|
|
1837
2214
|
};
|
|
1838
2215
|
const sync = () => { frame ||= requestAnimationFrame(tick); };
|
|
2216
|
+
// every route that shows the bar restarts its retirement from now
|
|
2217
|
+
const arm = () => { hideAt = performance.now() + SCROLLBAR_IDLE; sync(); };
|
|
1839
2218
|
|
|
1840
2219
|
addEventListener('scroll', e => {
|
|
1841
2220
|
const el = e.target === document || e.target === root ? root : e.target;
|
|
1842
2221
|
if (!(el instanceof Element) && el !== root) return;
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2222
|
+
// a bare scroller never claims the bar, nor arms it for the page behind it
|
|
2223
|
+
if (el.closest('.no-scrollbar')) return;
|
|
2224
|
+
// the event names the element that scrolled and never the axis, so it takes
|
|
2225
|
+
// the axes it can actually drive and leaves the other bar with its owner
|
|
2226
|
+
if (!drag) for (const axis of ['x', 'y']) if (scrolls(el, axis)) active[axis] = el;
|
|
2227
|
+
arm();
|
|
1846
2228
|
}, { capture: true, passive: true });
|
|
1847
2229
|
|
|
1848
2230
|
addEventListener('pointermove', e => {
|
|
@@ -1857,12 +2239,11 @@ export function initScrollbars() {
|
|
|
1857
2239
|
`active` by construction, so leave it alone. */
|
|
1858
2240
|
const onBar = e.target instanceof Element && e.target.classList.contains('ui-scrollbar');
|
|
1859
2241
|
if (!drag && !onBar) {
|
|
1860
|
-
|
|
1861
|
-
|
|
2242
|
+
active.x = scrollerFrom(e.target, 'x');
|
|
2243
|
+
active.y = scrollerFrom(e.target, 'y');
|
|
1862
2244
|
}
|
|
1863
2245
|
// the move is what arms the timer: a resting pointer retires the bar
|
|
1864
|
-
|
|
1865
|
-
sync();
|
|
2246
|
+
arm();
|
|
1866
2247
|
}, { passive: true });
|
|
1867
2248
|
|
|
1868
2249
|
/* `pointer` was write-only, and the Y bar's hold band runs to `innerWidth + 8`,
|
|
@@ -1872,11 +2253,11 @@ export function initScrollbars() {
|
|
|
1872
2253
|
for the same reason. */
|
|
1873
2254
|
document.documentElement.addEventListener('pointerleave', () => { pointer = null; sync(); });
|
|
1874
2255
|
|
|
1875
|
-
addEventListener('resize', () => { radii = new WeakMap(); sync(); });
|
|
2256
|
+
addEventListener('resize', () => { radii = new WeakMap(); anchors = new WeakMap(); sync(); });
|
|
1876
2257
|
|
|
1877
2258
|
for (const axis of ['y', 'x']) {
|
|
1878
2259
|
bars[axis].addEventListener('pointerdown', e => {
|
|
1879
|
-
const el = active;
|
|
2260
|
+
const el = active[axis];
|
|
1880
2261
|
if (!scrolls(el, axis)) return;
|
|
1881
2262
|
if (e.pointerType === 'mouse' && e.button !== 0) return; // middle and right are not a drag
|
|
1882
2263
|
if (drag) return; // one bar at a time: a second press would
|
|
@@ -1903,15 +2284,13 @@ export function initScrollbars() {
|
|
|
1903
2284
|
const now = axis === 'y' ? e.clientY : e.clientX;
|
|
1904
2285
|
const to = drag.from + (now - drag.origin) * drag.rate;
|
|
1905
2286
|
if (axis === 'y') drag.el.scrollTop = to; else drag.el.scrollLeft = to;
|
|
1906
|
-
|
|
1907
|
-
sync();
|
|
2287
|
+
arm();
|
|
1908
2288
|
});
|
|
1909
2289
|
const end = () => {
|
|
1910
2290
|
if (!drag || drag.axis !== axis) return;
|
|
1911
2291
|
drag = null;
|
|
1912
2292
|
bars[axis].classList.remove('is-drag');
|
|
1913
|
-
|
|
1914
|
-
sync();
|
|
2293
|
+
arm();
|
|
1915
2294
|
};
|
|
1916
2295
|
bars[axis].addEventListener('pointerup', end);
|
|
1917
2296
|
bars[axis].addEventListener('pointercancel', end);
|
|
@@ -1959,6 +2338,120 @@ export function initDropzone() {
|
|
|
1959
2338
|
});
|
|
1960
2339
|
}
|
|
1961
2340
|
|
|
2341
|
+
/* ============================== stepper =========================== */
|
|
2342
|
+
|
|
2343
|
+
/* Both bounds are read off the value, so an authored `disabled` is overwritten on
|
|
2344
|
+
the first sync: the state belongs to the component. A NaN loses both comparisons,
|
|
2345
|
+
which is what carries an empty field and an undeclared bound: neither can be at a
|
|
2346
|
+
floor or a ceiling, so neither button goes dead. */
|
|
2347
|
+
const syncParts = ({ input, minus, plus }) => {
|
|
2348
|
+
const n = input.valueAsNumber;
|
|
2349
|
+
minus.disabled = n <= parseFloat(input.min);
|
|
2350
|
+
plus.disabled = n >= parseFloat(input.max);
|
|
2351
|
+
};
|
|
2352
|
+
|
|
2353
|
+
/* The parts each wired wrap's listeners actually hold, so the reset writes bounds
|
|
2354
|
+
onto the same buttons a press does: a fresh lookup diverges the moment a wrap
|
|
2355
|
+
grows a third `.input-step`, and the button the user clicks then stays live at
|
|
2356
|
+
its own ceiling while an inert one goes dead. Weak, so a wrap a page drops is
|
|
2357
|
+
collectable rather than held by this file, which is what the per-form listener
|
|
2358
|
+
this replaced got wrong. Membership is also the only honest answer to "did THIS
|
|
2359
|
+
controller wire it": `once`'s mark is an attribute, and an attribute survives the
|
|
2360
|
+
clone that a repeated form row is built from, listeners and all left behind. */
|
|
2361
|
+
const wiredSteppers = new WeakMap();
|
|
2362
|
+
|
|
2363
|
+
/* The number stepper's two routes into one value: the buttons and the keyboard.
|
|
2364
|
+
`stepUp` and `stepDown` ARE the bounded move, so nothing here restates the grid
|
|
2365
|
+
snap, the clamp or the number formatting. They throw where the field declares no
|
|
2366
|
+
allowed value step, and a throw inside a listener reaches the cursor's `error`
|
|
2367
|
+
handler and takes the page's ring down with it, so a field that cannot step is
|
|
2368
|
+
left unwired rather than caught on every press. */
|
|
2369
|
+
const STEPPER = '[data-stepper]';
|
|
2370
|
+
|
|
2371
|
+
export function initStepper() {
|
|
2372
|
+
/* A form reset restores its fields with no event on any of them, so the bounds
|
|
2373
|
+
would stay where the old values left them and a `+` could sit dead over a
|
|
2374
|
+
mid-range number for the life of the page. `reset` bubbles, and the restore
|
|
2375
|
+
lands after it, on the NEXT TASK rather than on a microtask: measured on one
|
|
2376
|
+
instrumented handler, a trusted press reads the old value at the first and
|
|
2377
|
+
second microtask tick and the restored one at a timeout and a frame, while a
|
|
2378
|
+
scripted `.click()` reads it restored at the first tick. So the activation a
|
|
2379
|
+
person performs is exactly the one a microtask is too early for, and a probe
|
|
2380
|
+
that drives the button from inside the page cannot see it.
|
|
2381
|
+
|
|
2382
|
+
ONE page-level listener, never one per stepper on its own form. A listener on a
|
|
2383
|
+
node outside the once() mark outlives the markup it was wired for: measured, a
|
|
2384
|
+
wrap replaced inside a persisting form left the detached generation still being
|
|
2385
|
+
synced. A reset is rare and user-driven, so the sweep asks no question about
|
|
2386
|
+
which form owns which field, which is also what carries a field associated by
|
|
2387
|
+
`form=""` from outside the form's own subtree. What it does ask is whether this
|
|
2388
|
+
controller wired the wrap, so a stepper it refused cannot take a bound costume
|
|
2389
|
+
from a reset elsewhere on the page and still answer no press. */
|
|
2390
|
+
if (once(document.body, 'uiStepperReset')) {
|
|
2391
|
+
document.addEventListener('reset', () => setTimeout(() =>
|
|
2392
|
+
document.querySelectorAll(STEPPER).forEach(wrap => {
|
|
2393
|
+
const parts = wiredSteppers.get(wrap);
|
|
2394
|
+
if (parts) syncParts(parts);
|
|
2395
|
+
}), 0));
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// the field and its two controls, from the wrap alone: a `.input-step` before the
|
|
2399
|
+
// field decrements and one after it increments
|
|
2400
|
+
document.querySelectorAll(STEPPER).forEach(wrap => {
|
|
2401
|
+
const kids = [...wrap.children];
|
|
2402
|
+
const input = kids.find(el => el.matches('input[type="number"]'));
|
|
2403
|
+
if (!input) return;
|
|
2404
|
+
const at = kids.indexOf(input);
|
|
2405
|
+
const minus = kids.slice(0, at).findLast(el => el.matches('.input-step'));
|
|
2406
|
+
const plus = kids.slice(at + 1).find(el => el.matches('.input-step'));
|
|
2407
|
+
if (!minus || !plus) return;
|
|
2408
|
+
if (input.step.trim().toLowerCase() === 'any' || !once(wrap, 'uiStepper')) return;
|
|
2409
|
+
const parts = { input, minus, plus };
|
|
2410
|
+
wiredSteppers.set(wrap, parts);
|
|
2411
|
+
const sync = () => syncParts(parts);
|
|
2412
|
+
|
|
2413
|
+
// a programmatic value write fires neither event, so a consumer would hear a
|
|
2414
|
+
// press and a keystroke differently
|
|
2415
|
+
const notify = () => input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2416
|
+
const commit = () => {
|
|
2417
|
+
notify();
|
|
2418
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2419
|
+
};
|
|
2420
|
+
|
|
2421
|
+
const press = (btn, move) => btn.addEventListener('click', () => {
|
|
2422
|
+
if (input.disabled) return; // the group's :has() rule refuses the pointer, never the keyboard
|
|
2423
|
+
const before = input.value;
|
|
2424
|
+
move();
|
|
2425
|
+
sync();
|
|
2426
|
+
if (input.value !== before) commit();
|
|
2427
|
+
});
|
|
2428
|
+
press(minus, () => input.stepDown());
|
|
2429
|
+
press(plus, () => input.stepUp());
|
|
2430
|
+
|
|
2431
|
+
/* Typing is the route the platform leaves open: a number field holds 99 under
|
|
2432
|
+
max="12" and only reports itself invalid. `min` is the one floor the markup
|
|
2433
|
+
declares, so an empty or unparseable field falls back to it, and to nothing
|
|
2434
|
+
where no min is declared rather than to a zero this file would be inventing.
|
|
2435
|
+
|
|
2436
|
+
This one only NOTIFIES: the commit it corrects is the change still dispatching
|
|
2437
|
+
around it, and that one carries the clamped value to every listener after this
|
|
2438
|
+
file's, because the write above lands before they run. A second dispatch here
|
|
2439
|
+
would run the whole listener list a second time nested, and the outer dispatch
|
|
2440
|
+
would then resume into the same consumer, so one keystroke would commit twice. */
|
|
2441
|
+
input.addEventListener('change', () => {
|
|
2442
|
+
const before = input.value;
|
|
2443
|
+
const n = input.valueAsNumber;
|
|
2444
|
+
if (Number.isNaN(n)) { if (input.min !== '') input.value = input.min; }
|
|
2445
|
+
else if (n < parseFloat(input.min)) input.value = input.min;
|
|
2446
|
+
else if (n > parseFloat(input.max)) input.value = input.max;
|
|
2447
|
+
sync();
|
|
2448
|
+
if (input.value !== before) notify();
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
sync();
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
|
|
1962
2455
|
/* ============================== boot ============================== */
|
|
1963
2456
|
|
|
1964
2457
|
export function init() {
|
|
@@ -1970,6 +2463,7 @@ export function init() {
|
|
|
1970
2463
|
initToggles();
|
|
1971
2464
|
initDisclosures();
|
|
1972
2465
|
initSelect();
|
|
2466
|
+
initSlider();
|
|
1973
2467
|
initPalette();
|
|
1974
2468
|
initTooltip();
|
|
1975
2469
|
initPopovers();
|
|
@@ -1986,6 +2480,7 @@ export function init() {
|
|
|
1986
2480
|
initScrollFade();
|
|
1987
2481
|
initScrollbars();
|
|
1988
2482
|
initDropzone();
|
|
2483
|
+
initStepper();
|
|
1989
2484
|
}
|
|
1990
2485
|
|
|
1991
2486
|
// a module script is deferred, so the document is parsed by the time this runs
|