@cssints/headless 0.8.0 → 0.10.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, dialog-modal, disclosure, feed, 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, 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
 
@@ -123,6 +123,68 @@ Controlled is `sizes` + `onSizesChange` (the request), like every other primitiv
123
123
 
124
124
  Left to the host: pane bounds in pixels (percent only), nested groups, and F6 (with three panes a Tab is enough). Not modelled: react-resizable-panels' collapse-on-drag threshold (a pane collapses on Enter or a host write, never by dragging through MIN) and restoring a pane that was mounted already collapsed (it returns to its own `min`).
125
125
 
126
+ ## Form field
127
+
128
+ `createField` is the ARIA contract of a form field: a label, a control, a hint and an error. **There is no APG pattern for it**, so there is no Quint spec and no ported scenarios: the contract is WAI-ARIA (`aria-describedby`, `aria-invalid`, `aria-required`) and HTML (`label[for]`, `required`), and where those are silent the majority of Base UI Field, react-aria TextField and ariakit FormControl. `test/field.browser.test.tsx` is its oracle (plain assertions on the attribute objects; no DOM is needed, the file name is only what `vite.config.ts` includes).
129
+
130
+ ```tsx
131
+ const field = createField({
132
+ required: true,
133
+ hint: "At least 8 characters",
134
+ get invalid() {
135
+ return touched() && password().length < 8;
136
+ },
137
+ get error() {
138
+ return "Too short";
139
+ },
140
+ });
141
+
142
+ <label {...field.labelProps}>Password</label>
143
+ <input type="password" {...field.controlProps} />
144
+ <p {...field.hintProps}>{field.hint()}</p>
145
+ <p {...field.errorProps}>{field.error()}</p>;
146
+ ```
147
+
148
+ - **Ids** are `<id>-control`, `-label`, `-hint`, `-error` from `id` (or a unique one); `labelProps.for` is the control's id.
149
+ - **Render the hint and the error elements always.** They carry `hidden` while absent, and `aria-describedby` lists only the ones that are shown, hint first: a reference never points at a hidden or missing id.
150
+ - **The error does not replace the hint.** Both are listed, hint then error, as Base UI, react-aria and ariakit do: the hint is what tells the user how to fix the error.
151
+ - **`aria-invalid` only when invalid.** An error message makes the field invalid; `invalid: "message"` is both the flag and the message (an `error` wins over it); `invalid: true` without a message marks the control and shows no error; `invalid: false` wins over everything, so a host can keep the message ready and pass `touched() && failed()`.
152
+ - **`required`** sets both `required` and `aria-required` (a native control needs the first, a custom widget the second); use `novalidate` on the form if the browser's own bubbles are not wanted.
153
+ - **The error announces itself.** `errorProps.role` is `"alert"` while the error is shown and `undefined` while it is hidden, so spreading `errorProps` is all the host does (no hand-written `role={field.error() === undefined ? undefined : "alert"}`); an element that is hidden announces nothing, and one that appears is announced. The hint stays a plain element: `hintProps` has no `role`.
154
+ - **One refusal that belongs to two controls.** `describedby` is an id, or a list of ids, of an element the host owns, appended to `aria-describedby` after the field's own hint and error ids (`password-hint password-error form-error`) and dropped when empty. Give a form-level error ("The passwords do not match") one element with its own id and `role`, and pass that id to both fields: each control then names the same message, and it is announced once, not once per field. The field cannot see whether that element is shown, so pass the id only while it is (a getter: `get describedby() { return mismatch() ? "form-error" : undefined; }`); `describedIds()` includes it.
155
+ - **Getters that return `undefined` typecheck.** `id`, `required`, `invalid`, `hint`, `error` and `describedby` are all `T | undefined`, so a getter over an optional prop (`get required() { return props.required; }`) compiles under `exactOptionalPropertyTypes`.
156
+
157
+ ## Confirm
158
+
159
+ `createConfirm` is a promise-based confirmation over `createDialogModal({ role: "alertdialog" })`. Like the field it is not checked against a spec (the dialog is; `test/confirm.browser.test.tsx` covers the promise around it, in Chromium).
160
+
161
+ ```tsx
162
+ const confirm = createConfirm();
163
+
164
+ const remove = async () => {
165
+ if (await confirm.confirm({ title: "Delete post?", description: "This cannot be undone.", confirmLabel: "Delete", destructive: true })) {
166
+ await deletePost();
167
+ }
168
+ };
169
+
170
+ <button {...confirm.triggerProps} onClick={remove}>Delete</button>
171
+ <div {...confirm.backdropProps} />
172
+ <div {...confirm.dialogProps}>
173
+ <h2 {...confirm.titleProps}>{confirm.request()?.title}</h2>
174
+ <p {...confirm.descriptionProps}>{confirm.request()?.description}</p>
175
+ <button {...confirm.cancelProps}>{confirm.request()?.cancelLabel}</button>
176
+ <button {...confirm.confirmProps}>{confirm.request()?.confirmLabel}</button>
177
+ </div>;
178
+ ```
179
+
180
+ - `confirm(request)` resolves `true` on the confirm button, `false` on cancel, Escape or a click on the backdrop. `onOpenChange` fires when it opens and when it settles.
181
+ - **One request at a time.** A second `confirm()` while one is pending **rejects** with `ConfirmPendingError` and leaves the first alone; it is not queued (a double click on "Delete" would ask twice, and a queued question can outlive the screen that asked it). A pending request is answered `false` when the owner is disposed.
182
+ - **Focus.** The cancel button takes focus on open (the least destructive action); on settle focus returns to the element that had it when `confirm()` was called, before the promise resolves, so the caller may move it on.
183
+ - **The dialog shell, while it is open.** Tab and Shift+Tab cycle among the dialog's tabbable elements (from the last to the first and back; a focus that escaped is pulled in on the next Tab; with nothing tabbable the dialog itself keeps it). `overflow: hidden` is set on `document.documentElement` and the inline value it had is restored on close, on dispose and on every way the request settles; add `scrollbar-gutter: stable` to the page if the vanished scrollbar must not shift the layout (the lock does not pad for it). Escape is read from the **document**, in the capture phase, so it answers `false` wherever the focus is, and it stops there: the elements around the dialog (a dialog the confirm sits in) do not see it. The backdrop click is `backdropProps`, as before; keep the backdrop and the dialog siblings, or a click inside the dialog reaches the backdrop. Making the rest of the page `inert` or `aria-hidden` is still the host's.
184
+ - **Nesting.** Confirms from different `createConfirm` calls may be open together (a "discard changes?" over a "delete?" that opened it): the one opened last gets Escape and Tab, the one under it waits, and the scroll lock is counted, so the first takes it and the last to close restores it. One `createConfirm` is still one request at a time. A `createDialogModal` that is open under a confirm knows nothing of this stack.
185
+ - **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.
186
+ - `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.
187
+
126
188
  ## Commands
127
189
 
128
190
  ```sh
package/dist/index.d.mts CHANGED
@@ -1294,6 +1294,94 @@ export interface DialogModal {
1294
1294
  * are requests the host may ignore.
1295
1295
  */
1296
1296
  export declare const createDialogModal: (options?: DialogModalOptions) => DialogModal;
1297
+ export interface ConfirmOptions {
1298
+ readonly id?: string | undefined;
1299
+ /** Called with `true` when a request opens the dialog and `false` when it settles. */
1300
+ readonly onOpenChange?: (open: boolean) => void;
1301
+ }
1302
+ export interface ConfirmRequest {
1303
+ readonly title: string;
1304
+ readonly description?: string | undefined;
1305
+ /**
1306
+ * Default `"Confirm"`. The defaults are English and stay so: a host that
1307
+ * translates passes both labels (and the title) with every request.
1308
+ */
1309
+ readonly confirmLabel?: string | undefined;
1310
+ /** Default `"Cancel"`; see `confirmLabel`. */
1311
+ readonly cancelLabel?: string | undefined;
1312
+ /** The confirm action destroys something: the host styles it so. */
1313
+ readonly destructive?: boolean | undefined;
1314
+ }
1315
+ /** The request being shown (or shown last), with the defaults applied. */
1316
+ export interface ConfirmView {
1317
+ readonly title: string;
1318
+ readonly description: string | undefined;
1319
+ readonly confirmLabel: string;
1320
+ readonly cancelLabel: string;
1321
+ readonly destructive: boolean;
1322
+ }
1323
+ /** The attributes of dialog-modal's trigger; the click that calls `confirm()` is the host's. */
1324
+ export type ConfirmTriggerProps = Omit<DialogTriggerProps, "onClick" | "onKeyDown">;
1325
+ export interface ConfirmTextProps {
1326
+ readonly id: string;
1327
+ readonly hidden?: boolean;
1328
+ }
1329
+ export interface ConfirmButtonProps {
1330
+ readonly id: string;
1331
+ readonly type: "button";
1332
+ readonly onClick: () => void;
1333
+ }
1334
+ export interface Confirm {
1335
+ /**
1336
+ * Opens the dialog and resolves with the answer: `true` on confirm, `false`
1337
+ * on cancel, Escape or a backdrop click. A request made while one is pending
1338
+ * rejects with `ConfirmPendingError` and leaves the pending one alone.
1339
+ */
1340
+ readonly confirm: (request: ConfirmRequest) => Promise<boolean>;
1341
+ readonly open: Accessor<boolean>;
1342
+ readonly request: Accessor<ConfirmView | undefined>;
1343
+ readonly triggerProps: ConfirmTriggerProps;
1344
+ readonly dialogProps: DialogContentProps;
1345
+ readonly backdropProps: DialogBackdropProps;
1346
+ readonly titleProps: ConfirmTextProps;
1347
+ /** Render this element always: `aria-describedby` points at it. Hidden when there is no description. */
1348
+ readonly descriptionProps: ConfirmTextProps;
1349
+ readonly cancelProps: ConfirmButtonProps;
1350
+ readonly confirmProps: ConfirmButtonProps;
1351
+ }
1352
+ export declare class ConfirmPendingError extends Error {
1353
+ readonly name = "ConfirmPendingError";
1354
+ constructor();
1355
+ }
1356
+ /**
1357
+ * A promise-based confirmation over `createDialogModal({ role: "alertdialog" })`
1358
+ * (APG Alert Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/).
1359
+ * Not checked against a Quint spec: the dialog is, the promise around it is
1360
+ * covered by `test/confirm.browser.test.tsx`.
1361
+ *
1362
+ * Focus: the cancel button, the least destructive action, takes focus when the
1363
+ * dialog opens; when the request settles, focus goes back to the element that
1364
+ * had it when `confirm()` was called, before the promise resolves, so the
1365
+ * caller's continuation may move it again.
1366
+ *
1367
+ * The shell, while open: Tab and Shift+Tab cycle inside the dialog (and pull
1368
+ * focus back in if it escaped), `overflow: hidden` on the document element, and
1369
+ * Escape is read from the document in the capture phase, so it answers `false`
1370
+ * wherever focus is. The backdrop click is `backdropProps`; keep the backdrop
1371
+ * and the dialog siblings, or a click in the dialog reaches the backdrop. Hiding
1372
+ * the rest of the page (`inert`) stays the host's.
1373
+ *
1374
+ * Nesting: confirms from different `createConfirm` calls may be open together;
1375
+ * only the last one opened gets Escape and Tab (and Escape does not reach a
1376
+ * dialog it sits in), and the scroll lock is counted: the first takes it, the
1377
+ * last to close restores what `overflow` was. One confirm still has one request.
1378
+ *
1379
+ * One request at a time: a second `confirm()` while one is pending is rejected,
1380
+ * not queued. Queued, a double click on "Delete" would ask twice and a stale
1381
+ * question could outlive the screen that asked it. A pending request is
1382
+ * answered `false` when the owner is disposed.
1383
+ */
1384
+ export declare const createConfirm: (options?: ConfirmOptions) => Confirm;
1297
1385
  /**
1298
1386
  * Options are read lazily (getters); pass a component's `props`.
1299
1387
  */
@@ -1381,6 +1469,94 @@ export interface Feed {
1381
1469
  * request; `loadStart` / `loadFail` are adapter lifecycle events.
1382
1470
  */
1383
1471
  export declare const createFeed: (options?: FeedOptions) => Feed;
1472
+ export interface FieldOptions {
1473
+ /** Base of every id the field makes (`<id>-control`, `-label`, `-hint`, `-error`). */
1474
+ readonly id?: string | undefined;
1475
+ readonly required?: boolean | undefined;
1476
+ /**
1477
+ * `true` marks the control invalid; a string does too and is the error message
1478
+ * (unless `error` is set). `false` forces valid: an `error` is then not shown,
1479
+ * so a host can pass `touched() && failed()` and keep the message ready.
1480
+ * Left out, the field is invalid exactly when it has an error message.
1481
+ */
1482
+ readonly invalid?: boolean | string | undefined;
1483
+ /** Help text; absent (or empty) means the hint element is hidden and not referenced. */
1484
+ readonly hint?: string | undefined;
1485
+ /** The error message; absent (or empty) means no error element. */
1486
+ readonly error?: string | undefined;
1487
+ /**
1488
+ * Ids of elements the host owns, appended to `aria-describedby` after the
1489
+ * field's own hint and error: a form-level error that belongs to several
1490
+ * controls ("the passwords do not match" names both). One id or a list;
1491
+ * empty strings are dropped, and the attribute is absent when nothing is left.
1492
+ * The host keeps that element rendered, and decides whether it is hidden.
1493
+ */
1494
+ readonly describedby?: string | readonly string[] | undefined;
1495
+ }
1496
+ /** Spread onto the form control (`input`, `textarea`, `select`, or a custom widget). */
1497
+ export interface FieldControlProps {
1498
+ readonly id: string;
1499
+ /** The ids of the hint and the error that are shown, hint first, then the host's `describedby`; absent when there are none. */
1500
+ readonly "aria-describedby": string | undefined;
1501
+ readonly "aria-invalid": "true" | undefined;
1502
+ /** For a control that is not native (a native one has `required`). */
1503
+ readonly "aria-required": "true" | undefined;
1504
+ readonly required: true | undefined;
1505
+ }
1506
+ export interface FieldLabelProps {
1507
+ readonly id: string;
1508
+ readonly for: string;
1509
+ }
1510
+ /** Spread onto the hint and the error element; render both, always. */
1511
+ export interface FieldMessageProps {
1512
+ readonly id: string;
1513
+ readonly hidden: boolean;
1514
+ }
1515
+ /** The error element also announces itself: `role="alert"` while it is shown. */
1516
+ export interface FieldErrorProps extends FieldMessageProps {
1517
+ readonly role: "alert" | undefined;
1518
+ }
1519
+ export interface Field {
1520
+ readonly invalid: () => boolean;
1521
+ /** The hint, or `undefined` when there is none. */
1522
+ readonly hint: () => string | undefined;
1523
+ /** The error shown: `undefined` unless the field is invalid and has a message. */
1524
+ readonly error: () => string | undefined;
1525
+ /**
1526
+ * The ids `aria-describedby` lists now: hint, then error (never an id that is
1527
+ * hidden), then the host's `describedby`.
1528
+ */
1529
+ readonly describedIds: () => readonly string[];
1530
+ readonly controlProps: FieldControlProps;
1531
+ readonly labelProps: FieldLabelProps;
1532
+ readonly hintProps: FieldMessageProps;
1533
+ readonly errorProps: FieldErrorProps;
1534
+ }
1535
+ /**
1536
+ * The ARIA contract of a form field: a label, a control, a hint and an error.
1537
+ * There is no APG pattern for it (APG has no "form field"; the nearest is the
1538
+ * label and description rules of the accessible name computation), so the
1539
+ * contract comes from WAI-ARIA 1.2 (`aria-describedby`, `aria-invalid`,
1540
+ * `aria-required`) and HTML (`label[for]`, `required`), and from the
1541
+ * majority of Base UI Field, react-aria TextField and ariakit FormControl,
1542
+ * which list the description and the error message together. It is not checked
1543
+ * against a Quint spec; `test/field.browser.test.tsx` is its oracle.
1544
+ *
1545
+ * Decisions: the error does not replace the hint, both are listed, hint first
1546
+ * (the hint is what tells the user how to fix the error); an error implies
1547
+ * invalid and `invalid: false` overrides it; a hidden message is never
1548
+ * referenced, so `aria-describedby` has no dangling id. The error element is
1549
+ * `role="alert"` only while it is shown (a hidden element announces nothing, and
1550
+ * one that appears is announced); the hint is never an alert.
1551
+ *
1552
+ * `describedby` lists host-owned ids after the field's own: one refusal that
1553
+ * belongs to two controls is one form-level element (with its own `role`), named
1554
+ * by both. The field cannot know whether that element is shown: the host
1555
+ * passes the id only while it is.
1556
+ *
1557
+ * Options are read when an attribute is read: pass getters, never destructure.
1558
+ */
1559
+ export declare const createField: (options?: FieldOptions) => Field;
1384
1560
  /**
1385
1561
  * APG grid primitive on Solid 2: roving focus, 2-D navigation, edit mode, a
1386
1562
  * cell/row/column selection rectangle and sortable column headers. State plus
package/dist/index.mjs CHANGED
@@ -2584,6 +2584,174 @@ const createDialogModal = (options = {}) => {
2584
2584
  }
2585
2585
  };
2586
2586
  };
2587
+ // src/confirm.ts
2588
+ var ConfirmPendingError = class extends Error {
2589
+ name = "ConfirmPendingError";
2590
+ constructor() {
2591
+ super("a confirmation is already pending");
2592
+ }
2593
+ };
2594
+ const openDialogs = [];
2595
+ let scrollLocks = 0;
2596
+ let scrollBefore = "";
2597
+ const lockScroll = () => {
2598
+ const root = document.documentElement;
2599
+ if (scrollLocks++ === 0) {
2600
+ scrollBefore = root.style.overflow;
2601
+ root.style.overflow = "hidden";
2602
+ }
2603
+ return () => {
2604
+ if (--scrollLocks === 0) root.style.overflow = scrollBefore;
2605
+ };
2606
+ };
2607
+ const TABBABLE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
2608
+ const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
2609
+ /**
2610
+ * A promise-based confirmation over `createDialogModal({ role: "alertdialog" })`
2611
+ * (APG Alert Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/).
2612
+ * Not checked against a Quint spec: the dialog is, the promise around it is
2613
+ * covered by `test/confirm.browser.test.tsx`.
2614
+ *
2615
+ * Focus: the cancel button, the least destructive action, takes focus when the
2616
+ * dialog opens; when the request settles, focus goes back to the element that
2617
+ * had it when `confirm()` was called, before the promise resolves, so the
2618
+ * caller's continuation may move it again.
2619
+ *
2620
+ * The shell, while open: Tab and Shift+Tab cycle inside the dialog (and pull
2621
+ * focus back in if it escaped), `overflow: hidden` on the document element, and
2622
+ * Escape is read from the document in the capture phase, so it answers `false`
2623
+ * wherever focus is. The backdrop click is `backdropProps`; keep the backdrop
2624
+ * and the dialog siblings, or a click in the dialog reaches the backdrop. Hiding
2625
+ * the rest of the page (`inert`) stays the host's.
2626
+ *
2627
+ * Nesting: confirms from different `createConfirm` calls may be open together;
2628
+ * only the last one opened gets Escape and Tab (and Escape does not reach a
2629
+ * dialog it sits in), and the scroll lock is counted: the first takes it, the
2630
+ * last to close restores what `overflow` was. One confirm still has one request.
2631
+ *
2632
+ * One request at a time: a second `confirm()` while one is pending is rejected,
2633
+ * not queued. Queued, a double click on "Delete" would ask twice and a stale
2634
+ * question could outlive the screen that asked it. A pending request is
2635
+ * answered `false` when the owner is disposed.
2636
+ */
2637
+ const createConfirm = (options = {}) => {
2638
+ const base = options.id ?? createUniqueId();
2639
+ const titleId = `${base}-title`;
2640
+ const descriptionId = `${base}-description`;
2641
+ const cancelId = `${base}-cancel`;
2642
+ const [request, setRequest] = createSignal(void 0);
2643
+ let pending;
2644
+ const settle = (answer) => {
2645
+ const current = pending;
2646
+ if (current === void 0) return;
2647
+ pending = void 0;
2648
+ dialog.close();
2649
+ current.invoker?.focus();
2650
+ current.resolve(answer);
2651
+ };
2652
+ const dialog = createDialogModal({
2653
+ id: base,
2654
+ role: "alertdialog",
2655
+ labelledby: titleId,
2656
+ describedby: descriptionId,
2657
+ onOpenChange: (open) => {
2658
+ if (!open) settle(false);
2659
+ options.onOpenChange?.(open);
2660
+ }
2661
+ });
2662
+ const self = Symbol(base);
2663
+ const onDocumentKeyDown = (event) => {
2664
+ if (openDialogs.at(-1) !== self) return;
2665
+ if (event.key === "Escape") {
2666
+ event.preventDefault();
2667
+ event.stopPropagation();
2668
+ settle(false);
2669
+ return;
2670
+ }
2671
+ const root = document.getElementById(dialog.dialogProps.id);
2672
+ if (event.key !== "Tab" || root === null) return;
2673
+ const items = tabbables(root);
2674
+ const first = items[0];
2675
+ const last = items.at(-1);
2676
+ const active = document.activeElement;
2677
+ const inside = root.contains(active);
2678
+ if (first === void 0 || last === void 0) {
2679
+ event.preventDefault();
2680
+ root.focus();
2681
+ } else if (event.shiftKey && (!inside || active === first || active === root)) {
2682
+ event.preventDefault();
2683
+ last.focus();
2684
+ } else if (!event.shiftKey && (!inside || active === last)) {
2685
+ event.preventDefault();
2686
+ first.focus();
2687
+ }
2688
+ };
2689
+ createEffect(() => dialog.open(), (isOpen) => {
2690
+ if (!isOpen) return;
2691
+ document.getElementById(cancelId)?.focus();
2692
+ const unlock = lockScroll();
2693
+ openDialogs.push(self);
2694
+ document.addEventListener("keydown", onDocumentKeyDown, true);
2695
+ return () => {
2696
+ document.removeEventListener("keydown", onDocumentKeyDown, true);
2697
+ openDialogs.splice(openDialogs.indexOf(self), 1);
2698
+ unlock();
2699
+ };
2700
+ });
2701
+ onCleanup(() => {
2702
+ pending?.resolve(false);
2703
+ pending = void 0;
2704
+ });
2705
+ const confirm = (next) => {
2706
+ if (pending !== void 0) return Promise.reject(new ConfirmPendingError());
2707
+ return new Promise((resolve) => {
2708
+ pending = {
2709
+ resolve,
2710
+ invoker: document.activeElement instanceof HTMLElement ? document.activeElement : null
2711
+ };
2712
+ setRequest({
2713
+ title: next.title,
2714
+ description: next.description,
2715
+ confirmLabel: next.confirmLabel ?? "Confirm",
2716
+ cancelLabel: next.cancelLabel ?? "Cancel",
2717
+ destructive: next.destructive === true
2718
+ });
2719
+ dialog.openDialog();
2720
+ });
2721
+ };
2722
+ return {
2723
+ confirm,
2724
+ open: dialog.open,
2725
+ request,
2726
+ triggerProps: {
2727
+ id: dialog.triggerProps.id,
2728
+ "aria-haspopup": "dialog",
2729
+ get "aria-expanded"() {
2730
+ return dialog.triggerProps["aria-expanded"];
2731
+ },
2732
+ "aria-controls": dialog.triggerProps["aria-controls"]
2733
+ },
2734
+ dialogProps: dialog.dialogProps,
2735
+ backdropProps: dialog.backdropProps,
2736
+ titleProps: { id: titleId },
2737
+ descriptionProps: {
2738
+ id: descriptionId,
2739
+ get hidden() {
2740
+ return request()?.description === void 0;
2741
+ }
2742
+ },
2743
+ cancelProps: {
2744
+ id: cancelId,
2745
+ type: "button",
2746
+ onClick: () => settle(false)
2747
+ },
2748
+ confirmProps: {
2749
+ id: `${base}-confirm`,
2750
+ type: "button",
2751
+ onClick: () => settle(true)
2752
+ }
2753
+ };
2754
+ };
2587
2755
  // src/feed.ts
2588
2756
  /**
2589
2757
  * APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
@@ -2719,6 +2887,90 @@ const createFeed = (options = {}) => {
2719
2887
  }
2720
2888
  };
2721
2889
  };
2890
+ // src/field.ts
2891
+ const text = (value) => value === void 0 || value === "" ? void 0 : value;
2892
+ const external = (value) => (value === void 0 ? [] : typeof value === "string" ? [value] : value).filter((id) => id !== "");
2893
+ /**
2894
+ * The ARIA contract of a form field: a label, a control, a hint and an error.
2895
+ * There is no APG pattern for it (APG has no "form field"; the nearest is the
2896
+ * label and description rules of the accessible name computation), so the
2897
+ * contract comes from WAI-ARIA 1.2 (`aria-describedby`, `aria-invalid`,
2898
+ * `aria-required`) and HTML (`label[for]`, `required`), and from the
2899
+ * majority of Base UI Field, react-aria TextField and ariakit FormControl,
2900
+ * which list the description and the error message together. It is not checked
2901
+ * against a Quint spec; `test/field.browser.test.tsx` is its oracle.
2902
+ *
2903
+ * Decisions: the error does not replace the hint, both are listed, hint first
2904
+ * (the hint is what tells the user how to fix the error); an error implies
2905
+ * invalid and `invalid: false` overrides it; a hidden message is never
2906
+ * referenced, so `aria-describedby` has no dangling id. The error element is
2907
+ * `role="alert"` only while it is shown (a hidden element announces nothing, and
2908
+ * one that appears is announced); the hint is never an alert.
2909
+ *
2910
+ * `describedby` lists host-owned ids after the field's own: one refusal that
2911
+ * belongs to two controls is one form-level element (with its own `role`), named
2912
+ * by both. The field cannot know whether that element is shown: the host
2913
+ * passes the id only while it is.
2914
+ *
2915
+ * Options are read when an attribute is read: pass getters, never destructure.
2916
+ */
2917
+ const createField = (options = {}) => {
2918
+ const base = options.id ?? createUniqueId();
2919
+ const controlId = `${base}-control`;
2920
+ const labelId = `${base}-label`;
2921
+ const hintId = `${base}-hint`;
2922
+ const errorId = `${base}-error`;
2923
+ const hint = () => text(options.hint);
2924
+ const message = () => text(options.error) ?? (typeof options.invalid === "string" ? text(options.invalid) : void 0);
2925
+ const invalid = () => options.invalid === false ? false : options.invalid === true || message() !== void 0;
2926
+ const error = () => invalid() ? message() : void 0;
2927
+ const describedIds = () => [
2928
+ ...hint() === void 0 ? [] : [hintId],
2929
+ ...error() === void 0 ? [] : [errorId],
2930
+ ...external(options.describedby)
2931
+ ];
2932
+ return {
2933
+ invalid,
2934
+ hint,
2935
+ error,
2936
+ describedIds,
2937
+ controlProps: {
2938
+ id: controlId,
2939
+ get "aria-describedby"() {
2940
+ const ids = describedIds();
2941
+ return ids.length === 0 ? void 0 : ids.join(" ");
2942
+ },
2943
+ get "aria-invalid"() {
2944
+ return invalid() ? "true" : void 0;
2945
+ },
2946
+ get "aria-required"() {
2947
+ return options.required === true ? "true" : void 0;
2948
+ },
2949
+ get required() {
2950
+ return options.required === true ? true : void 0;
2951
+ }
2952
+ },
2953
+ labelProps: {
2954
+ id: labelId,
2955
+ for: controlId
2956
+ },
2957
+ hintProps: {
2958
+ id: hintId,
2959
+ get hidden() {
2960
+ return hint() === void 0;
2961
+ }
2962
+ },
2963
+ errorProps: {
2964
+ id: errorId,
2965
+ get hidden() {
2966
+ return error() === void 0;
2967
+ },
2968
+ get role() {
2969
+ return error() === void 0 ? void 0 : "alert";
2970
+ }
2971
+ }
2972
+ };
2973
+ };
2722
2974
  // src/grid.ts
2723
2975
  const GRID_KEYS = /* @__PURE__ */ new Set([
2724
2976
  "ArrowRight",
@@ -7239,4 +7491,4 @@ const createRearrangeableListbox = (options) => {
7239
7491
  })
7240
7492
  };
7241
7493
  };
7242
- export { 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, createDialogModal, createDisclosure, createFeed, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPlayer, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createSwitch, createTabs, createTimeline, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
7494
+ 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, createPlayer, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createSwitch, createTabs, 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.8.0",
3
+ "version": "0.10.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": {