@cplieger/ui 0.0.0 → 1.0.0-dev.1
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/NOTICE +3 -0
- package/README.md +145 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +36 -4
- package/package.json +86 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -8
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- package/src/signal/store.ts +84 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* focus-trap.ts — Keep Tab / Shift+Tab cycling within a container, per the
|
|
3
|
+
* WAI-ARIA dialog pattern. Headless: no DOM is created, only focus is managed.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const release = trapFocus(dialogEl, { returnFocus: true });
|
|
7
|
+
* // ... interaction ...
|
|
8
|
+
* release(); // restores focus to the previously-focused element
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { untracked } from "../signal/signal.js";
|
|
15
|
+
|
|
16
|
+
import { createDriver, ownedBinding } from "./owner.js";
|
|
17
|
+
|
|
18
|
+
/** The standard focusable-element selector. Elements are additionally filtered
|
|
19
|
+
* to those that are rendered (see `isVisible`) at each Tab. */
|
|
20
|
+
const FOCUSABLE =
|
|
21
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
22
|
+
|
|
23
|
+
/** Is `el` laid out (and, where the engine supports it, actually visible)?
|
|
24
|
+
* Uses `getClientRects().length` rather than `offsetParent !== null` so that
|
|
25
|
+
* `position: fixed` elements — whose `offsetParent` is `null` — still count as
|
|
26
|
+
* focusable. `checkVisibility` (when present) additionally rejects elements
|
|
27
|
+
* hidden via `visibility`/`content-visibility`. */
|
|
28
|
+
function isVisible(el: HTMLElement): boolean {
|
|
29
|
+
if (el.getClientRects().length === 0) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
typeof el.checkVisibility !== "function" || el.checkVisibility({ visibilityProperty: true })
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The two focus hand-offs a trap owns: which element takes focus on entry,
|
|
38
|
+
* and where focus lands when the release function runs. Both optional — the
|
|
39
|
+
* defaults focus the first focusable descendant and restore the element that
|
|
40
|
+
* was focused when the trap was installed. */
|
|
41
|
+
export interface FocusTrapOptions {
|
|
42
|
+
/** Element to focus on entry. Defaults to the first focusable descendant.
|
|
43
|
+
* A `null` value is treated the same as omitting it. */
|
|
44
|
+
initialFocus?: HTMLElement | null;
|
|
45
|
+
/** Where focus goes on release. `true` (default) or omitted restores the
|
|
46
|
+
* previously-focused element; an `HTMLElement` focuses that element; `false`
|
|
47
|
+
* leaves focus untouched. */
|
|
48
|
+
returnFocus?: boolean | HTMLElement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Trap Tab focus within `container`. Returns a release function. */
|
|
52
|
+
export function trapFocus(container: HTMLElement, opts?: FocusTrapOptions): () => void {
|
|
53
|
+
const o = untracked(() => ({
|
|
54
|
+
initialFocus: opts?.initialFocus ?? null,
|
|
55
|
+
returnFocus: opts?.returnFocus ?? true,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const getFocusable = (): HTMLElement[] =>
|
|
59
|
+
[...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isVisible);
|
|
60
|
+
|
|
61
|
+
return ownedBinding((binding) => {
|
|
62
|
+
const previousFocus =
|
|
63
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
64
|
+
let listening = false;
|
|
65
|
+
let entered = false;
|
|
66
|
+
|
|
67
|
+
/** Where Tab must go instead of the browser's default, or null to leave it. */
|
|
68
|
+
const redirect = (e: KeyboardEvent): HTMLElement | null => {
|
|
69
|
+
const focusable = getFocusable();
|
|
70
|
+
const active = document.activeElement;
|
|
71
|
+
const inside = active instanceof Node && container.contains(active);
|
|
72
|
+
const first = focusable[0];
|
|
73
|
+
const last = focusable[focusable.length - 1];
|
|
74
|
+
if (first === undefined || last === undefined) {
|
|
75
|
+
// No focusable targets: pin focus to the container, never let Tab out.
|
|
76
|
+
return container;
|
|
77
|
+
}
|
|
78
|
+
if (!inside) {
|
|
79
|
+
// Focus escaped, or was never inside: pull it back to the edge.
|
|
80
|
+
return e.shiftKey ? last : first;
|
|
81
|
+
}
|
|
82
|
+
if (e.shiftKey ? active === first : active === last) {
|
|
83
|
+
return e.shiftKey ? last : first;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
88
|
+
if (e.key !== "Tab") {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
driver.act((fx) => {
|
|
92
|
+
const target = redirect(e);
|
|
93
|
+
if (target !== null) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
fx.focus(target);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
// Capture phase on `document` (not `container`): focus that is currently
|
|
100
|
+
// outside the container still routes through here so it can be redirected in.
|
|
101
|
+
const listen = (on: boolean): void => {
|
|
102
|
+
if (listening !== on) {
|
|
103
|
+
listening = on;
|
|
104
|
+
if (on) {
|
|
105
|
+
document.addEventListener("keydown", onKey, true);
|
|
106
|
+
} else {
|
|
107
|
+
document.removeEventListener("keydown", onKey, true);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const driver = createDriver<null>({
|
|
113
|
+
initial: null,
|
|
114
|
+
realize(fx) {
|
|
115
|
+
listen(true);
|
|
116
|
+
if (entered) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
entered = true;
|
|
120
|
+
const focusable = getFocusable();
|
|
121
|
+
if (focusable.length === 0) {
|
|
122
|
+
// Fail closed: with nothing focusable, the container itself holds
|
|
123
|
+
// focus so Tab can't escape to the page behind it.
|
|
124
|
+
// The attribute: a div's tabIndex property already reads -1 without it.
|
|
125
|
+
fx.setAttr(container, "tabindex", "-1");
|
|
126
|
+
fx.focus(container);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// A detached initialFocus (an opener chained from a removed modal) is
|
|
130
|
+
// a safe no-op, not a throw.
|
|
131
|
+
const target = o.initialFocus ?? focusable[0];
|
|
132
|
+
if (target?.isConnected === true) {
|
|
133
|
+
fx.focus(target);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
terminal(fx) {
|
|
137
|
+
listen(false);
|
|
138
|
+
const target =
|
|
139
|
+
o.returnFocus === true ? previousFocus : o.returnFocus === false ? null : o.returnFocus;
|
|
140
|
+
// Focusing a detached node throws in some engines and no-ops in others.
|
|
141
|
+
if (target?.isConnected === true) {
|
|
142
|
+
fx.focus(target);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
controller: binding.dispose,
|
|
149
|
+
start: () => {
|
|
150
|
+
driver.flush();
|
|
151
|
+
},
|
|
152
|
+
release: () => {
|
|
153
|
+
driver.release();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// fx.ts — INTERNAL: the effect vocabulary a driver's realization uses. Every
|
|
2
|
+
// call that can run code outside the library is one method here, which writes
|
|
3
|
+
// only what differs and then checks the pass, so a pass made stale by that
|
|
4
|
+
// code restarts before its next effect.
|
|
5
|
+
|
|
6
|
+
/** The inline style properties realization writes. */
|
|
7
|
+
export type StyleProp =
|
|
8
|
+
"height" | "position" | "left" | "right" | "top" | "minWidth" | "width" | "overflow";
|
|
9
|
+
|
|
10
|
+
/** `showModal`'s outcome, read from the element after the call. */
|
|
11
|
+
type ShowResult = { readonly open: true } | { readonly open: false; readonly error: unknown };
|
|
12
|
+
|
|
13
|
+
type Reflected = "hidden" | "inert" | "id" | "tabIndex";
|
|
14
|
+
|
|
15
|
+
/** The checked effects of one pass. */
|
|
16
|
+
export interface Fx {
|
|
17
|
+
setAttr(el: Element, name: string, value: string): void;
|
|
18
|
+
removeAttr(el: Element, name: string): void;
|
|
19
|
+
/** Adds only the missing tokens, in one write. */
|
|
20
|
+
addClass(el: Element, ...tokens: string[]): void;
|
|
21
|
+
removeClass(el: Element, ...tokens: string[]): void;
|
|
22
|
+
toggleClass(el: Element, token: string, on: boolean): void;
|
|
23
|
+
setProp<E extends HTMLElement, K extends Reflected>(el: E, key: K, value: E[K]): void;
|
|
24
|
+
/** Compared with the value the browser would serialize, so `-0px` reaches a fixed point. */
|
|
25
|
+
setStyle(el: HTMLElement, prop: StyleProp, value: string): void;
|
|
26
|
+
/** Skipped when `child` is already a child of `parent`. */
|
|
27
|
+
append(parent: Node, child: Node): void;
|
|
28
|
+
/** Skipped when detached. */
|
|
29
|
+
remove(node: ChildNode): void;
|
|
30
|
+
/** Skipped when `el` is focused or detached. */
|
|
31
|
+
focus(el: HTMLElement): void;
|
|
32
|
+
/** Skipped unless `el` is focused. */
|
|
33
|
+
blur(el: HTMLElement): void;
|
|
34
|
+
click(el: HTMLElement): void;
|
|
35
|
+
select(input: HTMLInputElement): void;
|
|
36
|
+
/** Skipped when already open. Never throws: a platform throw comes back as `error`. */
|
|
37
|
+
showModal(dialog: HTMLDialogElement): ShowResult;
|
|
38
|
+
/** Skipped when not open. */
|
|
39
|
+
close(dialog: HTMLDialogElement): void;
|
|
40
|
+
/** A user hook or another driver's flush: a throw is collected and yields undefined. */
|
|
41
|
+
call<R>(fn: () => R): R | undefined;
|
|
42
|
+
/** `call` for reading the outside facts a kick reports. The pass marks the kick
|
|
43
|
+
* count just before `fn` runs, so a kick from inside `fn` makes it stale. */
|
|
44
|
+
measure<R>(fn: () => R): R | undefined;
|
|
45
|
+
/** Collect an error for the drive's edge. */
|
|
46
|
+
report(error: unknown): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a pass gives its `Fx`. */
|
|
50
|
+
interface Pass {
|
|
51
|
+
/** After an external call: throw to restart when the pass went stale. */
|
|
52
|
+
check(): void;
|
|
53
|
+
/** Before a measurement's reads. */
|
|
54
|
+
measured(): void;
|
|
55
|
+
/** Keep a hook's error for the drive's edge, or rethrow it. */
|
|
56
|
+
collect(error: unknown): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read through a call: TypeScript keeps `dialog.open` narrowed across the platform
|
|
60
|
+
* call that changes it (microsoft/TypeScript#9998). */
|
|
61
|
+
function isOpen(dialog: HTMLDialogElement): boolean {
|
|
62
|
+
return dialog.open;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let scratch: HTMLElement | null = null;
|
|
66
|
+
|
|
67
|
+
/** `value` as the browser serializes it for `prop`. */
|
|
68
|
+
function serialized(prop: StyleProp, value: string): string {
|
|
69
|
+
scratch ??= document.createElement("div");
|
|
70
|
+
scratch.style[prop] = value;
|
|
71
|
+
const out = scratch.style[prop];
|
|
72
|
+
scratch.style[prop] = "";
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `Fx` of one pass: every method diffs, performs, then checks `pass`. */
|
|
77
|
+
export function fxAt(pass: Pass): Fx {
|
|
78
|
+
const fx: Fx = {
|
|
79
|
+
setAttr(el, name, value) {
|
|
80
|
+
if (el.getAttribute(name) !== value) {
|
|
81
|
+
el.setAttribute(name, value);
|
|
82
|
+
pass.check();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
removeAttr(el, name) {
|
|
86
|
+
if (el.hasAttribute(name)) {
|
|
87
|
+
el.removeAttribute(name);
|
|
88
|
+
pass.check();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
addClass(el, ...tokens) {
|
|
92
|
+
const missing = tokens.filter((t) => !el.classList.contains(t));
|
|
93
|
+
if (missing.length > 0) {
|
|
94
|
+
el.classList.add(...missing);
|
|
95
|
+
pass.check();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
removeClass(el, ...tokens) {
|
|
99
|
+
const present = tokens.filter((t) => el.classList.contains(t));
|
|
100
|
+
if (present.length > 0) {
|
|
101
|
+
el.classList.remove(...present);
|
|
102
|
+
pass.check();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
toggleClass(el, token, on) {
|
|
106
|
+
if (on) {
|
|
107
|
+
fx.addClass(el, token);
|
|
108
|
+
} else {
|
|
109
|
+
fx.removeClass(el, token);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
setProp(el, key, value) {
|
|
113
|
+
if (el[key] !== value) {
|
|
114
|
+
el[key] = value;
|
|
115
|
+
pass.check();
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
setStyle(el, prop, value) {
|
|
119
|
+
if (el.style[prop] !== serialized(prop, value)) {
|
|
120
|
+
el.style[prop] = value;
|
|
121
|
+
pass.check();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
append(parent, child) {
|
|
125
|
+
if (child.parentNode !== parent) {
|
|
126
|
+
parent.appendChild(child);
|
|
127
|
+
pass.check();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
remove(node) {
|
|
131
|
+
if (node.parentNode !== null) {
|
|
132
|
+
node.remove();
|
|
133
|
+
pass.check();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
focus(el) {
|
|
137
|
+
if (el.isConnected && document.activeElement !== el) {
|
|
138
|
+
el.focus();
|
|
139
|
+
pass.check();
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
blur(el) {
|
|
143
|
+
if (document.activeElement === el) {
|
|
144
|
+
el.blur();
|
|
145
|
+
pass.check();
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
click(el) {
|
|
149
|
+
el.click();
|
|
150
|
+
pass.check();
|
|
151
|
+
},
|
|
152
|
+
select(input) {
|
|
153
|
+
input.select();
|
|
154
|
+
pass.check();
|
|
155
|
+
},
|
|
156
|
+
showModal(dialog) {
|
|
157
|
+
if (dialog.open) {
|
|
158
|
+
return { open: true };
|
|
159
|
+
}
|
|
160
|
+
let error: unknown = undefined;
|
|
161
|
+
try {
|
|
162
|
+
dialog.showModal();
|
|
163
|
+
} catch (e) {
|
|
164
|
+
error = e;
|
|
165
|
+
}
|
|
166
|
+
pass.check();
|
|
167
|
+
return isOpen(dialog) ? { open: true } : { open: false, error };
|
|
168
|
+
},
|
|
169
|
+
close(dialog) {
|
|
170
|
+
if (dialog.open) {
|
|
171
|
+
dialog.close();
|
|
172
|
+
pass.check();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
call(fn) {
|
|
176
|
+
let out: ReturnType<typeof fn> | undefined;
|
|
177
|
+
try {
|
|
178
|
+
out = fn();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
pass.collect(error);
|
|
181
|
+
}
|
|
182
|
+
pass.check();
|
|
183
|
+
return out;
|
|
184
|
+
},
|
|
185
|
+
measure(fn) {
|
|
186
|
+
pass.measured();
|
|
187
|
+
return fx.call(fn);
|
|
188
|
+
},
|
|
189
|
+
report(error) {
|
|
190
|
+
pass.collect(error);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
return fx;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The same diffs with no pass: nothing is checked and every throw propagates.
|
|
197
|
+
* Only the public `placeAnchored` uses it. */
|
|
198
|
+
export const directFx: Fx = fxAt({
|
|
199
|
+
check: () => undefined,
|
|
200
|
+
measured: () => undefined,
|
|
201
|
+
collect: (error) => {
|
|
202
|
+
throw error;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { el } from "../dom/el.js";
|
|
2
|
+
|
|
3
|
+
export type Politeness = "polite" | "assertive";
|
|
4
|
+
|
|
5
|
+
type RegionPair = Readonly<Record<Politeness, HTMLElement>>;
|
|
6
|
+
|
|
7
|
+
interface Mount {
|
|
8
|
+
pair: RegionPair;
|
|
9
|
+
readonly owners: Set<symbol>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const mounts = new WeakMap<HTMLElement, Mount>();
|
|
13
|
+
|
|
14
|
+
function buildRegion(politeness: Politeness): HTMLElement {
|
|
15
|
+
return el("div", {
|
|
16
|
+
className: "uip-visually-hidden",
|
|
17
|
+
role: politeness === "assertive" ? "alert" : "status",
|
|
18
|
+
"aria-live": politeness,
|
|
19
|
+
"aria-atomic": "true",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function appendPair(host: HTMLElement): RegionPair {
|
|
24
|
+
const pair: RegionPair = { polite: buildRegion("polite"), assertive: buildRegion("assertive") };
|
|
25
|
+
host.append(pair.polite, pair.assertive);
|
|
26
|
+
return pair;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function removePair(pair: RegionPair): void {
|
|
30
|
+
pair.polite.remove();
|
|
31
|
+
pair.assertive.remove();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isInside(pair: RegionPair, host: HTMLElement): boolean {
|
|
35
|
+
return pair.polite.parentElement === host && pair.assertive.parentElement === host;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The pair mounted in `host`, or `undefined` once its last owner released it
|
|
39
|
+
* or either region has left it (an app clearing the host's children takes the
|
|
40
|
+
* pair with them). */
|
|
41
|
+
export function regionsOf(host: HTMLElement): RegionPair | undefined {
|
|
42
|
+
const mount = mounts.get(host);
|
|
43
|
+
return mount !== undefined && isInside(mount.pair, host) ? mount.pair : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `host`'s mount, with a pair appended as its last two children unless one is
|
|
47
|
+
* still inside it. */
|
|
48
|
+
function mount(host: HTMLElement): Mount {
|
|
49
|
+
let mount = mounts.get(host);
|
|
50
|
+
if (mount === undefined) {
|
|
51
|
+
mount = { pair: appendPair(host), owners: new Set() };
|
|
52
|
+
mounts.set(host, mount);
|
|
53
|
+
} else if (!isInside(mount.pair, host)) {
|
|
54
|
+
removePair(mount.pair);
|
|
55
|
+
mount.pair = appendPair(host);
|
|
56
|
+
}
|
|
57
|
+
return mount;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The owner that never releases. */
|
|
61
|
+
const forLife = Symbol("live-region owner for the host's life");
|
|
62
|
+
|
|
63
|
+
/** Give `host` a pair for the rest of its life, appending one as its last two
|
|
64
|
+
* children unless one is still inside it. */
|
|
65
|
+
export function keepRegions(host: HTMLElement): void {
|
|
66
|
+
mount(host).owners.add(forLife);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Hold `host`'s polite and assertive pair as a new owner, appending a pair
|
|
70
|
+
* as its last two children unless one is still inside it. Returns this
|
|
71
|
+
* owner's release: idempotent, it drops only this owner, and the pair leaves
|
|
72
|
+
* `host` when its last owner releases. */
|
|
73
|
+
export function acquireRegions(host: HTMLElement): () => void {
|
|
74
|
+
const held = mount(host);
|
|
75
|
+
const owner = Symbol("live-region owner");
|
|
76
|
+
held.owners.add(owner);
|
|
77
|
+
return () => {
|
|
78
|
+
held.owners.delete(owner);
|
|
79
|
+
if (held.owners.size === 0) {
|
|
80
|
+
removePair(held.pair);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// showModal() makes everything outside the topmost modal dialog's SUBTREE
|
|
2
|
+
// inert regardless of stacking order, so chrome painted above it (the toast
|
|
3
|
+
// stack, a popup panel) is still dead to clicks and hover unless re-hosted inside it.
|
|
4
|
+
|
|
5
|
+
/** When `open` was last added to each dialog the observer saw. The platform exposes
|
|
6
|
+
* no top-layer order, and document order is not it (a dialog built early and
|
|
7
|
+
* opened last is on top), but showModal() adds `open` as the dialog enters the top
|
|
8
|
+
* layer, and one observer delivers its records in the order they were made. A
|
|
9
|
+
* dialog with no entry is one whose opening no observer saw: it ranks below every
|
|
10
|
+
* stamped one. */
|
|
11
|
+
const openedAt = new WeakMap<HTMLDialogElement, number>();
|
|
12
|
+
let clock = 0;
|
|
13
|
+
|
|
14
|
+
function rank(records: readonly MutationRecord[]): void {
|
|
15
|
+
let reopened = false;
|
|
16
|
+
for (const r of records) {
|
|
17
|
+
if (r.oldValue === null && r.target instanceof HTMLDialogElement) {
|
|
18
|
+
openedAt.set(r.target, ++clock);
|
|
19
|
+
observeRoot(r.target);
|
|
20
|
+
// A close and showModal() in one record batch leave the dialog :modal, so
|
|
21
|
+
// leftCovering() cannot see the close.
|
|
22
|
+
reopened ||= covering.has(r.target);
|
|
23
|
+
if (pendingClear !== null && isModal(r.target)) {
|
|
24
|
+
cover(r.target);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (reopened || leftCovering()) {
|
|
29
|
+
uncover();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Observing a node again replaces its options, so OPEN_RECORDS must cover OWN_OPEN:
|
|
34
|
+
// a tracked dialog can also be the root of its tree.
|
|
35
|
+
const OWN_OPEN: MutationObserverInit = {
|
|
36
|
+
attributes: true,
|
|
37
|
+
attributeFilter: ["open"],
|
|
38
|
+
attributeOldValue: true,
|
|
39
|
+
};
|
|
40
|
+
const OPEN_RECORDS: MutationObserverInit = { ...OWN_OPEN, subtree: true };
|
|
41
|
+
|
|
42
|
+
// Started at load, an import-time effect by design: an opening made before it
|
|
43
|
+
// is lost, since no platform API reports it later. package.json `sideEffects` lists
|
|
44
|
+
// every module whose evaluation reaches this one, so an effect-only import keeps it.
|
|
45
|
+
const additions = new MutationObserver(rank);
|
|
46
|
+
additions.observe(document, OPEN_RECORDS);
|
|
47
|
+
|
|
48
|
+
/** The open modals whose closing, reopening or removal uncovers what they hide, while
|
|
49
|
+
* `pendingClear` waits for that. */
|
|
50
|
+
const covering = new Set<HTMLDialogElement>();
|
|
51
|
+
let pendingClear: (() => void) | null = null;
|
|
52
|
+
// A modal removed from its tree leaves the top layer with no `open` record (HTML, the
|
|
53
|
+
// dialog removing steps), so the parent of each node on its path up to the document is
|
|
54
|
+
// watched. moveBefore() changes that path and keeps the modal in the top layer (DOM, the
|
|
55
|
+
// move algorithm), and its record lands on the old path, so each record walks it again.
|
|
56
|
+
const removals = new MutationObserver(() => {
|
|
57
|
+
if (leftCovering()) {
|
|
58
|
+
uncover();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const dialog of covering) {
|
|
62
|
+
cover(dialog);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function cover(dialog: HTMLDialogElement): void {
|
|
67
|
+
covering.add(dialog);
|
|
68
|
+
let node: Node = dialog;
|
|
69
|
+
for (let parent = node.parentNode; parent !== null; parent = node.parentNode) {
|
|
70
|
+
removals.observe(parent, { childList: true });
|
|
71
|
+
node = parent instanceof ShadowRoot ? parent.host : parent;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function leftCovering(): boolean {
|
|
76
|
+
for (const dialog of covering) {
|
|
77
|
+
if (!isModal(dialog)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function uncover(): void {
|
|
85
|
+
const clear = pendingClear;
|
|
86
|
+
pendingClear = null;
|
|
87
|
+
covering.clear();
|
|
88
|
+
removals.disconnect();
|
|
89
|
+
clear?.();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The observed shadow roots, whose open dialogs `document` queries miss. */
|
|
93
|
+
const shadowRoots = new Set<WeakRef<ShadowRoot>>();
|
|
94
|
+
const observed = new WeakSet<Node>();
|
|
95
|
+
/** The dialogs trackOpens tracks, wherever they are now. */
|
|
96
|
+
const tracked = new Set<WeakRef<HTMLDialogElement>>();
|
|
97
|
+
const isTracked = new WeakSet<HTMLDialogElement>();
|
|
98
|
+
|
|
99
|
+
/** Each target still alive, dropping the collected ones. */
|
|
100
|
+
function* alive<T extends object>(refs: Set<WeakRef<T>>): Generator<T> {
|
|
101
|
+
for (const ref of refs) {
|
|
102
|
+
const target = ref.deref();
|
|
103
|
+
if (target === undefined) {
|
|
104
|
+
refs.delete(ref);
|
|
105
|
+
} else {
|
|
106
|
+
yield target;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Observe the tree `dialog` is in now when that is not the document (a shadow root,
|
|
112
|
+
* or a detached subtree), which the document's registration does not reach. */
|
|
113
|
+
function observeRoot(dialog: HTMLDialogElement): void {
|
|
114
|
+
const root = dialog.getRootNode();
|
|
115
|
+
if (root === document || observed.has(root)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
observed.add(root);
|
|
119
|
+
additions.observe(root, OPEN_RECORDS);
|
|
120
|
+
if (root instanceof ShadowRoot) {
|
|
121
|
+
shadowRoots.add(new WeakRef(root));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Rank every opening of `dialog` from now on, wherever it moves, count it among the
|
|
126
|
+
* open dialogs chrome can host into, and rank the later openings of any dialog in
|
|
127
|
+
* the tree it is in now. Idempotent. */
|
|
128
|
+
export function trackOpens(dialog: HTMLDialogElement): void {
|
|
129
|
+
if (!isTracked.has(dialog)) {
|
|
130
|
+
isTracked.add(dialog);
|
|
131
|
+
tracked.add(new WeakRef(dialog));
|
|
132
|
+
// An element's own registration follows it into any tree. A dialog observed as a
|
|
133
|
+
// root already has the options that cover it.
|
|
134
|
+
if (!observed.has(dialog)) {
|
|
135
|
+
additions.observe(dialog, OWN_OPEN);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
observeRoot(dialog);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isModal(dialog: HTMLDialogElement): boolean {
|
|
142
|
+
return dialog.matches(":modal");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether `x` opened after `y`; of two whose openings no observer saw, the later in
|
|
146
|
+
* tree order (a fixed, arbitrary order across trees). */
|
|
147
|
+
function openedAfter(x: HTMLDialogElement, y: HTMLDialogElement): boolean {
|
|
148
|
+
const ax = openedAt.get(x) ?? 0;
|
|
149
|
+
const ay = openedAt.get(y) ?? 0;
|
|
150
|
+
return ax !== ay
|
|
151
|
+
? ax > ay
|
|
152
|
+
: (y.compareDocumentPosition(x) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The newest-opened of `dialogs`, or `null` for none. */
|
|
156
|
+
function newest(dialogs: readonly HTMLDialogElement[]): HTMLDialogElement | null {
|
|
157
|
+
let top: HTMLDialogElement | null = null;
|
|
158
|
+
for (const dialog of dialogs) {
|
|
159
|
+
if (top === null || openedAfter(dialog, top)) {
|
|
160
|
+
top = dialog;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return top;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Every open, connected dialog the library knows of: the document's, those of the
|
|
167
|
+
* observed shadow roots, and the tracked ones wherever they are. */
|
|
168
|
+
function knownOpen(): HTMLDialogElement[] {
|
|
169
|
+
const open = new Set(document.querySelectorAll<HTMLDialogElement>("dialog[open]"));
|
|
170
|
+
for (const root of alive(shadowRoots)) {
|
|
171
|
+
if (root.host.isConnected) {
|
|
172
|
+
for (const dialog of root.querySelectorAll<HTMLDialogElement>("dialog[open]")) {
|
|
173
|
+
open.add(dialog);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const dialog of alive(tracked)) {
|
|
178
|
+
if (dialog.open && dialog.isConnected) {
|
|
179
|
+
open.add(dialog);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [...open];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The open `<dialog>` chrome should host into, or `null` when none is open: of
|
|
186
|
+
* every open dialog the library knows of, the `:modal` one that opened last, the
|
|
187
|
+
* one subtree showModal() leaves live; else, when none is `:modal`, the open one
|
|
188
|
+
* that opened last. */
|
|
189
|
+
export function topmostOpenDialog(): HTMLDialogElement | null {
|
|
190
|
+
rank(additions.takeRecords());
|
|
191
|
+
const open = knownOpen();
|
|
192
|
+
return newest(open.filter(isModal)) ?? newest(open);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every dialog trackOpens tracks that is showing as a modal now. */
|
|
196
|
+
export function trackedModals(): HTMLDialogElement[] {
|
|
197
|
+
return [...alive(tracked)].filter(isModal);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Call `clear` once when a modal open now, or opened later, closes, opens again or leaves
|
|
201
|
+
* its tree, before the browser exposes what that modal hid; a later call replaces `clear`.
|
|
202
|
+
* A modal no observer sees, one the helpers do not drive in a shadow root they never
|
|
203
|
+
* reached, is not watched. */
|
|
204
|
+
export function whenUncovered(clear: () => void): void {
|
|
205
|
+
// Called in the change's own task (as under fake timers), records not yet delivered
|
|
206
|
+
// belong to the callback this one replaces.
|
|
207
|
+
rank(additions.takeRecords());
|
|
208
|
+
pendingClear = clear;
|
|
209
|
+
for (const dialog of knownOpen()) {
|
|
210
|
+
if (isModal(dialog)) {
|
|
211
|
+
cover(dialog);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|