@cplieger/ui 0.0.0-stage → 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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +3 -0
  3. package/README.md +157 -2
  4. package/THIRD_PARTY_NOTICES.md +8 -0
  5. package/css/ui-primitives.css +344 -0
  6. package/jsr.json +44 -0
  7. package/package.json +105 -4
  8. package/src/actions/api.ts +268 -0
  9. package/src/actions/async-feedback.ts +217 -0
  10. package/src/actions/cleanup.ts +66 -0
  11. package/src/actions/debounce.ts +150 -0
  12. package/src/actions/define-helpers.ts +87 -0
  13. package/src/actions/define.ts +851 -0
  14. package/src/actions/error.ts +161 -0
  15. package/src/actions/index.ts +60 -0
  16. package/src/actions/loading.ts +131 -0
  17. package/src/actions/notifier.ts +61 -0
  18. package/src/actions/poll-until.ts +133 -0
  19. package/src/actions/poll.ts +156 -0
  20. package/src/actions/registry.ts +259 -0
  21. package/src/actions/retry.ts +79 -0
  22. package/src/actions/testing.ts +29 -0
  23. package/src/actions/transport.ts +104 -0
  24. package/src/actions/types.ts +249 -0
  25. package/src/dom/bind-list.ts +103 -0
  26. package/src/dom/el.ts +67 -0
  27. package/src/dom/index.ts +9 -0
  28. package/src/dom/reconcile-tree.ts +268 -0
  29. package/src/dom/reconcile.ts +76 -0
  30. package/src/fetch/index.ts +18 -0
  31. package/src/fetch/instance.ts +37 -0
  32. package/src/fetch/request.ts +437 -0
  33. package/src/fetch/timeout.ts +67 -0
  34. package/src/fetch/types.ts +206 -0
  35. package/src/fetch/verbs.ts +103 -0
  36. package/src/index.ts +4 -0
  37. package/src/primitives/announce.ts +134 -0
  38. package/src/primitives/ask.ts +406 -0
  39. package/src/primitives/dialog-engine.ts +500 -0
  40. package/src/primitives/dialog.ts +263 -0
  41. package/src/primitives/disclosure.ts +287 -0
  42. package/src/primitives/focus-trap.ts +157 -0
  43. package/src/primitives/fx.ts +204 -0
  44. package/src/primitives/live-regions.ts +83 -0
  45. package/src/primitives/modal-host.ts +214 -0
  46. package/src/primitives/modal.ts +247 -0
  47. package/src/primitives/owner.ts +322 -0
  48. package/src/primitives/popover.ts +500 -0
  49. package/src/primitives/popup-core.ts +603 -0
  50. package/src/primitives/popup.ts +23 -0
  51. package/src/primitives/roving-focus.ts +198 -0
  52. package/src/primitives/scroll-lock.ts +119 -0
  53. package/src/primitives/skeleton.ts +142 -0
  54. package/src/primitives/theme.ts +202 -0
  55. package/src/primitives/toast/engine.ts +327 -0
  56. package/src/primitives/toast/index.ts +139 -0
  57. package/src/primitives/toast/view.ts +244 -0
  58. package/src/primitives/tooltip.ts +377 -0
  59. package/src/primitives/transition.ts +119 -0
  60. package/src/primitives/view-transition.ts +57 -0
  61. package/src/signal/bus.ts +126 -0
  62. package/src/signal/collection.ts +182 -0
  63. package/src/signal/index.ts +34 -0
  64. package/src/signal/resource.ts +102 -0
  65. package/src/signal/signal-map.ts +46 -0
  66. package/src/signal/signal.ts +1002 -0
  67. 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
+ }