@cssints/headless 0.13.1 → 0.15.0

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/README.md CHANGED
@@ -65,7 +65,7 @@ A primitive that has a seam honours an optional `prefs` entry (a `Partial<Prefs>
65
65
  - **menu / menubar** — `typeaheadDelayMs` (the typeahead buffer timeout).
66
66
  - **tooltip** — `tooltipShowDelayMs` / `tooltipHideDelayMs`, exposed as `showDelayMs()` / `hideDelayMs()` for the host to wire its `setTimeout` from (the spec keeps timers as events).
67
67
 
68
- Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
68
+ Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
69
69
 
70
70
  `motionDurationMs` is **not consumed**: canvas tweens are tick-driven, not millisecond-driven, and the look layer owns durations. It stays in `Prefs` only so a host's preference record fits the type.
71
71
 
@@ -220,6 +220,82 @@ const remove = async () => {
220
220
  - **Labels are the host's.** The defaults, `"Confirm"` and `"Cancel"`, stay English. An app that translates passes `confirmLabel` and `cancelLabel` (and the `title`) with each request, `confirm({ title: t("unsaved"), confirmLabel: t("discard"), cancelLabel: t("keep") })`; a label left out, or `undefined`, falls back to the English default. Nothing in the options needed to change for this: `ConfirmRequest` already carried both labels.
221
221
  - `aria-labelledby` is the title and `aria-describedby` **always** the description (an alertdialog needs one), so render `descriptionProps` always: it is `hidden` when the request has no description. `triggerProps` are dialog-modal's trigger attributes without the handlers; the click that calls `confirm()` is the host's. `request()` keeps the last request after it settles, so a closing animation does not flash empty.
222
222
 
223
+ ## Toast and status
224
+
225
+ Two live regions for the two sizes of feedback: `createStatus` is one `role=status` message whose text the host replaces ("Link copied", "Saved"); `createToast` is a queue of toasts, each with its own live region, a timeout, a ceiling and a countdown that pauses. Neither touches the DOM: like every primitive here they return state and attribute objects. **There is no APG pattern for either** (APG's Alert, `createAlert`, is imperative and assertive), so there is no Quint spec and `test/toast.browser.test.tsx` is the oracle. The behaviour is copied from Base UI's Toast and React Aria's Toast (a landmark, F6 into it, at least 5 s, timers paused on hover or focus, the announced text handed in rather than read from the DOM).
226
+
227
+ ```tsx
228
+ const toast = createToast();
229
+ const status = createStatus();
230
+
231
+ const copy = async () => {
232
+ await navigator.clipboard.writeText(url);
233
+ status.show("Link copied");
234
+ };
235
+
236
+ const upload = async () => {
237
+ try {
238
+ await send(file);
239
+ toast.show({ title: "Uploaded", description: file.name, tone: "success" });
240
+ } catch {
241
+ toast.show({
242
+ title: "Upload failed",
243
+ description: "Try again from the file row",
244
+ tone: "error",
245
+ });
246
+ }
247
+ };
248
+
249
+ <p {...status.statusProps}>{status.message()}</p>;
250
+
251
+ <div {...toast.regionProps} tabindex={-1}>
252
+ <For each={toast.toasts()}>
253
+ {(item) => (
254
+ <div {...toast.toastProps(item.id)}>
255
+ <p>{item.title}</p>
256
+ <p>{item.description}</p>
257
+ <button type="button" onClick={() => toast.dismiss(item.id)}>
258
+ Dismiss
259
+ </button>
260
+ </div>
261
+ )}
262
+ </For>
263
+ </div>;
264
+ ```
265
+
266
+ - **A status replaces, it does not stack.** `show(text)` writes the one region and `clear()` empties it; a second message before the first is read replaces it. A status is never for errors: an error is `createAlert` (`role=alert`, assertive), and a titled message with a timeout is a toast.
267
+ - **Toast roles.** `toastProps(id).role` is `"alert"` for `tone: "error"` and `"status"` otherwise, so an error interrupts and the rest wait. The container is `role="region"` with `aria-label` ("Notifications" unless `labels.region`), and each toast is `aria-atomic`, so its title and description are announced as one — the toast's own text is the announcement, a close button's label included, so keep a toast to its title and description. The role sits on the toast rather than on one container per tone: the container is the `region` landmark, so it cannot also be the live region, and a role per toast needs no second region from the host.
268
+ - **Timers.** `timeout` defaults to 5000 ms, the five seconds Base UI and React Aria call the minimum for reading a message; shorter values are for tests. One timer per toast, cleared by `dismiss`/`dismissAll` and all of them on dispose.
269
+ - **Pause.** Hovering a toast pauses its countdown and leaving resumes it with the time it had left (the deadline is kept, not restarted); focus always pauses, because a reader who tabbed into the toast must not lose it. `pauseOnHover: false` leaves focus as the only pause.
270
+ - **The ceiling.** `max` (default 3) keeps the newest: a `show` past it drops the oldest. `onDismiss(id)` fires for every toast that leaves, whichever way — a timeout, a dismiss, `dismissAll` or the ceiling.
271
+ - **F6.** Base UI and React Aria enter the toast region with F6 and return with F6: the region is the target, so give it `tabindex="-1"` and focus it from the host's F6 handler, remembering the element to restore. The primitive binds no keys: global hotkeys are a separate piece (bean cssints-h0d5).
272
+
273
+ ## Popover
274
+
275
+ `createPopover` is the non-modal overlay: an anchored surface a trigger opens, dismissed by a click on the trigger, an outside `pointerdown` or Escape, with focus moving into it and back to the trigger. **There is no APG pattern for a popover** (APG has Dialog, which is modal), so there is no Quint spec and `test/popover.browser.test.tsx` is the oracle. It is copied from Base UI's Popover (the trigger click toggle, Enter / Space / ArrowDown, light dismiss on an outside `pointerdown` that does not steal focus) and React Aria's `usePopover` (focus in on open, back on close, `role="dialog"` without `aria-modal`, a placement that flips at the viewport edge).
276
+
277
+ ```tsx
278
+ const popover = createPopover({ placement: "bottom-start" });
279
+ let trigger!: HTMLButtonElement;
280
+ let content!: HTMLDivElement;
281
+
282
+ onMount(() => {
283
+ const teardown = popover.attach(trigger, content);
284
+ onCleanup(teardown);
285
+ });
286
+
287
+ <button {...popover.triggerProps} ref={(element) => (trigger = element)}>Filters</button>
288
+ <div {...popover.contentProps} style={popover.contentStyle()} ref={(element) => (content = element)}>
289
+ …
290
+ </div>;
291
+ ```
292
+
293
+ - **`attach(trigger, content)`** is the whole DOM contract and returns its teardown: it measures and places on open (and on resize, scroll and a `ResizeObserver`), focuses the first focusable element in the content (else the content itself, which is `tabindex="-1"`), returns focus to the trigger on a close it caused, and closes on a `pointerdown` outside both elements. Call it from a `ref`; it creates an effect.
294
+ - **`position(triggerRect, contentRect, viewport)`** is pure: it returns `{ top, left, placement }` for the requested placement, flipped to the other side when the room is not there, and shifted (not flipped) into the viewport horizontally. The rects and the viewport must share a space; `attach` measures in client space and writes page space for `position: absolute`.
295
+ - **Non-modal, deliberately.** The page behind keeps its focus, its clicks and its scroll: no `inert`, no scroll lock, no Tab cycle, no `aria-modal`. That is `createDialogModal`, and `modal` is typed `false` for that reason.
296
+ - **Not a menu.** No roving focus, no arrow keys between items: it is the base for `createMenu` in a popover, and for the filterable menu, which own their own item focus.
297
+ - Absent on purpose: hover open (that is `createTooltipGroup`), submenus and item navigation (the menu), a scroll lock and `inert` (the modal dialog), and a vertical shift when neither side fits (the caller sees the requested placement).
298
+
223
299
  ## Commands
224
300
 
225
301
  ```sh
package/dist/index.d.mts CHANGED
@@ -1642,6 +1642,106 @@ export interface Pagination {
1642
1642
  * Target size is the skin's job: 24x24 CSS px at AA, 44x44 at AAA.
1643
1643
  */
1644
1644
  export declare const createPagination: (options: PaginationOptions) => Pagination;
1645
+ export type PopoverPlacement = "bottom-start" | "bottom" | "bottom-end" | "top-start" | "top" | "top-end";
1646
+ /** A rect in one space, as `getBoundingClientRect` gives it. */
1647
+ export interface PopoverRect {
1648
+ readonly top: number;
1649
+ readonly left: number;
1650
+ readonly width: number;
1651
+ readonly height: number;
1652
+ }
1653
+ export interface PopoverViewport {
1654
+ readonly width: number;
1655
+ readonly height: number;
1656
+ }
1657
+ export interface PopoverPosition {
1658
+ readonly top: number;
1659
+ readonly left: number;
1660
+ readonly placement: PopoverPlacement;
1661
+ }
1662
+ /** The last measure, as a style object for the content element. */
1663
+ export interface PopoverStyle {
1664
+ readonly position: "absolute";
1665
+ readonly top: string;
1666
+ readonly left: string;
1667
+ /** Custom properties, so the object is assignable to Solid's `style`. */
1668
+ readonly [property: `-${string}`]: string | number | undefined;
1669
+ }
1670
+ export interface PopoverOptions {
1671
+ /** Base for the generated trigger / content ids. */
1672
+ readonly id?: string | undefined;
1673
+ /** Controlled: the host owns the open state and this is the only thing that changes it. */
1674
+ readonly open?: boolean | undefined;
1675
+ /** Uncontrolled starting state; ignored while `open` is set. */
1676
+ readonly defaultOpen?: boolean | undefined;
1677
+ /** @default "bottom" */
1678
+ readonly placement?: PopoverPlacement | undefined;
1679
+ /** Gap between the trigger and the content, in px. @default 4 */
1680
+ readonly offset?: number | undefined;
1681
+ /**
1682
+ * Only `false`, and it is not read: a popover is the non-modal overlay (the
1683
+ * page behind stays interactive, nothing is made `inert`, no scroll lock).
1684
+ * A modal surface is `createDialogModal`, which owns `aria-modal` and the
1685
+ * focus trap; this option exists only so a host generic over overlays can
1686
+ * pass it and get the truth.
1687
+ */
1688
+ readonly modal?: false;
1689
+ /** A request to change the open state, called when the intent differs. */
1690
+ readonly onOpenChange?: ((open: boolean) => void) | undefined;
1691
+ }
1692
+ export interface PopoverTriggerProps {
1693
+ readonly id: string;
1694
+ readonly "aria-haspopup": "dialog";
1695
+ readonly "aria-expanded": "true" | "false";
1696
+ readonly "aria-controls": string;
1697
+ readonly onClick: () => void;
1698
+ readonly onKeyDown: (event: KeyboardEvent) => void;
1699
+ }
1700
+ export interface PopoverContentProps {
1701
+ readonly id: string;
1702
+ readonly role: "dialog";
1703
+ /** Always absent: a popover is not modal. */
1704
+ readonly "aria-modal": undefined;
1705
+ readonly tabindex: -1;
1706
+ readonly hidden: boolean;
1707
+ readonly onKeyDown: (event: KeyboardEvent) => void;
1708
+ }
1709
+ export interface Popover {
1710
+ readonly open: Accessor<boolean>;
1711
+ readonly triggerProps: PopoverTriggerProps;
1712
+ readonly contentProps: PopoverContentProps;
1713
+ /** `style` for the content element. Reads the last measure. */
1714
+ readonly contentStyle: Accessor<PopoverStyle>;
1715
+ /** Pure: the content's offset for these rects, flipped when the room is not there. */
1716
+ readonly position: (triggerRect: PopoverRect, contentRect: PopoverRect, viewport: PopoverViewport) => PopoverPosition;
1717
+ /**
1718
+ * DOM wiring: measure and place on open, focus into the content, return focus
1719
+ * to the trigger on close, close on an outside `pointerdown`. Call it from a
1720
+ * ref (it creates an effect) and call the teardown it returns on cleanup.
1721
+ */
1722
+ readonly attach: (trigger: HTMLElement, content: HTMLElement) => () => void;
1723
+ }
1724
+ /**
1725
+ * A non-modal popover on Solid 2: an anchored surface a trigger opens, dismissed
1726
+ * by a click on the trigger, an outside pointerdown or Escape, with focus moving
1727
+ * into it and back to the trigger. **There is no APG pattern for it** (APG has
1728
+ * Dialog, which is modal, and no popover), so there is no Quint spec and
1729
+ * `test/popover.browser.test.tsx` is the oracle. Copied from Base UI's Popover
1730
+ * (trigger click toggles, Enter / Space / ArrowDown open, light dismiss on an
1731
+ * outside `pointerdown` that does not steal focus) and React Aria's
1732
+ * `usePopover` (focus into the content on open, back to the trigger on close,
1733
+ * `role="dialog"` with no `aria-modal`, a placement that flips at the viewport
1734
+ * edge).
1735
+ *
1736
+ * Not modal, deliberately: the page behind keeps its focus, its clicks and its
1737
+ * scroll. No `inert`, no scroll lock, no Tab cycle — `createDialogModal` is the
1738
+ * modal one. No roving focus either: this is the base for a menu in a popover
1739
+ * (`createMenu` owns the item focus), not a menu itself.
1740
+ *
1741
+ * Request/commit (map ticket `rt1p`): uncontrolled by default (`defaultOpen`);
1742
+ * pass `open` + `onOpenChange` to own the open state.
1743
+ */
1744
+ export declare const createPopover: (options?: PopoverOptions) => Popover;
1645
1745
  /**
1646
1746
  * APG grid primitive on Solid 2: roving focus, 2-D navigation, edit mode, a
1647
1747
  * cell/row/column selection rectangle and sortable column headers. State plus
@@ -2612,6 +2712,37 @@ export interface Spinbutton {
2612
2712
  * APG text.
2613
2713
  */
2614
2714
  export declare const createSpinbutton: (options?: SpinbuttonOptions) => Spinbutton;
2715
+ export interface StatusOptions {
2716
+ /** Rendered on `statusProps`; generated when absent. */
2717
+ readonly id?: string | undefined;
2718
+ }
2719
+ /** Spread onto the `role=status` live region. Render it once, always. */
2720
+ export interface StatusProps {
2721
+ readonly id: string;
2722
+ readonly role: "status";
2723
+ readonly "aria-live": "polite";
2724
+ readonly "aria-atomic": "true";
2725
+ }
2726
+ export interface Status {
2727
+ /** The text currently shown, or `undefined` before the first `show`. */
2728
+ readonly message: Accessor<string | undefined>;
2729
+ /** Writes the message, replacing the previous one; nothing is queued. */
2730
+ readonly show: (text: string) => void;
2731
+ readonly clear: () => void;
2732
+ readonly statusProps: StatusProps;
2733
+ }
2734
+ /**
2735
+ * APG Alert's polite sibling: one `role=status` live region whose text the host
2736
+ * replaces ("Link copied", "Saved"). It is not a queue: a second message shows
2737
+ * in place of the first, so a burst reads as the last state, not as N alerts.
2738
+ * The region is rendered once and stays mounted, which is what makes a change
2739
+ * of its text announced.
2740
+ *
2741
+ * A status is never for errors: an error that needs attention now is
2742
+ * `createAlert` (`role=alert`, assertive), and a message with a title, a
2743
+ * description and a timeout is `createToast`.
2744
+ */
2745
+ export declare const createStatus: (options?: StatusOptions) => Status;
2615
2746
  export interface SwitchOptions {
2616
2747
  /** Controlled: the host owns the state and this is the only thing that changes it. */
2617
2748
  readonly value?: boolean | undefined;
@@ -2937,6 +3068,74 @@ export interface Timeline {
2937
3068
  * reactive.
2938
3069
  */
2939
3070
  export declare const createTimeline: (options?: TimelineOptions) => Timeline;
3071
+ /** A toast's urgency; `error` is the only tone that announces assertively. */
3072
+ export type ToastTone = "info" | "success" | "error";
3073
+ export interface ToastOptions {
3074
+ /** Auto-dismiss delay in ms, default 5000. A number; `0` dismisses on the next tick. */
3075
+ readonly timeout?: number | undefined;
3076
+ /** Visible toasts kept, default 3; a new one past the ceiling drops the oldest. */
3077
+ readonly max?: number | undefined;
3078
+ /** Pauses the countdown while a pointer is over a toast, default true. Focus always pauses. */
3079
+ readonly pauseOnHover?: boolean | undefined;
3080
+ /** The region's accessible name; `region` defaults to `"Notifications"`. */
3081
+ readonly labels?: {
3082
+ readonly region?: string | undefined;
3083
+ } | undefined;
3084
+ /** Called with the id whenever a toast leaves, for whatever reason. */
3085
+ readonly onDismiss?: ((id: string) => void) | undefined;
3086
+ }
3087
+ /** What `show` takes; `tone` defaults to `"info"`. */
3088
+ export interface ToastInput {
3089
+ readonly title: string;
3090
+ readonly description?: string | undefined;
3091
+ readonly tone?: ToastTone | undefined;
3092
+ }
3093
+ /** A toast in the queue, oldest first in `toasts()`. */
3094
+ export interface ToastItem {
3095
+ readonly id: string;
3096
+ readonly title: string;
3097
+ readonly description: string | undefined;
3098
+ readonly tone: ToastTone;
3099
+ }
3100
+ /** Spread onto the container that holds every toast and the F6 target. */
3101
+ export interface ToastRegionProps {
3102
+ readonly role: "region";
3103
+ readonly "aria-label": string;
3104
+ }
3105
+ /**
3106
+ * Spread onto one toast. `role` is `alert` for the `error` tone and `status`
3107
+ * otherwise; both pause the countdown while hovered or focused.
3108
+ */
3109
+ export interface ToastItemProps {
3110
+ readonly role: "alert" | "status";
3111
+ readonly "aria-atomic": "true";
3112
+ readonly onMouseEnter: () => void;
3113
+ readonly onMouseLeave: () => void;
3114
+ readonly onFocusIn: () => void;
3115
+ readonly onFocusOut: () => void;
3116
+ }
3117
+ export interface Toast {
3118
+ /** Oldest first. */
3119
+ readonly toasts: Accessor<readonly ToastItem[]>;
3120
+ /** Queues a toast and returns its id. */
3121
+ readonly show: (input: ToastInput) => string;
3122
+ readonly dismiss: (id: string) => void;
3123
+ readonly dismissAll: () => void;
3124
+ readonly regionProps: ToastRegionProps;
3125
+ readonly toastProps: (id: string) => ToastItemProps;
3126
+ }
3127
+ /**
3128
+ * A toast queue: one timeout per toast, a ceiling that drops the oldest, and a
3129
+ * countdown that pauses on hover and focus and resumes from where it stopped.
3130
+ * A toast announces itself through its own live region (`role=status`, or
3131
+ * `role=alert` for `error`), so the host renders one element per toast inside
3132
+ * `regionProps`. No DOM access here: the host renders, the primitive decides.
3133
+ *
3134
+ * The timeout is a floor of practice, not of code: the default is 5000 ms
3135
+ * because both Base UI and React Aria agree on at least five seconds to read a
3136
+ * message; shorter values exist for tests and are the host's judgement.
3137
+ */
3138
+ export declare const createToast: (options?: ToastOptions) => Toast;
2940
3139
  /**
2941
3140
  * APG Toolbar on Solid 2: state plus attribute objects, no elements, no
2942
3141
  * styling. A faithful transcription of `specs/apg/toolbar.qnt`.
package/dist/index.mjs CHANGED
@@ -3079,6 +3079,164 @@ const createPagination = (options) => {
3079
3079
  }
3080
3080
  };
3081
3081
  };
3082
+ // src/popover.ts
3083
+ const FLIPPED = {
3084
+ "bottom-start": "top-start",
3085
+ bottom: "top",
3086
+ "bottom-end": "top-end",
3087
+ "top-start": "bottom-start",
3088
+ top: "bottom",
3089
+ "top-end": "bottom-end"
3090
+ };
3091
+ const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
3092
+ const firstFocusable = (root) => Array.from(root.querySelectorAll(FOCUSABLE)).find((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
3093
+ /**
3094
+ * A non-modal popover on Solid 2: an anchored surface a trigger opens, dismissed
3095
+ * by a click on the trigger, an outside pointerdown or Escape, with focus moving
3096
+ * into it and back to the trigger. **There is no APG pattern for it** (APG has
3097
+ * Dialog, which is modal, and no popover), so there is no Quint spec and
3098
+ * `test/popover.browser.test.tsx` is the oracle. Copied from Base UI's Popover
3099
+ * (trigger click toggles, Enter / Space / ArrowDown open, light dismiss on an
3100
+ * outside `pointerdown` that does not steal focus) and React Aria's
3101
+ * `usePopover` (focus into the content on open, back to the trigger on close,
3102
+ * `role="dialog"` with no `aria-modal`, a placement that flips at the viewport
3103
+ * edge).
3104
+ *
3105
+ * Not modal, deliberately: the page behind keeps its focus, its clicks and its
3106
+ * scroll. No `inert`, no scroll lock, no Tab cycle — `createDialogModal` is the
3107
+ * modal one. No roving focus either: this is the base for a menu in a popover
3108
+ * (`createMenu` owns the item focus), not a menu itself.
3109
+ *
3110
+ * Request/commit (map ticket `rt1p`): uncontrolled by default (`defaultOpen`);
3111
+ * pass `open` + `onOpenChange` to own the open state.
3112
+ */
3113
+ const createPopover = (options = {}) => {
3114
+ const base = options.id ?? createUniqueId();
3115
+ const triggerId = `${base}-trigger`;
3116
+ const contentId = `${base}-content`;
3117
+ const [open, requestOpen] = createControllable({
3118
+ value: () => options.open,
3119
+ initial: options.defaultOpen ?? false,
3120
+ onChange: () => options.onOpenChange
3121
+ });
3122
+ const [measured, setMeasured] = createSignal(void 0);
3123
+ const position = (triggerRect, contentRect, viewport) => {
3124
+ const requested = options.placement ?? "bottom";
3125
+ const gap = options.offset ?? 4;
3126
+ const below = triggerRect.top + triggerRect.height + gap;
3127
+ const above = triggerRect.top - gap - contentRect.height;
3128
+ const fitsBelow = below + contentRect.height <= viewport.height;
3129
+ const fitsAbove = above >= 0;
3130
+ const wantsBelow = requested.startsWith("bottom");
3131
+ const placement = (wantsBelow ? fitsBelow : fitsAbove) || !(wantsBelow ? fitsAbove : fitsBelow) ? requested : FLIPPED[requested];
3132
+ const aligned = placement.endsWith("-start") === true ? triggerRect.left : placement.endsWith("-end") === true ? triggerRect.left + triggerRect.width - contentRect.width : triggerRect.left + (triggerRect.width - contentRect.width) / 2;
3133
+ return {
3134
+ top: placement.startsWith("top") ? above : below,
3135
+ left: Math.max(0, Math.min(aligned, viewport.width - contentRect.width)),
3136
+ placement
3137
+ };
3138
+ };
3139
+ const attach = (trigger, content) => {
3140
+ let restoreFocus = true;
3141
+ let wasOpen = false;
3142
+ const measure = () => {
3143
+ if (!open()) return;
3144
+ const t = trigger.getBoundingClientRect();
3145
+ const c = content.getBoundingClientRect();
3146
+ const at = position({
3147
+ top: t.top,
3148
+ left: t.left,
3149
+ width: t.width,
3150
+ height: t.height
3151
+ }, {
3152
+ top: 0,
3153
+ left: 0,
3154
+ width: c.width,
3155
+ height: c.height
3156
+ }, {
3157
+ width: window.innerWidth,
3158
+ height: window.innerHeight
3159
+ });
3160
+ setMeasured({
3161
+ top: at.top + window.scrollY,
3162
+ left: at.left + window.scrollX,
3163
+ placement: at.placement
3164
+ });
3165
+ };
3166
+ const onPointerDown = (event) => {
3167
+ const target = event.target;
3168
+ if (!(target instanceof Node) || trigger.contains(target) || content.contains(target)) return;
3169
+ restoreFocus = false;
3170
+ requestOpen(false);
3171
+ };
3172
+ createEffect(() => open(), (isOpen) => {
3173
+ if (isOpen) {
3174
+ restoreFocus = true;
3175
+ wasOpen = true;
3176
+ measure();
3177
+ (firstFocusable(content) ?? content).focus();
3178
+ } else if (wasOpen) {
3179
+ wasOpen = false;
3180
+ if (restoreFocus && trigger.isConnected) trigger.focus();
3181
+ }
3182
+ });
3183
+ document.addEventListener("pointerdown", onPointerDown, true);
3184
+ window.addEventListener("resize", measure);
3185
+ window.addEventListener("scroll", measure, true);
3186
+ const observer = new ResizeObserver(measure);
3187
+ observer.observe(trigger);
3188
+ observer.observe(content);
3189
+ return () => {
3190
+ observer.disconnect();
3191
+ document.removeEventListener("pointerdown", onPointerDown, true);
3192
+ window.removeEventListener("resize", measure);
3193
+ window.removeEventListener("scroll", measure, true);
3194
+ };
3195
+ };
3196
+ return {
3197
+ open,
3198
+ triggerProps: {
3199
+ id: triggerId,
3200
+ "aria-haspopup": "dialog",
3201
+ get "aria-expanded"() {
3202
+ return open() ? "true" : "false";
3203
+ },
3204
+ "aria-controls": contentId,
3205
+ onClick: () => requestOpen(!open()),
3206
+ onKeyDown: (event) => {
3207
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
3208
+ event.preventDefault();
3209
+ requestOpen(true);
3210
+ }
3211
+ }
3212
+ },
3213
+ contentProps: {
3214
+ id: contentId,
3215
+ role: "dialog",
3216
+ "aria-modal": void 0,
3217
+ tabindex: -1,
3218
+ get hidden() {
3219
+ return !open();
3220
+ },
3221
+ onKeyDown: (event) => {
3222
+ if (event.key === "Escape" && open()) {
3223
+ event.preventDefault();
3224
+ requestOpen(false);
3225
+ }
3226
+ }
3227
+ },
3228
+ contentStyle: () => {
3229
+ const at = measured();
3230
+ return {
3231
+ position: "absolute",
3232
+ top: `${at?.top ?? 0}px`,
3233
+ left: `${at?.left ?? 0}px`
3234
+ };
3235
+ },
3236
+ position,
3237
+ attach
3238
+ };
3239
+ };
3082
3240
  // src/grid.ts
3083
3241
  const GRID_KEYS = /* @__PURE__ */ new Set([
3084
3242
  "ArrowRight",
@@ -4824,7 +4982,7 @@ const createRadioGroup = (options) => {
4824
4982
  };
4825
4983
  // src/resizable-panels.ts
4826
4984
  const DEFAULT_MIN = 10;
4827
- const DEFAULT_MAX = 80;
4985
+ const DEFAULT_MAX$1 = 80;
4828
4986
  const DEFAULT_STEP = 10;
4829
4987
  const FULL = 100;
4830
4988
  const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
@@ -4894,7 +5052,7 @@ const createResizablePanels = (options) => {
4894
5052
  const minOf = (index) => panes()[index]?.min ?? DEFAULT_MIN;
4895
5053
  /** The smallest committed size (a collapsible pane may sit at collapsedSize). */
4896
5054
  const lowOf = (index) => panes()[index]?.collapsible === true ? collapsedSize(index) : minOf(index);
4897
- const highOf = (index) => panes()[index]?.max ?? DEFAULT_MAX;
5055
+ const highOf = (index) => panes()[index]?.max ?? DEFAULT_MAX$1;
4898
5056
  /** Clamp every pane, then push the residual into the panes in order. */
4899
5057
  const fit = (raw) => {
4900
5058
  const next = panes().map((_, index) => clamp(round2(raw[index] ?? FULL / Math.max(count(), 1)), lowOf(index), highOf(index)));
@@ -5544,6 +5702,37 @@ const createSpinbutton = (options = {}) => {
5544
5702
  }
5545
5703
  };
5546
5704
  };
5705
+ // src/status.ts
5706
+ /**
5707
+ * APG Alert's polite sibling: one `role=status` live region whose text the host
5708
+ * replaces ("Link copied", "Saved"). It is not a queue: a second message shows
5709
+ * in place of the first, so a burst reads as the last state, not as N alerts.
5710
+ * The region is rendered once and stays mounted, which is what makes a change
5711
+ * of its text announced.
5712
+ *
5713
+ * A status is never for errors: an error that needs attention now is
5714
+ * `createAlert` (`role=alert`, assertive), and a message with a title, a
5715
+ * description and a timeout is `createToast`.
5716
+ */
5717
+ const createStatus = (options = {}) => {
5718
+ const id = options.id ?? createUniqueId();
5719
+ const [message, setMessage] = createSignal();
5720
+ return {
5721
+ message,
5722
+ show: (text) => {
5723
+ setMessage(text);
5724
+ },
5725
+ clear: () => {
5726
+ setMessage(void 0);
5727
+ },
5728
+ statusProps: {
5729
+ id,
5730
+ role: "status",
5731
+ "aria-live": "polite",
5732
+ "aria-atomic": "true"
5733
+ }
5734
+ };
5735
+ };
5547
5736
  // src/switch.ts
5548
5737
  /**
5549
5738
  * APG Switch (https://www.w3.org/WAI/ARIA/apg/patterns/switch/): a binary on/off
@@ -6090,6 +6279,143 @@ const createTimeline = (options = {}) => {
6090
6279
  }
6091
6280
  };
6092
6281
  };
6282
+ // src/toast.ts
6283
+ const DEFAULT_TIMEOUT_MS = 5e3;
6284
+ const DEFAULT_MAX = 3;
6285
+ const DEFAULT_REGION_LABEL = "Notifications";
6286
+ /**
6287
+ * A toast queue: one timeout per toast, a ceiling that drops the oldest, and a
6288
+ * countdown that pauses on hover and focus and resumes from where it stopped.
6289
+ * A toast announces itself through its own live region (`role=status`, or
6290
+ * `role=alert` for `error`), so the host renders one element per toast inside
6291
+ * `regionProps`. No DOM access here: the host renders, the primitive decides.
6292
+ *
6293
+ * The timeout is a floor of practice, not of code: the default is 5000 ms
6294
+ * because both Base UI and React Aria agree on at least five seconds to read a
6295
+ * message; shorter values exist for tests and are the host's judgement.
6296
+ */
6297
+ const createToast = (options = {}) => {
6298
+ const [version, setVersion] = createSignal(0);
6299
+ let items = [];
6300
+ const timers = /* @__PURE__ */ new Map();
6301
+ const toasts = () => {
6302
+ version();
6303
+ return items;
6304
+ };
6305
+ const commit = (next) => {
6306
+ items = [...next];
6307
+ setVersion((count) => count + 1);
6308
+ };
6309
+ const clear = (id) => {
6310
+ const countdown = timers.get(id);
6311
+ if (countdown === void 0) return;
6312
+ if (countdown.handle !== void 0) clearTimeout(countdown.handle);
6313
+ timers.delete(id);
6314
+ };
6315
+ const dismiss = (id) => {
6316
+ if (!items.some((item) => item.id === id)) return;
6317
+ clear(id);
6318
+ commit(items.filter((item) => item.id !== id));
6319
+ options.onDismiss?.(id);
6320
+ };
6321
+ const dismissAll = () => {
6322
+ const ids = items.map((item) => item.id);
6323
+ if (ids.length === 0) return;
6324
+ for (const id of ids) clear(id);
6325
+ commit([]);
6326
+ for (const id of ids) options.onDismiss?.(id);
6327
+ };
6328
+ const arm = (id, countdown) => {
6329
+ countdown.handle = setTimeout(() => dismiss(id), countdown.remaining);
6330
+ };
6331
+ const pause = (id) => {
6332
+ const countdown = timers.get(id);
6333
+ if (countdown === void 0 || countdown.handle === void 0) return;
6334
+ clearTimeout(countdown.handle);
6335
+ countdown.handle = void 0;
6336
+ countdown.remaining = Math.max(0, countdown.deadline - Date.now());
6337
+ };
6338
+ const paused = (countdown) => countdown.focused || countdown.hovered && (options.pauseOnHover ?? true);
6339
+ const resume = (id) => {
6340
+ const countdown = timers.get(id);
6341
+ if (countdown === void 0 || countdown.handle !== void 0 || paused(countdown)) return;
6342
+ countdown.deadline = Date.now() + countdown.remaining;
6343
+ arm(id, countdown);
6344
+ };
6345
+ const sync = (id, countdown) => {
6346
+ if (paused(countdown)) pause(id);
6347
+ else resume(id);
6348
+ };
6349
+ const show = (input) => {
6350
+ const item = {
6351
+ id: createUniqueId(),
6352
+ title: input.title,
6353
+ description: input.description,
6354
+ tone: input.tone ?? "info"
6355
+ };
6356
+ const limit = Math.max(1, options.max ?? DEFAULT_MAX);
6357
+ const next = [...items, item];
6358
+ const dropped = [];
6359
+ while (next.length > limit) {
6360
+ const oldest = next.shift();
6361
+ if (oldest !== void 0) dropped.push(oldest.id);
6362
+ }
6363
+ commit(next);
6364
+ for (const id of dropped) {
6365
+ clear(id);
6366
+ options.onDismiss?.(id);
6367
+ }
6368
+ const duration = Math.max(0, options.timeout ?? DEFAULT_TIMEOUT_MS);
6369
+ const countdown = {
6370
+ handle: void 0,
6371
+ deadline: Date.now() + duration,
6372
+ remaining: duration,
6373
+ hovered: false,
6374
+ focused: false
6375
+ };
6376
+ timers.set(item.id, countdown);
6377
+ arm(item.id, countdown);
6378
+ return item.id;
6379
+ };
6380
+ const hover = (id, next) => {
6381
+ const countdown = timers.get(id);
6382
+ if (countdown === void 0) return;
6383
+ countdown.hovered = next;
6384
+ sync(id, countdown);
6385
+ };
6386
+ const focus = (id, next) => {
6387
+ const countdown = timers.get(id);
6388
+ if (countdown === void 0) return;
6389
+ countdown.focused = next;
6390
+ sync(id, countdown);
6391
+ };
6392
+ if (getOwner() !== null) onCleanup(() => {
6393
+ for (const countdown of timers.values()) if (countdown.handle !== void 0) clearTimeout(countdown.handle);
6394
+ timers.clear();
6395
+ });
6396
+ return {
6397
+ toasts,
6398
+ show,
6399
+ dismiss,
6400
+ dismissAll,
6401
+ regionProps: {
6402
+ role: "region",
6403
+ get "aria-label"() {
6404
+ return options.labels?.region ?? DEFAULT_REGION_LABEL;
6405
+ }
6406
+ },
6407
+ toastProps: (id) => ({
6408
+ get role() {
6409
+ return items.find((item) => item.id === id)?.tone === "error" ? "alert" : "status";
6410
+ },
6411
+ "aria-atomic": "true",
6412
+ onMouseEnter: () => hover(id, true),
6413
+ onMouseLeave: () => hover(id, false),
6414
+ onFocusIn: () => focus(id, true),
6415
+ onFocusOut: () => focus(id, false)
6416
+ })
6417
+ };
6418
+ };
6093
6419
  // src/toolbar.ts
6094
6420
  const AXIS = (o) => o === "horizontal" ? "inline" : "block";
6095
6421
  const delta = (axis, key) => {
@@ -7683,4 +8009,4 @@ const createRearrangeableListbox = (options) => {
7683
8009
  })
7684
8010
  };
7685
8011
  };
7686
- export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createFeed, createField, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPlayer, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createSwitch, createTabs, createTextArea, createTextInput, createTimeline, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
8012
+ export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createFeed, createField, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, createTabs, createTextArea, createTextInput, createTimeline, createToast, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cssints/headless",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Headless APG widgets on Solid 2: state plus attribute objects, no elements, no styling, each conformance-checked against its Quint spec",
5
5
  "license": "MIT",
6
6
  "repository": {