@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/LICENSE +21 -0
- package/README.md +135 -2
- package/dist/chart-BWjU2VF4.js +395 -0
- package/dist/compiled.d.ts +58 -0
- package/dist/compiled.js +43 -0
- package/dist/component-DDXOyYUI.d.ts +147 -0
- package/dist/dev-BAz6e3W-.js +79 -0
- package/dist/dom.d.ts +187 -0
- package/dist/dom.js +659 -0
- package/dist/glue/preact.d.ts +4 -0
- package/dist/glue/preact.js +54 -0
- package/dist/glue/react.d.ts +4 -0
- package/dist/glue/react.js +50 -0
- package/dist/glue/solid.d.ts +4 -0
- package/dist/glue/solid.js +42 -0
- package/dist/glue/svelte.svelte.d.ts +4 -0
- package/dist/glue/svelte.svelte.js +57 -0
- package/dist/glue/vanilla.d.ts +4 -0
- package/dist/glue/vanilla.js +83 -0
- package/dist/glue/vue.d.ts +4 -0
- package/dist/glue/vue.js +41 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +582 -0
- package/dist/inspect-BmyLUhcy.js +74 -0
- package/dist/inspect-C3qosuCm.d.ts +22 -0
- package/dist/instance-BM2UqeXW.js +418 -0
- package/dist/machine-BstWHtWL.d.ts +335 -0
- package/dist/naming-Csj5186j.js +492 -0
- package/dist/records-7FFepzo9.js +10 -0
- package/dist/source-DdFTPjID.js +15 -0
- package/dist/test.d.ts +63 -0
- package/dist/test.js +262 -0
- package/dist/types-Bk8svtO6.d.ts +580 -0
- package/dist/vicss.d.ts +19 -0
- package/dist/vicss.js +51 -0
- package/dist/vite.d.ts +27 -0
- package/dist/vite.js +4281 -0
- package/package.json +87 -3
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 };
|