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