@cssints/headless 0.9.0 → 0.11.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 +7 -2
- package/dist/index.d.mts +51 -16
- package/dist/index.mjs +82 -6
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
/**
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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.
|
|
1363
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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
|
|
1520
|
-
* `role=alert
|
|
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.
|
|
2604
|
-
*
|
|
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)
|
|
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
|
|
2844
|
-
* `role=alert
|
|
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 = () => [
|
|
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.
|
|
3
|
+
"version": "0.11.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": {
|