@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,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modal.ts — Native-`<dialog>` modal built from caller content. `dialog` wraps
|
|
3
|
+
* an EXISTING `<dialog>`; `modal` builds one from arbitrary content. Adds ARIA
|
|
4
|
+
* wiring, drag-safe backdrop dismissal, the shared fade-out lifecycle, and an
|
|
5
|
+
* iOS-safe scroll-lock (`scroll-lock.ts`).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { el } from "../dom/el.js";
|
|
11
|
+
import { untracked } from "../signal/signal.js";
|
|
12
|
+
|
|
13
|
+
import { listenBackdrop } from "./dialog.js";
|
|
14
|
+
import { dialogEngine, type DialogEngine, type DialogFacet } from "./dialog-engine.js";
|
|
15
|
+
import { SUPERSEDED, ownedBinding } from "./owner.js";
|
|
16
|
+
import { _resetScrollLockForTest, flushScrollLock, holdScrollLock } from "./scroll-lock.js";
|
|
17
|
+
|
|
18
|
+
/** Everything {@link createModal} needs beyond the content: the dismissal
|
|
19
|
+
* gestures and their veto, the ARIA role and the label/description ids (both
|
|
20
|
+
* auto-detected from a `-title` / `-desc` suffix when omitted), initial focus,
|
|
21
|
+
* the background scroll lock, and a post-close hook. All optional. */
|
|
22
|
+
export interface ModalOptions {
|
|
23
|
+
/** Close when the backdrop is clicked (drag-safe). Default `true`. */
|
|
24
|
+
closeOnBackdrop?: boolean;
|
|
25
|
+
/** Close on Escape. Default `true`. */
|
|
26
|
+
closeOnEscape?: boolean;
|
|
27
|
+
/** ARIA role for the dialog. Default `"dialog"` (the <dialog> implicit role);
|
|
28
|
+
* `"alertdialog"` sets the role + the `.uip-modal--alert` modifier. */
|
|
29
|
+
role?: "dialog" | "alertdialog";
|
|
30
|
+
/** `id` for `aria-labelledby`. When omitted, a descendant whose `id` ends in
|
|
31
|
+
* `-title` is auto-detected. */
|
|
32
|
+
labelledBy?: string;
|
|
33
|
+
/** `id` for `aria-describedby`. When omitted, a descendant whose `id` ends in
|
|
34
|
+
* `-desc` / `-description` is auto-detected. */
|
|
35
|
+
describedBy?: string;
|
|
36
|
+
/** Element to focus on open. Omit to leave focus to the platform (the first
|
|
37
|
+
* focusable / an `autofocus` element inside the dialog). */
|
|
38
|
+
initialFocus?: HTMLElement | null;
|
|
39
|
+
/** Dismiss guard, consulted on every USER dismissal attempt (backdrop click,
|
|
40
|
+
* Escape): return `false` to refuse it and keep the modal open. The wiring
|
|
41
|
+
* stays armed, so later attempts re-consult the guard. Programmatic
|
|
42
|
+
* `close()` is unaffected. Omitted = always dismissible. */
|
|
43
|
+
canDismiss?: () => boolean;
|
|
44
|
+
/** Lock background page scroll while open, ref-counted across nested modals.
|
|
45
|
+
* iOS-safe (position:fixed body + scroll restore). Default `true`. */
|
|
46
|
+
scrollLock?: boolean;
|
|
47
|
+
/** Invoked once each time the modal goes from shown to closed, whoever
|
|
48
|
+
* closed it. */
|
|
49
|
+
onClose?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Handle on a modal built from caller content. The `<dialog>` is built once
|
|
53
|
+
* and reused across open/close, so a controller is worth keeping for the
|
|
54
|
+
* lifetime of the thing it belongs to and disposing with it. */
|
|
55
|
+
export interface ModalController {
|
|
56
|
+
/** Show as a platform modal — focus containment, top layer and background
|
|
57
|
+
* inerting come from `showModal()` — and take the scroll lock. Throws an
|
|
58
|
+
* `InvalidStateError` once `dispose()` has begun; a refused `showModal()`
|
|
59
|
+
* throws as `openDialog` does, with no scroll lock held. */
|
|
60
|
+
open(): void;
|
|
61
|
+
/** Close through the fade-out lifecycle. Programmatic, so `canDismiss` does
|
|
62
|
+
* not apply. */
|
|
63
|
+
close(): void;
|
|
64
|
+
/** The `<dialog>` this controller built, with the caller's content inside.
|
|
65
|
+
* `openDialog(el)` and `closeDialog(el)` drive this modal as `open()` and
|
|
66
|
+
* `close()` do, and `createDialog(el)` throws. `dispose()` spends it: the
|
|
67
|
+
* node, and the caller's content with it, leaves the document, while this
|
|
68
|
+
* reference keeps reading it back. */
|
|
69
|
+
readonly el: HTMLDialogElement;
|
|
70
|
+
readonly isOpen: boolean;
|
|
71
|
+
/** Unwire the dismissal listeners, close immediately (no fade), release the
|
|
72
|
+
* scroll lock, and remove the dialog from the document. */
|
|
73
|
+
dispose(): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Auto-detect an accessible-name target: a descendant whose id ends `-title`. */
|
|
77
|
+
function autoLabelId(content: HTMLElement): string | null {
|
|
78
|
+
const titled = content.querySelector<HTMLElement>("[id$='-title']");
|
|
79
|
+
return titled !== null && titled.id !== "" ? titled.id : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Auto-detect a description target: a descendant whose id ends `-desc` or
|
|
83
|
+
* `-description`. */
|
|
84
|
+
function autoDescribeId(content: HTMLElement): string | null {
|
|
85
|
+
const described = content.querySelector<HTMLElement>("[id$='-desc'], [id$='-description']");
|
|
86
|
+
return described !== null && described.id !== "" ? described.id : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build a modal from `content`: wrap it in a native `<dialog class="uip-modal">`
|
|
90
|
+
* appended to `<body>`, and return a controller. The platform provides focus
|
|
91
|
+
* containment, the top layer, background inerting, Escape, nested stacking, and
|
|
92
|
+
* focus-return-to-opener; this adds ARIA wiring, drag-safe backdrop dismissal,
|
|
93
|
+
* the fade-out lifecycle, and an iOS-safe scroll-lock. */
|
|
94
|
+
export function createModal(content: HTMLElement, opts?: ModalOptions): ModalController {
|
|
95
|
+
const o = untracked(() => ({
|
|
96
|
+
closeOnBackdrop: opts?.closeOnBackdrop ?? true,
|
|
97
|
+
closeOnEscape: opts?.closeOnEscape ?? true,
|
|
98
|
+
role: opts?.role ?? "dialog",
|
|
99
|
+
scrollLock: opts?.scrollLock ?? true,
|
|
100
|
+
onClose: opts?.onClose,
|
|
101
|
+
canDismiss: opts?.canDismiss,
|
|
102
|
+
initialFocus: opts?.initialFocus ?? null,
|
|
103
|
+
labelledBy: opts?.labelledBy,
|
|
104
|
+
describedBy: opts?.describedBy,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
return ownedBinding((binding) => {
|
|
108
|
+
const labelledBy = o.labelledBy ?? autoLabelId(content);
|
|
109
|
+
const describedBy = o.describedBy ?? autoDescribeId(content);
|
|
110
|
+
const dialog = el("dialog", {
|
|
111
|
+
className: o.role === "alertdialog" ? "uip-modal uip-modal--alert" : "uip-modal",
|
|
112
|
+
...(o.role === "alertdialog" ? { role: "alertdialog" } : {}),
|
|
113
|
+
...(labelledBy === null ? {} : { "aria-labelledby": labelledBy }),
|
|
114
|
+
...(describedBy === null ? {} : { "aria-describedby": describedBy }),
|
|
115
|
+
}) as HTMLDialogElement;
|
|
116
|
+
const me = Symbol("modal");
|
|
117
|
+
let engine: DialogEngine | null = null;
|
|
118
|
+
let disarm: (() => void) | null = null;
|
|
119
|
+
let focusedFor = 0;
|
|
120
|
+
let reported: "shown" | "closed" = "closed";
|
|
121
|
+
|
|
122
|
+
const dismiss = (): void => {
|
|
123
|
+
const e = engine;
|
|
124
|
+
if (binding.ended || e?.phase !== "open") {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (o.canDismiss !== undefined) {
|
|
128
|
+
const answer = e.consult(o.canDismiss);
|
|
129
|
+
if (answer === SUPERSEDED || !answer) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
e.close();
|
|
134
|
+
e.flush();
|
|
135
|
+
};
|
|
136
|
+
const onCancel = (e: Event): void => {
|
|
137
|
+
// A descendant's bubbling `cancel` is not an Escape (see createDialog).
|
|
138
|
+
if (e.target !== dialog) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (o.closeOnEscape) {
|
|
143
|
+
dismiss();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const facet: DialogFacet = {
|
|
148
|
+
prepare(fx) {
|
|
149
|
+
if (disarm === null) {
|
|
150
|
+
dialog.addEventListener("cancel", onCancel);
|
|
151
|
+
const removeBackdrop = o.closeOnBackdrop ? listenBackdrop(dialog, dismiss) : null;
|
|
152
|
+
disarm = () => {
|
|
153
|
+
dialog.removeEventListener("cancel", onCancel);
|
|
154
|
+
removeBackdrop?.();
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
fx.addClass(content, "uip-modal-dialog");
|
|
158
|
+
if (content.parentNode !== dialog) {
|
|
159
|
+
fx.append(dialog, content);
|
|
160
|
+
}
|
|
161
|
+
if (!dialog.isConnected) {
|
|
162
|
+
fx.append(document.body, dialog);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
derive(d) {
|
|
166
|
+
if (!o.scrollLock) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const e = engine;
|
|
170
|
+
// Read live at every lock check, so a close the browser makes stops the lock's writes.
|
|
171
|
+
holdScrollLock(me, d === null || e === null ? null : () => e.phase !== "closed");
|
|
172
|
+
},
|
|
173
|
+
follow(fx, d) {
|
|
174
|
+
if (o.scrollLock) {
|
|
175
|
+
fx.call(flushScrollLock);
|
|
176
|
+
}
|
|
177
|
+
const target = o.initialFocus;
|
|
178
|
+
if (d.phase === "open" && focusedFor !== d.opens) {
|
|
179
|
+
focusedFor = d.opens;
|
|
180
|
+
if (target?.isConnected === true) {
|
|
181
|
+
fx.focus(target);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
notify(fx, d) {
|
|
186
|
+
const shown = d.phase !== "closed";
|
|
187
|
+
if (reported === "shown" && !shown) {
|
|
188
|
+
reported = "closed";
|
|
189
|
+
if (o.onClose !== undefined) {
|
|
190
|
+
fx.call(o.onClose);
|
|
191
|
+
}
|
|
192
|
+
} else if (shown) {
|
|
193
|
+
reported = "shown";
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
cleanup(fx) {
|
|
197
|
+
disarm?.();
|
|
198
|
+
disarm = null;
|
|
199
|
+
if (o.scrollLock) {
|
|
200
|
+
fx.call(flushScrollLock);
|
|
201
|
+
}
|
|
202
|
+
fx.removeClass(content, "uip-modal-dialog");
|
|
203
|
+
fx.remove(dialog);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
controller: {
|
|
209
|
+
el: dialog,
|
|
210
|
+
get isOpen(): boolean {
|
|
211
|
+
return !binding.ended && engine?.phase === "open";
|
|
212
|
+
},
|
|
213
|
+
open(): void {
|
|
214
|
+
if (binding.ended || engine === null) {
|
|
215
|
+
throw new DOMException("The modal is disposed", "InvalidStateError");
|
|
216
|
+
}
|
|
217
|
+
engine.open();
|
|
218
|
+
engine.flush();
|
|
219
|
+
},
|
|
220
|
+
close(): void {
|
|
221
|
+
if (!binding.ended && engine !== null) {
|
|
222
|
+
engine.close();
|
|
223
|
+
engine.flush();
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
dispose: binding.dispose,
|
|
227
|
+
},
|
|
228
|
+
start: () => {
|
|
229
|
+
const e = dialogEngine(dialog);
|
|
230
|
+
engine = e;
|
|
231
|
+
e.attach(facet);
|
|
232
|
+
e.flush();
|
|
233
|
+
},
|
|
234
|
+
release: () => {
|
|
235
|
+
if (engine !== null) {
|
|
236
|
+
engine.retire(facet);
|
|
237
|
+
engine.flush();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Test-only: restore any active scroll-lock and reset shared state. */
|
|
245
|
+
export function _resetForTest(): void {
|
|
246
|
+
_resetScrollLockForTest();
|
|
247
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// owner.ts — INTERNAL: the driver every controller is built on, and the owned
|
|
2
|
+
// binding that ends a controller with the reactive owner current at its call.
|
|
3
|
+
//
|
|
4
|
+
// A driver holds one committed desired state and realizes it through checked
|
|
5
|
+
// external calls (fx.ts). Code outside the library runs only inside those
|
|
6
|
+
// calls; when it changes the state, folds a platform change into it, kicks
|
|
7
|
+
// after the pass measured, or releases the driver, the pass is stale and the
|
|
8
|
+
// loop restarts realization from the newest state before its next effect.
|
|
9
|
+
|
|
10
|
+
import { createRoot, untracked } from "../signal/signal.js";
|
|
11
|
+
|
|
12
|
+
import { fxAt, type Fx } from "./fx.js";
|
|
13
|
+
|
|
14
|
+
/** Returned by `consult` when the state moved or the driver was released while the guard ran. */
|
|
15
|
+
export const SUPERSEDED: unique symbol = Symbol("superseded");
|
|
16
|
+
|
|
17
|
+
/** Thrown by a pass's checks and caught only by the drive or act that made that pass. */
|
|
18
|
+
const STALE: Error = Object.freeze(new Error("ui-primitives: stale pass"));
|
|
19
|
+
|
|
20
|
+
/** A restart needs app code to change the state again inside this driver's own
|
|
21
|
+
* calls, so this many in one drive is a loop the app made; React bounds the
|
|
22
|
+
* same mechanism at 50 (NESTED_UPDATE_LIMIT in react-reconciler's ReactFiberWorkLoop.js). */
|
|
23
|
+
const RESTART_LIMIT = 50;
|
|
24
|
+
|
|
25
|
+
/** How a driver realizes its state. */
|
|
26
|
+
interface DriverSpec<S> {
|
|
27
|
+
readonly initial: S;
|
|
28
|
+
/** Move the DOM toward `s`; read the DOM only to diff it and to see a platform
|
|
29
|
+
* call's outcome, and write only what differs. */
|
|
30
|
+
realize(fx: Fx, s: S): void;
|
|
31
|
+
/** Realize the released target: give back every resource and call no user hook. */
|
|
32
|
+
terminal?(fx: Fx, s: S): void;
|
|
33
|
+
/** After a converged pass: report the net change since the last report. */
|
|
34
|
+
notify?(fx: Fx, s: S): void;
|
|
35
|
+
/** Set the drivers whose state derives from this one. Runs inside every state
|
|
36
|
+
* change and the release, so it may only call `set` or `sync`. */
|
|
37
|
+
derive?(s: S, released: boolean): void;
|
|
38
|
+
/** Fold a change the platform made on its own into `s`, recording it as seen.
|
|
39
|
+
* Runs no app code; returns `s` itself when there is none. */
|
|
40
|
+
observe?(s: S): S;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One desired state, its revision and its realization loop. */
|
|
44
|
+
export interface Driver<S> {
|
|
45
|
+
readonly state: S;
|
|
46
|
+
// deadset:ignore DS1301 -- the driver tests read it to tell a released driver from a live one after a pass
|
|
47
|
+
readonly released: boolean;
|
|
48
|
+
/** Store `next` and move the revision without starting a drive. */
|
|
49
|
+
set(next: S): void;
|
|
50
|
+
/** Drive now when idle and the current revision or kick is not yet realized. */
|
|
51
|
+
flush(): void;
|
|
52
|
+
/** `set`, then `flush`. */
|
|
53
|
+
commit(next: S): void;
|
|
54
|
+
/** One `set` of `f` applied to the state with any platform change folded in. */
|
|
55
|
+
update(f: (s: S) => S): void;
|
|
56
|
+
/** Fold any platform change into the state with one `set`. */
|
|
57
|
+
sync(): void;
|
|
58
|
+
/** An outside fact that realization measures has changed. */
|
|
59
|
+
kick(): void;
|
|
60
|
+
/** Make the terminal target current. Idempotent. */
|
|
61
|
+
release(): void;
|
|
62
|
+
/** Move the revision with no state change: realization has work the state does not describe. */
|
|
63
|
+
invalidate(): void;
|
|
64
|
+
/** Call a guard whose answer decides the next state; SUPERSEDED when the state moved
|
|
65
|
+
* meanwhile, a platform change included, or the driver was released. */
|
|
66
|
+
consult<R>(fn: () => R): R | typeof SUPERSEDED;
|
|
67
|
+
/** One stateless effect now; refused once released. */
|
|
68
|
+
act(body: (fx: Fx) => void): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rethrow(errors: readonly unknown[]): void {
|
|
72
|
+
if (errors.length === 1) {
|
|
73
|
+
throw errors[0];
|
|
74
|
+
}
|
|
75
|
+
if (errors.length > 1) {
|
|
76
|
+
throw new AggregateError(errors, "ui-primitives: several callbacks threw");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collector(errors: unknown[]): (error: unknown) => void {
|
|
81
|
+
return (error) => {
|
|
82
|
+
if (error === STALE) {
|
|
83
|
+
throw STALE;
|
|
84
|
+
}
|
|
85
|
+
errors.push(error);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Create a driver; nothing runs until its first `flush`. */
|
|
90
|
+
export function createDriver<S>(spec: DriverSpec<S>): Driver<S> {
|
|
91
|
+
let state = spec.initial;
|
|
92
|
+
let revision = 0;
|
|
93
|
+
let realized = -1;
|
|
94
|
+
let kicks = 0;
|
|
95
|
+
let realizedKicks = 0;
|
|
96
|
+
let released = false;
|
|
97
|
+
let driving = false;
|
|
98
|
+
|
|
99
|
+
const set = (next: S): void => {
|
|
100
|
+
if (released || Object.is(state, next)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
state = next;
|
|
104
|
+
revision++;
|
|
105
|
+
spec.derive?.(state, false);
|
|
106
|
+
};
|
|
107
|
+
const sync = (): void => {
|
|
108
|
+
if (spec.observe !== undefined) {
|
|
109
|
+
set(spec.observe(state));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const drive = (): void => {
|
|
114
|
+
driving = true;
|
|
115
|
+
const errors: unknown[] = [];
|
|
116
|
+
let passes = 0;
|
|
117
|
+
try {
|
|
118
|
+
for (;;) {
|
|
119
|
+
sync();
|
|
120
|
+
const r = revision;
|
|
121
|
+
let measured: number | null = null;
|
|
122
|
+
const stale = (): boolean => revision !== r || (measured !== null && kicks !== measured);
|
|
123
|
+
const fx = fxAt({
|
|
124
|
+
check: () => {
|
|
125
|
+
sync();
|
|
126
|
+
if (stale()) {
|
|
127
|
+
throw STALE;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
measured: () => {
|
|
131
|
+
measured ??= kicks;
|
|
132
|
+
},
|
|
133
|
+
collect: collector(errors),
|
|
134
|
+
});
|
|
135
|
+
let restarted = false;
|
|
136
|
+
try {
|
|
137
|
+
untracked(() => {
|
|
138
|
+
if (released) {
|
|
139
|
+
spec.terminal?.(fx, state);
|
|
140
|
+
} else {
|
|
141
|
+
spec.realize(fx, state);
|
|
142
|
+
if (!stale()) {
|
|
143
|
+
spec.notify?.(fx, state);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error !== STALE) {
|
|
149
|
+
errors.unshift(error);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
restarted = true;
|
|
153
|
+
}
|
|
154
|
+
if (!restarted && !stale()) {
|
|
155
|
+
realized = r;
|
|
156
|
+
realizedKicks = kicks;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
// A released driver cannot restart, so its terminal pass is never cut off.
|
|
160
|
+
if (!released && ++passes > RESTART_LIMIT) {
|
|
161
|
+
errors.push(new Error("ui-primitives: a controller's realization did not converge"));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
driving = false;
|
|
167
|
+
}
|
|
168
|
+
rethrow(errors);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const flush = (): void => {
|
|
172
|
+
if (!driving && (revision !== realized || kicks !== realizedKicks)) {
|
|
173
|
+
drive();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
get state(): S {
|
|
179
|
+
return state;
|
|
180
|
+
},
|
|
181
|
+
get released(): boolean {
|
|
182
|
+
return released;
|
|
183
|
+
},
|
|
184
|
+
set,
|
|
185
|
+
sync,
|
|
186
|
+
flush,
|
|
187
|
+
commit(next) {
|
|
188
|
+
set(next);
|
|
189
|
+
flush();
|
|
190
|
+
},
|
|
191
|
+
update(f) {
|
|
192
|
+
set(f(spec.observe === undefined ? state : spec.observe(state)));
|
|
193
|
+
},
|
|
194
|
+
kick() {
|
|
195
|
+
if (released) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
kicks++;
|
|
199
|
+
flush();
|
|
200
|
+
},
|
|
201
|
+
release() {
|
|
202
|
+
if (released) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
released = true;
|
|
206
|
+
revision++;
|
|
207
|
+
spec.derive?.(state, true);
|
|
208
|
+
flush();
|
|
209
|
+
},
|
|
210
|
+
invalidate() {
|
|
211
|
+
if (!released) {
|
|
212
|
+
revision++;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
consult(fn) {
|
|
216
|
+
// Folded on both sides, so a platform change the guard made counts as a move.
|
|
217
|
+
sync();
|
|
218
|
+
const r = revision;
|
|
219
|
+
const answer = untracked(fn);
|
|
220
|
+
sync();
|
|
221
|
+
return released || revision !== r ? SUPERSEDED : answer;
|
|
222
|
+
},
|
|
223
|
+
act(body) {
|
|
224
|
+
if (released) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const errors: unknown[] = [];
|
|
228
|
+
const fx = fxAt({
|
|
229
|
+
check: () => {
|
|
230
|
+
if (released) {
|
|
231
|
+
throw STALE;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
measured: () => undefined,
|
|
235
|
+
collect: collector(errors),
|
|
236
|
+
});
|
|
237
|
+
// While the effect runs, a release or commit from code it reaches only sets,
|
|
238
|
+
// as in a drive; the drive runs once the effect returns.
|
|
239
|
+
const outer = driving;
|
|
240
|
+
driving = true;
|
|
241
|
+
try {
|
|
242
|
+
untracked(() => {
|
|
243
|
+
body(fx);
|
|
244
|
+
});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error !== STALE) {
|
|
247
|
+
errors.unshift(error);
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
driving = outer;
|
|
251
|
+
}
|
|
252
|
+
if (!outer) {
|
|
253
|
+
try {
|
|
254
|
+
flush();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
errors.push(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
rethrow(errors);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** What `make` hands back to `ownedBinding`. */
|
|
265
|
+
export interface Binding {
|
|
266
|
+
/** True from the instruction that requested the release. */
|
|
267
|
+
readonly ended: boolean;
|
|
268
|
+
/** Release, then dispose the binding's root. Idempotent. */
|
|
269
|
+
readonly dispose: () => void;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface BindingParts<T> {
|
|
273
|
+
readonly controller: T;
|
|
274
|
+
/** Realize the initial state. Runs after the root is made, outside it and untracked. */
|
|
275
|
+
readonly start: () => void;
|
|
276
|
+
/** Commit the terminal target into every driver this binding feeds. */
|
|
277
|
+
readonly release: () => void;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A binding that ends with the `@cplieger/ui/signal` owner current at the call.
|
|
281
|
+
* `make` must run no code outside the library: under an owner being disposed
|
|
282
|
+
* this throws before it runs. A teardown error from an owner's disposal goes
|
|
283
|
+
* to reactive's effect error handler; one from `dispose()` leaves `dispose()`. */
|
|
284
|
+
export function ownedBinding<T>(make: (binding: Binding) => BindingParts<T>): T {
|
|
285
|
+
let ended = false;
|
|
286
|
+
let parts: BindingParts<T> | null = null;
|
|
287
|
+
let disposeRoot = (): void => undefined;
|
|
288
|
+
const requestRelease = (): void => {
|
|
289
|
+
if (ended) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
ended = true;
|
|
293
|
+
parts?.release();
|
|
294
|
+
};
|
|
295
|
+
const binding: Binding = {
|
|
296
|
+
get ended(): boolean {
|
|
297
|
+
return ended;
|
|
298
|
+
},
|
|
299
|
+
dispose: () => {
|
|
300
|
+
// Teardown first, in the caller's context; the root's own callback then finds it done.
|
|
301
|
+
try {
|
|
302
|
+
requestRelease();
|
|
303
|
+
} finally {
|
|
304
|
+
disposeRoot();
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const made = createRoot((dispose, owner) => {
|
|
309
|
+
disposeRoot = dispose;
|
|
310
|
+
const p = make(binding);
|
|
311
|
+
parts = p;
|
|
312
|
+
owner.onDispose(requestRelease);
|
|
313
|
+
return p;
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
untracked(made.start);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
disposeRoot();
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
return made.controller;
|
|
322
|
+
}
|