@cssints/headless 0.13.0 → 0.14.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,32 @@ 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
+ ## Popover
224
+
225
+ `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).
226
+
227
+ ```tsx
228
+ const popover = createPopover({ placement: "bottom-start" });
229
+ let trigger!: HTMLButtonElement;
230
+ let content!: HTMLDivElement;
231
+
232
+ onMount(() => {
233
+ const teardown = popover.attach(trigger, content);
234
+ onCleanup(teardown);
235
+ });
236
+
237
+ <button {...popover.triggerProps} ref={(element) => (trigger = element)}>Filters</button>
238
+ <div {...popover.contentProps} style={popover.contentStyle()} ref={(element) => (content = element)}>
239
+ …
240
+ </div>;
241
+ ```
242
+
243
+ - **`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.
244
+ - **`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`.
245
+ - **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.
246
+ - **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.
247
+ - 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).
248
+
223
249
  ## Commands
224
250
 
225
251
  ```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
@@ -2669,7 +2769,7 @@ export declare const createSwitch: (options?: SwitchOptions) => Switch;
2669
2769
  /** The HTML `autocomplete` tokens worth naming; the attribute also takes an arbitrary token. */
2670
2770
  export type AutoCompleteToken = "off" | "on" | "name" | "given-name" | "family-name" | "username" | "email" | "tel" | "url" | "organization" | "street-address" | "postal-code" | "current-password" | "new-password" | "one-time-code" | "nickname";
2671
2771
  export interface TextInputOptions extends FieldOptions {
2672
- readonly type?: "text" | "email" | "tel" | "url" | "search" | "password";
2772
+ readonly type?: "text" | "email" | "tel" | "url" | "search" | "password" | undefined;
2673
2773
  /** The HTML token list: the browser's own autofill is the accessible path. */
2674
2774
  readonly autoComplete?: AutoCompleteToken | undefined;
2675
2775
  readonly spellCheck?: boolean | undefined;
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",
@@ -7683,4 +7841,4 @@ const createRearrangeableListbox = (options) => {
7683
7841
  })
7684
7842
  };
7685
7843
  };
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 };
7844
+ 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, createSwitch, createTabs, createTextArea, createTextInput, createTimeline, 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.0",
3
+ "version": "0.14.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": {