@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,263 @@
1
+ /**
2
+ * dialog.ts — Thin behavior layer over the native `<dialog>` element. The
3
+ * platform gives us focus containment, the top layer, and Escape-to-close for
4
+ * free; this module adds backdrop-click dismissal (drag-safe) and a fade-out
5
+ * lifecycle via a namespaced `is-leaving` class before the element is closed.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { untracked } from "../signal/signal.js";
11
+
12
+ import {
13
+ dialogEngine,
14
+ existingEngine,
15
+ type DialogEngine,
16
+ type DialogFacet,
17
+ } from "./dialog-engine.js";
18
+ import { SUPERSEDED, createDriver, ownedBinding } from "./owner.js";
19
+
20
+ /** Which user gestures dismiss a wrapped `<dialog>`, an optional veto over
21
+ * them, and a hook that runs once the fade-out has finished. All optional; by
22
+ * default both backdrop and Escape dismiss it. */
23
+ export interface DialogOptions {
24
+ /** Close when the backdrop is clicked. Default `true`. */
25
+ closeOnBackdrop?: boolean;
26
+ /** Close on Escape. Default `true`. */
27
+ closeOnEscape?: boolean;
28
+ /** Dismiss guard, consulted on every USER dismissal attempt (backdrop click,
29
+ * Escape): return `false` to refuse it and keep the dialog open. The wiring
30
+ * stays armed, so later attempts re-consult the guard. Perform any "why
31
+ * not" feedback (a toast, a shake) inside the guard before returning.
32
+ * Programmatic `close()` is unaffected. Omitted = always dismissible. */
33
+ canDismiss?: () => boolean;
34
+ /** Invoked once each time the dialog goes from shown to closed, whoever
35
+ * closed it: the fade, `closeDialog`, or the browser. */
36
+ onClose?: () => void;
37
+ }
38
+
39
+ /** Handle on a wrapped `<dialog>`. The element is the caller's throughout: this
40
+ * controller adds wiring, classes and the dialog's live-region pair, and creates
41
+ * or removes no other node. The pair is appended as the dialog's last two children
42
+ * at the first helper or controller call on it and stays for the dialog's life,
43
+ * through closes and `dispose()`: render content into a child element, never by
44
+ * replacing the dialog's children, or announcements miss it until the next open. */
45
+ export interface DialogController {
46
+ /** Open as a platform modal, cancelling a leave fade still in flight so a
47
+ * reopen mid-fade is safe. A no-op while already open. Throws as
48
+ * `openDialog` does, and an `InvalidStateError` once `dispose()` has begun,
49
+ * also from a handler that `dispose()` reaches. */
50
+ open(): void;
51
+ /** Close through the fade-out lifecycle. Programmatic, so `canDismiss` does
52
+ * not apply. Does nothing once `dispose()` has begun. */
53
+ close(): void;
54
+ /** The `<dialog>` handed to `createDialog`, never a node this controller
55
+ * made. Only the caller may remove it, and removing it neither unwires the
56
+ * controller nor clears this reference: it keeps reading back a detached
57
+ * node, listeners and all, until `dispose()` runs. */
58
+ readonly el: HTMLDialogElement;
59
+ /** Close the dialog at once if it is open (no fade, no `onClose`) and remove
60
+ * the dismissal wiring and the `uip-dialog` class. The live-region pair stays. */
61
+ dispose(): void;
62
+ }
63
+
64
+ /** Open a native <dialog> as a modal, first putting back its live-region pair if
65
+ * the app's render removed it, so `announce` reaches it while the rest of the page
66
+ * is inert. Toasts and popups go to the modal opened last; announcements are written
67
+ * to every open modal these helpers drive and to the root. A reopen during the leave
68
+ * fade cancels it. Throws `showModal()`'s `InvalidStateError` for a disconnected
69
+ * dialog or one showing as a popover, keeping only the pair, and an
70
+ * `InvalidStateError` for the dialog of a disposed modal. */
71
+ export function openDialog(dialog: HTMLDialogElement): void {
72
+ const engine = dialogEngine(dialog);
73
+ engine.open();
74
+ engine.flush();
75
+ }
76
+
77
+ /** Close a native <dialog> after a fade-out: add `is-leaving`, wait for
78
+ * `transitionend` (or the fallback), then close and invoke `onClosed`. A
79
+ * reopen cancels a pending close and drops its `onClosed`. On a closed dialog
80
+ * `onClosed` runs at once, or, when called from code a change of that dialog
81
+ * is running, as soon as that change finishes. */
82
+ export function closeDialog(dialog: HTMLDialogElement, onClosed?: () => void): void {
83
+ const engine = dialogEngine(dialog);
84
+ engine.close(onClosed);
85
+ engine.flush();
86
+ }
87
+
88
+ /** @internal Install drag-safe backdrop-press detection on `dialog`, with no
89
+ * ownership: `onPress` fires only when a press both starts and ends on the
90
+ * dialog element itself. Returns the remover. */
91
+ export function listenBackdrop(dialog: HTMLDialogElement, onPress: () => void): () => void {
92
+ let downOnDialog = false;
93
+ const onMouseDown = (e: MouseEvent): void => {
94
+ // A backdrop press targets the dialog element itself (content presses
95
+ // target a descendant), so a drag-select ending on the backdrop is not one.
96
+ downOnDialog = e.target === dialog;
97
+ };
98
+ const onMouseUp = (e: MouseEvent): void => {
99
+ const onBackdrop = e.target === dialog && downOnDialog;
100
+ downOnDialog = false;
101
+ if (onBackdrop) {
102
+ onPress();
103
+ }
104
+ };
105
+ dialog.addEventListener("mousedown", onMouseDown);
106
+ dialog.addEventListener("mouseup", onMouseUp);
107
+ return () => {
108
+ dialog.removeEventListener("mousedown", onMouseDown);
109
+ dialog.removeEventListener("mouseup", onMouseUp);
110
+ };
111
+ }
112
+
113
+ /** Wire drag-safe backdrop dismissal on a native <dialog>: `onDismiss` fires
114
+ * only when a press both starts and ends on the dialog element itself.
115
+ * Returns a cleanup fn that removes both listeners. */
116
+ export function wireBackdropDismiss(dialog: HTMLDialogElement, onDismiss: () => void): () => void {
117
+ return ownedBinding((binding) => {
118
+ let remove: (() => void) | null = null;
119
+ const driver = createDriver<null>({
120
+ initial: null,
121
+ realize() {
122
+ remove ??= listenBackdrop(dialog, () => {
123
+ driver.act((fx) => {
124
+ fx.call(onDismiss);
125
+ });
126
+ });
127
+ },
128
+ terminal() {
129
+ remove?.();
130
+ remove = null;
131
+ },
132
+ });
133
+ return {
134
+ controller: binding.dispose,
135
+ start: () => {
136
+ driver.flush();
137
+ },
138
+ release: () => {
139
+ driver.release();
140
+ },
141
+ };
142
+ });
143
+ }
144
+
145
+ /** Throw unless `dialog` can take a new controller. Creates nothing. */
146
+ function assertNoController(dialog: HTMLDialogElement): void {
147
+ const engine = existingEngine(dialog);
148
+ if (engine !== undefined && (engine.state.retired || engine.state.facet !== null)) {
149
+ throw new DOMException("The dialog already has a controller", "InvalidStateError");
150
+ }
151
+ }
152
+
153
+ /** Wrap an existing native <dialog> with backdrop + Escape dismissal and the
154
+ * fade-out close lifecycle. Adds the `uip-dialog` class for the base skin.
155
+ * Throws `InvalidStateError`, acquiring nothing, on a dialog that already
156
+ * has a controller (a live `createDialog`, or a modal's `el`). */
157
+ export function createDialog(dialog: HTMLDialogElement, opts?: DialogOptions): DialogController {
158
+ const o = untracked(() => ({
159
+ closeOnBackdrop: opts?.closeOnBackdrop ?? true,
160
+ closeOnEscape: opts?.closeOnEscape ?? true,
161
+ canDismiss: opts?.canDismiss,
162
+ onClose: opts?.onClose,
163
+ }));
164
+ assertNoController(dialog);
165
+
166
+ return ownedBinding((binding) => {
167
+ let engine: DialogEngine | null = null;
168
+ let disarm: (() => void) | null = null;
169
+ let reported: "shown" | "closed" = "closed";
170
+
171
+ const dismiss = (): void => {
172
+ const e = engine;
173
+ if (binding.ended || e?.phase !== "open") {
174
+ return;
175
+ }
176
+ const guard = o.canDismiss;
177
+ if (guard !== undefined) {
178
+ const answer = e.consult(guard);
179
+ if (answer === SUPERSEDED || !answer) {
180
+ return;
181
+ }
182
+ }
183
+ e.close();
184
+ e.flush();
185
+ };
186
+ const onCancel = (e: Event): void => {
187
+ // A nested `<input type=file>` fires a bubbling `cancel` when its picker
188
+ // is dismissed (https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/cancel_event);
189
+ // only the dialog's own `cancel` is an Escape.
190
+ if (e.target !== dialog) {
191
+ return;
192
+ }
193
+ // The platform closes at once after `cancel`; prevented, the fade runs instead.
194
+ e.preventDefault();
195
+ if (o.closeOnEscape) {
196
+ dismiss();
197
+ }
198
+ };
199
+
200
+ const facet: DialogFacet = {
201
+ prepare(fx) {
202
+ if (disarm === null) {
203
+ dialog.addEventListener("cancel", onCancel);
204
+ const removeBackdrop = o.closeOnBackdrop ? listenBackdrop(dialog, dismiss) : null;
205
+ disarm = () => {
206
+ dialog.removeEventListener("cancel", onCancel);
207
+ removeBackdrop?.();
208
+ };
209
+ }
210
+ fx.addClass(dialog, "uip-dialog");
211
+ },
212
+ notify(fx, d) {
213
+ const shown = d.phase !== "closed";
214
+ if (reported === "shown" && !shown) {
215
+ reported = "closed";
216
+ if (o.onClose !== undefined) {
217
+ fx.call(o.onClose);
218
+ }
219
+ } else if (shown) {
220
+ reported = "shown";
221
+ }
222
+ },
223
+ cleanup(fx) {
224
+ disarm?.();
225
+ disarm = null;
226
+ fx.removeClass(dialog, "uip-dialog");
227
+ },
228
+ };
229
+
230
+ return {
231
+ controller: {
232
+ el: dialog,
233
+ open: () => {
234
+ if (binding.ended || engine === null) {
235
+ throw new DOMException("The dialog controller is disposed", "InvalidStateError");
236
+ }
237
+ engine.open();
238
+ engine.flush();
239
+ },
240
+ close: () => {
241
+ if (!binding.ended && engine !== null) {
242
+ engine.close();
243
+ engine.flush();
244
+ }
245
+ },
246
+ dispose: binding.dispose,
247
+ },
248
+ start: () => {
249
+ const e = dialogEngine(dialog);
250
+ engine = e;
251
+ reported = e.phase === "closed" ? "closed" : "shown";
252
+ e.attach(facet);
253
+ e.flush();
254
+ },
255
+ release: () => {
256
+ if (engine !== null) {
257
+ engine.detach(facet);
258
+ engine.flush();
259
+ }
260
+ },
261
+ };
262
+ });
263
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * disclosure.ts — Animated collapsible (show/hide) region wired to a trigger,
3
+ * per the WAI-ARIA disclosure pattern. Headless: it wires two elements you
4
+ * supply — no DOM is created. The trigger gets button semantics
5
+ * (`aria-expanded`, Enter/Space when it isn't a native `<button>`) and is linked
6
+ * to the region via `aria-controls`; the region toggles `aria-hidden`.
7
+ *
8
+ * Height animates 0 <-> auto. Modern engines interpolate the `auto` keyword
9
+ * directly (`interpolate-size: allow-keywords`, set on the region in the base
10
+ * stylesheet); engines without it fall back to a measured `scrollHeight` px
11
+ * target. Both honor `prefers-reduced-motion` by skipping the tween.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { untracked } from "../signal/signal.js";
17
+
18
+ import type { Fx } from "./fx.js";
19
+ import { createDriver, ownedBinding } from "./owner.js";
20
+ import { watchSettle } from "./transition.js";
21
+
22
+ /** The initial open state, whether height changes tween, and the toggle hook.
23
+ * All optional: a disclosure with no options starts collapsed, animates, and
24
+ * reports nothing. */
25
+ export interface DisclosureOptions {
26
+ /** Initial open state. Default `false`. */
27
+ open?: boolean;
28
+ /** Animate height changes. Default `true`. Ignored under reduced motion. */
29
+ animate?: boolean;
30
+ /** Invoked whenever the open state changes. `source` distinguishes a
31
+ * user-initiated toggle (trigger click / Enter / Space) from a programmatic
32
+ * one (`open()` / `close()` / `toggle()` on the controller) — the seam an
33
+ * auto-collapse state machine needs to latch "the user took over". */
34
+ onToggle?: (open: boolean, source: DisclosureToggleSource) => void;
35
+ }
36
+
37
+ /** How a disclosure toggle was initiated: `"user"` = the wired trigger
38
+ * (click / Enter / Space); `"api"` = a controller method. */
39
+ export type DisclosureToggleSource = "user" | "api";
40
+
41
+ /** Handle on a wired disclosure. Every method here reports as the `"api"`
42
+ * toggle source, so an `onToggle` consumer can tell a controller call from a
43
+ * trigger press. The only state driver in region-only mode (`trigger: null`). */
44
+ export interface DisclosureController {
45
+ /** Expand the region. Does nothing once `dispose()` has begun, as do
46
+ * `close()` and `toggle()`. */
47
+ open(): void;
48
+ close(): void;
49
+ toggle(): void;
50
+ readonly isOpen: boolean;
51
+ /** Unwire the trigger. An in-flight tween is cancelled and the height
52
+ * settled (`auto` when open, `0` when collapsed) so no inline px height is
53
+ * left frozen; the region keeps its current state, `aria-hidden` and
54
+ * `inert` included. */
55
+ dispose(): void;
56
+ }
57
+
58
+ let idSeq = 0;
59
+
60
+ function prefersReducedMotion(): boolean {
61
+ return (
62
+ typeof window !== "undefined" &&
63
+ typeof window.matchMedia === "function" &&
64
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
65
+ );
66
+ }
67
+
68
+ /** Whether the engine can transition `height` to/from the `auto` keyword. */
69
+ function supportsInterpolateSize(): boolean {
70
+ return (
71
+ typeof CSS !== "undefined" &&
72
+ typeof CSS.supports === "function" &&
73
+ CSS.supports("interpolate-size", "allow-keywords")
74
+ );
75
+ }
76
+
77
+ /** Whether the page is rendering `el` at all. Measuring a region it does not
78
+ * render to animate it is a forced render for a transition that cannot run.
79
+ * No options object: `contentVisibilityAuto` would also catch a SKIPPED
80
+ * `content-visibility: auto` region, which the next scroll makes relevant again. */
81
+ function isRendered(el: HTMLElement): boolean {
82
+ return typeof el.checkVisibility !== "function" || el.checkVisibility();
83
+ }
84
+
85
+ /** Wire `trigger` (a button, or any element given button semantics) to `region`
86
+ * as an animated disclosure. With `trigger: null` nothing is wired (no
87
+ * aria-expanded, no click or keyboard handling) and only the controller
88
+ * changes the open state; the region still animates and gets `aria-hidden`
89
+ * plus `inert`. */
90
+ export function createDisclosure(
91
+ trigger: HTMLElement | null,
92
+ region: HTMLElement,
93
+ opts?: DisclosureOptions,
94
+ ): DisclosureController {
95
+ const o = untracked(() => ({
96
+ animate: opts?.animate ?? true,
97
+ onToggle: opts?.onToggle,
98
+ open: opts?.open ?? false,
99
+ }));
100
+
101
+ interface State {
102
+ readonly open: boolean;
103
+ readonly source: DisclosureToggleSource;
104
+ /** The id of the open tween whose settle has fired. */
105
+ readonly settled: number;
106
+ }
107
+
108
+ return ownedBinding((binding) => {
109
+ const regionId = region.id === "" ? `uip-disclosure-${(++idSeq).toString()}` : region.id;
110
+ const isNativeButton = trigger instanceof HTMLButtonElement;
111
+ let listening = false;
112
+ let reported = o.open;
113
+ let tweenSeq = 0;
114
+ // The height is a sequence: `shown` is the state it was last driven
115
+ // toward, and `tween.step` advances after each write, so a restart resumes
116
+ // the step that did not finish.
117
+ const height: {
118
+ shown: boolean;
119
+ tween: {
120
+ readonly id: number;
121
+ readonly toOpen: boolean;
122
+ step: "from" | "to" | "armed";
123
+ } | null;
124
+ watch: (() => void) | null;
125
+ } = { shown: o.open, tween: null, watch: null };
126
+
127
+ const toggleTo = (next: boolean, source: DisclosureToggleSource): void => {
128
+ if (binding.ended) {
129
+ return;
130
+ }
131
+ driver.commit(
132
+ driver.state.open === next ? driver.state : { ...driver.state, open: next, source },
133
+ );
134
+ };
135
+ const onClick = (): void => {
136
+ toggleTo(!driver.state.open, "user");
137
+ };
138
+ const onKeyDown = (e: KeyboardEvent): void => {
139
+ if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
140
+ // Space would scroll the page; Enter/Space both activate a button.
141
+ e.preventDefault();
142
+ toggleTo(!driver.state.open, "user");
143
+ }
144
+ };
145
+ const listen = (on: boolean): void => {
146
+ if (trigger === null || listening === on) {
147
+ return;
148
+ }
149
+ listening = on;
150
+ const method = on ? "addEventListener" : "removeEventListener";
151
+ trigger[method]("click", onClick);
152
+ if (!isNativeButton) {
153
+ trigger[method]("keydown", onKeyDown as EventListener);
154
+ }
155
+ };
156
+ const cancelWatch = (): void => {
157
+ height.watch?.();
158
+ height.watch = null;
159
+ };
160
+ const settle = (id: number): void => {
161
+ driver.commit({ ...driver.state, settled: id });
162
+ };
163
+
164
+ const driver = createDriver<State>({
165
+ initial: { open: o.open, source: "api", settled: 0 },
166
+ realize(fx, d) {
167
+ listen(true);
168
+ fx.addClass(region, "uip-disclosure-region");
169
+ if (region.id === "") {
170
+ fx.setProp(region, "id", regionId);
171
+ }
172
+ if (trigger !== null) {
173
+ fx.setAttr(trigger, "aria-controls", region.id);
174
+ if (!isNativeButton) {
175
+ if (!trigger.hasAttribute("role")) {
176
+ fx.setAttr(trigger, "role", "button");
177
+ }
178
+ if (!trigger.hasAttribute("tabindex")) {
179
+ fx.setAttr(trigger, "tabindex", "0");
180
+ }
181
+ }
182
+ }
183
+ reflect(fx, d.open);
184
+ if (height.shown !== d.open) {
185
+ cancelWatch();
186
+ height.shown = d.open;
187
+ const animated = o.animate && !prefersReducedMotion() && isRendered(region);
188
+ height.tween = animated ? { id: ++tweenSeq, toOpen: d.open, step: "from" } : null;
189
+ }
190
+ const tween = height.tween;
191
+ if (tween?.step === "from") {
192
+ // auto is not an animatable start on the fallback path, so measure first.
193
+ fx.setStyle(
194
+ region,
195
+ "height",
196
+ tween.toOpen ? "0px" : `${region.scrollHeight.toString()}px`,
197
+ );
198
+ tween.step = "to";
199
+ }
200
+ if (tween?.step === "to") {
201
+ // The start height is committed before the target, or both land in one frame.
202
+ region.getBoundingClientRect();
203
+ fx.setStyle(
204
+ region,
205
+ "height",
206
+ tween.toOpen
207
+ ? supportsInterpolateSize()
208
+ ? "auto"
209
+ : `${region.scrollHeight.toString()}px`
210
+ : "0px",
211
+ );
212
+ if (tween.toOpen) {
213
+ tween.step = "armed";
214
+ } else {
215
+ height.tween = null;
216
+ }
217
+ }
218
+ if (height.tween?.step === "armed") {
219
+ const armed = height.tween;
220
+ if (d.settled === armed.id) {
221
+ cancelWatch();
222
+ height.tween = null;
223
+ } else {
224
+ height.watch ??= watchSettle(region, () => {
225
+ settle(armed.id);
226
+ });
227
+ }
228
+ }
229
+ if (height.tween === null) {
230
+ fx.setStyle(region, "height", d.open ? "" : "0px");
231
+ }
232
+ },
233
+ notify(fx, d) {
234
+ if (reported !== d.open) {
235
+ reported = d.open;
236
+ const onToggle = o.onToggle;
237
+ if (onToggle !== undefined) {
238
+ fx.call(() => {
239
+ onToggle(d.open, d.source);
240
+ });
241
+ }
242
+ }
243
+ },
244
+ terminal(fx, d) {
245
+ listen(false);
246
+ cancelWatch();
247
+ height.tween = null;
248
+ reflect(fx, d.open);
249
+ fx.setStyle(region, "height", d.open ? "" : "0px");
250
+ },
251
+ });
252
+
253
+ const reflect = (fx: Fx, open: boolean): void => {
254
+ if (trigger !== null) {
255
+ fx.setAttr(trigger, "aria-expanded", open ? "true" : "false");
256
+ }
257
+ fx.setAttr(region, "aria-hidden", open ? "false" : "true");
258
+ // height:0 clips paint but keeps descendants keyboard-focusable, and
259
+ // aria-hidden on focusable content is invalid; `inert` removes both.
260
+ fx.setProp(region, "inert", !open);
261
+ };
262
+
263
+ return {
264
+ controller: {
265
+ get isOpen(): boolean {
266
+ return driver.state.open;
267
+ },
268
+ open(): void {
269
+ toggleTo(true, "api");
270
+ },
271
+ close(): void {
272
+ toggleTo(false, "api");
273
+ },
274
+ toggle(): void {
275
+ toggleTo(!driver.state.open, "api");
276
+ },
277
+ dispose: binding.dispose,
278
+ },
279
+ start: () => {
280
+ driver.flush();
281
+ },
282
+ release: () => {
283
+ driver.release();
284
+ },
285
+ };
286
+ });
287
+ }