@menapo/machine 0.0.0-stage → 0.1.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/dist/dom.js ADDED
@@ -0,0 +1,659 @@
1
+ //#region src/dom/layers.ts
2
+ const layers = [];
3
+ /** The top layer that has `key`: the one Escape, a press outside or Tab is for. */
4
+ const topWith = (key) => layers.findLast((layer) => layer[key]);
5
+ /**
6
+ * Told of each layer added, and returning what undoes its work when that
7
+ * layer goes: how `hideOthers` lifts `inert` from an overlay opened above
8
+ * its own.
9
+ */
10
+ const watchers = /* @__PURE__ */ new Set();
11
+ /**
12
+ * Adds `layer` on top, and returns its removal, which is true the first
13
+ * time only: each call registers its own entry, so cleaning one up twice
14
+ * never takes another off the stack, even on the same element.
15
+ */
16
+ function addLayer(layer) {
17
+ layers.push(layer);
18
+ const undo = [...watchers].map((watch) => watch(layer.el));
19
+ return () => {
20
+ const at = layers.indexOf(layer);
21
+ if (at < 0) return false;
22
+ layers.splice(at, 1);
23
+ for (const fn of undo) fn();
24
+ return true;
25
+ };
26
+ }
27
+ /** The parent in the composed tree: a shadow root's host for its top elements. */
28
+ const parentOf = (node) => node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode;
29
+ /** `container.contains(node)` across shadow roots, as the user sees nesting. */
30
+ function inside(container, node) {
31
+ for (; node; node = parentOf(node)) if (node === container) return true;
32
+ return false;
33
+ }
34
+ /**
35
+ * Whether `node` is in a layer above `layer`: one opened later that is not
36
+ * `layer`'s own element again (its `dismiss` beside its `focusTrap`).
37
+ */
38
+ const above = (layer, node) => layers.slice(layers.indexOf(layer) + 1).some((other) => inside(other.el, node) && !inside(other.el, layer.el));
39
+ /** The node an event happened on, inside shadow roots too. */
40
+ const targetOf = (event) => event.composedPath()[0];
41
+ //#endregion
42
+ //#region src/dom/listen.ts
43
+ /**
44
+ * `addEventListener` that returns its own removal, so every `/dom` helper
45
+ * can collect its listeners as cleanups (PLAN §4.5).
46
+ */
47
+ function listen(target, type, listener, options) {
48
+ target.addEventListener(type, listener, options);
49
+ return () => target.removeEventListener(type, listener, options);
50
+ }
51
+ //#endregion
52
+ //#region src/dom/dismiss.ts
53
+ /**
54
+ * Reports Escape and interaction outside `element` to the machine instead
55
+ * of closing anything itself, so a controlled overlay can refuse (PLAN D9).
56
+ * Layers stack: Escape goes to the top layer with `onEscape`, and a press
57
+ * or focus outside to the top layer with `onOutside`, so Escape in a nested
58
+ * menu or dialog closes the inner one alone. A layer above is part of the
59
+ * one below: a press in a menu opened from a dialog is not outside the
60
+ * dialog. Escape that no layer handles is left to the page and the browser.
61
+ *
62
+ * On a modal `<dialog>`, a press on the `::backdrop` counts as outside, and
63
+ * Escape is reported without the browser closing the dialog behind the
64
+ * machine's back (`closedby` is not Baseline). When it closes all the same,
65
+ * `onEscape` hears it, and a machine that stays open gets it shown again.
66
+ */
67
+ function dismiss(element, options) {
68
+ const { onEscape, onOutside, exclude } = options;
69
+ const layer = {
70
+ el: element,
71
+ escape: onEscape,
72
+ outside: onOutside
73
+ };
74
+ let pressed = null;
75
+ let modal = false;
76
+ const outside = (event) => {
77
+ const target = targetOf(event);
78
+ if (topWith("outside") !== layer) return;
79
+ if (inside(exclude, target) || above(layer, target)) return;
80
+ if (inside(element, target)) {
81
+ if (target !== element || event.type !== "pointerdown" || !element.matches("dialog:modal")) return;
82
+ const { clientX: x, clientY: y } = event;
83
+ const box = element.getBoundingClientRect();
84
+ if (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom) return;
85
+ }
86
+ onOutside();
87
+ };
88
+ queueMicrotask(() => modal = element.matches(":modal"));
89
+ const remove = addLayer(layer);
90
+ const cleanups = [
91
+ listen(document, "keydown", (event) => {
92
+ pressed = null;
93
+ if (event.key !== "Escape" || event.isComposing || event.defaultPrevented) return;
94
+ if (topWith("escape") !== layer) return;
95
+ event.preventDefault();
96
+ onEscape();
97
+ }),
98
+ listen(element, "cancel", (event) => {
99
+ if (event.target !== element || !event.cancelable || event.defaultPrevented) return;
100
+ const top = topWith("escape");
101
+ if (!top) return;
102
+ event.preventDefault();
103
+ top.escape();
104
+ }),
105
+ listen(element, "close", () => {
106
+ if (!onEscape || element.open) return;
107
+ onEscape();
108
+ if (layers.includes(layer) && element.isConnected) element[modal ? "showModal" : "show"]();
109
+ }),
110
+ listen(document, "pointerdown", (event) => {
111
+ pressed = targetOf(event);
112
+ outside(event);
113
+ }, true),
114
+ listen(document, "click", () => pressed = null, true),
115
+ listen(document, "focusin", (event) => {
116
+ const target = targetOf(event);
117
+ if (event.target !== document.activeElement || layers.some((other) => other.trap && inside(other.el, element) && !inside(other.el, target))) return;
118
+ if (!pressed || !inside(target, pressed)) outside(event);
119
+ })
120
+ ];
121
+ return () => {
122
+ if (remove()) for (const cleanup of cleanups) cleanup();
123
+ };
124
+ }
125
+ //#endregion
126
+ //#region src/dom/focusable.ts
127
+ /**
128
+ * What can take focus from the keyboard: `focusTrap` then drops what the
129
+ * browser itself would skip, and `hideOthers` keeps no live region holding
130
+ * any of it.
131
+ */
132
+ const FOCUSABLE = "a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable]";
133
+ //#endregion
134
+ //#region src/dom/focus-trap.ts
135
+ /**
136
+ * The elements Tab reaches inside `container`, open shadow roots included,
137
+ * in Tab's order: positive `tabindex` first, then document order.
138
+ * `tabIndex`, `:disabled` and the visibility check drop what the browser
139
+ * itself would skip.
140
+ */
141
+ function tabbables(container) {
142
+ const found = [];
143
+ const collect = (root) => {
144
+ for (const el of root.querySelectorAll("*")) {
145
+ if (el.matches("a[href],area[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable]") && el.tabIndex >= 0 && !el.matches(":disabled") && !el.closest("[inert]") && el.checkVisibility({ visibilityProperty: true }) && radioStop(el, root)) found.push(el);
146
+ if (el.shadowRoot) collect(el.shadowRoot);
147
+ }
148
+ };
149
+ collect(container);
150
+ return found.sort((a, b) => (a.tabIndex || 1e9) - (b.tabIndex || 1e9));
151
+ }
152
+ /** Tab stops once per radio group: on its checked radio, or on the first enabled one. */
153
+ function radioStop(el, root) {
154
+ if (!(el instanceof HTMLInputElement) || el.type !== "radio" || !el.name || el.checked) return true;
155
+ const group = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(el.name)}"]`)];
156
+ return !group.some((radio) => radio.checked) && group.find((radio) => !radio.disabled) === el;
157
+ }
158
+ /** Where Tab lands past the page's first and last element, instead of the browser's UI. */
159
+ function guard(tabIndex) {
160
+ const span = document.createElement("span");
161
+ span.tabIndex = tabIndex;
162
+ span.style.cssText = "position:fixed;opacity:0;pointer-events:none";
163
+ return span;
164
+ }
165
+ /**
166
+ * Keeps keyboard focus inside `container`, a modal overlay that is not a
167
+ * `<dialog>` (PLAN §4.5, §11): focus moves in (`[autofocus]`, else the first
168
+ * tabbable element, else the container), Tab and Shift+Tab wrap at the ends
169
+ * and are otherwise the browser's, focus that leaves is brought back (to
170
+ * the next element when Tab took it out, else to where it was), and the
171
+ * cleanup returns focus to where it was, or to `<body>` when that element
172
+ * is gone.
173
+ *
174
+ * Focus may sit in a layer opened above it with `dismiss` or another
175
+ * `focusTrap` (a menu, wherever it is rendered). A container with nothing
176
+ * to focus gets `tabindex="-1"` while the trap lasts.
177
+ *
178
+ * A modal `<dialog>` opened with `showModal()` needs none of this: the
179
+ * browser moves focus in, makes the page inert and restores focus on
180
+ * `close()`, and lets Tab reach its own UI, as it intends for every page.
181
+ */
182
+ function focusTrap(container) {
183
+ const previous = document.activeElement;
184
+ const layer = {
185
+ el: container,
186
+ trap: true
187
+ };
188
+ const top = () => topWith("trap") === layer;
189
+ let last = null;
190
+ let direction = 0;
191
+ let tabindex = false;
192
+ const focus = (el) => {
193
+ if (!el && !container.hasAttribute("tabindex")) {
194
+ container.tabIndex = -1;
195
+ tabindex = true;
196
+ }
197
+ (el ?? container).focus();
198
+ };
199
+ const remove = addLayer(layer);
200
+ queueMicrotask(() => {
201
+ if (top()) focus(container.querySelector("[autofocus]") ?? tabbables(container)[0]);
202
+ });
203
+ let guards = [];
204
+ const unguard = () => {
205
+ for (const span of guards) span.remove();
206
+ guards = [];
207
+ };
208
+ const cleanups = [listen(document, "keydown", (event) => {
209
+ if (event.key !== "Tab" || event.defaultPrevented || !top()) return;
210
+ let active = document.activeElement;
211
+ if (active && above(layer, active)) return;
212
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
213
+ last = active;
214
+ direction = event.shiftKey ? -1 : 1;
215
+ unguard();
216
+ guards = [guard(1), guard(0)];
217
+ document.body.prepend(guards[0]);
218
+ document.body.append(guards[1]);
219
+ }), listen(document, "focusin", (event) => {
220
+ const target = targetOf(event);
221
+ const tab = direction;
222
+ direction = 0;
223
+ unguard();
224
+ if (!top() || above(layer, target)) return;
225
+ if (inside(container, target)) {
226
+ last = target;
227
+ return;
228
+ }
229
+ const list = tabbables(container);
230
+ const at = list.indexOf(last);
231
+ focus(!tab ? last?.isConnected && inside(container, last) ? last : list[0] : at < 0 ? list.at(tab < 0 ? -1 : 0) : list.at((at + tab) % list.length));
232
+ })];
233
+ return () => {
234
+ if (!remove()) return;
235
+ for (const cleanup of cleanups) cleanup();
236
+ unguard();
237
+ if (tabindex) container.removeAttribute("tabindex");
238
+ queueMicrotask(() => {
239
+ const active = document.activeElement;
240
+ if (active && active !== document.body && !inside(container, active)) return;
241
+ previous?.focus?.();
242
+ if (inside(container, document.activeElement)) document.activeElement.blur();
243
+ });
244
+ };
245
+ }
246
+ //#endregion
247
+ //#region src/dom/hide-others.ts
248
+ /**
249
+ * Elements this module made inert, with how many `hideOthers` calls still
250
+ * want them so: nested overlays may clean up in any order.
251
+ */
252
+ const hidden = /* @__PURE__ */ new Map();
253
+ /** Live regions, by attribute or by role, `<output>`'s included. */
254
+ const LIVE = "[aria-live],[role=status],[role=alert],[role=log],output";
255
+ /**
256
+ * Makes everything outside `keep` inert: out of the accessibility tree,
257
+ * unfocusable and deaf to the pointer, with no `aria-hidden` walk or focus
258
+ * trap to keep in step (PLAN D15, S5). For a modal overlay that is not a
259
+ * `<dialog>`; `showModal()` does the same for a `<dialog>`. An overlay in a
260
+ * shadow root keeps its hosts, and the rest of its shadow tree is hidden.
261
+ *
262
+ * Live regions with nothing focusable inside stay outside it, so
263
+ * announcements keep reaching screen readers, and elements the page made
264
+ * inert itself are left as they are. A layer opened above while it lasts
265
+ * (`dismiss` or `focusTrap`: a menu from the overlay, rendered elsewhere)
266
+ * is kept too, until that layer goes.
267
+ */
268
+ function hideOthers(...keep) {
269
+ const kept = [...keep, ...[...document.querySelectorAll(LIVE)].filter((el) => !el.querySelector(FOCUSABLE))];
270
+ const mine = [];
271
+ const hide = (el) => {
272
+ if (hidden.has(el) || !el.hasAttribute("inert")) {
273
+ hidden.set(el, (hidden.get(el) ?? 0) + 1);
274
+ el.setAttribute("inert", "");
275
+ mine.push(el);
276
+ }
277
+ };
278
+ const lift = (el) => {
279
+ const lifted = [];
280
+ const start = mine.length;
281
+ for (let node = el, child = null; node && node !== document.body;) {
282
+ if (node instanceof Element && hidden.has(node) && node.hasAttribute("inert")) {
283
+ node.removeAttribute("inert");
284
+ lifted.push(node);
285
+ if (child) {
286
+ for (const other of child.parentNode.children) if (other !== child && !kept.includes(other)) hide(other);
287
+ }
288
+ }
289
+ child = node;
290
+ node = parentOf(node);
291
+ }
292
+ const added = mine.splice(start);
293
+ return () => {
294
+ unhide(added);
295
+ for (const node of lifted) if (hidden.has(node)) node.setAttribute("inert", "");
296
+ };
297
+ };
298
+ const undo = keep.map(lift);
299
+ const walk = (parent) => {
300
+ for (const child of parent.children) {
301
+ if (kept.includes(child) || /^(SCRIPT|STYLE|TEMPLATE|LINK)$/.test(child.tagName)) continue;
302
+ if (kept.some((el) => child.contains(el))) walk(child);
303
+ else if (child.shadowRoot && kept.some((el) => inside(child, el))) walk(child.shadowRoot);
304
+ else hide(child);
305
+ }
306
+ };
307
+ walk(document.body);
308
+ watchers.add(lift);
309
+ return () => {
310
+ if (!watchers.delete(lift)) return;
311
+ unhide(mine.splice(0));
312
+ for (const fn of undo) fn();
313
+ };
314
+ }
315
+ function unhide(elements) {
316
+ for (const el of elements) {
317
+ const count = hidden.get(el) - 1;
318
+ if (count) hidden.set(el, count);
319
+ else {
320
+ hidden.delete(el);
321
+ el.removeAttribute("inert");
322
+ }
323
+ }
324
+ }
325
+ //#endregion
326
+ //#region src/dom/live-region.ts
327
+ const HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
328
+ /**
329
+ * Announces `message` to screen readers while the effect runs: politely
330
+ * (`status`), or interrupting (`alert`) when `assertive`. The cleanup
331
+ * withdraws it if it is still showing.
332
+ *
333
+ * The region lives in the topmost modal `<dialog>` open when the message
334
+ * lands, since everything outside a modal dialog is inert and silent, else
335
+ * in `<body>`;
336
+ * `hideOthers` leaves live regions out. It is created once and kept: a
337
+ * screen reader only follows regions that existed before their text changed.
338
+ */
339
+ function liveRegion(message, options) {
340
+ const role = options?.assertive ? "alert" : "status";
341
+ let fresh = false;
342
+ const region = (parent = [...document.querySelectorAll("dialog:modal")].at(-1) ?? document.body) => {
343
+ let el = parent.querySelector(`:scope > [data-live-region=${role}]`);
344
+ if (!el) {
345
+ el = document.createElement("div");
346
+ el.dataset.liveRegion = role;
347
+ el.setAttribute("role", role);
348
+ el.setAttribute("aria-live", options?.assertive ? "assertive" : "polite");
349
+ el.style.cssText = HIDDEN;
350
+ parent.append(el);
351
+ fresh = true;
352
+ }
353
+ return el;
354
+ };
355
+ region(document.body);
356
+ let target = region();
357
+ target.textContent = "";
358
+ const fill = () => {
359
+ fresh = false;
360
+ target = region();
361
+ if (fresh) timer = setTimeout(fill, 100);
362
+ else target.textContent = message;
363
+ };
364
+ let timer = setTimeout(fill, 100);
365
+ return () => {
366
+ clearTimeout(timer);
367
+ if (target.textContent === message) target.textContent = "";
368
+ };
369
+ }
370
+ //#endregion
371
+ //#region src/dom/place.ts
372
+ const OPPOSITE = {
373
+ top: "bottom",
374
+ bottom: "top",
375
+ left: "right",
376
+ right: "left"
377
+ };
378
+ function place(anchor, floating, viewport, options, arrowSize) {
379
+ const { offset, padding } = options;
380
+ let [side, align] = options.placement.split("-");
381
+ const vertical = side === "top" || side === "bottom";
382
+ const room = {
383
+ top: anchor.y,
384
+ bottom: viewport.height - anchor.y - anchor.height,
385
+ left: anchor.x,
386
+ right: viewport.width - anchor.x - anchor.width
387
+ };
388
+ const need = (vertical ? floating.height : floating.width) + offset + padding;
389
+ if (options.flip && room[side] < need && room[OPPOSITE[side]] > room[side]) side = OPPOSITE[side];
390
+ if (align && vertical && options.rtl) align = align === "start" ? "end" : "start";
391
+ const [start, length, size, limit] = vertical ? [
392
+ anchor.x,
393
+ anchor.width,
394
+ floating.width,
395
+ viewport.width
396
+ ] : [
397
+ anchor.y,
398
+ anchor.height,
399
+ floating.height,
400
+ viewport.height
401
+ ];
402
+ let cross = align === "start" ? start : align === "end" ? start + length - size : start + (length - size) / 2;
403
+ if (options.shift) cross = Math.max(padding, Math.min(cross, limit - size - padding));
404
+ const main = side === "top" ? anchor.y - floating.height - offset : side === "bottom" ? anchor.y + anchor.height + offset : side === "left" ? anchor.x - floating.width - offset : anchor.x + anchor.width + offset;
405
+ const placement = align ? `${side}-${options.placement.split("-")[1]}` : side;
406
+ const [x, y] = vertical ? [cross, main] : [main, cross];
407
+ if (arrowSize === void 0) return {
408
+ x,
409
+ y,
410
+ placement
411
+ };
412
+ return {
413
+ x,
414
+ y,
415
+ placement,
416
+ arrow: Math.max(0, Math.min(start + length / 2 - cross - arrowSize / 2, size - arrowSize))
417
+ };
418
+ }
419
+ //#endregion
420
+ //#region src/dom/popper.ts
421
+ /**
422
+ * Keeps `floating` next to `anchor` while it is shown: placed, flipped and
423
+ * shifted inside the viewport, and moved again when either resizes, the
424
+ * window resizes or anything scrolls, once per animation frame
425
+ * (PLAN §4.5; no `popover` or anchor positioning while they are not Baseline
426
+ * widely available, D16).
427
+ *
428
+ * `floating` is positioned `fixed`, so its containing block must be the
429
+ * viewport: render it outside transformed, filtered or `contain`ed
430
+ * ancestors (a portal). The placement taken is set as `data-placement`
431
+ * for styles that depend on the side, the arrow's among them.
432
+ */
433
+ function popper(anchor, floating, options = {}) {
434
+ const { arrow, sameWidth } = options;
435
+ const settings = {
436
+ placement: options.placement ?? "bottom",
437
+ offset: options.offset ?? 8,
438
+ padding: options.padding ?? 8,
439
+ flip: options.flip ?? true,
440
+ shift: options.shift ?? true,
441
+ rtl: getComputedStyle(floating).direction === "rtl"
442
+ };
443
+ const vertical = /^(top|bottom)/.test(settings.placement);
444
+ const snap = (value) => `${Math.round(value * devicePixelRatio) / devicePixelRatio}px`;
445
+ let frame = 0;
446
+ const update = () => {
447
+ frame = 0;
448
+ const box = anchor.getBoundingClientRect();
449
+ const { style } = floating;
450
+ style.position = "fixed";
451
+ if (sameWidth) style.width = `${box.width}px`;
452
+ const root = document.documentElement;
453
+ const placed = place(box, {
454
+ width: floating.offsetWidth,
455
+ height: floating.offsetHeight
456
+ }, {
457
+ width: root.clientWidth,
458
+ height: root.clientHeight
459
+ }, settings, arrow ? vertical ? arrow.offsetWidth : arrow.offsetHeight : void 0);
460
+ style.left = snap(placed.x);
461
+ style.top = snap(placed.y);
462
+ floating.dataset.placement = placed.placement;
463
+ if (arrow && placed.arrow !== void 0) {
464
+ arrow.style.left = vertical ? snap(placed.arrow) : "";
465
+ arrow.style.top = vertical ? "" : snap(placed.arrow);
466
+ }
467
+ };
468
+ const schedule = () => {
469
+ frame ||= requestAnimationFrame(update);
470
+ };
471
+ update();
472
+ const observer = new ResizeObserver(schedule);
473
+ observer.observe(anchor);
474
+ observer.observe(floating);
475
+ const cleanups = [listen(window, "scroll", schedule, {
476
+ capture: true,
477
+ passive: true
478
+ }), listen(window, "resize", schedule)];
479
+ return () => {
480
+ cancelAnimationFrame(frame);
481
+ observer.disconnect();
482
+ for (const cleanup of cleanups) cleanup();
483
+ };
484
+ }
485
+ //#endregion
486
+ //#region src/dom/presence.ts
487
+ /**
488
+ * Calls `onDone` once `element`'s CSS animations and transitions have
489
+ * finished, its `::before`, `::after` and `::backdrop`'s included, so a closing state can leave the element mounted until its exit
490
+ * animation ends (PLAN §4.5). Called after the state change that starts the
491
+ * animation: reading the element's animations applies its new styles first.
492
+ *
493
+ * Done on the next microtask when nothing runs, when the user prefers
494
+ * reduced motion, or when an animation is cancelled; infinite animations
495
+ * (a spinner inside the styles) are not waited for. The cleanup stops a
496
+ * pending call, for a state left before the animation ended.
497
+ *
498
+ * A `<dialog>` animates out only while it is open: `close()` it after
499
+ * `onDone`, in the cleanup of a state that holds both `open` and `closing`.
500
+ */
501
+ function presence(element, onDone) {
502
+ let waiting = true;
503
+ const running = matchMedia("(prefers-reduced-motion: reduce)").matches ? [] : element.getAnimations({ subtree: true }).filter((animation) => {
504
+ const effect = animation.effect;
505
+ return effect?.target === element && effect.getComputedTiming().endTime !== Infinity;
506
+ });
507
+ Promise.allSettled(running.map((animation) => animation.finished)).then(() => waiting && onDone());
508
+ return () => {
509
+ waiting = false;
510
+ };
511
+ }
512
+ //#endregion
513
+ //#region src/dom/scroll-lock.ts
514
+ const LOCK = Symbol.for("@menapo/machine/dom:scrollLock");
515
+ /**
516
+ * Stops the page from scrolling under a modal overlay; the platform has no
517
+ * such lock (S5). Nested locks count: the page scrolls again when the last
518
+ * one is cleaned up.
519
+ *
520
+ * Hiding the root's overflow removes a classic scrollbar, so its width is
521
+ * added to the root's padding on the scrollbar's side and exposed as
522
+ * `--scrollbar-width` for fixed elements that must not shift either;
523
+ * under `scrollbar-gutter: stable` the space stays, and both are 0.
524
+ */
525
+ function scrollLock() {
526
+ const lock = document[LOCK] ??= {
527
+ count: 0,
528
+ unlock() {}
529
+ };
530
+ if (!lock.count++) {
531
+ const root = document.documentElement;
532
+ const { style } = root;
533
+ const computed = getComputedStyle(root);
534
+ const width = computed.scrollbarGutter.startsWith("stable") ? 0 : innerWidth - root.clientWidth;
535
+ const side = root.getBoundingClientRect().left + scrollX - parseFloat(computed.marginLeft) > width / 2 ? "paddingLeft" : "paddingRight";
536
+ const saved = [
537
+ style.overflow,
538
+ style[side],
539
+ style.getPropertyValue("--scrollbar-width")
540
+ ];
541
+ style.overflow = "hidden";
542
+ style.setProperty("--scrollbar-width", `${width}px`);
543
+ if (width) style[side] = `${parseFloat(computed[side]) + width}px`;
544
+ lock.unlock = () => {
545
+ style.overflow = saved[0];
546
+ style[side] = saved[1];
547
+ style.setProperty("--scrollbar-width", saved[2]);
548
+ };
549
+ }
550
+ let locked = true;
551
+ return () => {
552
+ if (locked && !--lock.count) lock.unlock();
553
+ locked = false;
554
+ };
555
+ }
556
+ //#endregion
557
+ //#region src/dom/spread.ts
558
+ const spreads = /* @__PURE__ */ new WeakMap();
559
+ const TEXT = /^(?:aria|data)-/;
560
+ const PROPS = /^(?:value|checked|selected|indeterminate)$/;
561
+ function write(element, spread, key, value) {
562
+ if (key.startsWith("on")) {
563
+ const type = key.slice(2).toLowerCase();
564
+ if (typeof value === "function") {
565
+ spread.on[type] = value;
566
+ element.addEventListener(type, spread.handle);
567
+ } else {
568
+ delete spread.on[type];
569
+ element.removeEventListener(type, spread.handle);
570
+ }
571
+ } else if (PROPS.test(key) && key in element) element[key] = value ?? (key === "value" ? "" : false);
572
+ else if (value == null || value === false && !TEXT.test(key)) element.removeAttribute(key);
573
+ else element.setAttribute(key, value === true && !TEXT.test(key) ? "" : String(value));
574
+ }
575
+ function spread(element, part) {
576
+ const props = part;
577
+ let spread = spreads.get(element);
578
+ if (!spread) {
579
+ const on = {};
580
+ spreads.set(element, spread = {
581
+ props: {},
582
+ on,
583
+ handle: (event) => on[event.type]?.(event)
584
+ });
585
+ }
586
+ const old = spread.props;
587
+ if (old === props) return;
588
+ for (const key of Object.keys(old)) if (!(key in props)) write(element, spread, key, void 0);
589
+ for (const key of Object.keys(props)) if (props[key] !== old[key]) write(element, spread, key, props[key]);
590
+ spread.props = props;
591
+ }
592
+ //#endregion
593
+ //#region src/dom/typeahead.ts
594
+ /** The items a typeahead searches: a listbox's options, a menu's items, a tree's items. */
595
+ const ITEMS = "[role=option],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],[role=treeitem]";
596
+ /** As long as Chromium's native `<select>` waits between keys. */
597
+ const TIMEOUT = 1e3;
598
+ let collator;
599
+ /**
600
+ * Whether `text` starts with `query`, ignoring case and accents as a native
601
+ * `<select>` does ("e" finds "Été"), by the collator's base letters: vowel
602
+ * signs that make a different letter (Devanagari's, Thai's) still count.
603
+ */
604
+ function startsWith(text, query) {
605
+ collator ??= new Intl.Collator(void 0, { sensitivity: "base" });
606
+ return !collator.compare(text.trim().normalize().slice(0, query.length), query);
607
+ }
608
+ /**
609
+ * The index of the first text starting with `query`, searching from
610
+ * `current` and wrapping around; -1 when none does. A single key, or the
611
+ * same key repeated, starts after `current`, so pressing "b" again moves
612
+ * to the next item starting with "b"; a longer query may stay on `current`.
613
+ */
614
+ function match(texts, query, current) {
615
+ let q = query.normalize();
616
+ const repeated = /^(.)\1*$/su.test(q.toLocaleLowerCase());
617
+ if (repeated) q = String.fromCodePoint(q.codePointAt(0));
618
+ const from = repeated ? current + 1 : Math.max(current, 0);
619
+ for (let i = 0; i < texts.length; i++) {
620
+ const at = (from + i) % texts.length;
621
+ if (startsWith(texts[at], q)) return at;
622
+ }
623
+ return -1;
624
+ }
625
+ let query = "";
626
+ let last = 0;
627
+ let lastContainer;
628
+ /**
629
+ * Typeahead for a part's `onKeyDown`: the item in `container` whose text
630
+ * starts with what was typed within the last second, or undefined. Items
631
+ * are the container's options, menu items and tree items that are not
632
+ * `aria-disabled` or hidden; the current one is the key's target, or the
633
+ * element its `aria-activedescendant` names. A key it uses has its default
634
+ * prevented, so a Space inside a query does not activate the item.
635
+ *
636
+ * ```ts
637
+ * onKeyDown: ({ el, send }, event) => {
638
+ * const item = typeahead(event, el.content);
639
+ * if (item) send("HIGHLIGHT", { id: item.id });
640
+ * },
641
+ * ```
642
+ */
643
+ function typeahead(event, container) {
644
+ const { key } = event;
645
+ if (!/^.$/su.test(key) || event.ctrlKey || event.metaKey || event.altKey) return;
646
+ if (event.timeStamp - last > TIMEOUT || container !== lastContainer?.deref()) query = "";
647
+ if (key === " " && !query) return;
648
+ event.preventDefault();
649
+ query += key;
650
+ last = event.timeStamp;
651
+ lastContainer = new WeakRef(container);
652
+ const items = [...container.querySelectorAll(ITEMS)].filter((item) => item.getAttribute("aria-disabled") !== "true" && item.checkVisibility());
653
+ const target = event.target;
654
+ const active = target.getAttribute("aria-activedescendant");
655
+ const current = active && document.getElementById(active) || target;
656
+ return items[match(items.map((item) => item.textContent ?? ""), query, items.indexOf(current))];
657
+ }
658
+ //#endregion
659
+ export { dismiss, focusTrap, hideOthers, liveRegion, popper, presence, scrollLock, spread, typeahead };
@@ -0,0 +1,4 @@
1
+ import { t as View } from "../component-DDXOyYUI.js";
2
+ //#region src/glue/preact.d.ts
3
+ export declare function useMachine(machine: object, props?: object): View;
4
+ //#endregion