@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,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
|
+
}
|