reviewzy 0.2.0 → 0.3.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 +1 -1
- package/package.json +1 -1
- package/src/dashboard/static/components.css +6780 -546
- package/src/dashboard/static/cursor-rules.js +83 -0
- package/src/dashboard/static/cursor.css +105 -0
- package/src/dashboard/static/cursor.js +358 -0
- package/src/dashboard/static/dashboard.css +52 -125
- package/src/dashboard/static/dashboard.js +6 -29
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +421 -110
- package/src/dashboard/static/ui.js +1992 -0
- package/src/dashboard/views.tsx +158 -201
- package/src/db/queries.ts +1 -1
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +45 -22
- package/src/mcp/list-entries.ts +42 -55
- package/src/mcp/mark-applied.ts +5 -5
- package/src/mcp/server.ts +8 -3
|
@@ -0,0 +1,1992 @@
|
|
|
1
|
+
/* Controllers. Every component that needs script, in one module.
|
|
2
|
+
Load as `<script type="module" src="ui.js"></script>`. It wires itself on load
|
|
3
|
+
against whatever markup is present and does nothing where a component is absent,
|
|
4
|
+
so one script tag is correct for every page in the system.
|
|
5
|
+
|
|
6
|
+
Contract, and the reason this file exists rather than a set of paste-in blocks:
|
|
7
|
+
- NOTHING here throws on a page that lacks a component. The pasted controllers
|
|
8
|
+
this replaces crashed on a null `#navbar-nav`, a null `#modal-btn` and three
|
|
9
|
+
null table ids, and the cursor's `window.onerror` handler reads any thrown
|
|
10
|
+
error as a bail: one missing modal took the page's ring down with it.
|
|
11
|
+
- `init()` is idempotent. Every element it wires carries a `data-ui-*` mark, so
|
|
12
|
+
calling it again after rendering new markup wires only what is new. The one
|
|
13
|
+
step outside that is the header label, which re-runs on every call by design
|
|
14
|
+
(a table may re-render its head) and is idempotent because the span it builds
|
|
15
|
+
is the thing it looks for.
|
|
16
|
+
- The data half of a table is the page's. This file owns sort cycling, selection
|
|
17
|
+
sync, paging clicks and the filter menu, and announces each as an event.
|
|
18
|
+
|
|
19
|
+
Named exports let a page wire one piece by hand; the auto-init at the bottom is
|
|
20
|
+
the default and covers every page in this system. */
|
|
21
|
+
|
|
22
|
+
import { collectPropSelectors } from './cursor-rules.js';
|
|
23
|
+
|
|
24
|
+
/* ============================ helpers ============================ */
|
|
25
|
+
|
|
26
|
+
// a component is wired once, whatever calls init(). The mark is on the element the
|
|
27
|
+
// listener is attached to, never on an ancestor, or a re-render silently skips it.
|
|
28
|
+
const once = (el, key) => {
|
|
29
|
+
if (!el || el.dataset[key]) return false;
|
|
30
|
+
el.dataset[key] = '1';
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// an exit costume's own clock, read off the element, so a token change or reduced
|
|
35
|
+
// motion retimes every hide without a number in this file
|
|
36
|
+
const animMs = el => (parseFloat(getComputedStyle(el).animationDuration) || 0) * 1000 + 20;
|
|
37
|
+
const transMs = el => Math.max(...getComputedStyle(el).transitionDuration.split(',').map(parseFloat)) * 1000;
|
|
38
|
+
|
|
39
|
+
/* ====================== sliding inks (tabs, navbar) ====================== */
|
|
40
|
+
|
|
41
|
+
/* First placement lands in place: with the transform transition live, an ink
|
|
42
|
+
fading in would slide from x=0 behind its own fade, reading as a teleport.
|
|
43
|
+
Kill the transition once, flush, restore.
|
|
44
|
+
|
|
45
|
+
An ink declaring --ink-base (.tab-ink, .navbar-ink) is laid out at that width
|
|
46
|
+
and scaled, so its length rides the same transform as its travel and cannot
|
|
47
|
+
snap to the target while the bar is still moving. The boxed pill has no
|
|
48
|
+
--ink-base: scaleX would distort its 11px corners, so it resizes instead.
|
|
49
|
+
|
|
50
|
+
Both ride translate3d: the z holds the ink's layer for the life of the page,
|
|
51
|
+
so the sub-pixel geometry painted during the slide is the same geometry
|
|
52
|
+
painted after it. A layer dropped at transitionend re-rasterizes the
|
|
53
|
+
fractional size and position at whole pixels, which is the visible snap
|
|
54
|
+
after landing. */
|
|
55
|
+
export function placeInk(inkEl, width, x) {
|
|
56
|
+
const first = !inkEl.dataset.placed;
|
|
57
|
+
if (first) {
|
|
58
|
+
inkEl.dataset.placed = '1';
|
|
59
|
+
inkEl.style.transition = 'none';
|
|
60
|
+
}
|
|
61
|
+
const base = parseFloat(getComputedStyle(inkEl).getPropertyValue('--ink-base'));
|
|
62
|
+
const next = base > 0
|
|
63
|
+
? `translate3d(${x}px, 0, 0) scaleX(${width / base})`
|
|
64
|
+
: `translate3d(${x}px, 0, 0)`;
|
|
65
|
+
if (!(base > 0)) inkEl.style.width = width + 'px';
|
|
66
|
+
inkEl.style.transform = next;
|
|
67
|
+
if (first) {
|
|
68
|
+
inkEl.getBoundingClientRect(); // flush: the jump happens un-animated
|
|
69
|
+
requestAnimationFrame(() => { inkEl.style.transition = ''; });
|
|
70
|
+
}
|
|
71
|
+
inkEl.style.opacity = '1';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// one geometry read for every ink on the page: the active child's box against its rail
|
|
75
|
+
function placeFrom(wrapEl, inkEl, activeSel) {
|
|
76
|
+
if (!wrapEl || !inkEl) return;
|
|
77
|
+
const active = wrapEl.querySelector(activeSel);
|
|
78
|
+
if (!active) { inkEl.style.opacity = '0'; return; }
|
|
79
|
+
const wRect = wrapEl.getBoundingClientRect();
|
|
80
|
+
const aRect = active.getBoundingClientRect();
|
|
81
|
+
placeInk(inkEl, aRect.width, aRect.left - wRect.left);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* A MARKER A CONSUMER HAS TO WRITE IS THE COMPONENT DEFINED TWICE, and the copy
|
|
85
|
+
that gets forgotten is the one a reader meets. Four of them used to be markup:
|
|
86
|
+
`.tab-ink`, `.tabs-boxed-ink`, `.navbar-ink` and the sidebar row's `.nav-dot`,
|
|
87
|
+
61 hand-written spans on the pages in this repo alone. They are built here, so
|
|
88
|
+
a page writes the wrap and gets the whole component. Where a marker sits is
|
|
89
|
+
part of it: these are positioned with no z-index, so paint order is document
|
|
90
|
+
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
|
+
function ownChild(parent, cls, { tag = 'div', first = false } = {}) {
|
|
93
|
+
let el = parent.querySelector(`:scope > .${cls}`);
|
|
94
|
+
if (!el) {
|
|
95
|
+
el = document.createElement(tag);
|
|
96
|
+
el.className = cls;
|
|
97
|
+
el.setAttribute('aria-hidden', 'true');
|
|
98
|
+
if (first) parent.prepend(el); else parent.append(el);
|
|
99
|
+
}
|
|
100
|
+
return el;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Same reading, one step further: a marker is not the only internal a consumer can
|
|
104
|
+
be left to write. A table header's label needs a box of its own to centre in,
|
|
105
|
+
because a cell centres its LINE BOX and all-caps ink rides high in one. The
|
|
106
|
+
measurement and the levers that do not work are in `components.css` beside
|
|
107
|
+
`.th-label`. This one carries the column's accessible name, so unlike `ownChild`
|
|
108
|
+
it is never `aria-hidden`. It wraps the cell's text into one span placed where
|
|
109
|
+
the text starts, which is why it REFUSES a cell whose text is split by an
|
|
110
|
+
element: joining `P50 <span class=tag>ms</span> avg` would move the tag to the
|
|
111
|
+
end and read the column out as `P50 avg ms`. Nothing on disk writes that shape,
|
|
112
|
+
and a design system is consumed by markup nobody here wrote. The shapes it does
|
|
113
|
+
take are a sort button (text, then the glyph) and a plain cell; the select-all
|
|
114
|
+
cell holds a checkbox and no text, so it is left alone. */
|
|
115
|
+
function ownLabel(parent, cls) {
|
|
116
|
+
const have = parent.querySelector(`:scope > .${cls}`);
|
|
117
|
+
if (have) return have;
|
|
118
|
+
const kids = [...parent.childNodes];
|
|
119
|
+
const texts = kids.filter(n => n.nodeType === 3 && n.nodeValue.trim());
|
|
120
|
+
if (!texts.length) return null;
|
|
121
|
+
const span = kids.slice(kids.indexOf(texts[0]), kids.indexOf(texts[texts.length - 1]) + 1);
|
|
122
|
+
if (span.some(n => n.nodeType === 1)) return null; // an element splits the text
|
|
123
|
+
const el = document.createElement('span');
|
|
124
|
+
el.className = cls;
|
|
125
|
+
el.textContent = texts.map(n => n.nodeValue).join('').trim();
|
|
126
|
+
texts.slice(1).forEach(n => n.remove());
|
|
127
|
+
parent.replaceChild(el, texts[0]);
|
|
128
|
+
return el;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// a resize or a scroll moves the ink without animating the slide: the indicator is
|
|
132
|
+
// following its tab, not travelling to a new one
|
|
133
|
+
function replace(inkEl, run) {
|
|
134
|
+
if (!inkEl) return;
|
|
135
|
+
inkEl.style.transition = 'none';
|
|
136
|
+
run();
|
|
137
|
+
requestAnimationFrame(() => { inkEl.style.transition = ''; });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ============================== tabs ============================== */
|
|
141
|
+
|
|
142
|
+
/* Underline tabs with a sliding ink, and boxed segments with a sliding pill.
|
|
143
|
+
|
|
144
|
+
`data-tabs="group-id"` groups tabs that live in different wraps and `data-panel`
|
|
145
|
+
names each one's panel. BOTH ARE OPTIONAL: with neither, the wrap is the group
|
|
146
|
+
and the control just moves its own marker, which is what a segmented control
|
|
147
|
+
with no panels needs. Keying only on `data-tabs` is why every page carrying a
|
|
148
|
+
panel-less boxed control had a hand-written copy of this. */
|
|
149
|
+
export function initTabs() {
|
|
150
|
+
/* examples/dashboard.html is what this cost: its wrap held no pill, so the
|
|
151
|
+
segments cross-faded their own backgrounds instead. The two read identical
|
|
152
|
+
AT REST, which is why no screenshot could fail on it; measured 60ms into a
|
|
153
|
+
click, the sheet's pill at translate 24.55 on its way to 42.42 against the
|
|
154
|
+
dashboard's nothing. The boxed pill paints under its segments, so it goes
|
|
155
|
+
first. */
|
|
156
|
+
const inkFor = wrap => wrap && (wrap.classList.contains('tabs-boxed-wrap')
|
|
157
|
+
? ownChild(wrap, 'tabs-boxed-ink', { first: true })
|
|
158
|
+
: ownChild(wrap, 'tab-ink'));
|
|
159
|
+
const wraps = () => document.querySelectorAll('.tabs-wrap, .tabs-boxed-wrap');
|
|
160
|
+
const placeWrap = wrap => {
|
|
161
|
+
const ink = inkFor(wrap);
|
|
162
|
+
if (!ink) return;
|
|
163
|
+
const boxed = ink.classList.contains('tabs-boxed-ink');
|
|
164
|
+
placeFrom(wrap, ink, boxed ? '.tab-box.active' : '.tab.active');
|
|
165
|
+
if (boxed) wrap.classList.add('has-ink');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
document.querySelectorAll('.tab, .tab-box').forEach(btn => {
|
|
169
|
+
if (!once(btn, 'uiTab')) return;
|
|
170
|
+
const wrap = btn.closest('.tabs-wrap, .tabs-boxed-wrap');
|
|
171
|
+
const kind = btn.classList.contains('tab-box') ? '.tab-box' : '.tab';
|
|
172
|
+
btn.addEventListener('click', () => {
|
|
173
|
+
const group = btn.dataset.tabs;
|
|
174
|
+
const peers = group
|
|
175
|
+
? [...document.querySelectorAll(`[data-tabs="${group}"]`)]
|
|
176
|
+
: [...(wrap ? wrap.querySelectorAll(kind) : [btn])];
|
|
177
|
+
peers.forEach(b => b.classList.remove('active'));
|
|
178
|
+
btn.classList.add('active');
|
|
179
|
+
|
|
180
|
+
new Set(peers.map(b => b.dataset.panel)).forEach(id => {
|
|
181
|
+
const el = id && document.getElementById(id);
|
|
182
|
+
if (el) el.classList.remove('active');
|
|
183
|
+
});
|
|
184
|
+
const panel = btn.dataset.panel && document.getElementById(btn.dataset.panel);
|
|
185
|
+
if (panel) panel.classList.add('active');
|
|
186
|
+
|
|
187
|
+
// a data-tabs group can span wraps, so every wrap holding a peer re-places
|
|
188
|
+
new Set(peers.map(b => b.closest('.tabs-wrap, .tabs-boxed-wrap')).filter(Boolean))
|
|
189
|
+
.forEach(placeWrap);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
requestAnimationFrame(() => wraps().forEach(placeWrap)); // first placement, after layout
|
|
194
|
+
if (once(document.body, 'uiTabsResize')) {
|
|
195
|
+
// a resize moves the ink without animating the slide: it is following its tab,
|
|
196
|
+
// not travelling to a new one
|
|
197
|
+
addEventListener('resize', () => wraps().forEach(w => replace(inkFor(w), () => placeWrap(w))));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ============================= navbar ============================= */
|
|
202
|
+
|
|
203
|
+
/* The tab ink's physics on the horizontal navbar, plus the bar's docking state.
|
|
204
|
+
|
|
205
|
+
Every `.navbar-nav` holding a `.navbar-ink` is wired, and each one owns only its
|
|
206
|
+
own links: a page carrying a second navbar (a narrow variant, a docs sub-bar)
|
|
207
|
+
used to need a hand-written copy of this controller, and the copy drifted.
|
|
208
|
+
Nothing here is required to exist; a page with no navbar wires nothing rather
|
|
209
|
+
than throwing on a null lookup, which is what the pasted version did. */
|
|
210
|
+
export function initNavbar() {
|
|
211
|
+
document.querySelectorAll('.navbar-nav').forEach(nav => {
|
|
212
|
+
if (!once(nav, 'uiNavbar')) return;
|
|
213
|
+
const ink = ownChild(nav, 'navbar-ink');
|
|
214
|
+
const place = () => placeFrom(nav, ink, '.navbar-link.active');
|
|
215
|
+
|
|
216
|
+
nav.querySelectorAll('.navbar-link').forEach(link => {
|
|
217
|
+
link.addEventListener('click', e => {
|
|
218
|
+
if (link.getAttribute('href') === '#') e.preventDefault();
|
|
219
|
+
nav.querySelectorAll('.navbar-link').forEach(l => l.classList.remove('active'));
|
|
220
|
+
link.classList.add('active');
|
|
221
|
+
place();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
addEventListener('resize', () => replace(ink, place));
|
|
226
|
+
// under 768px the band scrolls and the ink rides the content: the same contract
|
|
227
|
+
nav.addEventListener('scroll', () => replace(ink, place), { passive: true });
|
|
228
|
+
requestAnimationFrame(place);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/* The docking state. scrollY is the whole state: `.is-top` drops the moment the
|
|
232
|
+
page moves and returns at the top, so the bar is a floating card at rest and a
|
|
233
|
+
band once scrolled. Below 768px the CSS ignores it. */
|
|
234
|
+
document.querySelectorAll('.navbar').forEach(bar => {
|
|
235
|
+
if (!once(bar, 'uiNavbarDock')) return;
|
|
236
|
+
const sync = () => bar.classList.toggle('is-top', scrollY <= 0);
|
|
237
|
+
sync();
|
|
238
|
+
addEventListener('scroll', sync, { passive: true });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ============================= sidebar ============================ */
|
|
243
|
+
|
|
244
|
+
/* The marker is the active row's own dot, so nothing is measured or positioned:
|
|
245
|
+
the controller moves one class and the CSS does the rest.
|
|
246
|
+
|
|
247
|
+
Every `.nav-item` is wired, each owning only the rows in its own sidebar.
|
|
248
|
+
Keying on `.nav-item[data-section]` left a plain sidebar inert, so the worked
|
|
249
|
+
dashboard example carried a hand-written copy of these six lines. */
|
|
250
|
+
export function initSidebar() {
|
|
251
|
+
document.querySelectorAll('.nav-item').forEach(item => {
|
|
252
|
+
if (!once(item, 'uiNav')) return;
|
|
253
|
+
// the row's own dot is the marker, so it belongs to the row, not to the page
|
|
254
|
+
ownChild(item, 'nav-dot', { tag: 'span', first: true });
|
|
255
|
+
const scope = item.closest('.sidebar') || document;
|
|
256
|
+
item.addEventListener('click', () => {
|
|
257
|
+
scope.querySelectorAll('.nav-item').forEach(n => {
|
|
258
|
+
n.classList.remove('active');
|
|
259
|
+
n.removeAttribute('aria-current');
|
|
260
|
+
});
|
|
261
|
+
item.classList.add('active');
|
|
262
|
+
item.setAttribute('aria-current', 'true');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// the button is optional, and its own sidebar is the one it folds
|
|
267
|
+
document.querySelectorAll('.sidebar-collapse').forEach(collapse => {
|
|
268
|
+
const column = collapse.closest('.sidebar');
|
|
269
|
+
if (!column || !once(collapse, 'uiCollapse')) return;
|
|
270
|
+
const sync = () => {
|
|
271
|
+
const folded = column.classList.contains('rail');
|
|
272
|
+
collapse.setAttribute('aria-expanded', String(!folded));
|
|
273
|
+
collapse.setAttribute('aria-label', folded ? 'Expand sidebar' : 'Collapse sidebar');
|
|
274
|
+
};
|
|
275
|
+
sync(); // a sidebar may ship folded
|
|
276
|
+
collapse.addEventListener('click', () => { column.classList.toggle('rail'); sync(); });
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ========================== mobile drawer ========================= */
|
|
281
|
+
|
|
282
|
+
export function initDrawer() {
|
|
283
|
+
const sidebar = document.getElementById('sidebar');
|
|
284
|
+
const overlay = document.getElementById('sidebar-overlay');
|
|
285
|
+
const hamburger = document.getElementById('hamburger-btn');
|
|
286
|
+
if (!sidebar) return;
|
|
287
|
+
|
|
288
|
+
const setOpen = open => {
|
|
289
|
+
sidebar.classList.toggle('open', open);
|
|
290
|
+
overlay?.classList.toggle('open', open);
|
|
291
|
+
hamburger?.setAttribute('aria-expanded', String(open));
|
|
292
|
+
};
|
|
293
|
+
if (hamburger && once(hamburger, 'uiDrawer')) hamburger.addEventListener('click', () => setOpen(true));
|
|
294
|
+
if (overlay && once(overlay, 'uiDrawer')) overlay.addEventListener('click', () => setOpen(false));
|
|
295
|
+
if (once(sidebar, 'uiDrawerRows')) {
|
|
296
|
+
document.querySelectorAll('.nav-item, .navbar-link').forEach(item => {
|
|
297
|
+
item.addEventListener('click', () => { if (innerWidth <= 768) setOpen(false); });
|
|
298
|
+
});
|
|
299
|
+
addEventListener('keydown', e => { if (e.key === 'Escape') setOpen(false); });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ====================== switches and their rows ====================== */
|
|
304
|
+
|
|
305
|
+
/* A `.toggle` is a real button carrying `aria-pressed`, so the state and the
|
|
306
|
+
semantics move together and never disagree.
|
|
307
|
+
|
|
308
|
+
THE ROW IS THE TARGET, her call 2026-08-29: a coarse pointer gets its 44px on
|
|
309
|
+
the row and the control keeps its proportions, so the row has to be clickable
|
|
310
|
+
or that floor buys nothing. The guard skips a click that landed on another
|
|
311
|
+
control, so the control's own handler runs once and the row cannot double-toggle
|
|
312
|
+
it. Every page carrying a settings panel had written this by hand. */
|
|
313
|
+
export function initToggles() {
|
|
314
|
+
const isControl = t => t.closest?.('button, a, input, select, textarea, label');
|
|
315
|
+
|
|
316
|
+
document.querySelectorAll('.toggle').forEach(t => {
|
|
317
|
+
if (t.disabled || !once(t, 'uiToggle')) return;
|
|
318
|
+
t.addEventListener('click', () => {
|
|
319
|
+
t.setAttribute('aria-pressed', String(t.classList.toggle('on')));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
document.querySelectorAll('.toggle-wrap, .setting-row').forEach(row => {
|
|
324
|
+
if (!once(row, 'uiRowTarget')) return;
|
|
325
|
+
row.addEventListener('click', e => {
|
|
326
|
+
if (isControl(e.target)) return;
|
|
327
|
+
row.querySelector('.toggle:not(:disabled)')?.click();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* ============================ disclosure ========================== */
|
|
333
|
+
|
|
334
|
+
/* `details` has no disabled state, so a row that cannot act says so with
|
|
335
|
+
aria-disabled and the summary stays focusable to announce why. Its click IS
|
|
336
|
+
the toggle (keyboard Enter and Space ride the same event), so cancelling the
|
|
337
|
+
click cancels the row; the costume lives in components.css beside the
|
|
338
|
+
accordion. */
|
|
339
|
+
export function initDisclosures() {
|
|
340
|
+
document.querySelectorAll('.disclosure[aria-disabled="true"] > summary').forEach(s => {
|
|
341
|
+
if (!once(s, 'uiDisclosure')) return;
|
|
342
|
+
s.addEventListener('click', e => e.preventDefault());
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* ============================== select ============================ */
|
|
347
|
+
|
|
348
|
+
/* The trigger keeps DOM focus for the whole interaction and the panel is a listbox,
|
|
349
|
+
so the arrow-key row is named by aria-activedescendant instead of being focused.
|
|
350
|
+
Escape therefore always lands back on the control that opened. */
|
|
351
|
+
const selectControllers = [];
|
|
352
|
+
|
|
353
|
+
export function initSelect() {
|
|
354
|
+
document.querySelectorAll('.select').forEach((root, i) => {
|
|
355
|
+
const trigger = root.querySelector('.select-trigger');
|
|
356
|
+
const menu = root.querySelector('.select-menu');
|
|
357
|
+
const options = [...root.querySelectorAll('[role="option"]')];
|
|
358
|
+
const empty = root.querySelector('.palette-empty');
|
|
359
|
+
|
|
360
|
+
/* A div trigger stays focusable under aria-disabled (that is the pattern:
|
|
361
|
+
it can announce why it cannot act), and an input's disabled state stops its
|
|
362
|
+
own events but not a clear sitting beside it, so every activation path
|
|
363
|
+
reads the state here rather than trusting the pointer's `none`. It has to
|
|
364
|
+
exist before the adapter builds: the multi adapter wires chip listeners at
|
|
365
|
+
construction, and a root the guards below return early for (its menu not
|
|
366
|
+
rendered yet) would leave this in its dead zone and throw at the first
|
|
367
|
+
chip click. */
|
|
368
|
+
const dead = () => trigger.disabled || trigger.getAttribute('aria-disabled') === 'true';
|
|
369
|
+
|
|
370
|
+
// one value surface per shape: the shipped .select-value, an <input>
|
|
371
|
+
// trigger's value (combobox), or the chips in a multi-select trigger
|
|
372
|
+
const valueAdapter = valueEl => valueEl && ({
|
|
373
|
+
read: () => valueEl.textContent.trim(),
|
|
374
|
+
value: () => valueEl.textContent.trim(),
|
|
375
|
+
pick(opt) {
|
|
376
|
+
options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
|
|
377
|
+
valueEl.textContent = opt.textContent.trim();
|
|
378
|
+
return true;
|
|
379
|
+
},
|
|
380
|
+
clear() {},
|
|
381
|
+
});
|
|
382
|
+
const comboAdapter = (root, trigger) => {
|
|
383
|
+
let committed = trigger.value.trim();
|
|
384
|
+
const showAll = () => { options.forEach(o => { o.hidden = false; }); if (empty) empty.hidden = true; };
|
|
385
|
+
return {
|
|
386
|
+
read: () => committed,
|
|
387
|
+
value: () => committed,
|
|
388
|
+
// every open starts from the committed value with the full list, so a
|
|
389
|
+
// query a blur left behind cannot pin the panel to one match
|
|
390
|
+
onOpen: () => { trigger.value = committed; showAll(); },
|
|
391
|
+
pick(opt) {
|
|
392
|
+
options.forEach(o => o.setAttribute('aria-selected', String(o === opt)));
|
|
393
|
+
committed = opt.textContent.trim();
|
|
394
|
+
trigger.value = committed;
|
|
395
|
+
return true;
|
|
396
|
+
},
|
|
397
|
+
clear: () => { committed = ''; trigger.value = ''; showAll(); },
|
|
398
|
+
restore: () => { trigger.value = committed; showAll(); },
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
const multiAdapter = (root, trigger) => {
|
|
402
|
+
const chips = root.querySelector('.select-chips');
|
|
403
|
+
const picked = () => options.filter(o => o.getAttribute('aria-selected') === 'true')
|
|
404
|
+
.map(o => o.textContent.trim()).join(', ');
|
|
405
|
+
const sync = () => { root.dataset.value = picked(); };
|
|
406
|
+
const announce = () => root.dispatchEvent(
|
|
407
|
+
new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
408
|
+
const removeChip = chip => {
|
|
409
|
+
if (dead()) return;
|
|
410
|
+
const label = chip.querySelector('span')?.textContent ?? '';
|
|
411
|
+
const opt = options.find(o => o.textContent.trim() === label);
|
|
412
|
+
if (opt) opt.setAttribute('aria-selected', 'false');
|
|
413
|
+
chip.remove();
|
|
414
|
+
sync();
|
|
415
|
+
announce();
|
|
416
|
+
};
|
|
417
|
+
// one wiring path for a chip whatever created it: the root's once mark
|
|
418
|
+
// never covers one, since construction wires before that guard runs
|
|
419
|
+
const wireChip = chip => {
|
|
420
|
+
if (!once(chip, 'uiSelectChip')) return;
|
|
421
|
+
chip.addEventListener('click', e => { e.stopPropagation(); removeChip(chip); });
|
|
422
|
+
};
|
|
423
|
+
const addChip = label => {
|
|
424
|
+
if (!chips) return;
|
|
425
|
+
const chip = document.createElement('button');
|
|
426
|
+
chip.type = 'button';
|
|
427
|
+
chip.className = 'tag filter-chip select-chip';
|
|
428
|
+
chip.setAttribute('aria-label', `remove ${label}`);
|
|
429
|
+
chip.innerHTML = '<span></span><svg class="icon close" aria-hidden="true"><use href="#i-x"/></svg>';
|
|
430
|
+
chip.querySelector('span').textContent = label;
|
|
431
|
+
wireChip(chip);
|
|
432
|
+
chips.appendChild(chip);
|
|
433
|
+
};
|
|
434
|
+
// the fold looks like a chip, but it is authored summary text: wiring
|
|
435
|
+
// it as removable deletes the indicator on the click that should open
|
|
436
|
+
chips?.querySelectorAll('.select-chip:not(.select-chip-more)').forEach(wireChip);
|
|
437
|
+
return {
|
|
438
|
+
read: picked,
|
|
439
|
+
value: () => root.dataset.value,
|
|
440
|
+
pick(opt) {
|
|
441
|
+
const on = opt.getAttribute('aria-selected') === 'true';
|
|
442
|
+
opt.setAttribute('aria-selected', String(!on));
|
|
443
|
+
const label = opt.textContent.trim();
|
|
444
|
+
if (on) {
|
|
445
|
+
const chip = [...(chips?.querySelectorAll('.select-chip') ?? [])]
|
|
446
|
+
.find(c => c.querySelector('span')?.textContent === label);
|
|
447
|
+
chip?.remove();
|
|
448
|
+
} else {
|
|
449
|
+
addChip(label);
|
|
450
|
+
}
|
|
451
|
+
sync();
|
|
452
|
+
return false; // aria-multiselectable: a toggle does not close the panel
|
|
453
|
+
},
|
|
454
|
+
clear() {
|
|
455
|
+
options.forEach(o => o.setAttribute('aria-selected', 'false'));
|
|
456
|
+
chips?.querySelectorAll('.select-chip').forEach(c => c.remove());
|
|
457
|
+
sync();
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const adapter = !trigger ? null
|
|
463
|
+
: root.classList.contains('select-multi') ? multiAdapter(root, trigger)
|
|
464
|
+
: trigger.tagName === 'INPUT' ? comboAdapter(root, trigger)
|
|
465
|
+
: valueAdapter(root.querySelector('.select-value'));
|
|
466
|
+
if (!trigger || !menu || !options.length || !adapter) return;
|
|
467
|
+
if (!once(root, 'uiSelect')) return;
|
|
468
|
+
|
|
469
|
+
menu.id ||= `select-${i}-menu`;
|
|
470
|
+
trigger.setAttribute('aria-controls', menu.id);
|
|
471
|
+
options.forEach((o, j) => { o.id ||= `select-${i}-opt-${j}`; });
|
|
472
|
+
|
|
473
|
+
const visible = () => options.filter(o => !o.hidden);
|
|
474
|
+
const selectedVisible = () => {
|
|
475
|
+
const vis = visible();
|
|
476
|
+
const at = vis.findIndex(o => o.getAttribute('aria-selected') === 'true');
|
|
477
|
+
return at >= 0 ? at : 0;
|
|
478
|
+
};
|
|
479
|
+
let focusIdx = selectedVisible();
|
|
480
|
+
// the value surface exists from load, or a consumer reading it before the
|
|
481
|
+
// first pick disagrees with what aria-selected already announces
|
|
482
|
+
root.dataset.value = adapter.read();
|
|
483
|
+
let closingTimer = 0;
|
|
484
|
+
const isOpen = () => root.classList.contains('open');
|
|
485
|
+
// read off --fold, the move itself, so a token change or reduced motion
|
|
486
|
+
// retimes the hold without touching this number
|
|
487
|
+
const exitMs = () => transMs(root);
|
|
488
|
+
|
|
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
|
+
const revealFocused = opt => {
|
|
492
|
+
const top = opt.offsetTop;
|
|
493
|
+
const bottom = top + opt.offsetHeight;
|
|
494
|
+
if (top < menu.scrollTop) menu.scrollTop = top;
|
|
495
|
+
else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const markFocus = idx => {
|
|
499
|
+
const vis = visible();
|
|
500
|
+
if (!vis.length) {
|
|
501
|
+
trigger.removeAttribute('aria-activedescendant');
|
|
502
|
+
options.forEach(o => o.classList.remove('focus'));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
focusIdx = ((idx % vis.length) + vis.length) % vis.length;
|
|
506
|
+
const opt = vis[focusIdx];
|
|
507
|
+
options.forEach(o => o.classList.toggle('focus', o === opt));
|
|
508
|
+
trigger.setAttribute('aria-activedescendant', opt.id);
|
|
509
|
+
revealFocused(opt);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const close = ({ focus = false } = {}) => {
|
|
513
|
+
if (!isOpen()) return;
|
|
514
|
+
root.classList.remove('open');
|
|
515
|
+
// .closing keeps the body's rules applying while --fold runs back to 1;
|
|
516
|
+
// it drops on a frame where every value already equals the closed one
|
|
517
|
+
root.classList.add('closing');
|
|
518
|
+
clearTimeout(closingTimer);
|
|
519
|
+
closingTimer = setTimeout(() => root.classList.remove('closing'), exitMs());
|
|
520
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
521
|
+
trigger.removeAttribute('aria-activedescendant');
|
|
522
|
+
options.forEach(o => o.classList.remove('focus'));
|
|
523
|
+
if (focus) trigger.focus();
|
|
524
|
+
// .select-up stays: dropping it mid-exit flips the panel across the trigger
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const open = () => {
|
|
528
|
+
if (dead() || isOpen()) return;
|
|
529
|
+
clearTimeout(closingTimer);
|
|
530
|
+
root.classList.remove('closing');
|
|
531
|
+
selectControllers.forEach(c => { if (c.root !== root) c.close(); });
|
|
532
|
+
adapter.onOpen?.();
|
|
533
|
+
// the side with room wins; visibility:hidden still lays out, so this measures
|
|
534
|
+
const r = trigger.getBoundingClientRect();
|
|
535
|
+
const below = innerHeight - r.bottom;
|
|
536
|
+
root.classList.toggle('select-up', below < menu.offsetHeight + 8 && r.top > below);
|
|
537
|
+
root.classList.add('open');
|
|
538
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
539
|
+
markFocus(selectedVisible());
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const pick = opt => {
|
|
543
|
+
if (!opt) return;
|
|
544
|
+
if (adapter.pick(opt)) close({ focus: true });
|
|
545
|
+
root.dataset.value = adapter.value();
|
|
546
|
+
root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const filter = () => {
|
|
550
|
+
const typed = trigger.value.trim();
|
|
551
|
+
// every word has to land, the same rule the palette filters its rows by
|
|
552
|
+
const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
553
|
+
options.forEach(opt => {
|
|
554
|
+
const hay = opt.textContent.trim().toLowerCase();
|
|
555
|
+
opt.hidden = !words.every(w => hay.includes(w));
|
|
556
|
+
});
|
|
557
|
+
if (empty) empty.hidden = visible().length > 0;
|
|
558
|
+
markFocus(selectedVisible());
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// an <input> trigger edits the query: focus opens, Space types instead of
|
|
562
|
+
// committing, and clicking never closes. A button or div trigger toggles.
|
|
563
|
+
if (trigger.tagName === 'INPUT') {
|
|
564
|
+
trigger.addEventListener('focus', open);
|
|
565
|
+
trigger.addEventListener('input', filter);
|
|
566
|
+
}
|
|
567
|
+
trigger.addEventListener('click', () => {
|
|
568
|
+
if (trigger.tagName === 'INPUT') open();
|
|
569
|
+
else isOpen() ? close() : open();
|
|
570
|
+
});
|
|
571
|
+
trigger.addEventListener('blur', () => close());
|
|
572
|
+
trigger.addEventListener('keydown', e => {
|
|
573
|
+
// a key on a chip or the clear belongs to that control: the trigger's
|
|
574
|
+
// own handling would swallow the activation it should not answer for
|
|
575
|
+
if (e.target !== trigger) return;
|
|
576
|
+
switch (e.key) {
|
|
577
|
+
case 'ArrowDown': e.preventDefault(); isOpen() ? markFocus(focusIdx + 1) : open(); break;
|
|
578
|
+
case 'ArrowUp': e.preventDefault(); isOpen() ? markFocus(focusIdx - 1) : open(); break;
|
|
579
|
+
case 'Home': if (isOpen()) { e.preventDefault(); markFocus(0); } break;
|
|
580
|
+
case 'End': if (isOpen()) { e.preventDefault(); markFocus(visible().length - 1); } break;
|
|
581
|
+
case 'Enter': e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); break;
|
|
582
|
+
case ' ': if (trigger.tagName !== 'INPUT') { e.preventDefault(); isOpen() ? pick(visible()[focusIdx]) : open(); } break;
|
|
583
|
+
case 'Escape': if (isOpen()) { e.preventDefault(); adapter.restore?.(); close({ focus: true }); } break;
|
|
584
|
+
case 'Tab': close(); break;
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// the panel must not take focus off the trigger, or the blur above closes
|
|
589
|
+
// it before the option's own click can land
|
|
590
|
+
menu.addEventListener('mousedown', e => e.preventDefault());
|
|
591
|
+
options.forEach(opt => {
|
|
592
|
+
opt.addEventListener('click', () => pick(opt));
|
|
593
|
+
opt.addEventListener('pointermove', () => {
|
|
594
|
+
const pos = visible().indexOf(opt);
|
|
595
|
+
if (pos >= 0 && pos !== focusIdx) markFocus(pos);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
root.querySelector('.select-clear')?.addEventListener('click', e => {
|
|
600
|
+
e.stopPropagation();
|
|
601
|
+
if (dead()) return;
|
|
602
|
+
adapter.clear();
|
|
603
|
+
root.dataset.value = adapter.value();
|
|
604
|
+
root.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: root.dataset.value } }));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
selectControllers.push({ root, close });
|
|
608
|
+
});
|
|
609
|
+
|
|
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
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ============================= palette ============================ */
|
|
618
|
+
|
|
619
|
+
/* The query field keeps DOM focus for the whole interaction and the list is a
|
|
620
|
+
listbox, so the arrow-key row is named by aria-activedescendant. The controller
|
|
621
|
+
owns nothing a command does: it dispatches `run` on the backdrop with the row's
|
|
622
|
+
data-command. Ctrl/Cmd+K opens it whether or not a trigger button exists. */
|
|
623
|
+
export function initPalette() {
|
|
624
|
+
// the sheet's own catalogue anchor is named `palette`, so an id lookup finds
|
|
625
|
+
// the section that documents the component instead of the live palette
|
|
626
|
+
const backdrop = document.querySelector('.palette-backdrop');
|
|
627
|
+
if (!backdrop || !once(backdrop, 'uiPalette')) return;
|
|
628
|
+
const panel = backdrop.querySelector('.palette');
|
|
629
|
+
const input = backdrop.querySelector('.palette-input');
|
|
630
|
+
const list = backdrop.querySelector('.palette-list');
|
|
631
|
+
const empty = backdrop.querySelector('.palette-empty');
|
|
632
|
+
const announcer = backdrop.querySelector('[role="status"]');
|
|
633
|
+
const trigger = document.getElementById('palette-trigger');
|
|
634
|
+
if (!panel || !input || !list) return;
|
|
635
|
+
const rows = [...backdrop.querySelectorAll('.palette-row')];
|
|
636
|
+
const groups = [...backdrop.querySelectorAll('.palette-group')];
|
|
637
|
+
|
|
638
|
+
// a row matches on its own name plus its group's label, so "navigate" pulls
|
|
639
|
+
// the whole group. Keycaps stay out of it: nobody searches for a shortcut.
|
|
640
|
+
rows.forEach((row, i) => {
|
|
641
|
+
row.id ||= `palette-opt-${i}`;
|
|
642
|
+
const label = row.closest('.palette-group')?.querySelector('.palette-group-label');
|
|
643
|
+
row.dataset.hay = `${row.querySelector('.palette-name')?.textContent || ''} ${label?.textContent || ''}`
|
|
644
|
+
.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
let shown = rows.slice();
|
|
648
|
+
let activeIdx = 0;
|
|
649
|
+
let lastFocus = null;
|
|
650
|
+
let closeTimer = 0;
|
|
651
|
+
|
|
652
|
+
// .closing counts as closed: a second Escape mid-exit must not restart the
|
|
653
|
+
// exit, and a reopen mid-exit must not return early
|
|
654
|
+
const isOpen = () => !backdrop.hidden && !backdrop.classList.contains('closing');
|
|
655
|
+
const exitMs = () => animMs(panel);
|
|
656
|
+
|
|
657
|
+
// keeps the active row inside the scroll box without scrollIntoView, which
|
|
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.
|
|
660
|
+
const reveal = row => {
|
|
661
|
+
const group = row.parentElement;
|
|
662
|
+
const opensGroup = row === group.querySelector('.palette-row:not([hidden])');
|
|
663
|
+
const top = (opensGroup ? group : row).offsetTop;
|
|
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;
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const markActive = i => {
|
|
670
|
+
if (!shown.length) { input.removeAttribute('aria-activedescendant'); return; }
|
|
671
|
+
activeIdx = (i + shown.length) % shown.length;
|
|
672
|
+
rows.forEach(r => { r.classList.remove('active'); r.setAttribute('aria-selected', 'false'); });
|
|
673
|
+
const row = shown[activeIdx];
|
|
674
|
+
row.classList.add('active');
|
|
675
|
+
row.setAttribute('aria-selected', 'true');
|
|
676
|
+
input.setAttribute('aria-activedescendant', row.id);
|
|
677
|
+
reveal(row);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const filter = () => {
|
|
681
|
+
const typed = input.value.trim();
|
|
682
|
+
// every word has to land, so the hint names the actual fix: another word
|
|
683
|
+
// for a one-word query, fewer for the rest
|
|
684
|
+
const words = typed.toLowerCase().split(/\s+/).filter(Boolean);
|
|
685
|
+
shown = [];
|
|
686
|
+
rows.forEach(row => {
|
|
687
|
+
const hit = words.every(w => row.dataset.hay.includes(w));
|
|
688
|
+
row.hidden = !hit;
|
|
689
|
+
if (hit) shown.push(row);
|
|
690
|
+
});
|
|
691
|
+
groups.forEach(g => { g.hidden = !g.querySelector('.palette-row:not([hidden])'); });
|
|
692
|
+
const none = !shown.length;
|
|
693
|
+
list.hidden = none;
|
|
694
|
+
if (empty) empty.hidden = !none;
|
|
695
|
+
input.setAttribute('aria-expanded', String(!none));
|
|
696
|
+
if (none) {
|
|
697
|
+
const msg = words.length > 1
|
|
698
|
+
? `No match for '${typed}'. Try fewer words.`
|
|
699
|
+
: `No match for '${typed}'. Try another word.`;
|
|
700
|
+
if (empty) empty.textContent = msg;
|
|
701
|
+
// the visible row is content; this region is what announces it, and it
|
|
702
|
+
// stays in the tree so the change lands on a live region
|
|
703
|
+
if (announcer) announcer.textContent = msg;
|
|
704
|
+
input.removeAttribute('aria-activedescendant');
|
|
705
|
+
} else {
|
|
706
|
+
if (announcer) announcer.textContent = '';
|
|
707
|
+
markActive(0);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const open = () => {
|
|
712
|
+
if (isOpen()) return;
|
|
713
|
+
clearTimeout(closeTimer);
|
|
714
|
+
backdrop.classList.remove('closing');
|
|
715
|
+
panel.classList.remove('closing');
|
|
716
|
+
lastFocus = document.activeElement;
|
|
717
|
+
backdrop.hidden = false;
|
|
718
|
+
input.value = '';
|
|
719
|
+
list.scrollTop = 0; // a hidden box keeps the last open's scroll position
|
|
720
|
+
filter();
|
|
721
|
+
input.focus();
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const close = () => {
|
|
725
|
+
if (!isOpen()) return;
|
|
726
|
+
backdrop.classList.add('closing');
|
|
727
|
+
panel.classList.add('closing');
|
|
728
|
+
clearTimeout(closeTimer);
|
|
729
|
+
closeTimer = setTimeout(() => {
|
|
730
|
+
backdrop.hidden = true;
|
|
731
|
+
backdrop.classList.remove('closing');
|
|
732
|
+
panel.classList.remove('closing');
|
|
733
|
+
}, exitMs());
|
|
734
|
+
// focus goes back where it came from, or to the control that names the palette
|
|
735
|
+
const back = lastFocus && lastFocus.isConnected && lastFocus !== document.body ? lastFocus : trigger;
|
|
736
|
+
back?.focus();
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// the palette names the command and closes; what a command does belongs to
|
|
740
|
+
// the page, keyed on data-command rather than on a label that gets reworded
|
|
741
|
+
const run = row => {
|
|
742
|
+
backdrop.dispatchEvent(new CustomEvent('run', {
|
|
743
|
+
bubbles: true,
|
|
744
|
+
detail: { command: row.dataset.command, name: row.querySelector('.palette-name')?.textContent },
|
|
745
|
+
}));
|
|
746
|
+
close();
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
panel.addEventListener('keydown', e => {
|
|
750
|
+
switch (e.key) {
|
|
751
|
+
case 'ArrowDown': e.preventDefault(); markActive(activeIdx + 1); break;
|
|
752
|
+
case 'ArrowUp': e.preventDefault(); markActive(activeIdx - 1); break;
|
|
753
|
+
case 'Home': e.preventDefault(); markActive(0); break;
|
|
754
|
+
case 'End': e.preventDefault(); markActive(shown.length - 1); break;
|
|
755
|
+
case 'Enter': if (shown.length) { e.preventDefault(); run(shown[activeIdx]); } break;
|
|
756
|
+
case 'Escape': e.preventDefault(); close(); break;
|
|
757
|
+
// one focusable element under an aria-modal, so Tab steps the row
|
|
758
|
+
// instead of walking focus into a document assistive tech cannot see
|
|
759
|
+
case 'Tab': e.preventDefault(); markActive(activeIdx + (e.shiftKey ? -1 : 1)); break;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
input.addEventListener('input', filter);
|
|
763
|
+
|
|
764
|
+
// a press anywhere but the query field keeps focus on the query field
|
|
765
|
+
panel.addEventListener('mousedown', e => { if (e.target !== input) e.preventDefault(); });
|
|
766
|
+
backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) e.preventDefault(); });
|
|
767
|
+
backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
|
|
768
|
+
|
|
769
|
+
rows.forEach(row => {
|
|
770
|
+
row.addEventListener('click', () => run(row));
|
|
771
|
+
row.addEventListener('pointermove', () => {
|
|
772
|
+
const i = shown.indexOf(row);
|
|
773
|
+
if (i >= 0 && i !== activeIdx) markActive(i);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
trigger?.addEventListener('click', open);
|
|
778
|
+
addEventListener('keydown', e => {
|
|
779
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ============================= tooltip ============================ */
|
|
784
|
+
|
|
785
|
+
const tooltipControllers = [];
|
|
786
|
+
|
|
787
|
+
export function initTooltip() {
|
|
788
|
+
document.querySelectorAll('.tooltip').forEach(tip => {
|
|
789
|
+
const root = tip.parentElement;
|
|
790
|
+
const trigger = root && root.querySelector(`[aria-describedby="${tip.id}"]`);
|
|
791
|
+
if (!root || !trigger || !once(tip, 'uiTooltip')) return;
|
|
792
|
+
|
|
793
|
+
let hovering = false, focused = false, hideTimer = 0, hoverTimer = 0;
|
|
794
|
+
const isOpen = () => !tip.hidden;
|
|
795
|
+
const exitMs = () => animMs(tip);
|
|
796
|
+
|
|
797
|
+
const place = () => {
|
|
798
|
+
const r = root.getBoundingClientRect();
|
|
799
|
+
const w = tip.offsetWidth, h = tip.offsetHeight;
|
|
800
|
+
// no room above and room below: open toward the space that exists
|
|
801
|
+
tip.classList.toggle('below', r.top < h + 12 && innerHeight - r.bottom > h + 12);
|
|
802
|
+
let x = (r.width - w) / 2; // centered on the trigger
|
|
803
|
+
x = Math.max(8 - r.left, Math.min(x, innerWidth - 8 - w - r.left));
|
|
804
|
+
tip.style.left = Math.round(x) + 'px';
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const open = () => {
|
|
808
|
+
clearTimeout(hoverTimer);
|
|
809
|
+
clearTimeout(hideTimer);
|
|
810
|
+
tip.classList.remove('closing'); // a reopen mid-exit restarts the entrance
|
|
811
|
+
if (isOpen()) { place(); return; }
|
|
812
|
+
tip.hidden = false;
|
|
813
|
+
place();
|
|
814
|
+
};
|
|
815
|
+
const close = () => {
|
|
816
|
+
if (!isOpen()) return;
|
|
817
|
+
tip.classList.add('closing');
|
|
818
|
+
clearTimeout(hideTimer);
|
|
819
|
+
hideTimer = setTimeout(() => {
|
|
820
|
+
tip.hidden = true;
|
|
821
|
+
tip.classList.remove('closing');
|
|
822
|
+
}, exitMs());
|
|
823
|
+
};
|
|
824
|
+
const maybeClose = () => { if (!hovering && !focused) close(); };
|
|
825
|
+
|
|
826
|
+
trigger.addEventListener('pointerenter', e => {
|
|
827
|
+
if (e.pointerType !== 'mouse') return;
|
|
828
|
+
hovering = true;
|
|
829
|
+
// hover intent: 300ms, so a scan across the toolbar does not flicker
|
|
830
|
+
clearTimeout(hoverTimer);
|
|
831
|
+
hoverTimer = setTimeout(open, 300);
|
|
832
|
+
});
|
|
833
|
+
trigger.addEventListener('pointerleave', e => {
|
|
834
|
+
if (e.pointerType !== 'mouse') return;
|
|
835
|
+
hovering = false;
|
|
836
|
+
clearTimeout(hoverTimer);
|
|
837
|
+
maybeClose();
|
|
838
|
+
});
|
|
839
|
+
// touch has no hover: the first contact shows it
|
|
840
|
+
trigger.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') open(); });
|
|
841
|
+
trigger.addEventListener('focus', () => { focused = true; open(); });
|
|
842
|
+
trigger.addEventListener('blur', () => { focused = false; maybeClose(); });
|
|
843
|
+
trigger.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
|
844
|
+
|
|
845
|
+
tooltipControllers.push({ root, tip, close, place, isOpen });
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
if (once(document.body, 'uiTooltipGlobal')) {
|
|
849
|
+
document.addEventListener('pointerdown', e => {
|
|
850
|
+
tooltipControllers.forEach(c => { if (!c.root.contains(e.target)) c.close(); });
|
|
851
|
+
});
|
|
852
|
+
// an open tooltip re-places on resize; it is anchored to its wrapper,
|
|
853
|
+
// so scrolling carries it with the page
|
|
854
|
+
addEventListener('resize', () => {
|
|
855
|
+
tooltipControllers.forEach(c => { if (c.isOpen()) c.place(); });
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/* ============================== modal ============================= */
|
|
861
|
+
|
|
862
|
+
/* A dialog opens from `[data-modal-open="<backdrop id>"]` and closes on Escape, on
|
|
863
|
+
the backdrop's own click, and on any `[data-modal-close]` inside it.
|
|
864
|
+
|
|
865
|
+
THE FOCUS PROTOCOL IS THE PART EVERY CONSUMER GOT WRONG, so it lives here rather
|
|
866
|
+
than in prose. On open, every other child of <body> goes `inert`: that is the
|
|
867
|
+
focus trap, with no key handling and no tabbable-list arithmetic, and it also
|
|
868
|
+
takes the page behind out of the accessibility tree. Focus lands on the panel's
|
|
869
|
+
first close control, and returns to the opener at close.
|
|
870
|
+
|
|
871
|
+
A MENU THAT OPENS A MODAL STEALS FOCUS BACK, because the menu's own restore runs
|
|
872
|
+
after the dialog has taken it, and Escape then does nothing. Opening on the next
|
|
873
|
+
frame is what orders the two. */
|
|
874
|
+
// module scope, not per call: the listeners a first init() registered close over
|
|
875
|
+
// this map, so a second init() creating a fresh one would leave initModal.open
|
|
876
|
+
// writing to a map nothing reads
|
|
877
|
+
const modalState = new WeakMap();
|
|
878
|
+
|
|
879
|
+
export function initModal() {
|
|
880
|
+
const state = modalState;
|
|
881
|
+
|
|
882
|
+
const openers = document.querySelectorAll('[data-modal-open]');
|
|
883
|
+
openers.forEach(btn => {
|
|
884
|
+
if (!once(btn, 'uiModalOpen')) return;
|
|
885
|
+
btn.addEventListener('click', () => {
|
|
886
|
+
const backdrop = document.getElementById(btn.dataset.modalOpen);
|
|
887
|
+
if (backdrop) requestAnimationFrame(() => openModal(backdrop, btn));
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// the palette is a modal backdrop wearing a second class, and its own controller
|
|
892
|
+
// owns its open, close, focus and exit. Two controllers on one element left it
|
|
893
|
+
// visible after a command ran, swallowing every click on the page behind it.
|
|
894
|
+
const dialogs = () => [...document.querySelectorAll('.modal-backdrop:not(.palette-backdrop)')];
|
|
895
|
+
|
|
896
|
+
dialogs().forEach(backdrop => {
|
|
897
|
+
if (!once(backdrop, 'uiModal')) return;
|
|
898
|
+
backdrop.addEventListener('click', e => {
|
|
899
|
+
if (e.target === backdrop || e.target.closest('[data-modal-close]')) closeModal(backdrop);
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
if (once(document.body, 'uiModalEsc')) {
|
|
904
|
+
addEventListener('keydown', e => {
|
|
905
|
+
if (e.key !== 'Escape') return;
|
|
906
|
+
const open = dialogs().filter(b => !b.hidden && !b.classList.contains('closing')).pop();
|
|
907
|
+
if (open) closeModal(open);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/* The ambient layer is not page content, so the trap never reaches it: the ring
|
|
912
|
+
and the grid paint, the toast stack still announces, and the drawn scrollbar
|
|
913
|
+
keeps taking a drag. `inert` on that bar would leave the dialog's own scroller
|
|
914
|
+
with no bar to grab, since the platform's is hidden by then. */
|
|
915
|
+
function siblings(backdrop) {
|
|
916
|
+
return [...document.body.children].filter(el =>
|
|
917
|
+
el !== backdrop && el.id !== 'cursor' && el.id !== 'bg-dots' && el.id !== 'toast-stack'
|
|
918
|
+
&& !el.classList.contains('ui-scrollbar'));
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function openModal(backdrop, opener) {
|
|
922
|
+
const s = state.get(backdrop) || {};
|
|
923
|
+
clearTimeout(s.timer);
|
|
924
|
+
backdrop.classList.remove('closing');
|
|
925
|
+
backdrop.querySelector('.modal')?.classList.remove('closing');
|
|
926
|
+
s.opener = opener || document.activeElement;
|
|
927
|
+
s.inert = siblings(backdrop).filter(el => !el.inert);
|
|
928
|
+
s.inert.forEach(el => { el.inert = true; });
|
|
929
|
+
state.set(backdrop, s);
|
|
930
|
+
backdrop.hidden = false;
|
|
931
|
+
const focusTarget = backdrop.querySelector('[data-modal-autofocus]')
|
|
932
|
+
|| backdrop.querySelector('[data-modal-close]')
|
|
933
|
+
|| backdrop.querySelector('.modal');
|
|
934
|
+
focusTarget?.focus?.();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function closeModal(backdrop) {
|
|
938
|
+
if (backdrop.hidden || backdrop.classList.contains('closing')) return;
|
|
939
|
+
const s = state.get(backdrop) || {};
|
|
940
|
+
const panel = backdrop.querySelector('.modal');
|
|
941
|
+
backdrop.classList.add('closing');
|
|
942
|
+
panel?.classList.add('closing');
|
|
943
|
+
clearTimeout(s.timer);
|
|
944
|
+
s.timer = setTimeout(() => {
|
|
945
|
+
backdrop.hidden = true;
|
|
946
|
+
backdrop.classList.remove('closing');
|
|
947
|
+
panel?.classList.remove('closing');
|
|
948
|
+
}, panel ? animMs(panel) : 180);
|
|
949
|
+
s.inert?.forEach(el => { el.inert = false; });
|
|
950
|
+
s.inert = null;
|
|
951
|
+
state.set(backdrop, s);
|
|
952
|
+
// an opener inside a popover is hidden the moment its menu folds, and
|
|
953
|
+
// focusing a hidden element does nothing, so the menu's own trigger is
|
|
954
|
+
// the fallback. The .closing branch covers the window where the menu
|
|
955
|
+
// has not hidden yet but will: focus lands on the row and then drops.
|
|
956
|
+
if (s.opener?.isConnected) s.opener.focus();
|
|
957
|
+
const menu = s.opener?.closest?.('.popover');
|
|
958
|
+
if (menu && (menu.hidden || menu.classList.contains('closing'))) {
|
|
959
|
+
document.querySelector(`[aria-controls="${menu.id}"]`)?.focus();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
initModal.open = openModal;
|
|
964
|
+
initModal.close = closeModal;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/* ============================== toasts ============================ */
|
|
968
|
+
|
|
969
|
+
/* THE SPRITE IS THE ONE DRAWING OF EVERY GLYPH. These four were redrawn here
|
|
970
|
+
and each one had drifted from the symbol it stands for: the success tick ran
|
|
971
|
+
M5 8 l2 2 l4 -4 against the sprite's M5 8 L7 10 L11 6, the warning triangle
|
|
972
|
+
sat a half unit lower, and both dots were 0.5-unit strokes where the sprite
|
|
973
|
+
draws a .01 round cap. Nothing could see it, because a redrawn glyph passes
|
|
974
|
+
every guard the sprite has. */
|
|
975
|
+
const toastIcons = {
|
|
976
|
+
success: '<svg aria-hidden="true"><use href="#i-success"/></svg>',
|
|
977
|
+
warning: '<svg aria-hidden="true"><use href="#i-warning"/></svg>',
|
|
978
|
+
danger: '<svg aria-hidden="true"><use href="#i-error"/></svg>',
|
|
979
|
+
info: '<svg aria-hidden="true"><use href="#i-info"/></svg>',
|
|
980
|
+
};
|
|
981
|
+
const toastIconColors = {
|
|
982
|
+
success: 'var(--success)', warning: 'var(--warning)',
|
|
983
|
+
danger: 'var(--danger)', info: 'var(--accent)',
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
/* `title` and `msg` are caller strings: a service name, a log line, a filename.
|
|
987
|
+
They go in as TEXT. The markup around them is the only thing built from a
|
|
988
|
+
template, so a value carrying angle brackets lands as characters, not markup. */
|
|
989
|
+
export function showToast(type, title, msg) {
|
|
990
|
+
const stack = document.getElementById('toast-stack');
|
|
991
|
+
if (!stack) return null;
|
|
992
|
+
const t = document.createElement('div');
|
|
993
|
+
t.className = 'toast';
|
|
994
|
+
t.setAttribute('role', 'status');
|
|
995
|
+
t.innerHTML = `
|
|
996
|
+
<div class="toast-icon" style="color:${toastIconColors[type] || toastIconColors.info}">${toastIcons[type] || toastIcons.info}</div>
|
|
997
|
+
<div class="toast-body">
|
|
998
|
+
<div class="toast-title"></div>
|
|
999
|
+
<div class="toast-msg"></div>
|
|
1000
|
+
</div>
|
|
1001
|
+
<button class="close" aria-label="Dismiss">
|
|
1002
|
+
<svg aria-hidden="true"><use href="#i-x"/></svg>
|
|
1003
|
+
</button>`;
|
|
1004
|
+
t.querySelector('.toast-title').textContent = title;
|
|
1005
|
+
t.querySelector('.toast-msg').textContent = msg;
|
|
1006
|
+
t.querySelector('.close').addEventListener('click', () => removeToast(t));
|
|
1007
|
+
stack.appendChild(t);
|
|
1008
|
+
setTimeout(() => removeToast(t), 5000);
|
|
1009
|
+
return t;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function removeToast(t) {
|
|
1013
|
+
if (!t || !t.parentNode) return;
|
|
1014
|
+
t.classList.add('removing');
|
|
1015
|
+
setTimeout(() => t.remove(), 110); // --dur-out-near, the toast's own exit
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/* Dismiss controls that already sit in the markup: a toast written into the page,
|
|
1019
|
+
and the page-level banner. `.close` is the one remove. */
|
|
1020
|
+
export function initDismiss() {
|
|
1021
|
+
document.querySelectorAll('.close').forEach(btn => {
|
|
1022
|
+
if (!once(btn, 'uiDismiss')) return;
|
|
1023
|
+
btn.addEventListener('click', () => {
|
|
1024
|
+
const toast = btn.closest('.toast');
|
|
1025
|
+
if (toast) return removeToast(toast);
|
|
1026
|
+
const banner = btn.closest('.banner');
|
|
1027
|
+
if (!banner) return;
|
|
1028
|
+
banner.classList.add('closing');
|
|
1029
|
+
setTimeout(() => banner.remove(), animMs(banner));
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/* ============================== theme ============================= */
|
|
1035
|
+
|
|
1036
|
+
/* 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
|
|
1038
|
+
here, because a module runs too late to beat the first paint. */
|
|
1039
|
+
export function setTheme(t) {
|
|
1040
|
+
document.documentElement.dataset.theme = t;
|
|
1041
|
+
try { localStorage.setItem('cloudy-theme', t); } catch { /* private mode */ }
|
|
1042
|
+
document.querySelectorAll('[data-theme-toggle]').forEach(b => {
|
|
1043
|
+
b.setAttribute('aria-pressed', String(t === 'light'));
|
|
1044
|
+
b.setAttribute('aria-label', t === 'light' ? 'Switch to dark theme' : 'Switch to light theme');
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* Exported, because the button is not the only thing that swaps a theme: a palette
|
|
1049
|
+
command and a keyboard shortcut both want to call this, and inlining it in the
|
|
1050
|
+
click handler is what made a consumer write its own copy. */
|
|
1051
|
+
export function toggleTheme() {
|
|
1052
|
+
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
|
1053
|
+
// the crossfade snapshots the old frame and fades the two together; a
|
|
1054
|
+
// browser without view transitions gets the same swap on the spot
|
|
1055
|
+
if (!document.startViewTransition) return setTheme(next);
|
|
1056
|
+
document.startViewTransition(() => setTheme(next));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export function initTheme() {
|
|
1060
|
+
setTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
|
|
1061
|
+
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
|
|
1062
|
+
if (!once(btn, 'uiTheme')) return;
|
|
1063
|
+
btn.addEventListener('click', toggleTheme);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/* ====================== reveals and sticky headers ====================== */
|
|
1068
|
+
|
|
1069
|
+
/* --vp-w is the viewport width minus a classic scrollbar, which 100vw wrongly
|
|
1070
|
+
includes. Every full-bleed rule reads it (.band, .section-sticky.is-stuck), so
|
|
1071
|
+
it is written unconditionally: the old split, where only the reveal controller
|
|
1072
|
+
wrote it, shipped a horizontal scrollbar on any page with a sticky header and
|
|
1073
|
+
no reveals. In a sidebar shell, call this with the scrolling column instead. */
|
|
1074
|
+
export function initViewportWidth(el = document.documentElement) {
|
|
1075
|
+
const set = () => el.style.setProperty('--vp-w', el.clientWidth + 'px');
|
|
1076
|
+
set();
|
|
1077
|
+
// the guard is per element, not global: a sidebar shell calls this a second time
|
|
1078
|
+
// for its scrolling column, and a global latch would leave that column's --vp-w
|
|
1079
|
+
// correct at boot and frozen on every resize after it
|
|
1080
|
+
if (once(el === document.documentElement ? document.body : el, 'uiVpw')) {
|
|
1081
|
+
addEventListener('resize', set);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/* A 1px sentinel above each header toggles .is-stuck. Shipped in two places
|
|
1086
|
+
before this file existed, so a page pasting both blocks inserted two sentinels
|
|
1087
|
+
per header and toggled the class twice per scroll.
|
|
1088
|
+
|
|
1089
|
+
A SENTINEL IS OUT OF VIEW IN BOTH DIRECTIONS, so `!isIntersecting` alone reads
|
|
1090
|
+
"the reader has not reached this section yet" as "this header is stuck". The
|
|
1091
|
+
stuck costume is a full-bleed page-toned band, so the reference sheet's one
|
|
1092
|
+
header painted that band at its natural position for the whole scroll and
|
|
1093
|
+
dropped it on arrival, the state backwards from top to bottom: measured, stuck
|
|
1094
|
+
with its top 14080px below the fold and unstuck with its top at 80. Which side
|
|
1095
|
+
the sentinel left on is the whole state, so the side is what the test reads. */
|
|
1096
|
+
export function initSticky() {
|
|
1097
|
+
document.querySelectorAll('.section-sticky').forEach(h => {
|
|
1098
|
+
if (!once(h, 'uiSticky')) return;
|
|
1099
|
+
const sentinel = document.createElement('div');
|
|
1100
|
+
h.parentNode.insertBefore(sentinel, h);
|
|
1101
|
+
/* THE PIN OFFSET IS A BREAKPOINT, not a constant: the header pins under the
|
|
1102
|
+
navbar at 56px above 768 and under the mobile topbar at 52 below it, and
|
|
1103
|
+
both the rootMargin and the side test are built from it. Read once, a
|
|
1104
|
+
viewport crossing 768 left the class flipping 4px early, exactly the
|
|
1105
|
+
difference between the two bars. `initViewportWidth` in this same section
|
|
1106
|
+
already re-reads on resize for the same reason. */
|
|
1107
|
+
let io = null;
|
|
1108
|
+
const arm = () => {
|
|
1109
|
+
const top = parseInt(getComputedStyle(h).top) || 0;
|
|
1110
|
+
if (io) io.disconnect();
|
|
1111
|
+
io = new IntersectionObserver(
|
|
1112
|
+
([e]) => h.classList.toggle('is-stuck',
|
|
1113
|
+
!e.isIntersecting && e.boundingClientRect.top < top + 1),
|
|
1114
|
+
{ rootMargin: `-${top + 1}px 0px 0px 0px`, threshold: 1 }
|
|
1115
|
+
);
|
|
1116
|
+
io.observe(sentinel);
|
|
1117
|
+
};
|
|
1118
|
+
arm();
|
|
1119
|
+
addEventListener('resize', arm);
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/* JS adds the hiding class at runtime, so no-JS keeps the content visible.
|
|
1124
|
+
Reduced motion skips the hide entirely rather than zeroing a duration. */
|
|
1125
|
+
export function initReveal() {
|
|
1126
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1127
|
+
if (reduced || !('IntersectionObserver' in window)) return;
|
|
1128
|
+
|
|
1129
|
+
const targets = [...document.querySelectorAll('[data-reveal]')].filter(el => once(el, 'uiReveal'));
|
|
1130
|
+
if (!targets.length) return;
|
|
1131
|
+
targets.forEach(el => el.classList.add('reveal')); // hiding class added by JS, never authored
|
|
1132
|
+
|
|
1133
|
+
/* The stagger belongs to what arrives TOGETHER, so it is applied at reveal time
|
|
1134
|
+
rather than at init. A document-order index makes a section that crosses the
|
|
1135
|
+
trigger on its own wait for a position the reader cannot see: measured on the
|
|
1136
|
+
sheet, section 5 first moved at 250ms and settled at 399, its neighbour 6
|
|
1137
|
+
moved at 60 and settled at 211, on no grouping reason. 40ms, capped at six. */
|
|
1138
|
+
const land = els => els.forEach((el, i) => {
|
|
1139
|
+
el.style.transitionDelay = Math.min(i, 5) * 40 + 'ms';
|
|
1140
|
+
el.classList.add('in');
|
|
1141
|
+
io.unobserve(el);
|
|
1142
|
+
});
|
|
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));
|
|
1148
|
+
|
|
1149
|
+
/* The net covers an observer that never fired, so it reveals only what is ON
|
|
1150
|
+
SCREEN when it lands. Revealing every target instead settles the whole page
|
|
1151
|
+
before the reader reaches any of it: measured on the rendered sheet, 44 of
|
|
1152
|
+
46 sections were off screen at 1500ms, so no section could ever animate. */
|
|
1153
|
+
setTimeout(() => land(targets.filter(el => {
|
|
1154
|
+
if (el.classList.contains('in')) return false;
|
|
1155
|
+
const r = el.getBoundingClientRect();
|
|
1156
|
+
return r.top < innerHeight && r.bottom > 0;
|
|
1157
|
+
})), 1500);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/* ============================= chart entrance ============================= */
|
|
1161
|
+
|
|
1162
|
+
/* JS adds the hiding class at runtime, so a page with no controller paints its
|
|
1163
|
+
marks instead of nothing. `.is-drawn` lands once, when the chart intersects
|
|
1164
|
+
the viewport, and the observer unobserves it: the entrance never replays on a
|
|
1165
|
+
second scroll past. */
|
|
1166
|
+
/* A card whose whole surface is the control cannot be a `<button>`: its content
|
|
1167
|
+
is block-level, and a button may not hold a heading or a paragraph. So it is a
|
|
1168
|
+
div with `role="button"` and a tabindex, and a div gets no activation for
|
|
1169
|
+
free: Enter and Space are wired here, and Space's own default is refused
|
|
1170
|
+
because it scrolls the page. Reaching the card is the consumer's half of the
|
|
1171
|
+
contract (the two attributes); activating it is this system's. */
|
|
1172
|
+
function initCardButtons() {
|
|
1173
|
+
document.querySelectorAll('.card-interactive[role="button"]').forEach(card => {
|
|
1174
|
+
if (!once(card, 'uiCardButton')) return;
|
|
1175
|
+
if (!card.hasAttribute('tabindex')) card.tabIndex = 0;
|
|
1176
|
+
card.addEventListener('keydown', e => {
|
|
1177
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
1178
|
+
e.preventDefault();
|
|
1179
|
+
card.click();
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/* A legend built from buttons is a control, and the design docs had described this
|
|
1185
|
+
one for a round before anything wrote it: every key on every page was inert
|
|
1186
|
+
text beside a swatch. The toggle names its slot with the same
|
|
1187
|
+
`chart-series-N` class the mark carries, so the pair cannot drift, and it
|
|
1188
|
+
finds its chart by walking up to the first ancestor holding one, which lets a
|
|
1189
|
+
legend sit either side of the figure with nothing to wire. */
|
|
1190
|
+
function initLegend() {
|
|
1191
|
+
document.querySelectorAll('.legend-toggle').forEach(btn => {
|
|
1192
|
+
const slot = [...btn.classList].find(c => /^chart-series-\d+$/.test(c));
|
|
1193
|
+
if (!slot || !once(btn, 'uiLegend')) return;
|
|
1194
|
+
let chart = null;
|
|
1195
|
+
for (let p = btn.parentElement; p && !chart; p = p.parentElement) chart = p.querySelector('.chart');
|
|
1196
|
+
if (!chart) return;
|
|
1197
|
+
btn.addEventListener('click', () => {
|
|
1198
|
+
const on = btn.getAttribute('aria-pressed') !== 'false';
|
|
1199
|
+
btn.setAttribute('aria-pressed', String(!on));
|
|
1200
|
+
chart.querySelectorAll(`.chart-series.${slot}`).forEach(s => s.classList.toggle('is-off', on));
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export function initChart() {
|
|
1206
|
+
initLegend();
|
|
1207
|
+
const charts = [...document.querySelectorAll('.chart')].filter(el => once(el, 'uiChart'));
|
|
1208
|
+
if (!charts.length) return;
|
|
1209
|
+
|
|
1210
|
+
// the hiding class lands in this same synchronous task, before the first paint
|
|
1211
|
+
charts.forEach(el => el.classList.add('will-draw'));
|
|
1212
|
+
|
|
1213
|
+
if (!('IntersectionObserver' in window)) {
|
|
1214
|
+
charts.forEach(el => el.classList.add('is-drawn'));
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const io = new IntersectionObserver(entries => {
|
|
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));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/* ========================== popovers and menus ========================== */
|
|
1229
|
+
|
|
1230
|
+
/* Any `.popover` opened by a button that names it with `aria-controls` AND
|
|
1231
|
+
`aria-haspopup`. Covers the table's filter menu and the context menu, and it is
|
|
1232
|
+
what `.popover` needed to stop being an entrance with no exit: the exit runs the
|
|
1233
|
+
`.closing` costume for the popover's own animation duration.
|
|
1234
|
+
|
|
1235
|
+
THE GLOBAL HANDLERS TOUCH ONLY THE PAIRS THIS CONTROLLER WIRED. `.popover` is
|
|
1236
|
+
also the shell the palette and the select panel are built from, so a handler
|
|
1237
|
+
written against every `.popover` on the page reaches into components that own
|
|
1238
|
+
their own open state: it hid the palette's panel behind its own backdrop, and
|
|
1239
|
+
the next open showed an empty dialog that swallowed every click on the page. */
|
|
1240
|
+
const popoverPairs = [];
|
|
1241
|
+
|
|
1242
|
+
export function initPopovers() {
|
|
1243
|
+
const isControl = t => t.closest?.('button, a, input, select, textarea, label');
|
|
1244
|
+
const close = (menu, btn) => {
|
|
1245
|
+
if (menu.hidden || menu.classList.contains('closing')) return;
|
|
1246
|
+
menu.classList.add('closing');
|
|
1247
|
+
btn?.setAttribute('aria-expanded', 'false');
|
|
1248
|
+
setTimeout(() => { menu.hidden = true; menu.classList.remove('closing'); }, animMs(menu));
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
document.querySelectorAll('[aria-controls][aria-haspopup]').forEach(btn => {
|
|
1252
|
+
const menu = document.getElementById(btn.getAttribute('aria-controls'));
|
|
1253
|
+
if (!menu || !menu.classList.contains('popover') || !once(btn, 'uiPopover')) return;
|
|
1254
|
+
popoverPairs.push({ btn, menu, close });
|
|
1255
|
+
// a checkbox row has to survive several picks, so an input inside the
|
|
1256
|
+
// item keeps the menu open; a button row is an action, and an action
|
|
1257
|
+
// row that opens a modal leaves a menu open behind the dialog, whose
|
|
1258
|
+
// Escape handler then eats the dialog's
|
|
1259
|
+
menu.addEventListener('click', e => {
|
|
1260
|
+
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;
|
|
1267
|
+
close(menu, btn);
|
|
1268
|
+
btn.focus();
|
|
1269
|
+
});
|
|
1270
|
+
btn.addEventListener('click', () => {
|
|
1271
|
+
if (!menu.hidden) return close(menu, btn);
|
|
1272
|
+
menu.classList.remove('closing');
|
|
1273
|
+
menu.hidden = false;
|
|
1274
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
if (!once(document.body, 'uiPopoverGlobal')) return;
|
|
1278
|
+
|
|
1279
|
+
const openPairs = () => popoverPairs.filter(p => !p.menu.hidden);
|
|
1280
|
+
document.addEventListener('pointerdown', e => {
|
|
1281
|
+
openPairs().forEach(({ btn, menu }) => {
|
|
1282
|
+
if (menu.contains(e.target) || btn.contains(e.target)) return;
|
|
1283
|
+
close(menu, btn);
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
document.addEventListener('keydown', e => {
|
|
1287
|
+
if (e.key !== 'Escape') return;
|
|
1288
|
+
const open = openPairs();
|
|
1289
|
+
if (!open.length) return;
|
|
1290
|
+
e.stopPropagation(); // a menu's Escape belongs to the menu, not to a dialog behind it
|
|
1291
|
+
open.forEach(({ btn, menu }) => { close(menu, btn); btn.focus(); });
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* ============================== tables ============================= */
|
|
1296
|
+
|
|
1297
|
+
/* The DOM half of sort, selection and paging. The data half belongs to the page,
|
|
1298
|
+
which renders rows and listens for these events:
|
|
1299
|
+
`table:sort` {key, dir} dir is 'asc' | 'desc' | 'none'
|
|
1300
|
+
`table:selection` {selected} the data-name values currently checked
|
|
1301
|
+
`table:page` {page}
|
|
1302
|
+
Every one of them bubbles from the `.table-wrap`. Nothing here needs to know
|
|
1303
|
+
what a row holds, which is why it can wire every table on every page. */
|
|
1304
|
+
export function initTables() {
|
|
1305
|
+
const ariaSort = { asc: 'ascending', desc: 'descending' };
|
|
1306
|
+
|
|
1307
|
+
document.querySelectorAll('.table-wrap').forEach(wrap => {
|
|
1308
|
+
// outside the once() guard, so a table that re-renders its head is wrapped again.
|
|
1309
|
+
// A sortable cell's text lives in the button, not the cell, so both are offered.
|
|
1310
|
+
wrap.querySelectorAll('thead th, thead .th-sort').forEach(el => ownLabel(el, 'th-label'));
|
|
1311
|
+
if (!once(wrap, 'uiTable')) return;
|
|
1312
|
+
const state = { key: null, dir: 'none' };
|
|
1313
|
+
|
|
1314
|
+
wrap.querySelectorAll('.th-sort').forEach(btn => {
|
|
1315
|
+
btn.addEventListener('click', () => {
|
|
1316
|
+
const key = btn.dataset.sort;
|
|
1317
|
+
const th = btn.closest('th');
|
|
1318
|
+
if (state.key === key) {
|
|
1319
|
+
state.dir = state.dir === 'none' ? 'asc' : state.dir === 'asc' ? 'desc' : 'none';
|
|
1320
|
+
} else { state.key = key; state.dir = 'asc'; }
|
|
1321
|
+
wrap.querySelectorAll('th[aria-sort]').forEach(h => {
|
|
1322
|
+
h.setAttribute('aria-sort', h === th && state.dir !== 'none' ? ariaSort[state.dir] : 'none');
|
|
1323
|
+
});
|
|
1324
|
+
wrap.dispatchEvent(new CustomEvent('table:sort', { bubbles: true, detail: { ...state } }));
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
const selectAll = wrap.querySelector('input[type="checkbox"][data-select-all], thead input[type="checkbox"]');
|
|
1329
|
+
const boxes = () => [...wrap.querySelectorAll('tbody input[type="checkbox"][data-name]')];
|
|
1330
|
+
const selected = () => boxes().filter(b => b.checked).map(b => b.dataset.name);
|
|
1331
|
+
const syncAll = () => {
|
|
1332
|
+
if (!selectAll) return;
|
|
1333
|
+
const all = boxes();
|
|
1334
|
+
const n = all.filter(b => b.checked).length;
|
|
1335
|
+
selectAll.checked = all.length > 0 && n === all.length;
|
|
1336
|
+
selectAll.indeterminate = n > 0 && n < all.length;
|
|
1337
|
+
};
|
|
1338
|
+
const announce = () => wrap.dispatchEvent(new CustomEvent('table:selection',
|
|
1339
|
+
{ bubbles: true, detail: { selected: selected() } }));
|
|
1340
|
+
|
|
1341
|
+
selectAll?.addEventListener('change', () => {
|
|
1342
|
+
boxes().forEach(b => {
|
|
1343
|
+
b.checked = selectAll.checked;
|
|
1344
|
+
// the class toggles in place, so the selection tint transitions
|
|
1345
|
+
b.closest('tr')?.classList.toggle('row-selected', b.checked);
|
|
1346
|
+
});
|
|
1347
|
+
syncAll();
|
|
1348
|
+
announce();
|
|
1349
|
+
});
|
|
1350
|
+
wrap.addEventListener('change', e => {
|
|
1351
|
+
const b = e.target.closest?.('tbody input[type="checkbox"][data-name]');
|
|
1352
|
+
if (!b) return;
|
|
1353
|
+
b.closest('tr')?.classList.toggle('row-selected', b.checked);
|
|
1354
|
+
syncAll();
|
|
1355
|
+
announce();
|
|
1356
|
+
});
|
|
1357
|
+
// the row is the target, the same ruling the setting row runs on: a click
|
|
1358
|
+
// anywhere on a selectable row that missed a control drives its own box
|
|
1359
|
+
wrap.addEventListener('click', e => {
|
|
1360
|
+
if (e.target.closest?.('button, a, input, select, textarea, label')) return;
|
|
1361
|
+
e.target.closest('tr')?.querySelector('input[type="checkbox"][data-name]')?.click();
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
// a re-rendered body arrives with its own checked state: re-derive rather
|
|
1365
|
+
// than carry the last render's count
|
|
1366
|
+
new MutationObserver(syncAll).observe(wrap, { childList: true, subtree: true });
|
|
1367
|
+
syncAll();
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
/* A pager usually sits OUTSIDE the .table-wrap it drives, so an event dispatched
|
|
1371
|
+
from it bubbles past the wrap and never reaches a listener there. Name the
|
|
1372
|
+
table with `aria-controls` and the event lands on that table's wrap, next to
|
|
1373
|
+
`table:sort` and `table:selection`. With no `aria-controls` it fires on the
|
|
1374
|
+
pager itself, which is then where the page has to listen. */
|
|
1375
|
+
document.querySelectorAll('.pagination').forEach(pager => {
|
|
1376
|
+
if (!once(pager, 'uiPager')) return;
|
|
1377
|
+
pager.addEventListener('click', e => {
|
|
1378
|
+
const b = e.target.closest('button[data-page]');
|
|
1379
|
+
if (!b || b.disabled) return;
|
|
1380
|
+
const named = pager.getAttribute('aria-controls');
|
|
1381
|
+
const target = (named && document.getElementById(named)?.closest('.table-wrap'))
|
|
1382
|
+
|| b.closest('.table-wrap') || pager;
|
|
1383
|
+
target.dispatchEvent(new CustomEvent('table:page',
|
|
1384
|
+
{ bubbles: true, detail: { page: Number(b.dataset.page) } }));
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/* ============================= code copy =========================== */
|
|
1390
|
+
|
|
1391
|
+
/* Copies the block's text and swaps to a check for a beat. A failed write leaves
|
|
1392
|
+
the button unchanged; there is no state to celebrate. */
|
|
1393
|
+
export function initCodeCopy() {
|
|
1394
|
+
document.querySelectorAll('.code-copy').forEach(btn => {
|
|
1395
|
+
const code = btn.closest('.code-body')?.querySelector('code');
|
|
1396
|
+
if (!code || !once(btn, 'uiCopy')) return;
|
|
1397
|
+
const iconCopy = btn.querySelector('.icon-copy');
|
|
1398
|
+
const iconCheck = btn.querySelector('.icon-check');
|
|
1399
|
+
let timer = 0;
|
|
1400
|
+
btn.addEventListener('click', async () => {
|
|
1401
|
+
try {
|
|
1402
|
+
await navigator.clipboard.writeText(code.textContent);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
console.warn('copy failed', err);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (iconCopy) iconCopy.hidden = true;
|
|
1408
|
+
if (iconCheck) iconCheck.hidden = false;
|
|
1409
|
+
btn.setAttribute('aria-label', 'Copied');
|
|
1410
|
+
clearTimeout(timer);
|
|
1411
|
+
timer = setTimeout(() => {
|
|
1412
|
+
if (iconCopy) iconCopy.hidden = false;
|
|
1413
|
+
if (iconCheck) iconCheck.hidden = true;
|
|
1414
|
+
btn.setAttribute('aria-label', 'Copy code');
|
|
1415
|
+
}, 1200);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/* ======================= typed placeholder ======================= */
|
|
1421
|
+
|
|
1422
|
+
/* The third sanctioned ambient effect. An overlay sitting on the field types one
|
|
1423
|
+
real example, holds it, deletes it, and moves to the next. Ported from the
|
|
1424
|
+
contact form it was built for; the timings are that build's, unchanged.
|
|
1425
|
+
|
|
1426
|
+
A field declares its own examples and nothing else:
|
|
1427
|
+
<input class="input" data-typed='["you@example.com", "discord: cool_person"]'>
|
|
1428
|
+
|
|
1429
|
+
Three things the obvious version gets wrong:
|
|
1430
|
+
- It renders REAL example values, so it is content rather than decoration, and
|
|
1431
|
+
it takes a text colour that clears 4.5:1 rather than a placeholder grey.
|
|
1432
|
+
- A value in the field hides the overlay, so the loop parks at the frame before
|
|
1433
|
+
the next example instead of running invisibly, and resumes on the next clear.
|
|
1434
|
+
Resuming moves forward, so the interrupted example does not replay.
|
|
1435
|
+
- Autofill never fires `input`, so a value already present at boot is checked. */
|
|
1436
|
+
const TYPE_CHAR = 80, TYPE_ERASE = 50, TYPE_HOLD = 5000, TYPE_GAP = 400, TYPE_START = 600;
|
|
1437
|
+
|
|
1438
|
+
export function initTypedPlaceholder() {
|
|
1439
|
+
// ambient motion is off under reduced motion, and this one has no reduced form:
|
|
1440
|
+
// a typed line with the typing removed is just a placeholder the field already has
|
|
1441
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
1442
|
+
|
|
1443
|
+
document.querySelectorAll('input[data-typed], textarea[data-typed]').forEach(input => {
|
|
1444
|
+
if (!once(input, 'uiTyped')) return;
|
|
1445
|
+
let examples;
|
|
1446
|
+
try {
|
|
1447
|
+
examples = JSON.parse(input.dataset.typed);
|
|
1448
|
+
} catch {
|
|
1449
|
+
console.warn('data-typed is not JSON on', input);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (!Array.isArray(examples) || !examples.length) return;
|
|
1453
|
+
|
|
1454
|
+
const wrap = document.createElement('div');
|
|
1455
|
+
wrap.className = 'typed-wrap';
|
|
1456
|
+
input.parentNode.insertBefore(wrap, input);
|
|
1457
|
+
wrap.appendChild(input);
|
|
1458
|
+
// a marker this module owns, aria-hidden like the inks: the field's own
|
|
1459
|
+
// label names it, so the typed examples need no second voice
|
|
1460
|
+
const overlay = ownChild(wrap, 'typed-overlay');
|
|
1461
|
+
|
|
1462
|
+
let idx = 0, timer = 0, paused = false;
|
|
1463
|
+
|
|
1464
|
+
// one span per character, so each one fades on its own clock
|
|
1465
|
+
const build = text => {
|
|
1466
|
+
overlay.textContent = '';
|
|
1467
|
+
const frag = document.createDocumentFragment();
|
|
1468
|
+
for (const ch of text) {
|
|
1469
|
+
const span = document.createElement('span');
|
|
1470
|
+
span.className = 'typed-char';
|
|
1471
|
+
span.textContent = ch;
|
|
1472
|
+
frag.appendChild(span);
|
|
1473
|
+
}
|
|
1474
|
+
overlay.appendChild(frag);
|
|
1475
|
+
return [...overlay.children];
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
const type = (spans, i, done) => {
|
|
1479
|
+
if (i >= spans.length) { timer = setTimeout(done, TYPE_HOLD); return; }
|
|
1480
|
+
spans[i].style.opacity = '1';
|
|
1481
|
+
timer = setTimeout(() => type(spans, i + 1, done), TYPE_CHAR);
|
|
1482
|
+
};
|
|
1483
|
+
const erase = (spans, i, done) => {
|
|
1484
|
+
if (i < 0) { done(); return; }
|
|
1485
|
+
spans[i].style.opacity = '0';
|
|
1486
|
+
timer = setTimeout(() => erase(spans, i - 1, done), TYPE_ERASE);
|
|
1487
|
+
};
|
|
1488
|
+
const cycle = () => {
|
|
1489
|
+
const spans = build(examples[idx]);
|
|
1490
|
+
new Promise(done => type(spans, 0, done))
|
|
1491
|
+
.then(() => new Promise(done => erase(spans, spans.length - 1, done)))
|
|
1492
|
+
.then(() => {
|
|
1493
|
+
idx = (idx + 1) % examples.length;
|
|
1494
|
+
timer = setTimeout(cycle, TYPE_GAP);
|
|
1495
|
+
});
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
const pause = () => {
|
|
1499
|
+
paused = true;
|
|
1500
|
+
clearTimeout(timer);
|
|
1501
|
+
overlay.textContent = '';
|
|
1502
|
+
idx = (idx + 1) % examples.length; // resume moves forward, never replays
|
|
1503
|
+
};
|
|
1504
|
+
const resume = () => { paused = false; timer = setTimeout(cycle, TYPE_GAP); };
|
|
1505
|
+
|
|
1506
|
+
input.addEventListener('input', () => {
|
|
1507
|
+
const filled = !!input.value;
|
|
1508
|
+
overlay.hidden = filled;
|
|
1509
|
+
if (filled && !paused) pause();
|
|
1510
|
+
else if (!filled && paused) resume();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
if (input.value) { overlay.hidden = true; pause(); }
|
|
1514
|
+
else timer = setTimeout(cycle, TYPE_START);
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/* ============================ scroll fade =========================== */
|
|
1519
|
+
|
|
1520
|
+
/* The cue that a scroller has more. The scrollers carry it themselves, so no
|
|
1521
|
+
consumer opts in: this one selector names every component the stylesheet makes
|
|
1522
|
+
scroll, and the controller writes the cue class onto each. The axis is derived
|
|
1523
|
+
from what the element actually scrolls, never from a class the author wrote.
|
|
1524
|
+
`no-scroll-fade` on any scroller is the one opt-out. Off at rest, so a no-JS
|
|
1525
|
+
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, .select-menu, .palette-list';
|
|
1527
|
+
|
|
1528
|
+
export function initScrollFade() {
|
|
1529
|
+
document.querySelectorAll(SCROLL_FADE_SELECTOR).forEach(el => {
|
|
1530
|
+
if (el.classList.contains('no-scroll-fade') || !once(el, 'uiFade')) return;
|
|
1531
|
+
el.classList.add('scroll-fade');
|
|
1532
|
+
const sync = () => {
|
|
1533
|
+
const y = el.scrollHeight > el.clientHeight + 1;
|
|
1534
|
+
el.classList.toggle('scroll-fade-y', y);
|
|
1535
|
+
const pos = y ? el.scrollTop : el.scrollLeft;
|
|
1536
|
+
const view = y ? el.clientHeight : el.clientWidth;
|
|
1537
|
+
const full = y ? el.scrollHeight : el.scrollWidth;
|
|
1538
|
+
el.classList.toggle('has-more-start', pos > 1);
|
|
1539
|
+
el.classList.toggle('has-more-end', pos + view < full - 1);
|
|
1540
|
+
};
|
|
1541
|
+
el.addEventListener('scroll', sync, { passive: true });
|
|
1542
|
+
addEventListener('resize', sync);
|
|
1543
|
+
/* The box changes size with no scroll and no window resize: the rail collapse
|
|
1544
|
+
takes this nav's clientHeight 756 to 722, which left the cue saying nothing
|
|
1545
|
+
was below with 34px still there, until the next scroll. */
|
|
1546
|
+
new ResizeObserver(sync).observe(el);
|
|
1547
|
+
sync();
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/* ============================ scrollbar =========================== */
|
|
1552
|
+
|
|
1553
|
+
/* The page draws its own, because `cursor: none` cannot reach a native
|
|
1554
|
+
scrollbar's shadow tree and the platform pointer paints over the ring for as
|
|
1555
|
+
long as one is dragged. `components.css` hides the native bars; this puts a
|
|
1556
|
+
real element back, so the ring paints over it like any other surface.
|
|
1557
|
+
|
|
1558
|
+
ONE bar per axis for the whole page, moved onto whichever scroller is active,
|
|
1559
|
+
which is what an overlay scrollbar already is. Per-scroller bars would need a
|
|
1560
|
+
positioned wrapper around every scrolling component in the system.
|
|
1561
|
+
|
|
1562
|
+
It appears on any pointer move inside a scroller and on a scroll, and retires
|
|
1563
|
+
`SCROLLBAR_IDLE` after both stop, her call 2026-09-02. Two things hold it past
|
|
1564
|
+
that: a drag, and the pointer resting on the thumb itself, or the bar would
|
|
1565
|
+
retire out from under a pointer on its way to grab it. */
|
|
1566
|
+
const SCROLLBAR_IDLE = 900;
|
|
1567
|
+
const SCROLLBAR_MIN = 28; // a thumb shorter than this stops reading as a handle
|
|
1568
|
+
const SCROLLBAR_GAP = 2; // the scroller's own edge to the thumb's outer edge
|
|
1569
|
+
const SCROLLBAR_HOLD = 10; // slop around a 6px thumb, so the hold is reachable
|
|
1570
|
+
const SCROLLBAR_Z = 999; // the bar's own slot in the stacking ladder
|
|
1571
|
+
const SCROLLBAR_CHAIN = 1; // px of gap a cover chain still composes through:
|
|
1572
|
+
// sub-pixel rounding in a stuck header's rect, never
|
|
1573
|
+
// a real strip of visible page
|
|
1574
|
+
|
|
1575
|
+
export function initScrollbars() {
|
|
1576
|
+
if (!once(document.documentElement, 'uiScrollbars')) return;
|
|
1577
|
+
const root = document.scrollingElement || document.documentElement;
|
|
1578
|
+
const bars = { y: document.createElement('div'), x: document.createElement('div') };
|
|
1579
|
+
bars.y.className = 'ui-scrollbar ui-scrollbar-y';
|
|
1580
|
+
bars.x.className = 'ui-scrollbar ui-scrollbar-x';
|
|
1581
|
+
for (const b of Object.values(bars)) b.setAttribute('aria-hidden', 'true');
|
|
1582
|
+
document.body.append(bars.y, bars.x);
|
|
1583
|
+
// the stylesheet hides the platform's bars only once these two exist
|
|
1584
|
+
document.documentElement.classList.add('has-drawn-scrollbars');
|
|
1585
|
+
|
|
1586
|
+
const view = (el, axis) => axis === 'y'
|
|
1587
|
+
? (el === root ? innerHeight : el.clientHeight)
|
|
1588
|
+
: (el === root ? innerWidth : el.clientWidth);
|
|
1589
|
+
const full = (el, axis) => axis === 'y' ? el.scrollHeight : el.scrollWidth;
|
|
1590
|
+
const at = (el, axis) => axis === 'y' ? el.scrollTop : el.scrollLeft;
|
|
1591
|
+
|
|
1592
|
+
const scrolls = (el, axis) => {
|
|
1593
|
+
if (full(el, axis) <= view(el, axis) + 1) return false;
|
|
1594
|
+
if (el === root) return true;
|
|
1595
|
+
const ov = getComputedStyle(el)[axis === 'y' ? 'overflowY' : 'overflowX'];
|
|
1596
|
+
return ov === 'auto' || ov === 'scroll';
|
|
1597
|
+
};
|
|
1598
|
+
// the INNERMOST scroller under a node, so a table inside a modal wins
|
|
1599
|
+
const scrollerFrom = node => {
|
|
1600
|
+
for (let el = node; el instanceof Element; el = el.parentElement) {
|
|
1601
|
+
if (scrolls(el, 'y') || scrolls(el, 'x')) return el;
|
|
1602
|
+
}
|
|
1603
|
+
return root;
|
|
1604
|
+
};
|
|
1605
|
+
const boxOf = el => el === root
|
|
1606
|
+
? { top: 0, left: 0, right: innerWidth, bottom: innerHeight }
|
|
1607
|
+
: el.getBoundingClientRect();
|
|
1608
|
+
|
|
1609
|
+
/* A rounded panel takes its own corner out from under the thumb, so a track
|
|
1610
|
+
running the full length leaves the thumb's last pixels outside the paint.
|
|
1611
|
+
The corner is often not the scroller's own: a modal body scrolls inside the
|
|
1612
|
+
modal's radius and declares none itself, so the radius comes from every box
|
|
1613
|
+
that shares the edge the bar runs along. The thumb's outer edge sits
|
|
1614
|
+
SCROLLBAR_GAP in from that edge, and a corner of radius r is inset by more
|
|
1615
|
+
than the gap for the first r - sqrt(2*r*gap - gap^2) px along it: 6.79px on
|
|
1616
|
+
the 14px corner of --r-2 at a 2px gap. The track gives that up at each end.
|
|
1617
|
+
|
|
1618
|
+
Cached per element, because it is layout that decides which ancestors share
|
|
1619
|
+
an edge and the walk would otherwise run on every frame of a scroll. */
|
|
1620
|
+
const inset = r => (r <= SCROLLBAR_GAP ? 0
|
|
1621
|
+
: r - Math.sqrt(2 * r * SCROLLBAR_GAP - SCROLLBAR_GAP * SCROLLBAR_GAP));
|
|
1622
|
+
let radii = new WeakMap();
|
|
1623
|
+
const corner = (el, axis) => {
|
|
1624
|
+
let cached = radii.get(el);
|
|
1625
|
+
if (!cached) radii.set(el, cached = {});
|
|
1626
|
+
if (cached[axis]) return cached[axis];
|
|
1627
|
+
const pads = [0, 0];
|
|
1628
|
+
if (el !== root) {
|
|
1629
|
+
const own = el.getBoundingClientRect();
|
|
1630
|
+
const px = v => (v.includes('px') ? parseFloat(v) || 0 : 0);
|
|
1631
|
+
const same = (a, b) => Math.abs(a - b) <= 1;
|
|
1632
|
+
for (let a = el; a instanceof Element; a = a.parentElement) {
|
|
1633
|
+
const r = a.getBoundingClientRect();
|
|
1634
|
+
const cs = getComputedStyle(a);
|
|
1635
|
+
if (axis === 'y' && same(r.right, own.right)) {
|
|
1636
|
+
if (same(r.top, own.top)) pads[0] = Math.max(pads[0], inset(px(cs.borderTopRightRadius)));
|
|
1637
|
+
if (same(r.bottom, own.bottom)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
|
|
1638
|
+
} else if (axis === 'x' && same(r.bottom, own.bottom)) {
|
|
1639
|
+
if (same(r.left, own.left)) pads[0] = Math.max(pads[0], inset(px(cs.borderBottomLeftRadius)));
|
|
1640
|
+
if (same(r.right, own.right)) pads[1] = Math.max(pads[1], inset(px(cs.borderBottomRightRadius)));
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
cached[axis] = pads;
|
|
1645
|
+
return pads;
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
/* Chrome pinned over the scrollport hides the track under it, and a thumb
|
|
1649
|
+
drawn there marks a position nobody can scroll to. Her call 2026-09-02, and
|
|
1650
|
+
the covers are DERIVED rather than named: every visible fixed or sticky box
|
|
1651
|
+
is a candidate, and one counts against this bar only where it overlaps the
|
|
1652
|
+
bar's own lane and sits below the bar in the stacking ladder.
|
|
1653
|
+
|
|
1654
|
+
A cover counts only CONTIGUOUS with the run it joins: it may start no
|
|
1655
|
+
further than SCROLLBAR_CHAIN past the end already given up, which is what
|
|
1656
|
+
lets a header stuck flush under a navbar extend the run the navbar
|
|
1657
|
+
started. A navbar floating clear of the viewport edge as a card counts
|
|
1658
|
+
for nothing, because the strip of page above it is fully visible and the
|
|
1659
|
+
thumb's origin must not move with the viewport width; an unstuck sticky
|
|
1660
|
+
header sitting mid-page stays out of the sum the same way. The ambient
|
|
1661
|
+
layer drops out by taking no pointer, the scroller's own pinned ancestors
|
|
1662
|
+
by containment: the sidebar is fixed and spans its nav's whole
|
|
1663
|
+
scrollport. */
|
|
1664
|
+
/* The stylesheets name which selectors CAN pin, so the candidates come from one
|
|
1665
|
+
`querySelectorAll` rather than a computed read of every element: that read
|
|
1666
|
+
forces rendering inside any `content-visibility` subtree it crosses, and this
|
|
1667
|
+
system's collapsed rows all carry one. Selectors are proved usable once,
|
|
1668
|
+
because one bad one poisons the whole list. The list itself is static, since
|
|
1669
|
+
stylesheets do not change; the VERDICTS are not, so they are re-read every
|
|
1670
|
+
time. An earlier version cached them per interaction burst on the claim that
|
|
1671
|
+
an overlay can only mount between bursts. That claim was false: the move that
|
|
1672
|
+
carries a pointer to a control arms the timer, so the click that opens the
|
|
1673
|
+
overlay always lands inside a live burst, and the stale verdict shipped for up
|
|
1674
|
+
to a full idle period. The whole walk measures 0.37ms on the reference sheet
|
|
1675
|
+
(16 selectors, 18 matches, 9 kept), which buys nothing worth being wrong for.
|
|
1676
|
+
|
|
1677
|
+
One cover this can never name: a pseudo-element. `collectPropSelectors` drops
|
|
1678
|
+
any selector holding `::`, so the navbar's fixed `body::before` dock strip is
|
|
1679
|
+
outside the derivation by construction. It is subsumed today, since the
|
|
1680
|
+
navbar band above it already covers that strip's whole height, and the next
|
|
1681
|
+
such cover may not be. */
|
|
1682
|
+
const pinned = collectPropSelectors(document.styleSheets, 'position', ['fixed', 'sticky'])
|
|
1683
|
+
.filter(sel => { try { bars.y.matches(sel); return true; } catch { return false; } })
|
|
1684
|
+
.join(', ');
|
|
1685
|
+
const coverList = () => (pinned ? [...document.querySelectorAll(pinned)] : []).flatMap(c => {
|
|
1686
|
+
if (c.classList.contains('ui-scrollbar')) return [];
|
|
1687
|
+
const cs = getComputedStyle(c);
|
|
1688
|
+
if (cs.position !== 'fixed' && cs.position !== 'sticky') return [];
|
|
1689
|
+
if (cs.pointerEvents === 'none') return [];
|
|
1690
|
+
const z = parseInt(cs.zIndex, 10); // `auto` is NaN, which counts as 0
|
|
1691
|
+
if (z >= SCROLLBAR_Z) return []; // paints over the bar, so it hides the bar
|
|
1692
|
+
return [{ el: c, z: Number.isNaN(z) ? 0 : z }];
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
/* The scroller's own paint slot. A z-index orders siblings inside ONE stacking
|
|
1696
|
+
context, so "is this candidate below the bar" answers the wrong question: it
|
|
1697
|
+
says whether a cover hides the BAR, never whether it hides the SCROLLER. The
|
|
1698
|
+
drawer's scrim sits at 299 and the drawer itself at 300, so the scrim was
|
|
1699
|
+
charged against the drawer's own nav, which paints above it, and that nav lost
|
|
1700
|
+
its bar entirely at exactly the moment it is on screen. */
|
|
1701
|
+
const floorOf = el => {
|
|
1702
|
+
for (let n = el; n instanceof Element; n = n.parentElement) {
|
|
1703
|
+
const cs = getComputedStyle(n);
|
|
1704
|
+
if (cs.position === 'static') continue;
|
|
1705
|
+
const z = parseInt(cs.zIndex, 10);
|
|
1706
|
+
if (!Number.isNaN(z)) return z;
|
|
1707
|
+
}
|
|
1708
|
+
return 0;
|
|
1709
|
+
};
|
|
1710
|
+
|
|
1711
|
+
const cover = (el, axis) => {
|
|
1712
|
+
const box = boxOf(el);
|
|
1713
|
+
const lane = axis === 'y' ? [box.right - 8, box.right - SCROLLBAR_GAP]
|
|
1714
|
+
: [box.bottom - 8, box.bottom - SCROLLBAR_GAP];
|
|
1715
|
+
const spans = [];
|
|
1716
|
+
/* `checkVisibility` is guarded because this runs inside an rAF callback, and a
|
|
1717
|
+
TypeError there reaches the ring's `window.onerror` bail, which drops the
|
|
1718
|
+
hidden cursor for the life of the page: a missing method would cost the
|
|
1719
|
+
whole ambient layer, not the bar. Every other newer capability in this
|
|
1720
|
+
system is guarded the same way. `getClientRects().length` is the fallback,
|
|
1721
|
+
which answers the same question for a `display: none` subtree. */
|
|
1722
|
+
const shown = c => (typeof c.checkVisibility === 'function'
|
|
1723
|
+
? c.checkVisibility() : c.getClientRects().length > 0);
|
|
1724
|
+
const floor = floorOf(el);
|
|
1725
|
+
for (const { el: c, z } of coverList()) {
|
|
1726
|
+
if (c === el || c.contains(el) || z < floor || !shown(c)) continue;
|
|
1727
|
+
const r = c.getBoundingClientRect();
|
|
1728
|
+
if (!r.width || !r.height) continue;
|
|
1729
|
+
const across = axis === 'y' ? r.left < lane[1] && r.right > lane[0]
|
|
1730
|
+
: r.top < lane[1] && r.bottom > lane[0];
|
|
1731
|
+
if (across) spans.push(axis === 'y' ? [r.top, r.bottom] : [r.left, r.right]);
|
|
1732
|
+
}
|
|
1733
|
+
const [lo, hi] = axis === 'y' ? [box.top, box.bottom] : [box.left, box.right];
|
|
1734
|
+
// each pass admits the covers the previous one brought within reach
|
|
1735
|
+
const run = (from, forward) => {
|
|
1736
|
+
for (let pass = 0; pass < spans.length; pass++) {
|
|
1737
|
+
let next = from;
|
|
1738
|
+
for (const [a, b] of spans) {
|
|
1739
|
+
if (forward) { if (a <= from + SCROLLBAR_CHAIN && b > next) next = b; }
|
|
1740
|
+
else if (b >= from - SCROLLBAR_CHAIN && a < next) next = a;
|
|
1741
|
+
}
|
|
1742
|
+
if (next === from) return from;
|
|
1743
|
+
from = next;
|
|
1744
|
+
}
|
|
1745
|
+
return from;
|
|
1746
|
+
};
|
|
1747
|
+
return [Math.max(0, run(lo, true) - lo), Math.max(0, hi - run(hi, false))];
|
|
1748
|
+
};
|
|
1749
|
+
|
|
1750
|
+
/* One place derives the thumb, because the drag's scroll rate has to agree
|
|
1751
|
+
with it: a rate taken from a different track length drifts away from the
|
|
1752
|
+
pointer over the length of the drag. */
|
|
1753
|
+
const metrics = (el, axis) => {
|
|
1754
|
+
const v = view(el, axis);
|
|
1755
|
+
const [cornerA, cornerB] = corner(el, axis);
|
|
1756
|
+
/* A DRAG FREEZES ITS OWN TRACK. `drag.rate` is captured at the press, and the
|
|
1757
|
+
cover is live, so chrome that docks or sticks mid-drag re-derives `track`
|
|
1758
|
+
under a rate that cannot follow it and the thumb slips against the pointer.
|
|
1759
|
+
Measured on the playground, one drag: the grab offset ran 49.72 to -5.33 to
|
|
1760
|
+
109.17, a range of 114.5px on a 99.4px thumb, and the 48.0px of it owed to
|
|
1761
|
+
the navbar docking was predicted from `track - len` 800.55 to 744.55 before
|
|
1762
|
+
it was measured. Freezing is also the honest reading: the track a hand is
|
|
1763
|
+
holding does not change length under it. */
|
|
1764
|
+
const [coverA, coverB] = (drag && drag.axis === axis && drag.el === el)
|
|
1765
|
+
? drag.pads : cover(el, axis);
|
|
1766
|
+
const padA = Math.max(cornerA, coverA), padB = Math.max(cornerB, coverB);
|
|
1767
|
+
const room = v - padA - padB;
|
|
1768
|
+
const track = Math.max(SCROLLBAR_MIN, room);
|
|
1769
|
+
const len = Math.min(track, Math.max(SCROLLBAR_MIN, v * v / full(el, axis)));
|
|
1770
|
+
return { padA, track, len, room, max: full(el, axis) - v };
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
let active = root;
|
|
1774
|
+
let hideAt = 0;
|
|
1775
|
+
let drag = null;
|
|
1776
|
+
let frame = 0;
|
|
1777
|
+
let pointer = null;
|
|
1778
|
+
|
|
1779
|
+
const place = axis => {
|
|
1780
|
+
const bar = bars[axis];
|
|
1781
|
+
const el = drag ? drag.el : active;
|
|
1782
|
+
if (!el.isConnected || !scrolls(el, axis)) { bar.classList.remove('is-on'); return; }
|
|
1783
|
+
const box = boxOf(el);
|
|
1784
|
+
const { padA, track, len, room, max } = metrics(el, axis);
|
|
1785
|
+
// pinned chrome can take the whole scrollport, and a track with no room for
|
|
1786
|
+
// a handle is a mark, not a bar
|
|
1787
|
+
if (room < SCROLLBAR_MIN) { bar.classList.remove('is-on'); return; }
|
|
1788
|
+
const run = padA + (max > 0 ? (at(el, axis) / max) * (track - len) : 0);
|
|
1789
|
+
if (axis === 'y') {
|
|
1790
|
+
bar.style.top = `${box.top + run}px`;
|
|
1791
|
+
bar.style.left = `${box.right - 8}px`;
|
|
1792
|
+
bar.style.height = `${len}px`;
|
|
1793
|
+
} else {
|
|
1794
|
+
bar.style.left = `${box.left + run}px`;
|
|
1795
|
+
bar.style.top = `${box.bottom - 8}px`;
|
|
1796
|
+
bar.style.width = `${len}px`;
|
|
1797
|
+
}
|
|
1798
|
+
// the thumb's own box, padded: a bar that retired under a pointer reaching
|
|
1799
|
+
// for it would take the grab with it
|
|
1800
|
+
const near = pointer && (axis === 'y'
|
|
1801
|
+
? pointer.x >= box.right - 8 - SCROLLBAR_HOLD && pointer.x <= box.right - SCROLLBAR_GAP + SCROLLBAR_HOLD
|
|
1802
|
+
&& pointer.y >= box.top + run - SCROLLBAR_HOLD && pointer.y <= box.top + run + len + SCROLLBAR_HOLD
|
|
1803
|
+
: pointer.y >= box.bottom - 8 - SCROLLBAR_HOLD && pointer.y <= box.bottom - SCROLLBAR_GAP + SCROLLBAR_HOLD
|
|
1804
|
+
&& pointer.x >= box.left + run - SCROLLBAR_HOLD && pointer.x <= box.left + run + len + SCROLLBAR_HOLD);
|
|
1805
|
+
const on = drag ? drag.axis === axis : Boolean(near) || performance.now() < hideAt;
|
|
1806
|
+
const wasOn = bar.classList.contains('is-on');
|
|
1807
|
+
bar.classList.toggle('is-on', on);
|
|
1808
|
+
/* THE MOVE THAT ARMS A RETIRED BAR is hit-tested while the bar still
|
|
1809
|
+
computes `pointer-events: none`, so `e.target` named the page and the
|
|
1810
|
+
ring derived nothing it can hover; a press with no further move then
|
|
1811
|
+
read no hover and painted neither the pressed costume nor the release
|
|
1812
|
+
pulse. The bar is what a real move would hit now that it takes a
|
|
1813
|
+
pointer, so the pointermove path is re-run with the BAR as the target.
|
|
1814
|
+
The bubbled event reaches four handlers, all traced: this controller's
|
|
1815
|
+
own move handler, which leaves `active` alone for a bar target; the
|
|
1816
|
+
bar's drag handler, inert without a held drag; and cursor.js's grid and
|
|
1817
|
+
ring handlers, of which only the ring's state moves, to `hover`.
|
|
1818
|
+
Dispatching on window instead would re-derive the scroller off
|
|
1819
|
+
`document` and hand the bar to the page. */
|
|
1820
|
+
if (on && !wasOn && !drag && pointer
|
|
1821
|
+
&& document.elementFromPoint(pointer.x, pointer.y) === bar) {
|
|
1822
|
+
bar.dispatchEvent(new PointerEvent('pointermove',
|
|
1823
|
+
{ pointerType: 'mouse', clientX: pointer.x, clientY: pointer.y, bubbles: true }));
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
/* The loop runs only while something is actually moving: a drag, or the tail of
|
|
1828
|
+
a scroll or a pointer move. A bar held on because the pointer rests on the
|
|
1829
|
+
thumb needs no frames, and `place` has already run for that state; the next
|
|
1830
|
+
pointermove is what changes it. An rAF that never stops is ambient motion
|
|
1831
|
+
with a battery cost, which this system bans everywhere else. */
|
|
1832
|
+
const tick = () => {
|
|
1833
|
+
frame = 0;
|
|
1834
|
+
place('y');
|
|
1835
|
+
place('x');
|
|
1836
|
+
if (drag || performance.now() < hideAt) sync();
|
|
1837
|
+
};
|
|
1838
|
+
const sync = () => { frame ||= requestAnimationFrame(tick); };
|
|
1839
|
+
|
|
1840
|
+
addEventListener('scroll', e => {
|
|
1841
|
+
const el = e.target === document || e.target === root ? root : e.target;
|
|
1842
|
+
if (!(el instanceof Element) && el !== root) return;
|
|
1843
|
+
if (!drag) active = el;
|
|
1844
|
+
hideAt = performance.now() + SCROLLBAR_IDLE;
|
|
1845
|
+
sync();
|
|
1846
|
+
}, { capture: true, passive: true });
|
|
1847
|
+
|
|
1848
|
+
addEventListener('pointermove', e => {
|
|
1849
|
+
pointer = { x: e.clientX, y: e.clientY };
|
|
1850
|
+
/* THE BAR IS ITS OWN POINTER TARGET while it is on, so a move onto the thumb
|
|
1851
|
+
makes `e.target` the bar and `scrollerFrom` walks bar -> body -> the root,
|
|
1852
|
+
which scrolls. Measured on the reference sheet: the sidebar nav's thumb sat
|
|
1853
|
+
at left 243 and jumped to 1432 on the first move that reached it, and the
|
|
1854
|
+
press that then landed dragged the page. No inner scroller's bar could be
|
|
1855
|
+
grabbed, and the native one is already hidden. A move ON the bar says
|
|
1856
|
+
nothing about which scroller is under the pointer: the bar belongs to
|
|
1857
|
+
`active` by construction, so leave it alone. */
|
|
1858
|
+
const onBar = e.target instanceof Element && e.target.classList.contains('ui-scrollbar');
|
|
1859
|
+
if (!drag && !onBar) {
|
|
1860
|
+
const el = scrollerFrom(e.target);
|
|
1861
|
+
if (el !== active) active = el;
|
|
1862
|
+
}
|
|
1863
|
+
// the move is what arms the timer: a resting pointer retires the bar
|
|
1864
|
+
hideAt = performance.now() + SCROLLBAR_IDLE;
|
|
1865
|
+
sync();
|
|
1866
|
+
}, { passive: true });
|
|
1867
|
+
|
|
1868
|
+
/* `pointer` was write-only, and the Y bar's hold band runs to `innerWidth + 8`,
|
|
1869
|
+
so a pointer leaving the window to the right exits THROUGH it and its last
|
|
1870
|
+
move is inside it: the bar then stayed lit for the life of the page. Measured
|
|
1871
|
+
parked at x 1439, still on after 3s. The ring's own host does the same thing
|
|
1872
|
+
for the same reason. */
|
|
1873
|
+
document.documentElement.addEventListener('pointerleave', () => { pointer = null; sync(); });
|
|
1874
|
+
|
|
1875
|
+
addEventListener('resize', () => { radii = new WeakMap(); sync(); });
|
|
1876
|
+
|
|
1877
|
+
for (const axis of ['y', 'x']) {
|
|
1878
|
+
bars[axis].addEventListener('pointerdown', e => {
|
|
1879
|
+
const el = active;
|
|
1880
|
+
if (!scrolls(el, axis)) return;
|
|
1881
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return; // middle and right are not a drag
|
|
1882
|
+
if (drag) return; // one bar at a time: a second press would
|
|
1883
|
+
e.preventDefault(); // orphan the first bar's capture and is-drag
|
|
1884
|
+
const m = metrics(el, axis);
|
|
1885
|
+
// a track with no room for a handle cannot be dragged: without this the
|
|
1886
|
+
// `Math.max(SCROLLBAR_MIN, room)` clamp makes `track - len` 0 and one pixel
|
|
1887
|
+
// of travel scrolls the whole document
|
|
1888
|
+
if (m.room < SCROLLBAR_MIN) return;
|
|
1889
|
+
bars[axis].setPointerCapture(e.pointerId);
|
|
1890
|
+
bars[axis].classList.add('is-drag');
|
|
1891
|
+
drag = {
|
|
1892
|
+
axis, el,
|
|
1893
|
+
pads: cover(el, axis), // frozen for the drag, see `metrics`
|
|
1894
|
+
from: at(el, axis),
|
|
1895
|
+
origin: axis === 'y' ? e.clientY : e.clientX,
|
|
1896
|
+
// one pixel of thumb travel is this much scroll
|
|
1897
|
+
rate: m.max / Math.max(1, m.track - m.len),
|
|
1898
|
+
};
|
|
1899
|
+
sync();
|
|
1900
|
+
});
|
|
1901
|
+
bars[axis].addEventListener('pointermove', e => {
|
|
1902
|
+
if (!drag || drag.axis !== axis) return;
|
|
1903
|
+
const now = axis === 'y' ? e.clientY : e.clientX;
|
|
1904
|
+
const to = drag.from + (now - drag.origin) * drag.rate;
|
|
1905
|
+
if (axis === 'y') drag.el.scrollTop = to; else drag.el.scrollLeft = to;
|
|
1906
|
+
hideAt = performance.now() + SCROLLBAR_IDLE;
|
|
1907
|
+
sync();
|
|
1908
|
+
});
|
|
1909
|
+
const end = () => {
|
|
1910
|
+
if (!drag || drag.axis !== axis) return;
|
|
1911
|
+
drag = null;
|
|
1912
|
+
bars[axis].classList.remove('is-drag');
|
|
1913
|
+
hideAt = performance.now() + SCROLLBAR_IDLE;
|
|
1914
|
+
sync();
|
|
1915
|
+
};
|
|
1916
|
+
bars[axis].addEventListener('pointerup', end);
|
|
1917
|
+
bars[axis].addEventListener('pointercancel', end);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
sync();
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/* ============================= dropzone =========================== */
|
|
1924
|
+
|
|
1925
|
+
/* The native input stays the accessible control and the label keeps opening the
|
|
1926
|
+
picker, but the input no longer covers the zone: a stretched one paints the
|
|
1927
|
+
platform pointer straight through the ring's `cursor: none`, because its UA
|
|
1928
|
+
shadow tree is out of the page's reach. The zone takes the drop instead and
|
|
1929
|
+
hands the files over. Drag counting is by depth, since a dragleave fires for
|
|
1930
|
+
every child the pointer crosses. */
|
|
1931
|
+
export function initDropzone() {
|
|
1932
|
+
document.querySelectorAll('.dropzone').forEach(zone => {
|
|
1933
|
+
const input = zone.querySelector('.dropzone-input');
|
|
1934
|
+
if (!input || !once(zone, 'uiDropzone')) return;
|
|
1935
|
+
let depth = 0;
|
|
1936
|
+
const clear = () => { depth = 0; zone.classList.remove('is-over'); };
|
|
1937
|
+
|
|
1938
|
+
/* a disabled input takes no files from the keyboard, so the zone must not
|
|
1939
|
+
take them from the pointer either: refusing the dragenter and dragover
|
|
1940
|
+
defaults also keeps the browser from marking the dead zone a target */
|
|
1941
|
+
zone.addEventListener('dragenter', e => {
|
|
1942
|
+
if (input.disabled) return;
|
|
1943
|
+
e.preventDefault();
|
|
1944
|
+
if (++depth === 1) zone.classList.add('is-over');
|
|
1945
|
+
});
|
|
1946
|
+
zone.addEventListener('dragover', e => {
|
|
1947
|
+
if (input.disabled) return;
|
|
1948
|
+
e.preventDefault();
|
|
1949
|
+
});
|
|
1950
|
+
zone.addEventListener('dragleave', () => { if (--depth <= 0) clear(); });
|
|
1951
|
+
zone.addEventListener('drop', e => {
|
|
1952
|
+
if (input.disabled) { clear(); return; } // a drag that began live can end disabled
|
|
1953
|
+
e.preventDefault();
|
|
1954
|
+
clear();
|
|
1955
|
+
if (!e.dataTransfer?.files.length) return;
|
|
1956
|
+
input.files = e.dataTransfer.files;
|
|
1957
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/* ============================== boot ============================== */
|
|
1963
|
+
|
|
1964
|
+
export function init() {
|
|
1965
|
+
initViewportWidth();
|
|
1966
|
+
initTabs();
|
|
1967
|
+
initNavbar();
|
|
1968
|
+
initSidebar();
|
|
1969
|
+
initDrawer();
|
|
1970
|
+
initToggles();
|
|
1971
|
+
initDisclosures();
|
|
1972
|
+
initSelect();
|
|
1973
|
+
initPalette();
|
|
1974
|
+
initTooltip();
|
|
1975
|
+
initPopovers();
|
|
1976
|
+
initModal();
|
|
1977
|
+
initDismiss();
|
|
1978
|
+
initTheme();
|
|
1979
|
+
initSticky();
|
|
1980
|
+
initReveal();
|
|
1981
|
+
initChart();
|
|
1982
|
+
initCardButtons();
|
|
1983
|
+
initTables();
|
|
1984
|
+
initCodeCopy();
|
|
1985
|
+
initTypedPlaceholder();
|
|
1986
|
+
initScrollFade();
|
|
1987
|
+
initScrollbars();
|
|
1988
|
+
initDropzone();
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// a module script is deferred, so the document is parsed by the time this runs
|
|
1992
|
+
init();
|