@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.
- package/LICENSE +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -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 -0
- 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,500 @@
|
|
|
1
|
+
// dialog-engine.ts — INTERNAL: the one owner of every <dialog> the library
|
|
2
|
+
// drives. openDialog, closeDialog, createDialog, createModal and ask all reach
|
|
3
|
+
// a dialog through its engine, which holds its phase, its live regions, its
|
|
4
|
+
// fade, its closeDialog callbacks and its view of the opens and closes the
|
|
5
|
+
// browser makes on its own. An API that adds behaviour to a dialog
|
|
6
|
+
// does so as the engine's one controller facet, realized inside its passes.
|
|
7
|
+
|
|
8
|
+
import type { Fx } from "./fx.js";
|
|
9
|
+
import { keepRegions } from "./live-regions.js";
|
|
10
|
+
import { trackOpens } from "./modal-host.js";
|
|
11
|
+
import { type SUPERSEDED, createDriver } from "./owner.js";
|
|
12
|
+
import { watchSettle } from "./transition.js";
|
|
13
|
+
|
|
14
|
+
/** A dialog's committed state. */
|
|
15
|
+
interface DialogState {
|
|
16
|
+
readonly phase: "closed" | "open" | "leaving";
|
|
17
|
+
/** Counts leaves; the settle token. */
|
|
18
|
+
readonly leave: number;
|
|
19
|
+
/** The dialog was open when the leave was asked: a leave of one never shown has
|
|
20
|
+
* nothing to fade, and one the engine's own earlier close closed is shown again. */
|
|
21
|
+
readonly shown: boolean;
|
|
22
|
+
/** Counts closed-to-open changes; the initial-focus token. */
|
|
23
|
+
readonly opens: number;
|
|
24
|
+
/** The close a closeDialog callback waits for; moves at every reclaim. */
|
|
25
|
+
readonly cycle: number;
|
|
26
|
+
readonly facet: DialogFacet | null;
|
|
27
|
+
/** The facet's content token (ask's question id; 0 otherwise). */
|
|
28
|
+
readonly subject: number;
|
|
29
|
+
/** A library-built dialog its facet has spent: closed for good. */
|
|
30
|
+
readonly retired: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The part of a dialog's realization one API adds. Every hook runs inside the engine's pass. */
|
|
34
|
+
export interface DialogFacet {
|
|
35
|
+
/** Before the dialog steps, every pass while attached. */
|
|
36
|
+
prepare?(fx: Fx, d: DialogState): void;
|
|
37
|
+
/** After the dialog steps, every pass while attached. */
|
|
38
|
+
follow?(fx: Fx, d: DialogState): void;
|
|
39
|
+
/** After convergence, while attached. */
|
|
40
|
+
notify?(fx: Fx, d: DialogState): void;
|
|
41
|
+
/** After detach, each pass until it returns. Idempotent. */
|
|
42
|
+
cleanup?(fx: Fx): void;
|
|
43
|
+
/** Set the drivers derived from this dialog's state (`null` once detached). Only `set` or `sync`. */
|
|
44
|
+
derive?(d: DialogState | null): void;
|
|
45
|
+
/** Take a refused open instead of the engine reporting it. */
|
|
46
|
+
refused?(subject: number, error: unknown): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DialogEngine {
|
|
50
|
+
readonly state: DialogState;
|
|
51
|
+
/** The phase with every browser change so far folded in. It commits nothing; a
|
|
52
|
+
* change it reads is realized at the next microtask. */
|
|
53
|
+
readonly phase: DialogState["phase"];
|
|
54
|
+
/** The methods below commit at once and run no app code; the caller flushes. */
|
|
55
|
+
open(subject?: number): void;
|
|
56
|
+
close(onClosed?: () => void): void;
|
|
57
|
+
attach(facet: DialogFacet): void;
|
|
58
|
+
detach(facet: DialogFacet): void;
|
|
59
|
+
/** `detach`, and closed for good: for a library-built dialog its facet spends. */
|
|
60
|
+
retire(facet: DialogFacet): void;
|
|
61
|
+
flush(): void;
|
|
62
|
+
consult<R>(fn: () => R): R | typeof SUPERSEDED;
|
|
63
|
+
/** The closeDialog callbacks still held, for tests. */
|
|
64
|
+
// deadset:ignore DS1301 -- the dialog and modal tests read it to prove every closeDialog callback is delivered and none is kept
|
|
65
|
+
readonly outboxSize: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function invalidState(message: string): DOMException {
|
|
69
|
+
return new DOMException(message, "InvalidStateError");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const engines = new WeakMap<HTMLDialogElement, DialogEngine>();
|
|
73
|
+
|
|
74
|
+
/** Each engine's own call under way, told of every `beforetoggle` of its dialog as the
|
|
75
|
+
* dispatch starts. */
|
|
76
|
+
const starts = new WeakMap<HTMLDialogElement, (e: ToggleEvent) => void>();
|
|
77
|
+
const startRoots = new WeakSet<ShadowRoot>();
|
|
78
|
+
|
|
79
|
+
function onStart(e: Event): void {
|
|
80
|
+
if (e.target instanceof HTMLDialogElement) {
|
|
81
|
+
starts.get(e.target)?.(e as ToggleEvent);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A capture listener on the window from load runs before those the page adds later, so
|
|
86
|
+
// an event is seen before code its listeners reach starts a nested one.
|
|
87
|
+
addEventListener("beforetoggle", onStart, true);
|
|
88
|
+
|
|
89
|
+
/** A dialog's `beforetoggle` stays inside its shadow root, so the root is listened on too. */
|
|
90
|
+
function listenForStarts(dialog: HTMLDialogElement): void {
|
|
91
|
+
const root = dialog.getRootNode();
|
|
92
|
+
if (root instanceof ShadowRoot && !startRoots.has(root)) {
|
|
93
|
+
startRoots.add(root);
|
|
94
|
+
root.addEventListener("beforetoggle", onStart, true);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The element's engine when it has one. Creates nothing. */
|
|
99
|
+
export function existingEngine(dialog: HTMLDialogElement): DialogEngine | undefined {
|
|
100
|
+
return engines.get(dialog);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The element's engine, created with its `open` observer on first use. */
|
|
104
|
+
export function dialogEngine(dialog: HTMLDialogElement): DialogEngine {
|
|
105
|
+
let engine = engines.get(dialog);
|
|
106
|
+
if (engine === undefined) {
|
|
107
|
+
engine = createEngine(dialog);
|
|
108
|
+
engines.set(dialog, engine);
|
|
109
|
+
}
|
|
110
|
+
return engine;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface OwnCall {
|
|
114
|
+
readonly state: "open" | "closed";
|
|
115
|
+
event: Event | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface Waiter {
|
|
119
|
+
readonly fn: () => void;
|
|
120
|
+
readonly cycle: number;
|
|
121
|
+
due: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createEngine(dialog: HTMLDialogElement): DialogEngine {
|
|
125
|
+
let watch: { readonly leave: number; readonly cancel: () => void } | null = null;
|
|
126
|
+
// Since the record (the last observation), the page added `open`, removed it,
|
|
127
|
+
// or added it after the element had been open: the level alone cannot show these.
|
|
128
|
+
// The first record is D's initial `closed`, so an element open at creation was opened by the page.
|
|
129
|
+
let pageOpened = dialog.open;
|
|
130
|
+
let pageClosed = false;
|
|
131
|
+
let reopened = false;
|
|
132
|
+
// The element has been open at some moment since the record.
|
|
133
|
+
let seenOpen = dialog.open;
|
|
134
|
+
// The edge the engine's own showModal (true) or close (false) writes next, from the
|
|
135
|
+
// end of its `beforetoggle` dispatch until a record makes it (`own`). The write follows
|
|
136
|
+
// at once and fx's check after the call takes its record, so it is null again when
|
|
137
|
+
// `own` returns; `own` never nests, since one engine's passes do not.
|
|
138
|
+
let expected: boolean | null = null;
|
|
139
|
+
|
|
140
|
+
/** Reduce `open` records, oldest first. A record holds the presence before its
|
|
141
|
+
* write; the next record's, or the live level for the last, is the presence
|
|
142
|
+
* after it, so a write of the value the attribute has is no edge. True when a
|
|
143
|
+
* record is not the engine's own edge: the page wrote `open`. */
|
|
144
|
+
const take = (records: readonly MutationRecord[]): boolean => {
|
|
145
|
+
let page = false;
|
|
146
|
+
for (const [i, r] of records.entries()) {
|
|
147
|
+
const before = r.oldValue !== null;
|
|
148
|
+
const next = records[i + 1];
|
|
149
|
+
const after = next === undefined ? dialog.open : next.oldValue !== null;
|
|
150
|
+
if (before !== after && expected === after) {
|
|
151
|
+
expected = null;
|
|
152
|
+
} else {
|
|
153
|
+
page = true;
|
|
154
|
+
if (!before && after) {
|
|
155
|
+
pageOpened = true;
|
|
156
|
+
reopened ||= seenOpen;
|
|
157
|
+
} else if (before && !after) {
|
|
158
|
+
pageClosed = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
seenOpen = true;
|
|
162
|
+
}
|
|
163
|
+
return page;
|
|
164
|
+
};
|
|
165
|
+
// A microtask checkpoint delivers the records no check took: a page change
|
|
166
|
+
// outside every engine call. It is realized at once, since a dialog fires no
|
|
167
|
+
// `toggle` for some changes (an `open` attribute write, a show() in its own `toggle`).
|
|
168
|
+
const opens = new MutationObserver((records) => {
|
|
169
|
+
take(records);
|
|
170
|
+
realizePageChange();
|
|
171
|
+
});
|
|
172
|
+
opens.observe(dialog, { attributes: true, attributeFilter: ["open"], attributeOldValue: true });
|
|
173
|
+
// From creation, so the page's own opening has a pair before the adoption pass.
|
|
174
|
+
keepRegions(dialog);
|
|
175
|
+
trackOpens(dialog);
|
|
176
|
+
/** The engine's own showModal() or close() under way, and its event once it starts. */
|
|
177
|
+
let call: OwnCall | null = null;
|
|
178
|
+
starts.set(dialog, (e) => {
|
|
179
|
+
if (call !== null && call.event === null && e.newState === call.state) {
|
|
180
|
+
call.event = e;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const onEnd = (e: Event): void => {
|
|
184
|
+
const c = call;
|
|
185
|
+
if (c?.event !== e) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (take(opens.takeRecords())) {
|
|
189
|
+
queueMicrotask(realizePageChange);
|
|
190
|
+
}
|
|
191
|
+
const armed =
|
|
192
|
+
c.state === "open"
|
|
193
|
+
? !e.defaultPrevented &&
|
|
194
|
+
!dialog.open &&
|
|
195
|
+
dialog.isConnected &&
|
|
196
|
+
!dialog.matches(":popover-open")
|
|
197
|
+
: dialog.open;
|
|
198
|
+
expected = armed ? c.state === "open" : null;
|
|
199
|
+
};
|
|
200
|
+
/** Run `fn`, the engine's own showModal() (`edge` true) or close(), so that only
|
|
201
|
+
* the edge it writes is taken as the engine's. Its `beforetoggle` listeners run first
|
|
202
|
+
* and can open or close the dialog themselves: those edges are the page's. When the
|
|
203
|
+
* dialog's last listener sees the call's own event, no code runs before the write
|
|
204
|
+
* (HTML, the showModal() and close-the-dialog steps). Returns the call's result and
|
|
205
|
+
* whether its event fired (C3 reads it). */
|
|
206
|
+
const own = <R>(edge: boolean, fn: () => R): [R, boolean] => {
|
|
207
|
+
const mine: OwnCall = { state: edge ? "open" : "closed", event: null };
|
|
208
|
+
call = mine;
|
|
209
|
+
listenForStarts(dialog);
|
|
210
|
+
// Added again so it runs after every listener the page added since.
|
|
211
|
+
dialog.removeEventListener("beforetoggle", onEnd);
|
|
212
|
+
dialog.addEventListener("beforetoggle", onEnd);
|
|
213
|
+
try {
|
|
214
|
+
const result = fn();
|
|
215
|
+
return [result, mine.event !== null];
|
|
216
|
+
} finally {
|
|
217
|
+
call = null;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
// The last pass ended inside a close of the dialog that C3 could not make: it
|
|
221
|
+
// is not realized until that close finishes, so it notifies nothing.
|
|
222
|
+
let closing = false;
|
|
223
|
+
const prepared = new Set<DialogFacet>();
|
|
224
|
+
let derivedFor: DialogFacet | null = null;
|
|
225
|
+
const outbox: Waiter[] = [];
|
|
226
|
+
|
|
227
|
+
const fold = (d: DialogState): DialogState => {
|
|
228
|
+
// Records taken here never reach the callback: a page change among them is
|
|
229
|
+
// realized at the microtask it would have been, whatever folds it meanwhile.
|
|
230
|
+
if (take(opens.takeRecords())) {
|
|
231
|
+
queueMicrotask(realizePageChange);
|
|
232
|
+
}
|
|
233
|
+
if (d.retired) {
|
|
234
|
+
return d;
|
|
235
|
+
}
|
|
236
|
+
if (!dialog.open) {
|
|
237
|
+
// The page's close is the newest edge, whatever D intends. The cycle stays:
|
|
238
|
+
// the callbacks waiting for that close are due.
|
|
239
|
+
return pageClosed && d.phase !== "closed" ? { ...d, phase: "closed" } : d;
|
|
240
|
+
}
|
|
241
|
+
return reopened || (pageOpened && d.phase === "closed")
|
|
242
|
+
? { ...d, phase: "open", opens: d.opens + 1, cycle: d.cycle + 1 }
|
|
243
|
+
: d;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const cancelWatch = (): void => {
|
|
247
|
+
watch?.cancel();
|
|
248
|
+
watch = null;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const settle = (leave: number): void => {
|
|
252
|
+
driver.update((d) =>
|
|
253
|
+
d.phase === "leaving" && d.leave === leave ? { ...d, phase: "closed" } : d,
|
|
254
|
+
);
|
|
255
|
+
driver.flush();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Cleanup for every facet that has been replaced or detached. */
|
|
259
|
+
const cleanupReplaced = (fx: Fx, current: DialogFacet | null): void => {
|
|
260
|
+
for (const facet of [...prepared]) {
|
|
261
|
+
if (facet !== current) {
|
|
262
|
+
facet.cleanup?.(fx);
|
|
263
|
+
prepared.delete(facet);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Move the element to `d.phase`. False when the outcome committed a new
|
|
269
|
+
* state the next pass realizes. */
|
|
270
|
+
const steps = (fx: Fx, d: DialogState): boolean => {
|
|
271
|
+
closing = false;
|
|
272
|
+
switch (d.phase) {
|
|
273
|
+
case "open": {
|
|
274
|
+
cancelWatch();
|
|
275
|
+
fx.removeClass(dialog, "is-leaving");
|
|
276
|
+
// Since creation the dialog may have moved to another tree, or lost its pair to
|
|
277
|
+
// the app's render.
|
|
278
|
+
trackOpens(dialog);
|
|
279
|
+
keepRegions(dialog);
|
|
280
|
+
const [shown] = own(true, () => fx.showModal(dialog));
|
|
281
|
+
if (shown.open) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (d.facet?.refused !== undefined) {
|
|
285
|
+
d.facet.refused(d.subject, shown.error);
|
|
286
|
+
} else if (shown.error !== undefined) {
|
|
287
|
+
fx.report(shown.error);
|
|
288
|
+
}
|
|
289
|
+
driver.set({ ...d, phase: "closed" });
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
case "leaving": {
|
|
293
|
+
if (!dialog.open && d.shown) {
|
|
294
|
+
// The engine's own close of an earlier state closed it; a failed show leaves
|
|
295
|
+
// nothing to fade.
|
|
296
|
+
own(true, () => fx.showModal(dialog));
|
|
297
|
+
}
|
|
298
|
+
if (!dialog.open) {
|
|
299
|
+
driver.set({ ...d, phase: "closed" });
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (!dialog.classList.contains("is-leaving")) {
|
|
303
|
+
// The start state, flushed so the class change animates from it.
|
|
304
|
+
dialog.getBoundingClientRect();
|
|
305
|
+
fx.addClass(dialog, "is-leaving");
|
|
306
|
+
}
|
|
307
|
+
if (watch?.leave !== d.leave) {
|
|
308
|
+
cancelWatch();
|
|
309
|
+
const leave = d.leave;
|
|
310
|
+
watch = {
|
|
311
|
+
leave,
|
|
312
|
+
cancel: watchSettle(dialog, () => {
|
|
313
|
+
settle(leave);
|
|
314
|
+
}),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
case "closed": {
|
|
320
|
+
cancelWatch();
|
|
321
|
+
fx.removeClass(dialog, "is-leaving");
|
|
322
|
+
const [, fired] = own(false, () => {
|
|
323
|
+
fx.close(dialog);
|
|
324
|
+
});
|
|
325
|
+
if (!dialog.open) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (!fired) {
|
|
329
|
+
// Chromium ignores a close while the dialog's own close is in progress
|
|
330
|
+
// (`is_closing_` in blink's html_dialog_element.cc). That close closes it
|
|
331
|
+
// when this call returns, and the pass its records start completes this one.
|
|
332
|
+
closing = true;
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
// Code the close reached opened it again. fx's check folded that open and
|
|
336
|
+
// restarted the pass, unless the engine is retired and follows no edge:
|
|
337
|
+
// then close it again.
|
|
338
|
+
driver.invalidate();
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const driver = createDriver<DialogState>({
|
|
345
|
+
initial: {
|
|
346
|
+
phase: "closed",
|
|
347
|
+
leave: 0,
|
|
348
|
+
shown: false,
|
|
349
|
+
opens: 0,
|
|
350
|
+
cycle: 0,
|
|
351
|
+
facet: null,
|
|
352
|
+
subject: 0,
|
|
353
|
+
retired: false,
|
|
354
|
+
},
|
|
355
|
+
observe(d) {
|
|
356
|
+
const next = fold(d);
|
|
357
|
+
pageOpened = false;
|
|
358
|
+
pageClosed = false;
|
|
359
|
+
reopened = false;
|
|
360
|
+
seenOpen = dialog.open;
|
|
361
|
+
return next;
|
|
362
|
+
},
|
|
363
|
+
derive(d) {
|
|
364
|
+
if (d.facet !== derivedFor) {
|
|
365
|
+
const previous = derivedFor;
|
|
366
|
+
derivedFor = d.facet;
|
|
367
|
+
previous?.derive?.(null);
|
|
368
|
+
}
|
|
369
|
+
d.facet?.derive?.(d);
|
|
370
|
+
},
|
|
371
|
+
realize(fx, d) {
|
|
372
|
+
const facet = d.facet;
|
|
373
|
+
if (facet !== null) {
|
|
374
|
+
// A replaced facet's cleanup runs before its successor's prepare: they
|
|
375
|
+
// write the same element.
|
|
376
|
+
cleanupReplaced(fx, facet);
|
|
377
|
+
prepared.add(facet);
|
|
378
|
+
facet.prepare?.(fx, d);
|
|
379
|
+
}
|
|
380
|
+
if (!steps(fx, d)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (facet === null) {
|
|
384
|
+
// After the steps: a modal's cleanup removes the dialog it has just closed.
|
|
385
|
+
cleanupReplaced(fx, null);
|
|
386
|
+
} else {
|
|
387
|
+
facet.follow?.(fx, d);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
notify(fx, d) {
|
|
391
|
+
if (closing) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// First, so a hook that reopens or disposes cannot cancel a close that completed.
|
|
395
|
+
if (d.phase === "closed") {
|
|
396
|
+
for (const w of outbox) {
|
|
397
|
+
if (w.cycle === d.cycle) {
|
|
398
|
+
w.due = true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
d.facet?.notify?.(fx, d);
|
|
403
|
+
for (let i = outbox.length - 1; i >= 0; i--) {
|
|
404
|
+
const w = outbox[i];
|
|
405
|
+
if (w !== undefined && !w.due && w.cycle !== d.cycle) {
|
|
406
|
+
outbox.splice(i, 1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Removed before its call, so a restart never delivers a callback twice.
|
|
410
|
+
for (;;) {
|
|
411
|
+
const i = outbox.findIndex((w) => w.due);
|
|
412
|
+
const w = outbox[i];
|
|
413
|
+
if (w === undefined) {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
outbox.splice(i, 1);
|
|
417
|
+
fx.call(w.fn);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// The element may have changed and back again while a dependent read it live
|
|
423
|
+
// (the scroll lock): realize again, whatever the records fold to.
|
|
424
|
+
function realizePageChange(): void {
|
|
425
|
+
driver.invalidate();
|
|
426
|
+
driver.sync();
|
|
427
|
+
driver.flush();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const detachTo = (facet: DialogFacet, retired: boolean): void => {
|
|
431
|
+
driver.update((d) =>
|
|
432
|
+
d.facet !== facet
|
|
433
|
+
? d
|
|
434
|
+
: {
|
|
435
|
+
...d,
|
|
436
|
+
facet: null,
|
|
437
|
+
phase: "closed",
|
|
438
|
+
// Teardown closes at once with no fade: callbacks waiting for one are dropped.
|
|
439
|
+
cycle: d.phase === "closed" ? d.cycle : d.cycle + 1,
|
|
440
|
+
retired: d.retired || retired,
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
get state(): DialogState {
|
|
447
|
+
return driver.state;
|
|
448
|
+
},
|
|
449
|
+
get phase(): DialogState["phase"] {
|
|
450
|
+
return fold(driver.state).phase;
|
|
451
|
+
},
|
|
452
|
+
open(subject) {
|
|
453
|
+
if (driver.state.retired) {
|
|
454
|
+
throw invalidState("The dialog's controller is disposed");
|
|
455
|
+
}
|
|
456
|
+
driver.update((d) => {
|
|
457
|
+
const s = subject ?? d.subject;
|
|
458
|
+
if (d.phase === "closed") {
|
|
459
|
+
return { ...d, phase: "open", opens: d.opens + 1, cycle: d.cycle + 1, subject: s };
|
|
460
|
+
}
|
|
461
|
+
if (d.phase === "leaving") {
|
|
462
|
+
// A reopen drops the callbacks waiting for this fade.
|
|
463
|
+
return { ...d, phase: "open", cycle: d.cycle + 1, subject: s };
|
|
464
|
+
}
|
|
465
|
+
return d.subject === s ? d : { ...d, subject: s };
|
|
466
|
+
});
|
|
467
|
+
},
|
|
468
|
+
close(onClosed) {
|
|
469
|
+
driver.update((d) =>
|
|
470
|
+
d.phase === "open" ? { ...d, phase: "leaving", leave: d.leave + 1, shown: dialog.open } : d,
|
|
471
|
+
);
|
|
472
|
+
if (onClosed !== undefined) {
|
|
473
|
+
const d = driver.state;
|
|
474
|
+
outbox.push({ fn: onClosed, cycle: d.cycle, due: d.phase === "closed" && !dialog.open });
|
|
475
|
+
// The outbox is work the state does not describe: the flush must drive.
|
|
476
|
+
driver.invalidate();
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
attach(facet) {
|
|
480
|
+
const d = driver.state;
|
|
481
|
+
if (d.retired || d.facet !== null) {
|
|
482
|
+
throw invalidState("The dialog already has a controller");
|
|
483
|
+
}
|
|
484
|
+
driver.update((s) => ({ ...s, facet }));
|
|
485
|
+
},
|
|
486
|
+
detach(facet) {
|
|
487
|
+
detachTo(facet, false);
|
|
488
|
+
},
|
|
489
|
+
retire(facet) {
|
|
490
|
+
detachTo(facet, true);
|
|
491
|
+
},
|
|
492
|
+
flush: () => {
|
|
493
|
+
driver.flush();
|
|
494
|
+
},
|
|
495
|
+
consult: (fn) => driver.consult(fn),
|
|
496
|
+
get outboxSize(): number {
|
|
497
|
+
return outbox.length;
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
}
|