@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,603 @@
1
+ // popup-core.ts — INTERNAL lifecycle core shared by popup and popover (not a
2
+ // subpath export). `popup` re-exports it as-is; `popover` layers placement on
3
+ // top through `PopupLayer`. One module keeps the group registry shared, so a
4
+ // popup and a popover can share a `group`.
5
+
6
+ import { untracked } from "../signal/signal.js";
7
+
8
+ import type { Fx } from "./fx.js";
9
+ import { topmostOpenDialog } from "./modal-host.js";
10
+ import { createDriver, ownedBinding, type Driver } from "./owner.js";
11
+ import { watchSettle } from "./transition.js";
12
+
13
+ /** The lifecycle half of a popup or popover: the trigger to annotate, which
14
+ * gestures dismiss the panel, single-open grouping, the two opt-in focus
15
+ * hand-offs, and the open/close hooks. All optional. Activation is
16
+ * deliberately absent — the caller decides what opens the panel. */
17
+ export interface PopupOptions {
18
+ /** Trigger element to wire ARIA on (`aria-expanded`, `aria-haspopup`) and to
19
+ * exempt from outside-click dismissal (so a trigger click-handler can
20
+ * toggle without the document listener closing first). The controller does
21
+ * NOT wire click/keyboard activation on it — the caller owns that. Omit or
22
+ * pass `null` for a trigger-less popup. */
23
+ trigger?: HTMLElement | null;
24
+ /** Click outside the panel and trigger closes the popup. Default `true`. */
25
+ closeOnOutside?: boolean;
26
+ /** Escape closes the popup. Default `true`. */
27
+ closeOnEscape?: boolean;
28
+ /** Stop the Escape keydown's propagation when this popup consumes it, so a
29
+ * popup inside a modal doesn't also close the modal. Default `true`.
30
+ * Disable when an app-level Escape coordinator must still observe the key. */
31
+ isolateEscape?: boolean;
32
+ /** Single-open coordination group: opening this popup closes any open peer
33
+ * created with the same group name. See also `closePopupGroup`. */
34
+ group?: string;
35
+ /** Focus this element after the popup opens, if it is a connected element.
36
+ * Omit (or pass `null`) to leave focus alone — by default the caller owns
37
+ * focus. Opt-in. */
38
+ initialFocus?: HTMLElement | null;
39
+ /** Restore focus when the popup closes. `true` captures whatever was focused
40
+ * at open time and refocuses it on close; an element refocuses that element;
41
+ * `false`/omitted leaves focus alone. Opt-in. */
42
+ returnFocus?: boolean | HTMLElement;
43
+ /** aria-haspopup value advertised on the trigger. Match the panel's role.
44
+ * Default `true` (menu). Ignored without a trigger. */
45
+ haspopup?: "menu" | "listbox" | "tree" | "grid" | "dialog" | true;
46
+ /** Invoked after the popup opens. */
47
+ onOpen?: () => void;
48
+ /** Invoked after the popup closes. */
49
+ onClose?: () => void;
50
+ }
51
+
52
+ /** A merge-patch for `setOptions`: keys PRESENT in the patch override the
53
+ * current value — including an explicit `undefined`, which clears the option
54
+ * back to its default. Keys absent from the patch are left unchanged. */
55
+ export type PopupOptionsPatch = {
56
+ [K in keyof PopupOptions]?: PopupOptions[K] | undefined;
57
+ };
58
+
59
+ /** Handle on a revealed-and-dismissed panel. The panel is the caller's element:
60
+ * this reveals it, hides it, and wires light dismissal, but never positions it
61
+ * and never removes it — motion belongs to the app's CSS, keyed off the state
62
+ * classes. */
63
+ export interface PopupController {
64
+ show(): void;
65
+ hide(): void;
66
+ toggle(): void;
67
+ readonly isOpen: boolean;
68
+ /** The panel element (the caller's, never one this controller created). */
69
+ readonly el: HTMLElement;
70
+ /** Merge-patch the options (see {@link PopupOptionsPatch}), each key read
71
+ * once. Changed dismissal flags re-arm the listeners while open; a
72
+ * `trigger` or `haspopup` change applies at once: the old trigger loses the
73
+ * ARIA and, while open, the new one gains it. A patch whose values equal
74
+ * the current ones changes nothing. */
75
+ setOptions(patch: PopupOptionsPatch): void;
76
+ /** Hide the panel at once, with no fade and no `onClose`, restore focus the
77
+ * popup moved, drop the trigger's ARIA and leave the group. */
78
+ dispose(): void;
79
+ }
80
+
81
+ /** Every PopupOptions key present, defaults resolved, frozen; `null` for an
82
+ * absent option whose default is no value. */
83
+ export interface PopupConfig {
84
+ readonly trigger: HTMLElement | null;
85
+ readonly closeOnOutside: boolean;
86
+ readonly closeOnEscape: boolean;
87
+ readonly isolateEscape: boolean;
88
+ readonly group: string | null;
89
+ readonly initialFocus: HTMLElement | null;
90
+ readonly returnFocus: boolean | HTMLElement;
91
+ /** The serialized attribute; `true` and absence normalize to `"true"`. */
92
+ readonly haspopup: "true" | "menu" | "listbox" | "tree" | "grid" | "dialog";
93
+ readonly onOpen: (() => void) | null;
94
+ readonly onClose: (() => void) | null;
95
+ }
96
+
97
+ /** The normalized value of one popup option, or `undefined` for a key that is not one. */
98
+ export function normalizePopupKey(key: string, value: unknown): unknown {
99
+ switch (key) {
100
+ case "trigger":
101
+ case "initialFocus":
102
+ return value ?? null;
103
+ case "closeOnOutside":
104
+ case "closeOnEscape":
105
+ case "isolateEscape":
106
+ return value ?? true;
107
+ case "group":
108
+ return value ?? null;
109
+ case "returnFocus":
110
+ return value ?? false;
111
+ case "haspopup":
112
+ return value === undefined || value === true ? "true" : value;
113
+ case "onOpen":
114
+ case "onClose":
115
+ return value ?? null;
116
+ default:
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ export const POPUP_KEYS = [
122
+ "trigger",
123
+ "closeOnOutside",
124
+ "closeOnEscape",
125
+ "isolateEscape",
126
+ "group",
127
+ "initialFocus",
128
+ "returnFocus",
129
+ "haspopup",
130
+ "onOpen",
131
+ "onClose",
132
+ ] as const;
133
+
134
+ /** Read each of `keys` from `opts` once, normalized, into a frozen record. */
135
+ export function readOptions(
136
+ opts: object | undefined,
137
+ keys: readonly string[],
138
+ normalize: (key: string, value: unknown) => unknown,
139
+ ): Readonly<Record<string, unknown>> {
140
+ const source = (opts ?? {}) as Partial<Record<string, unknown>>;
141
+ return Object.freeze(Object.fromEntries(keys.map((k) => [k, normalize(k, source[k])])));
142
+ }
143
+
144
+ /** Read each popup option once into a frozen config. */
145
+ export function normalizePopupOptions(opts: PopupOptions | undefined): PopupConfig {
146
+ return readOptions(opts, POPUP_KEYS, normalizePopupKey) as unknown as PopupConfig;
147
+ }
148
+
149
+ /** A patch read once: each own enumerable key's normalized value. */
150
+ export type ReadPatch = readonly (readonly [string, unknown])[];
151
+
152
+ /** Read `patch`'s own enumerable keys once each, normalized; keys `normalize`
153
+ * does not know are dropped. An explicit `undefined` resets a key to its default. */
154
+ export function readPatch(
155
+ patch: object,
156
+ normalize: (key: string, value: unknown) => unknown,
157
+ ): ReadPatch {
158
+ const source = patch as Record<string, unknown>;
159
+ const out: (readonly [string, unknown])[] = [];
160
+ for (const key of Object.keys(source)) {
161
+ const value = normalize(key, source[key]);
162
+ if (value !== undefined) {
163
+ out.push([key, value]);
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** `current` with `entries` applied, or `current` itself when no field differs under Object.is. */
170
+ export function mergeOptions<C extends object>(current: C, entries: ReadPatch): C {
171
+ let next: Record<string, unknown> | null = null;
172
+ for (const [key, value] of entries) {
173
+ if (!Object.is((current as Record<string, unknown>)[key], value)) {
174
+ next ??= { ...(current as Record<string, unknown>) };
175
+ next[key] = value;
176
+ }
177
+ }
178
+ return next === null ? current : (Object.freeze(next) as C);
179
+ }
180
+
181
+ /** What a layer adds to a popup's realization (popover's placement). */
182
+ export interface PopupLayer<C> {
183
+ readonly stateClass: string;
184
+ /** Realize the layer's part of an open panel, on every pass while open. */
185
+ shown?(fx: Fx, config: C): void;
186
+ /** Realize the layer's part of a finished leave. */
187
+ leaveEnd?(fx: Fx): void;
188
+ /** Alongside the dismissal listeners' arm and disarm. */
189
+ listeners?(armed: boolean): void;
190
+ }
191
+
192
+ interface Member {
193
+ readonly isOpen: () => boolean;
194
+ /** Set this member closed without driving it. */
195
+ readonly close: () => void;
196
+ readonly flush: () => void;
197
+ }
198
+
199
+ const groups = new Map<string, Set<Member>>();
200
+
201
+ function joinGroup(name: string, member: Member): void {
202
+ let set = groups.get(name);
203
+ if (set === undefined) {
204
+ set = new Set();
205
+ groups.set(name, set);
206
+ }
207
+ set.add(member);
208
+ }
209
+
210
+ function leaveGroup(name: string, member: Member): void {
211
+ const set = groups.get(name);
212
+ if (set === undefined) {
213
+ return;
214
+ }
215
+ set.delete(member);
216
+ if (set.size === 0) {
217
+ groups.delete(name);
218
+ }
219
+ }
220
+
221
+ /** Close every open popup in `group` (e.g. collapse all expandable pills when
222
+ * the app moves focus elsewhere). Every member is hidden, then a callback's
223
+ * error is thrown, or an `AggregateError` of several. Unknown group names are
224
+ * a no-op. */
225
+ export function closePopupGroup(group: string): void {
226
+ const members = [...(groups.get(group) ?? [])].filter((m) => m.isOpen());
227
+ for (const m of members) {
228
+ m.close();
229
+ }
230
+ const errors: unknown[] = [];
231
+ for (const m of members) {
232
+ try {
233
+ m.flush();
234
+ } catch (error) {
235
+ errors.push(error);
236
+ }
237
+ }
238
+ if (errors.length === 1) {
239
+ throw errors[0];
240
+ }
241
+ if (errors.length > 1) {
242
+ throw new AggregateError(errors, "ui-primitives: several callbacks threw");
243
+ }
244
+ }
245
+
246
+ interface PopupState<C> {
247
+ readonly open: boolean;
248
+ readonly config: C;
249
+ /** The element focused when show() was called. */
250
+ readonly opener: HTMLElement | null;
251
+ readonly shows: number;
252
+ readonly hides: number;
253
+ /** The hide whose leave has settled. */
254
+ readonly settled: number;
255
+ /** The peers this show set closed, flushed by its realization. */
256
+ readonly peers: readonly Member[];
257
+ }
258
+
259
+ /** Core implementation behind `createPopup` and `createPopover`. `config` is
260
+ * already normalized; `normalizeKey` normalizes a `setOptions` patch. */
261
+ export function createPopupCore<C extends PopupConfig>(
262
+ panel: HTMLElement,
263
+ config: C,
264
+ layer: PopupLayer<C>,
265
+ normalizeKey: (key: string, value: unknown) => unknown,
266
+ ): { readonly controller: PopupController; readonly kick: () => void } {
267
+ return ownedBinding((binding) => {
268
+ let revealed = false;
269
+ let annotated: HTMLElement | null = null;
270
+ let joined: string | null = null;
271
+ let armed: { readonly outside: boolean; readonly escape: boolean } | null = null;
272
+ let armTimer: ReturnType<typeof setTimeout> | null = null;
273
+ let listening = false;
274
+ let leave: { readonly forHide: number; readonly cancel: () => void } | null = null;
275
+ let focus: { forShow: number; restore: HTMLElement | null; moved: boolean } = {
276
+ forShow: 0,
277
+ restore: null,
278
+ moved: false,
279
+ };
280
+ let restoredFor = 0;
281
+ let announced = false;
282
+ let endingFor: number | null = null;
283
+
284
+ const hide = (): void => {
285
+ const d = driver.state;
286
+ if (!binding.ended && d.open) {
287
+ driver.commit({ ...d, open: false, hides: d.hides + 1 });
288
+ }
289
+ };
290
+ const show = (): void => {
291
+ if (binding.ended) {
292
+ return;
293
+ }
294
+ const d = driver.state;
295
+ if (d.open) {
296
+ // A show() while open re-places a popover.
297
+ driver.kick();
298
+ return;
299
+ }
300
+ const peers =
301
+ d.config.group === null
302
+ ? []
303
+ : [...(groups.get(d.config.group) ?? [])].filter((m) => m !== member && m.isOpen());
304
+ for (const peer of peers) {
305
+ peer.close();
306
+ }
307
+ const active = document.activeElement;
308
+ driver.commit({
309
+ ...d,
310
+ open: true,
311
+ shows: d.shows + 1,
312
+ opener: active instanceof HTMLElement ? active : null,
313
+ peers,
314
+ });
315
+ };
316
+
317
+ const onDocClick = (e: MouseEvent): void => {
318
+ const target = e.target;
319
+ // A click on the trigger keeps the popup open: its own handler typically toggles.
320
+ if (
321
+ target instanceof Node &&
322
+ (panel.contains(target) || driver.state.config.trigger?.contains(target) === true)
323
+ ) {
324
+ return;
325
+ }
326
+ hide();
327
+ };
328
+ const onKeyDown = (e: KeyboardEvent): void => {
329
+ if (e.key === "Escape") {
330
+ // Isolated by default so the same Escape doesn't also close a parent modal.
331
+ if (driver.state.config.isolateEscape) {
332
+ e.stopPropagation();
333
+ }
334
+ hide();
335
+ }
336
+ };
337
+ const disarm = (): void => {
338
+ if (armTimer !== null) {
339
+ clearTimeout(armTimer);
340
+ armTimer = null;
341
+ }
342
+ armed = null;
343
+ if (listening) {
344
+ listening = false;
345
+ document.removeEventListener("click", onDocClick);
346
+ document.removeEventListener("keydown", onKeyDown);
347
+ layer.listeners?.(false);
348
+ }
349
+ };
350
+ const arm = (flags: { readonly outside: boolean; readonly escape: boolean }): void => {
351
+ disarm();
352
+ armed = flags;
353
+ // Deferred so the click that opened the popup doesn't immediately self-close.
354
+ armTimer = setTimeout(() => {
355
+ armTimer = null;
356
+ listening = true;
357
+ if (flags.outside) {
358
+ document.addEventListener("click", onDocClick);
359
+ }
360
+ if (flags.escape) {
361
+ document.addEventListener("keydown", onKeyDown);
362
+ }
363
+ layer.listeners?.(true);
364
+ }, 0);
365
+ };
366
+ const syncGroup = (name: string | null): void => {
367
+ if (joined !== name) {
368
+ if (joined !== null) {
369
+ leaveGroup(joined, member);
370
+ }
371
+ joined = name;
372
+ if (name !== null) {
373
+ joinGroup(name, member);
374
+ }
375
+ }
376
+ };
377
+ const cancelLeave = (): void => {
378
+ leave?.cancel();
379
+ leave = null;
380
+ };
381
+ /** Move focus back out, once per hide: to the restore target, else off the panel. */
382
+ const restoreFocus = (fx: Fx): void => {
383
+ const { restore, moved } = focus;
384
+ focus = { ...focus, restore: null, moved: false };
385
+ if (restore?.isConnected === true) {
386
+ fx.focus(restore);
387
+ return;
388
+ }
389
+ const active = document.activeElement;
390
+ if (moved && active instanceof HTMLElement && panel.contains(active)) {
391
+ fx.blur(active);
392
+ }
393
+ };
394
+ const settle = (h: number): void => {
395
+ const d = driver.state;
396
+ if (!d.open && d.hides === h && d.settled !== h) {
397
+ driver.commit({ ...d, settled: h });
398
+ }
399
+ };
400
+
401
+ const driver: Driver<PopupState<C>> = createDriver<PopupState<C>>({
402
+ initial: { open: false, config, opener: null, shows: 0, hides: 0, settled: 0, peers: [] },
403
+ realize(fx, d) {
404
+ const c = d.config;
405
+ syncGroup(c.group);
406
+ if (annotated !== null && annotated !== c.trigger) {
407
+ const old = annotated;
408
+ fx.removeAttr(old, "aria-expanded");
409
+ fx.removeAttr(old, "aria-haspopup");
410
+ annotated = null;
411
+ }
412
+ if (d.open) {
413
+ cancelLeave();
414
+ fx.removeClass(panel, "is-leaving");
415
+ for (const peer of d.peers) {
416
+ fx.call(peer.flush);
417
+ }
418
+ revealed = true;
419
+ fx.addClass(panel, layer.stateClass);
420
+ fx.setProp(panel, "hidden", false);
421
+ if (!panel.isConnected) {
422
+ // showModal() inerts everything outside the dialog subtree, so a
423
+ // disconnected panel must host inside the open dialog to stay usable.
424
+ const host = c.trigger?.closest("dialog[open]") ?? topmostOpenDialog() ?? document.body;
425
+ fx.append(host, panel);
426
+ }
427
+ if (!panel.classList.contains("is-open")) {
428
+ // The resting state is committed first, so the transition plays from it.
429
+ panel.getBoundingClientRect();
430
+ fx.addClass(panel, "is-open");
431
+ }
432
+ layer.shown?.(fx, c);
433
+ if (c.trigger !== null) {
434
+ annotated = c.trigger;
435
+ fx.setAttr(c.trigger, "aria-expanded", "true");
436
+ fx.setAttr(c.trigger, "aria-haspopup", c.haspopup);
437
+ }
438
+ if (focus.forShow !== d.shows) {
439
+ const moved = c.initialFocus?.isConnected === true;
440
+ const restore =
441
+ c.returnFocus instanceof HTMLElement
442
+ ? c.returnFocus
443
+ : c.returnFocus || moved
444
+ ? d.opener
445
+ : null;
446
+ // The token first: a restart must not move focus twice.
447
+ focus = { forShow: d.shows, restore, moved };
448
+ if (moved) {
449
+ fx.focus(c.initialFocus);
450
+ }
451
+ }
452
+ if (armed?.outside !== c.closeOnOutside || armed.escape !== c.closeOnEscape) {
453
+ arm({ outside: c.closeOnOutside, escape: c.closeOnEscape });
454
+ }
455
+ return;
456
+ }
457
+ disarm();
458
+ if (annotated !== null) {
459
+ // Both halves: a restart inside the open branch's pair can leave one unwritten.
460
+ fx.setAttr(annotated, "aria-haspopup", c.haspopup);
461
+ fx.setAttr(annotated, "aria-expanded", "false");
462
+ }
463
+ if (restoredFor !== d.hides) {
464
+ restoredFor = d.hides;
465
+ restoreFocus(fx);
466
+ }
467
+ if (!revealed) {
468
+ return;
469
+ }
470
+ // The leave: two class diffs, the watcher, then the end writes once it
471
+ // settled. A panel a restart left hidden has nothing to fade.
472
+ if (d.settled !== d.hides && !(panel.hidden && !panel.classList.contains("is-leaving"))) {
473
+ if (panel.classList.contains("is-open") || !panel.classList.contains("is-leaving")) {
474
+ panel.getBoundingClientRect();
475
+ }
476
+ fx.removeClass(panel, "is-open");
477
+ fx.addClass(panel, "is-leaving");
478
+ if (leave?.forHide !== d.hides) {
479
+ cancelLeave();
480
+ const h = d.hides;
481
+ leave = {
482
+ forHide: h,
483
+ cancel: watchSettle(panel, () => {
484
+ settle(h);
485
+ }),
486
+ };
487
+ }
488
+ return;
489
+ }
490
+ if (endingFor !== d.hides) {
491
+ if (!panel.classList.contains("is-leaving")) {
492
+ // Nothing to fade: another controller revealed the panel, or a
493
+ // restart left it hidden before it was revealed.
494
+ cancelLeave();
495
+ revealed = false;
496
+ return;
497
+ }
498
+ // Recorded first: a restart inside the end writes must finish them.
499
+ endingFor = d.hides;
500
+ }
501
+ cancelLeave();
502
+ fx.removeClass(panel, "is-leaving");
503
+ layer.leaveEnd?.(fx);
504
+ fx.setProp(panel, "hidden", true);
505
+ revealed = false;
506
+ endingFor = null;
507
+ },
508
+ notify(fx, d) {
509
+ if (d.open && !announced) {
510
+ announced = true;
511
+ const onOpen = d.config.onOpen;
512
+ if (onOpen !== null) {
513
+ fx.call(onOpen);
514
+ }
515
+ } else if (!d.open && announced) {
516
+ announced = false;
517
+ const onClose = d.config.onClose;
518
+ if (onClose !== null) {
519
+ fx.call(onClose);
520
+ }
521
+ }
522
+ },
523
+ terminal(fx) {
524
+ disarm();
525
+ cancelLeave();
526
+ syncGroup(null);
527
+ restoreFocus(fx);
528
+ if (annotated !== null) {
529
+ const trigger = annotated;
530
+ annotated = null;
531
+ fx.removeAttr(trigger, "aria-haspopup");
532
+ fx.removeAttr(trigger, "aria-expanded");
533
+ }
534
+ if (revealed) {
535
+ fx.removeClass(panel, "is-open", "is-leaving");
536
+ layer.leaveEnd?.(fx);
537
+ fx.setProp(panel, "hidden", true);
538
+ revealed = false;
539
+ }
540
+ },
541
+ });
542
+
543
+ const member: Member = {
544
+ isOpen: () => !binding.ended && driver.state.open,
545
+ close: () => {
546
+ const d = driver.state;
547
+ if (d.open) {
548
+ driver.set({ ...d, open: false, hides: d.hides + 1 });
549
+ }
550
+ },
551
+ flush: () => {
552
+ driver.flush();
553
+ },
554
+ };
555
+
556
+ const controller: PopupController = {
557
+ show,
558
+ hide,
559
+ toggle(): void {
560
+ if (driver.state.open) {
561
+ hide();
562
+ } else {
563
+ show();
564
+ }
565
+ },
566
+ get isOpen(): boolean {
567
+ return !binding.ended && driver.state.open;
568
+ },
569
+ get el(): HTMLElement {
570
+ return panel;
571
+ },
572
+ setOptions(patch: PopupOptionsPatch): void {
573
+ if (binding.ended) {
574
+ return;
575
+ }
576
+ // Read before the state, so a getter that re-enters commits first.
577
+ const entries = untracked(() => readPatch(patch, normalizeKey));
578
+ const d = driver.state;
579
+ const next = mergeOptions(d.config, entries);
580
+ // A getter that disposed the controller released the driver, which then ignores this.
581
+ if (next !== d.config) {
582
+ driver.commit({ ...d, config: next });
583
+ }
584
+ },
585
+ dispose: binding.dispose,
586
+ };
587
+
588
+ return {
589
+ controller: {
590
+ controller,
591
+ kick: () => {
592
+ driver.kick();
593
+ },
594
+ },
595
+ start: () => {
596
+ driver.flush();
597
+ },
598
+ release: () => {
599
+ driver.release();
600
+ },
601
+ };
602
+ });
603
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * popup.ts — Reveal + light-dismiss lifecycle for a caller-supplied panel,
3
+ * WITHOUT placement. Use for an in-flow or self-positioned panel; use popover
4
+ * when the panel floats anchored to something. Public facade over the shared
5
+ * lifecycle core (`popup-core.ts`, internal).
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { untracked } from "../signal/signal.js";
11
+
12
+ import { createPopupCore, normalizePopupKey, normalizePopupOptions } from "./popup-core.js";
13
+ import type { PopupController, PopupOptions } from "./popup-core.js";
14
+
15
+ export { closePopupGroup } from "./popup-core.js";
16
+ export type { PopupController, PopupOptions, PopupOptionsPatch } from "./popup-core.js";
17
+
18
+ /** Wire `panel` into a revealable, light-dismissing popup. Never positions or
19
+ * removes it from the DOM — the caller owns the element, placement, and motion. */
20
+ export function createPopup(panel: HTMLElement, opts?: PopupOptions): PopupController {
21
+ const config = untracked(() => normalizePopupOptions(opts));
22
+ return createPopupCore(panel, config, { stateClass: "uip-popup" }, normalizePopupKey).controller;
23
+ }