@cssints/headless 0.9.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
@@ -150,7 +150,9 @@ const field = createField({
150
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
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
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 has no `role=alert`: announcing it when it appears is the host's (`createAlert`).
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`.
154
156
 
155
157
  ## Confirm
156
158
 
@@ -177,7 +179,10 @@ const remove = async () => {
177
179
 
178
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.
179
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.
180
- - **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. A focus trap and a scroll lock are the host's, as for dialog-modal.
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.
181
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.
182
187
 
183
188
  ## Commands
package/dist/index.d.mts CHANGED
@@ -1295,19 +1295,22 @@ export interface DialogModal {
1295
1295
  */
1296
1296
  export declare const createDialogModal: (options?: DialogModalOptions) => DialogModal;
1297
1297
  export interface ConfirmOptions {
1298
- readonly id?: string;
1298
+ readonly id?: string | undefined;
1299
1299
  /** Called with `true` when a request opens the dialog and `false` when it settles. */
1300
1300
  readonly onOpenChange?: (open: boolean) => void;
1301
1301
  }
1302
1302
  export interface ConfirmRequest {
1303
1303
  readonly title: string;
1304
- readonly description?: string;
1305
- /** Default `"Confirm"`. */
1306
- readonly confirmLabel?: string;
1307
- /** Default `"Cancel"`. */
1308
- readonly cancelLabel?: 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;
1309
1312
  /** The confirm action destroys something: the host styles it so. */
1310
- readonly destructive?: boolean;
1313
+ readonly destructive?: boolean | undefined;
1311
1314
  }
1312
1315
  /** The request being shown (or shown last), with the defaults applied. */
1313
1316
  export interface ConfirmView {
@@ -1359,8 +1362,19 @@ export declare class ConfirmPendingError extends Error {
1359
1362
  * Focus: the cancel button, the least destructive action, takes focus when the
1360
1363
  * dialog opens; when the request settles, focus goes back to the element that
1361
1364
  * had it when `confirm()` was called, before the promise resolves, so the
1362
- * caller's continuation may move it again. A focus trap and a scroll lock are
1363
- * the host's (as for dialog-modal).
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.
1364
1378
  *
1365
1379
  * One request at a time: a second `confirm()` while one is pending is rejected,
1366
1380
  * not queued. Queued, a double click on "Delete" would ask twice and a stale
@@ -1457,8 +1471,8 @@ export interface Feed {
1457
1471
  export declare const createFeed: (options?: FeedOptions) => Feed;
1458
1472
  export interface FieldOptions {
1459
1473
  /** Base of every id the field makes (`<id>-control`, `-label`, `-hint`, `-error`). */
1460
- readonly id?: string;
1461
- readonly required?: boolean;
1474
+ readonly id?: string | undefined;
1475
+ readonly required?: boolean | undefined;
1462
1476
  /**
1463
1477
  * `true` marks the control invalid; a string does too and is the error message
1464
1478
  * (unless `error` is set). `false` forces valid: an `error` is then not shown,
@@ -1470,11 +1484,19 @@ export interface FieldOptions {
1470
1484
  readonly hint?: string | undefined;
1471
1485
  /** The error message; absent (or empty) means no error element. */
1472
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;
1473
1495
  }
1474
1496
  /** Spread onto the form control (`input`, `textarea`, `select`, or a custom widget). */
1475
1497
  export interface FieldControlProps {
1476
1498
  readonly id: string;
1477
- /** The ids of the hint and the error that are shown, hint first; absent when neither is. */
1499
+ /** The ids of the hint and the error that are shown, hint first, then the host's `describedby`; absent when there are none. */
1478
1500
  readonly "aria-describedby": string | undefined;
1479
1501
  readonly "aria-invalid": "true" | undefined;
1480
1502
  /** For a control that is not native (a native one has `required`). */
@@ -1490,18 +1512,25 @@ export interface FieldMessageProps {
1490
1512
  readonly id: string;
1491
1513
  readonly hidden: boolean;
1492
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
+ }
1493
1519
  export interface Field {
1494
1520
  readonly invalid: () => boolean;
1495
1521
  /** The hint, or `undefined` when there is none. */
1496
1522
  readonly hint: () => string | undefined;
1497
1523
  /** The error shown: `undefined` unless the field is invalid and has a message. */
1498
1524
  readonly error: () => string | undefined;
1499
- /** The ids `aria-describedby` lists now: hint, then error; never an id that is hidden. */
1525
+ /**
1526
+ * The ids `aria-describedby` lists now: hint, then error (never an id that is
1527
+ * hidden), then the host's `describedby`.
1528
+ */
1500
1529
  readonly describedIds: () => readonly string[];
1501
1530
  readonly controlProps: FieldControlProps;
1502
1531
  readonly labelProps: FieldLabelProps;
1503
1532
  readonly hintProps: FieldMessageProps;
1504
- readonly errorProps: FieldMessageProps;
1533
+ readonly errorProps: FieldErrorProps;
1505
1534
  }
1506
1535
  /**
1507
1536
  * The ARIA contract of a form field: a label, a control, a hint and an error.
@@ -1516,8 +1545,14 @@ export interface Field {
1516
1545
  * Decisions: the error does not replace the hint, both are listed, hint first
1517
1546
  * (the hint is what tells the user how to fix the error); an error implies
1518
1547
  * invalid and `invalid: false` overrides it; a hidden message is never
1519
- * referenced, so `aria-describedby` has no dangling id. The error has no
1520
- * `role=alert`: announcing it is the host's (compose `createAlert`).
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.
1521
1556
  *
1522
1557
  * Options are read when an attribute is read: pass getters, never destructure.
1523
1558
  */
package/dist/index.mjs CHANGED
@@ -2591,6 +2591,21 @@ var ConfirmPendingError = class extends Error {
2591
2591
  super("a confirmation is already pending");
2592
2592
  }
2593
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);
2594
2609
  /**
2595
2610
  * A promise-based confirmation over `createDialogModal({ role: "alertdialog" })`
2596
2611
  * (APG Alert Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/).
@@ -2600,8 +2615,19 @@ var ConfirmPendingError = class extends Error {
2600
2615
  * Focus: the cancel button, the least destructive action, takes focus when the
2601
2616
  * dialog opens; when the request settles, focus goes back to the element that
2602
2617
  * had it when `confirm()` was called, before the promise resolves, so the
2603
- * caller's continuation may move it again. A focus trap and a scroll lock are
2604
- * the host's (as for dialog-modal).
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.
2605
2631
  *
2606
2632
  * One request at a time: a second `confirm()` while one is pending is rejected,
2607
2633
  * not queued. Queued, a double click on "Delete" would ask twice and a stale
@@ -2633,8 +2659,44 @@ const createConfirm = (options = {}) => {
2633
2659
  options.onOpenChange?.(open);
2634
2660
  }
2635
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
+ };
2636
2689
  createEffect(() => dialog.open(), (isOpen) => {
2637
- if (isOpen) document.getElementById(cancelId)?.focus();
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
+ };
2638
2700
  });
2639
2701
  onCleanup(() => {
2640
2702
  pending?.resolve(false);
@@ -2827,6 +2889,7 @@ const createFeed = (options = {}) => {
2827
2889
  };
2828
2890
  // src/field.ts
2829
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 !== "");
2830
2893
  /**
2831
2894
  * The ARIA contract of a form field: a label, a control, a hint and an error.
2832
2895
  * There is no APG pattern for it (APG has no "form field"; the nearest is the
@@ -2840,8 +2903,14 @@ const text = (value) => value === void 0 || value === "" ? void 0 : value;
2840
2903
  * Decisions: the error does not replace the hint, both are listed, hint first
2841
2904
  * (the hint is what tells the user how to fix the error); an error implies
2842
2905
  * invalid and `invalid: false` overrides it; a hidden message is never
2843
- * referenced, so `aria-describedby` has no dangling id. The error has no
2844
- * `role=alert`: announcing it is the host's (compose `createAlert`).
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.
2845
2914
  *
2846
2915
  * Options are read when an attribute is read: pass getters, never destructure.
2847
2916
  */
@@ -2855,7 +2924,11 @@ const createField = (options = {}) => {
2855
2924
  const message = () => text(options.error) ?? (typeof options.invalid === "string" ? text(options.invalid) : void 0);
2856
2925
  const invalid = () => options.invalid === false ? false : options.invalid === true || message() !== void 0;
2857
2926
  const error = () => invalid() ? message() : void 0;
2858
- const describedIds = () => [...hint() === void 0 ? [] : [hintId], ...error() === void 0 ? [] : [errorId]];
2927
+ const describedIds = () => [
2928
+ ...hint() === void 0 ? [] : [hintId],
2929
+ ...error() === void 0 ? [] : [errorId],
2930
+ ...external(options.describedby)
2931
+ ];
2859
2932
  return {
2860
2933
  invalid,
2861
2934
  hint,
@@ -2891,6 +2964,9 @@ const createField = (options = {}) => {
2891
2964
  id: errorId,
2892
2965
  get hidden() {
2893
2966
  return error() === void 0;
2967
+ },
2968
+ get role() {
2969
+ return error() === void 0 ? void 0 : "alert";
2894
2970
  }
2895
2971
  }
2896
2972
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cssints/headless",
3
- "version": "0.9.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": {