@cssints/headless 0.14.0 → 0.17.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 +137 -1
- package/dist/index.d.mts +391 -1
- package/dist/index.mjs +556 -36
- package/package.json +1 -1
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, 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.
|
|
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, table, 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
|
|
|
@@ -189,6 +189,70 @@ const pages = createPagination({ page: 3, pageCount: 12, hrefFor: (page) => `/is
|
|
|
189
189
|
- **`hrefFor` is what makes it work without JS**: with it every page is a real link; without it they are buttons and the host owns navigation.
|
|
190
190
|
- **Target size is the skin's job**: 24x24 CSS px at AA, 44x44 at AAA.
|
|
191
191
|
|
|
192
|
+
## Table
|
|
193
|
+
|
|
194
|
+
`createTable` is sortable column headers and row selection on a **native `<table>`**: a `<caption>`, `<thead>`, `<tbody>`, `<th scope="col">` and `<td>` carry the semantics, so the primitive adds no `role` and no `aria-rowindex`, only what the elements cannot say themselves (`aria-sort` on the sorted column, `aria-selected` on a row, the mixed select-all state, and the totals a windowed table hides). It is a grid with a header row but **without the grid's navigation**: the cells keep their own focus, and `createGrid` stays the widget for roving cell focus. There is no spec of its own — it composes the two state models the package already has, `grid-sort.qnt` and `grid-selection.qnt` — so `test/table.browser.test.tsx` is its oracle.
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
const table = createTable({
|
|
198
|
+
columns: [
|
|
199
|
+
{ key: "title", abbr: "Title" },
|
|
200
|
+
{ key: "status", sortable: false },
|
|
201
|
+
{ key: "count", numeric: true },
|
|
202
|
+
],
|
|
203
|
+
rows, // the host's ids, in the host's order: it sorts its data from the descriptor
|
|
204
|
+
selection: "multiple",
|
|
205
|
+
defaultSort: { key: "title", direction: "ascending" },
|
|
206
|
+
labels: { table: "Open issues", selectRow: (id) => `Select ${nameOf(id)}` },
|
|
207
|
+
onSortChange: (sort) => setSort(sort), // the host re-sorts its data and passes `rows` again
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
<table {...table.tableProps}>
|
|
211
|
+
<caption {...table.captionProps}>Open issues</caption>
|
|
212
|
+
<thead>
|
|
213
|
+
<tr>
|
|
214
|
+
{table.selectAllProps && (
|
|
215
|
+
<th>
|
|
216
|
+
<span {...table.selectAllProps} />
|
|
217
|
+
</th>
|
|
218
|
+
)}
|
|
219
|
+
{columns.map((column) => {
|
|
220
|
+
const sortButton = table.headerButtonProps(column.key); // undefined when not sortable
|
|
221
|
+
return (
|
|
222
|
+
<th {...table.headerCellProps(column.key)}>
|
|
223
|
+
{sortButton ? <button {...sortButton}>{column.key}</button> : column.key}
|
|
224
|
+
</th>
|
|
225
|
+
);
|
|
226
|
+
})}
|
|
227
|
+
</tr>
|
|
228
|
+
</thead>
|
|
229
|
+
<tbody>
|
|
230
|
+
{rows.map((id) => (
|
|
231
|
+
<tr {...table.rowProps(id)}>
|
|
232
|
+
{table.selectAllProps && (
|
|
233
|
+
<td>
|
|
234
|
+
<span {...table.rowCheckboxProps(id)} />
|
|
235
|
+
</td>
|
|
236
|
+
)}
|
|
237
|
+
{columns.map((column) => (
|
|
238
|
+
<td {...table.cellProps(id, column.key)}>{cellOf(id, column.key)}</td>
|
|
239
|
+
))}
|
|
240
|
+
</tr>
|
|
241
|
+
))}
|
|
242
|
+
</tbody>
|
|
243
|
+
</table>;
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- **Sorting is the host's data.** The primitive carries the descriptor (`sort()`) and announces it; it never reorders anything. The host maps the descriptor onto its own array and renders `rows` again.
|
|
247
|
+
- **`aria-sort` is on the sorted column only.** A sortable header that is not sorted carries nothing (ARIA's default is "not sorted"), and a `sortable: false` column is fully inert: no `aria-sort`, and `headerButtonProps` is `undefined`, so the host renders no button and it is not a tab stop.
|
|
248
|
+
- **The cycle is tri-state by default** (`ascending` -> `descending` -> unsorted), so a table can be put back in its own order; `sortCycle: "toggle"` gives `ascending` <-> `descending` and never returns to unsorted, which is what the APG grid example does. A new column always starts `ascending`, and only one column is ever sorted.
|
|
249
|
+
- **Rows are ids, not a count.** The host sorts its data, so an id keeps naming the same row across a re-sort while an index would not; the primitive never learns the row's content.
|
|
250
|
+
- **Selection** is `"none"` (the default), `"single"` or `"multiple"`, over ids: `selected()` is the list, `rowProps(id)` carries `aria-selected` and a click that selects, and `toggleRow` / `toggleAll` are the programmatic path. The row's checkbox stops its own click from reaching the row, so the two controls never toggle the same row twice.
|
|
251
|
+
- **Select-all exists only for `"multiple"`** (`selectAllProps` and `rowCheckboxProps` are `undefined` otherwise: one control over one selection is meaningless). It is a `role="checkbox"`, not a native `input`: the mixed state of a native checkbox is a DOM property (`indeterminate`), and what a screen reader must hear is `aria-checked="mixed"`. It toggles the rows the table renders — a windowed table therefore toggles its window and keeps the rest of the selection.
|
|
252
|
+
- **Windowed rows or columns** pass `rowCount` / `columnCount`: then `aria-rowcount` and `aria-colcount` are carried, and every cell gets its `aria-colindex`. A table that renders all of them carries neither (it would only repeat what the markup says).
|
|
253
|
+
- **`numeric` is a layout hint** for the host's skin (right alignment, tabular figures): HTML has no attribute for it, so the primitive carries nothing.
|
|
254
|
+
- Solid 2 arrives through delegated events; a host that reads the DOM right after a click waits a tick.
|
|
255
|
+
|
|
192
256
|
## Confirm
|
|
193
257
|
|
|
194
258
|
`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).
|
|
@@ -220,6 +284,56 @@ const remove = async () => {
|
|
|
220
284
|
- **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
285
|
- `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
286
|
|
|
287
|
+
## Toast and status
|
|
288
|
+
|
|
289
|
+
Two live regions for the two sizes of feedback: `createStatus` is one `role=status` message whose text the host replaces ("Link copied", "Saved"); `createToast` is a queue of toasts, each with its own live region, a timeout, a ceiling and a countdown that pauses. Neither touches the DOM: like every primitive here they return state and attribute objects. **There is no APG pattern for either** (APG's Alert, `createAlert`, is imperative and assertive), so there is no Quint spec and `test/toast.browser.test.tsx` is the oracle. The behaviour is copied from Base UI's Toast and React Aria's Toast (a landmark, F6 into it, at least 5 s, timers paused on hover or focus, the announced text handed in rather than read from the DOM).
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
const toast = createToast();
|
|
293
|
+
const status = createStatus();
|
|
294
|
+
|
|
295
|
+
const copy = async () => {
|
|
296
|
+
await navigator.clipboard.writeText(url);
|
|
297
|
+
status.show("Link copied");
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const upload = async () => {
|
|
301
|
+
try {
|
|
302
|
+
await send(file);
|
|
303
|
+
toast.show({ title: "Uploaded", description: file.name, tone: "success" });
|
|
304
|
+
} catch {
|
|
305
|
+
toast.show({
|
|
306
|
+
title: "Upload failed",
|
|
307
|
+
description: "Try again from the file row",
|
|
308
|
+
tone: "error",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
<p {...status.statusProps}>{status.message()}</p>;
|
|
314
|
+
|
|
315
|
+
<div {...toast.regionProps} tabindex={-1}>
|
|
316
|
+
<For each={toast.toasts()}>
|
|
317
|
+
{(item) => (
|
|
318
|
+
<div {...toast.toastProps(item.id)}>
|
|
319
|
+
<p>{item.title}</p>
|
|
320
|
+
<p>{item.description}</p>
|
|
321
|
+
<button type="button" onClick={() => toast.dismiss(item.id)}>
|
|
322
|
+
Dismiss
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</For>
|
|
327
|
+
</div>;
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
- **A status replaces, it does not stack.** `show(text)` writes the one region and `clear()` empties it; a second message before the first is read replaces it. A status is never for errors: an error is `createAlert` (`role=alert`, assertive), and a titled message with a timeout is a toast.
|
|
331
|
+
- **Toast roles.** `toastProps(id).role` is `"alert"` for `tone: "error"` and `"status"` otherwise, so an error interrupts and the rest wait. The container is `role="region"` with `aria-label` ("Notifications" unless `labels.region`), and each toast is `aria-atomic`, so its title and description are announced as one — the toast's own text is the announcement, a close button's label included, so keep a toast to its title and description. The role sits on the toast rather than on one container per tone: the container is the `region` landmark, so it cannot also be the live region, and a role per toast needs no second region from the host.
|
|
332
|
+
- **Timers.** `timeout` defaults to 5000 ms, the five seconds Base UI and React Aria call the minimum for reading a message; shorter values are for tests. One timer per toast, cleared by `dismiss`/`dismissAll` and all of them on dispose.
|
|
333
|
+
- **Pause.** Hovering a toast pauses its countdown and leaving resumes it with the time it had left (the deadline is kept, not restarted); focus always pauses, because a reader who tabbed into the toast must not lose it. `pauseOnHover: false` leaves focus as the only pause.
|
|
334
|
+
- **The ceiling.** `max` (default 3) keeps the newest: a `show` past it drops the oldest. `onDismiss(id)` fires for every toast that leaves, whichever way — a timeout, a dismiss, `dismissAll` or the ceiling.
|
|
335
|
+
- **F6.** Base UI and React Aria enter the toast region with F6 and return with F6: the region is the target, so give it `tabindex="-1"` and focus it from the host's F6 handler, remembering the element to restore. The primitive binds no keys: global hotkeys are a separate piece (bean cssints-h0d5).
|
|
336
|
+
|
|
223
337
|
## Popover
|
|
224
338
|
|
|
225
339
|
`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).
|
|
@@ -246,6 +360,28 @@ onMount(() => {
|
|
|
246
360
|
- **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
361
|
- 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
362
|
|
|
363
|
+
## Drawer
|
|
364
|
+
|
|
365
|
+
`createDrawer` is the drawer / sheet shell: an edge-anchored panel over the Dialog (Modal) semantics, with the same focus, Escape, scroll-lock and backdrop behaviour as `createConfirm`. **No APG pattern exists for a drawer or a bottom sheet**, and [docs/bottom-sheet.md](docs/bottom-sheet.md) settled that the library composes rather than adding a sheet primitive: the semantics are Dialog (Modal), the position, the slide and the snap points are the host's native CSS, and this is the panel both were missing. `test/drawer.browser.test.tsx` is the oracle.
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
const drawer = createDrawer({ side: "right", labelledby: "nav-title" });
|
|
369
|
+
|
|
370
|
+
<button {...drawer.triggerProps}>Menu</button>;
|
|
371
|
+
<div {...drawer.backdropProps} />;
|
|
372
|
+
<div {...drawer.contentProps}>
|
|
373
|
+
<h2 id="nav-title">Site</h2>
|
|
374
|
+
<nav>{/* links */}</nav>
|
|
375
|
+
</div>;
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- **`side` is presentation only**, `"right"` (the default), `"left"` or `"bottom"`: a sheet is `{ side: "bottom" }`, a side rail is `{ side: "left" }`, and it is one API either way. The primitive lays out nothing; the host's CSS anchors the panel, and `handleProps` is the grab handle of a sheet.
|
|
379
|
+
- **`modal` is the breakpoint switch.** It is read on every attribute read, so a host may flip it while the drawer is open (crossing the breakpoint) and `aria-modal`, the backdrop and the lock follow. `breakpoint` is a px number the primitive **does not read**: it has no viewport, so the host watches `matchMedia` and passes `side` and `modal`. A sheet below a breakpoint is modal; a side rail above it may not be.
|
|
380
|
+
- **Modal** (the default): `aria-modal="true"`, a backdrop, focus into the panel's first focusable on open (else the panel, which is `tabindex="-1"`) and back to the trigger on close, Tab and Shift+Tab cycling inside, `overflow: hidden` on `document.documentElement` counted (as in `createConfirm`, and shared with it: a confirm over a drawer nests), and Escape read from the document in the capture phase, so it closes wherever the focus is. **Non-modal**: no `aria-modal`, no backdrop, no lock and no Tab trap, so the page keeps its scroll and its clicks; an outside `pointerdown` still dismisses it when `dismissable`, and Escape still closes it.
|
|
381
|
+
- **The trigger toggles** (a hamburger), and Enter / Space open it. `dismissable: false` keeps the backdrop and an outside `pointerdown` from closing the drawer; Escape still closes it, because there is no way out otherwise.
|
|
382
|
+
- **No drag-to-dismiss.** A drag is not a dialog interaction: it is a scroll snap the platform runs off the main thread, and it is the host's CSS (`scroll-snap-type: y mandatory`, snap markers, `scrollsnapchange`; see [docs/bottom-sheet.md](docs/bottom-sheet.md)). Re-implementing a native snap in pointer events would be the gesture duplication that research forbids, and the handle would drag on top of the snap that already moved the panel. So `handleProps` is decoration (`aria-hidden`, no pointer handlers) and the ways out are the backdrop, Escape, a close control the host puts in the panel, and the trigger. Swipe-to-dismiss, if it is ever wanted, is a separate decision with its own oracle.
|
|
383
|
+
- **No `attach`.** The panel is edge-anchored: nothing is measured and no placement is computed, so focus and the Tab cycle find the panel through `contentProps.id` and the host only spreads attributes onto its own elements. Give the panel a name: pass `labelledby`, or set `aria-labelledby` / `aria-label` after the spread.
|
|
384
|
+
|
|
249
385
|
## Commands
|
|
250
386
|
|
|
251
387
|
```sh
|
package/dist/index.d.mts
CHANGED
|
@@ -1374,7 +1374,9 @@ export declare class ConfirmPendingError extends Error {
|
|
|
1374
1374
|
* Nesting: confirms from different `createConfirm` calls may be open together;
|
|
1375
1375
|
* only the last one opened gets Escape and Tab (and Escape does not reach a
|
|
1376
1376
|
* dialog it sits in), and the scroll lock is counted: the first takes it, the
|
|
1377
|
-
* last to close restores what `overflow` was.
|
|
1377
|
+
* last to close restores what `overflow` was. `createDrawer` claims the same
|
|
1378
|
+
* page-wide stack and lock, so a confirm over a drawer (or the reverse) nests
|
|
1379
|
+
* the same way. One confirm still has one request.
|
|
1378
1380
|
*
|
|
1379
1381
|
* One request at a time: a second `confirm()` while one is pending is rejected,
|
|
1380
1382
|
* not queued. Queued, a double click on "Delete" would ask twice and a stale
|
|
@@ -1382,6 +1384,119 @@ export declare class ConfirmPendingError extends Error {
|
|
|
1382
1384
|
* answered `false` when the owner is disposed.
|
|
1383
1385
|
*/
|
|
1384
1386
|
export declare const createConfirm: (options?: ConfirmOptions) => Confirm;
|
|
1387
|
+
/** The edge a drawer occupies. `side()` reads the option at call time. */
|
|
1388
|
+
export type DrawerSide = "left" | "right" | "bottom";
|
|
1389
|
+
export interface DrawerOptions {
|
|
1390
|
+
/** Base for the generated trigger / content / backdrop ids. */
|
|
1391
|
+
readonly id?: string | undefined;
|
|
1392
|
+
/** Controlled: the host owns the open state and this is the only thing that changes it. */
|
|
1393
|
+
readonly open?: boolean | undefined;
|
|
1394
|
+
/** Uncontrolled starting state; ignored while `open` is set. */
|
|
1395
|
+
readonly defaultOpen?: boolean | undefined;
|
|
1396
|
+
/**
|
|
1397
|
+
* The edge the panel is anchored to. @default "right"
|
|
1398
|
+
*
|
|
1399
|
+
* Presentation only: a `bottom` panel is a sheet, `left` / `right` is a side
|
|
1400
|
+
* rail, and the same drawer is one API either way.
|
|
1401
|
+
*/
|
|
1402
|
+
readonly side?: DrawerSide | undefined;
|
|
1403
|
+
/**
|
|
1404
|
+
* Whether the panel is modal: `aria-modal`, the Tab cycle, the scroll lock and
|
|
1405
|
+
* the backdrop. @default true
|
|
1406
|
+
*
|
|
1407
|
+
* This is the switch a breakpoint flips: below it a panel is a sheet and
|
|
1408
|
+
* modal, above it a side rail may not be. It is read on every attribute read,
|
|
1409
|
+
* so the host may change it while the drawer is open (crossing the
|
|
1410
|
+
* breakpoint) and the backdrop, `aria-modal` and the lock follow.
|
|
1411
|
+
*/
|
|
1412
|
+
readonly modal?: boolean | undefined;
|
|
1413
|
+
/**
|
|
1414
|
+
* The width in px at which the host switches `side` and `modal` (a sheet
|
|
1415
|
+
* below it, a dialog above). **Not read here**: the primitive has no viewport
|
|
1416
|
+
* to measure and no `matchMedia`, so it only documents the pattern, and the
|
|
1417
|
+
* host watches the breakpoint and passes `modal` / `side`. A host that wants
|
|
1418
|
+
* the sheet drag behaviour composes CSS scroll snap around this, as
|
|
1419
|
+
* [docs/bottom-sheet.md](../docs/bottom-sheet.md) lays out.
|
|
1420
|
+
*/
|
|
1421
|
+
readonly breakpoint?: number | undefined;
|
|
1422
|
+
/** A backdrop click or an outside `pointerdown` closes the drawer. @default true */
|
|
1423
|
+
readonly dismissable?: boolean | undefined;
|
|
1424
|
+
/** Id of the element naming the panel (`aria-labelledby`); a dialog without a name is unusable. */
|
|
1425
|
+
readonly labelledby?: string | undefined;
|
|
1426
|
+
/** A request to change the open state, called when the intent differs. */
|
|
1427
|
+
readonly onOpenChange?: ((open: boolean) => void) | undefined;
|
|
1428
|
+
}
|
|
1429
|
+
export interface DrawerTriggerProps {
|
|
1430
|
+
readonly id: string;
|
|
1431
|
+
readonly "aria-haspopup": "dialog";
|
|
1432
|
+
readonly "aria-expanded": "true" | "false";
|
|
1433
|
+
readonly "aria-controls": string;
|
|
1434
|
+
readonly onClick: () => void;
|
|
1435
|
+
readonly onKeyDown: (event: KeyboardEvent) => void;
|
|
1436
|
+
}
|
|
1437
|
+
export interface DrawerBackdropProps {
|
|
1438
|
+
readonly id: string;
|
|
1439
|
+
/** Always hidden when the drawer is closed or not modal: a non-modal drawer keeps the page clickable. */
|
|
1440
|
+
readonly hidden: boolean;
|
|
1441
|
+
readonly onClick: () => void;
|
|
1442
|
+
}
|
|
1443
|
+
export interface DrawerContentProps {
|
|
1444
|
+
readonly id: string;
|
|
1445
|
+
readonly role: "dialog";
|
|
1446
|
+
readonly "aria-modal": "true" | undefined;
|
|
1447
|
+
readonly "aria-labelledby": string | undefined;
|
|
1448
|
+
readonly tabindex: -1;
|
|
1449
|
+
readonly hidden: boolean;
|
|
1450
|
+
readonly onKeyDown: (event: KeyboardEvent) => void;
|
|
1451
|
+
}
|
|
1452
|
+
/** A bottom sheet's grab handle. Decorative: there is no drag, so it says nothing. */
|
|
1453
|
+
export interface DrawerHandleProps {
|
|
1454
|
+
readonly "aria-hidden": "true";
|
|
1455
|
+
}
|
|
1456
|
+
export interface Drawer {
|
|
1457
|
+
readonly open: Accessor<boolean>;
|
|
1458
|
+
readonly side: Accessor<DrawerSide>;
|
|
1459
|
+
readonly triggerProps: DrawerTriggerProps;
|
|
1460
|
+
readonly backdropProps: DrawerBackdropProps;
|
|
1461
|
+
readonly contentProps: DrawerContentProps;
|
|
1462
|
+
readonly handleProps: DrawerHandleProps;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* The shell for a drawer / bottom sheet: an edge-anchored panel over the
|
|
1466
|
+
* Dialog (Modal) semantics (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
|
1467
|
+
* with the same shell as `createConfirm` — focus into the panel on open and back
|
|
1468
|
+
* to the trigger on close, Tab cycling while modal, Escape read from the
|
|
1469
|
+
* document (so it closes wherever focus is), a counted scroll lock and an
|
|
1470
|
+
* outside dismissal.
|
|
1471
|
+
*
|
|
1472
|
+
* **Compose, do not add a sheet primitive.** [docs/bottom-sheet.md](../docs/bottom-sheet.md)
|
|
1473
|
+
* settled it: the semantics are Dialog (Modal), the position, the slide and the
|
|
1474
|
+
* snap points are the host's CSS (native scroll snap), and the panel that both
|
|
1475
|
+
* need is this shell. `side` and `modal` are the whole presentation API: a sheet
|
|
1476
|
+
* is `{ side: "bottom", modal: true }`, a side rail is `{ side: "right" }` and a
|
|
1477
|
+
* non-modal rail is `{ side: "right", modal: false }`.
|
|
1478
|
+
*
|
|
1479
|
+
* **No drag-to-dismiss.** A drag is not a dialog interaction, it is a scroll
|
|
1480
|
+
* snap the platform runs off the main thread, and it belongs to the host's
|
|
1481
|
+
* scroll-snap CSS (see bottom-sheet.md: `scroll-snap-type: y mandatory`, snap
|
|
1482
|
+
* markers, `scrollsnapchange`). Mirroring a native snap in JS would be the
|
|
1483
|
+
* gesture re-implementation that the platform research forbids, and a pointer
|
|
1484
|
+
* gesture would exist on top of the snap that already moved the panel. So the
|
|
1485
|
+
* handle is decoration (`aria-hidden`, no pointer handlers): the ways out are
|
|
1486
|
+
* the backdrop, Escape, the close control the host puts in the panel, and the
|
|
1487
|
+
* trigger. Swipe-to-dismiss is a separate decision with its own oracle.
|
|
1488
|
+
*
|
|
1489
|
+
* The panel is edge-anchored, so there is **no `attach`**: nothing is measured
|
|
1490
|
+
* and no placement is computed. Focus and Tab find the panel through
|
|
1491
|
+
* `contentProps.id`, like `createConfirm` does, so the host only spreads
|
|
1492
|
+
* attributes onto its own elements.
|
|
1493
|
+
*
|
|
1494
|
+
* Not checked against a Quint spec: the dialog is, and this is its shell, so
|
|
1495
|
+
* `test/drawer.browser.test.tsx` is the oracle. A pointerdown outside the panel
|
|
1496
|
+
* closes it when `dismissable` (that is the backdrop click too, and the only
|
|
1497
|
+
* dismissal a non-modal drawer has); closing returns focus to the trigger.
|
|
1498
|
+
*/
|
|
1499
|
+
export declare const createDrawer: (options?: DrawerOptions) => Drawer;
|
|
1385
1500
|
/**
|
|
1386
1501
|
* Options are read lazily (getters); pass a component's `props`.
|
|
1387
1502
|
*/
|
|
@@ -2712,6 +2827,37 @@ export interface Spinbutton {
|
|
|
2712
2827
|
* APG text.
|
|
2713
2828
|
*/
|
|
2714
2829
|
export declare const createSpinbutton: (options?: SpinbuttonOptions) => Spinbutton;
|
|
2830
|
+
export interface StatusOptions {
|
|
2831
|
+
/** Rendered on `statusProps`; generated when absent. */
|
|
2832
|
+
readonly id?: string | undefined;
|
|
2833
|
+
}
|
|
2834
|
+
/** Spread onto the `role=status` live region. Render it once, always. */
|
|
2835
|
+
export interface StatusProps {
|
|
2836
|
+
readonly id: string;
|
|
2837
|
+
readonly role: "status";
|
|
2838
|
+
readonly "aria-live": "polite";
|
|
2839
|
+
readonly "aria-atomic": "true";
|
|
2840
|
+
}
|
|
2841
|
+
export interface Status {
|
|
2842
|
+
/** The text currently shown, or `undefined` before the first `show`. */
|
|
2843
|
+
readonly message: Accessor<string | undefined>;
|
|
2844
|
+
/** Writes the message, replacing the previous one; nothing is queued. */
|
|
2845
|
+
readonly show: (text: string) => void;
|
|
2846
|
+
readonly clear: () => void;
|
|
2847
|
+
readonly statusProps: StatusProps;
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* APG Alert's polite sibling: one `role=status` live region whose text the host
|
|
2851
|
+
* replaces ("Link copied", "Saved"). It is not a queue: a second message shows
|
|
2852
|
+
* in place of the first, so a burst reads as the last state, not as N alerts.
|
|
2853
|
+
* The region is rendered once and stays mounted, which is what makes a change
|
|
2854
|
+
* of its text announced.
|
|
2855
|
+
*
|
|
2856
|
+
* A status is never for errors: an error that needs attention now is
|
|
2857
|
+
* `createAlert` (`role=alert`, assertive), and a message with a title, a
|
|
2858
|
+
* description and a timeout is `createToast`.
|
|
2859
|
+
*/
|
|
2860
|
+
export declare const createStatus: (options?: StatusOptions) => Status;
|
|
2715
2861
|
export interface SwitchOptions {
|
|
2716
2862
|
/** Controlled: the host owns the state and this is the only thing that changes it. */
|
|
2717
2863
|
readonly value?: boolean | undefined;
|
|
@@ -2766,6 +2912,182 @@ export interface Switch {
|
|
|
2766
2912
|
* pass `value` + `onValueChange` to own the state.
|
|
2767
2913
|
*/
|
|
2768
2914
|
export declare const createSwitch: (options?: SwitchOptions) => Switch;
|
|
2915
|
+
/**
|
|
2916
|
+
* A data table on Solid 2: sortable column headers and row selection, as state
|
|
2917
|
+
* plus getter-backed attribute objects; no elements, no styling.
|
|
2918
|
+
*
|
|
2919
|
+
* **The host renders a native `<table>`**: `<caption>`, `<thead>`, `<tbody>`,
|
|
2920
|
+
* `<th scope="col">` and `<td>` carry the semantics, so the primitive adds no
|
|
2921
|
+
* `role` and no `aria-rowindex` — only what a native table cannot say itself
|
|
2922
|
+
* (which column is sorted, whether a row is selected, the mixed select-all
|
|
2923
|
+
* state, and the totals a windowed table hides).
|
|
2924
|
+
*
|
|
2925
|
+
* **Sorting is the host's data.** The primitive carries the sort descriptor and
|
|
2926
|
+
* announces it (`aria-sort`); it never sorts. The host maps `sort()` onto its
|
|
2927
|
+
* own array before rendering `rows`.
|
|
2928
|
+
*
|
|
2929
|
+
* Composes the two state models the package already has — `grid-sort.qnt` and
|
|
2930
|
+
* `grid-selection.qnt` — without their navigation: a table is a grid with a
|
|
2931
|
+
* `th[scope]` header row, and the cells keep their own focus.
|
|
2932
|
+
*
|
|
2933
|
+
* Request/commit (map ticket `rt1p`): the sort and the selection are
|
|
2934
|
+
* uncontrolled by default (`defaultSort` / `defaultSelected`) and can be owned
|
|
2935
|
+
* by the host with `sort` + `onSortChange` / `selected` + `onSelectionChange`.
|
|
2936
|
+
* `null` as `sort` is "no column sorted" (in a controlled host); `undefined` is
|
|
2937
|
+
* "uncontrolled". `toggleRow`, `toggleAll` and the header button are requests:
|
|
2938
|
+
* a controlled host may ignore them.
|
|
2939
|
+
*/
|
|
2940
|
+
export type TableSortDirection = "ascending" | "descending";
|
|
2941
|
+
export type TableSelectionMode = "none" | "single" | "multiple";
|
|
2942
|
+
/**
|
|
2943
|
+
* `"tri-state"`: ascending -> descending -> unsorted, so a table can be put back
|
|
2944
|
+
* in its own order (the bean's requirement). `"toggle"`: ascending <-> descending,
|
|
2945
|
+
* never unsorted, which is what the APG grid example (and `grid-sort.qnt`) does.
|
|
2946
|
+
*/
|
|
2947
|
+
export type TableSortCycle = "tri-state" | "toggle";
|
|
2948
|
+
export interface TableSort {
|
|
2949
|
+
readonly key: string;
|
|
2950
|
+
readonly direction: TableSortDirection;
|
|
2951
|
+
}
|
|
2952
|
+
export interface TableColumn {
|
|
2953
|
+
/** Identity of the column: what `cellProps` and `headerCellProps` are asked for. */
|
|
2954
|
+
readonly key: string;
|
|
2955
|
+
/** Defaults to `true`; `false` makes the header inert (no sort button, no `aria-sort`). */
|
|
2956
|
+
readonly sortable?: boolean | undefined;
|
|
2957
|
+
/**
|
|
2958
|
+
* A layout hint for the host's skin (right-aligned, tabular figures). HTML has
|
|
2959
|
+
* no attribute for it, so the primitive carries nothing.
|
|
2960
|
+
*/
|
|
2961
|
+
readonly numeric?: boolean | undefined;
|
|
2962
|
+
/** The `abbr` of the header cell: the short form a screen reader uses. */
|
|
2963
|
+
readonly abbr?: string | undefined;
|
|
2964
|
+
}
|
|
2965
|
+
export interface TableLabels {
|
|
2966
|
+
/** The table's accessible name; a host that renders a `<caption>` leaves it out. */
|
|
2967
|
+
readonly table?: string | undefined;
|
|
2968
|
+
/** Defaults to "Select all rows". */
|
|
2969
|
+
readonly selectAll?: string | undefined;
|
|
2970
|
+
/** Defaults to `Select row <id>`. */
|
|
2971
|
+
readonly selectRow?: ((id: string) => string) | undefined;
|
|
2972
|
+
}
|
|
2973
|
+
export interface TableOptions {
|
|
2974
|
+
readonly columns: ReadonlyArray<TableColumn>;
|
|
2975
|
+
/**
|
|
2976
|
+
* The row ids the host renders, in render order. Ids, not a count: the sort
|
|
2977
|
+
* reorders the host's data and a selection must survive that, while an index
|
|
2978
|
+
* would then name a different row. The row's visible content stays the host's.
|
|
2979
|
+
*/
|
|
2980
|
+
readonly rows: ReadonlyArray<string>;
|
|
2981
|
+
readonly selection?: TableSelectionMode | undefined;
|
|
2982
|
+
readonly sortCycle?: TableSortCycle | undefined;
|
|
2983
|
+
/** Controlled: the host owns the sort and this is the only thing that changes it; `null` is "unsorted". */
|
|
2984
|
+
readonly sort?: TableSort | null | undefined;
|
|
2985
|
+
/** Uncontrolled starting sort; ignored while `sort` is set. */
|
|
2986
|
+
readonly defaultSort?: TableSort | undefined;
|
|
2987
|
+
/** A request to re-sort (`undefined` = unsorted); uncontrolled it is accepted. */
|
|
2988
|
+
readonly onSortChange?: ((sort: TableSort | undefined) => void) | undefined;
|
|
2989
|
+
/** Controlled: the host owns the selected row ids and this is the only thing that changes them. */
|
|
2990
|
+
readonly selected?: ReadonlyArray<string> | undefined;
|
|
2991
|
+
/** Uncontrolled starting selection; ignored while `selected` is set. */
|
|
2992
|
+
readonly defaultSelected?: ReadonlyArray<string> | undefined;
|
|
2993
|
+
/** A request to change the selection; uncontrolled it is accepted. */
|
|
2994
|
+
readonly onSelectionChange?: ((selected: ReadonlyArray<string>) => void) | undefined;
|
|
2995
|
+
/**
|
|
2996
|
+
* The total row count when the host renders a window of `rows`
|
|
2997
|
+
* (`aria-rowcount`); absent on a table that renders all of them.
|
|
2998
|
+
*/
|
|
2999
|
+
readonly rowCount?: number | undefined;
|
|
3000
|
+
/**
|
|
3001
|
+
* The total column count when the host renders a window of `columns`
|
|
3002
|
+
* (`aria-colcount` and `aria-colindex`); absent on a table that renders all.
|
|
3003
|
+
*/
|
|
3004
|
+
readonly columnCount?: number | undefined;
|
|
3005
|
+
readonly labels?: TableLabels | undefined;
|
|
3006
|
+
/** Base for generated ids. */
|
|
3007
|
+
readonly id?: string | undefined;
|
|
3008
|
+
}
|
|
3009
|
+
/** Spread onto `<table>`; the host renders the table, the head and the body. */
|
|
3010
|
+
export interface TableProps {
|
|
3011
|
+
/** `labels.table`; leave it out when the host renders a caption. */
|
|
3012
|
+
readonly "aria-label": string | undefined;
|
|
3013
|
+
/** Only while the host windows its rows. */
|
|
3014
|
+
readonly "aria-rowcount": number | undefined;
|
|
3015
|
+
/** Only while the host windows its columns. */
|
|
3016
|
+
readonly "aria-colcount": number | undefined;
|
|
3017
|
+
}
|
|
3018
|
+
/** Spread onto `<caption>`. */
|
|
3019
|
+
export interface TableCaptionProps {
|
|
3020
|
+
readonly id: string;
|
|
3021
|
+
}
|
|
3022
|
+
/** Spread onto `<th scope="col">` in the header row. */
|
|
3023
|
+
export interface TableHeaderCellProps {
|
|
3024
|
+
readonly scope: "col";
|
|
3025
|
+
/** The sorted column only; an unsorted one carries nothing (ARIA's default). */
|
|
3026
|
+
readonly "aria-sort": TableSortDirection | undefined;
|
|
3027
|
+
readonly abbr: string | undefined;
|
|
3028
|
+
}
|
|
3029
|
+
/** Spread onto the `<button type="button">` inside a sortable `<th>`. */
|
|
3030
|
+
export interface TableHeaderButtonProps {
|
|
3031
|
+
readonly type: "button";
|
|
3032
|
+
readonly onClick: () => void;
|
|
3033
|
+
}
|
|
3034
|
+
/** Spread onto `<tr>` in the body. */
|
|
3035
|
+
export interface TableRowProps {
|
|
3036
|
+
readonly "aria-selected": "true" | "false" | undefined;
|
|
3037
|
+
/** Selects the row; `undefined` when nothing is selectable. */
|
|
3038
|
+
readonly onClick: (() => void) | undefined;
|
|
3039
|
+
}
|
|
3040
|
+
/** Spread onto a `<td>`; `aria-colindex` only while the host windows its columns. */
|
|
3041
|
+
export interface TableCellProps {
|
|
3042
|
+
readonly "aria-colindex": number | undefined;
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Spread onto the select-all control: a `role=checkbox` element (not a native
|
|
3046
|
+
* `input`, which spells the mixed state as a DOM property, not an attribute).
|
|
3047
|
+
*/
|
|
3048
|
+
export interface TableSelectAllProps {
|
|
3049
|
+
readonly role: "checkbox";
|
|
3050
|
+
readonly "aria-checked": "true" | "false" | "mixed";
|
|
3051
|
+
readonly "aria-label": string;
|
|
3052
|
+
readonly tabindex: 0;
|
|
3053
|
+
readonly onClick: () => void;
|
|
3054
|
+
readonly onKeyDown: (event: KeyboardEvent) => void;
|
|
3055
|
+
}
|
|
3056
|
+
/** Spread onto a row's selection control. */
|
|
3057
|
+
export interface TableRowCheckboxProps {
|
|
3058
|
+
readonly role: "checkbox";
|
|
3059
|
+
readonly "aria-checked": "true" | "false";
|
|
3060
|
+
readonly "aria-label": string;
|
|
3061
|
+
readonly tabindex: 0;
|
|
3062
|
+
/** Selects the row and stops there: the control sits inside the row, whose click would select again. */
|
|
3063
|
+
readonly onClick: (event: MouseEvent) => void;
|
|
3064
|
+
readonly onKeyDown: (event: KeyboardEvent) => void;
|
|
3065
|
+
}
|
|
3066
|
+
export interface Table {
|
|
3067
|
+
readonly selectionMode: Accessor<TableSelectionMode>;
|
|
3068
|
+
/** The sort descriptor, or `undefined` when nothing is sorted. */
|
|
3069
|
+
readonly sort: Accessor<TableSort | undefined>;
|
|
3070
|
+
readonly selected: Accessor<ReadonlyArray<string>>;
|
|
3071
|
+
readonly isSelected: (id: string) => boolean;
|
|
3072
|
+
readonly tableProps: TableProps;
|
|
3073
|
+
readonly captionProps: TableCaptionProps;
|
|
3074
|
+
readonly headerCellProps: (key: string) => TableHeaderCellProps;
|
|
3075
|
+
/** `undefined` for a column that is not sortable — render no button there. */
|
|
3076
|
+
readonly headerButtonProps: (key: string) => TableHeaderButtonProps | undefined;
|
|
3077
|
+
readonly rowProps: (id: string) => TableRowProps;
|
|
3078
|
+
readonly cellProps: (rowId: string, key: string) => TableCellProps;
|
|
3079
|
+
/** `undefined` unless `selection` is `"multiple"`: one control over one selection is meaningless. */
|
|
3080
|
+
readonly selectAllProps: TableSelectAllProps | undefined;
|
|
3081
|
+
/** `undefined` while `selection` is `"none"`. */
|
|
3082
|
+
readonly rowCheckboxProps: (id: string) => TableRowCheckboxProps | undefined;
|
|
3083
|
+
readonly toggleAll: () => void;
|
|
3084
|
+
readonly toggleRow: (id: string) => void;
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* Every option is read when an attribute is read, so pass props/getters, never a
|
|
3088
|
+
* destructured snapshot. `id` is captured once: it is the base of the caption id.
|
|
3089
|
+
*/
|
|
3090
|
+
export declare const createTable: (options: TableOptions) => Table;
|
|
2769
3091
|
/** The HTML `autocomplete` tokens worth naming; the attribute also takes an arbitrary token. */
|
|
2770
3092
|
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";
|
|
2771
3093
|
export interface TextInputOptions extends FieldOptions {
|
|
@@ -3037,6 +3359,74 @@ export interface Timeline {
|
|
|
3037
3359
|
* reactive.
|
|
3038
3360
|
*/
|
|
3039
3361
|
export declare const createTimeline: (options?: TimelineOptions) => Timeline;
|
|
3362
|
+
/** A toast's urgency; `error` is the only tone that announces assertively. */
|
|
3363
|
+
export type ToastTone = "info" | "success" | "error";
|
|
3364
|
+
export interface ToastOptions {
|
|
3365
|
+
/** Auto-dismiss delay in ms, default 5000. A number; `0` dismisses on the next tick. */
|
|
3366
|
+
readonly timeout?: number | undefined;
|
|
3367
|
+
/** Visible toasts kept, default 3; a new one past the ceiling drops the oldest. */
|
|
3368
|
+
readonly max?: number | undefined;
|
|
3369
|
+
/** Pauses the countdown while a pointer is over a toast, default true. Focus always pauses. */
|
|
3370
|
+
readonly pauseOnHover?: boolean | undefined;
|
|
3371
|
+
/** The region's accessible name; `region` defaults to `"Notifications"`. */
|
|
3372
|
+
readonly labels?: {
|
|
3373
|
+
readonly region?: string | undefined;
|
|
3374
|
+
} | undefined;
|
|
3375
|
+
/** Called with the id whenever a toast leaves, for whatever reason. */
|
|
3376
|
+
readonly onDismiss?: ((id: string) => void) | undefined;
|
|
3377
|
+
}
|
|
3378
|
+
/** What `show` takes; `tone` defaults to `"info"`. */
|
|
3379
|
+
export interface ToastInput {
|
|
3380
|
+
readonly title: string;
|
|
3381
|
+
readonly description?: string | undefined;
|
|
3382
|
+
readonly tone?: ToastTone | undefined;
|
|
3383
|
+
}
|
|
3384
|
+
/** A toast in the queue, oldest first in `toasts()`. */
|
|
3385
|
+
export interface ToastItem {
|
|
3386
|
+
readonly id: string;
|
|
3387
|
+
readonly title: string;
|
|
3388
|
+
readonly description: string | undefined;
|
|
3389
|
+
readonly tone: ToastTone;
|
|
3390
|
+
}
|
|
3391
|
+
/** Spread onto the container that holds every toast and the F6 target. */
|
|
3392
|
+
export interface ToastRegionProps {
|
|
3393
|
+
readonly role: "region";
|
|
3394
|
+
readonly "aria-label": string;
|
|
3395
|
+
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Spread onto one toast. `role` is `alert` for the `error` tone and `status`
|
|
3398
|
+
* otherwise; both pause the countdown while hovered or focused.
|
|
3399
|
+
*/
|
|
3400
|
+
export interface ToastItemProps {
|
|
3401
|
+
readonly role: "alert" | "status";
|
|
3402
|
+
readonly "aria-atomic": "true";
|
|
3403
|
+
readonly onMouseEnter: () => void;
|
|
3404
|
+
readonly onMouseLeave: () => void;
|
|
3405
|
+
readonly onFocusIn: () => void;
|
|
3406
|
+
readonly onFocusOut: () => void;
|
|
3407
|
+
}
|
|
3408
|
+
export interface Toast {
|
|
3409
|
+
/** Oldest first. */
|
|
3410
|
+
readonly toasts: Accessor<readonly ToastItem[]>;
|
|
3411
|
+
/** Queues a toast and returns its id. */
|
|
3412
|
+
readonly show: (input: ToastInput) => string;
|
|
3413
|
+
readonly dismiss: (id: string) => void;
|
|
3414
|
+
readonly dismissAll: () => void;
|
|
3415
|
+
readonly regionProps: ToastRegionProps;
|
|
3416
|
+
readonly toastProps: (id: string) => ToastItemProps;
|
|
3417
|
+
}
|
|
3418
|
+
/**
|
|
3419
|
+
* A toast queue: one timeout per toast, a ceiling that drops the oldest, and a
|
|
3420
|
+
* countdown that pauses on hover and focus and resumes from where it stopped.
|
|
3421
|
+
* A toast announces itself through its own live region (`role=status`, or
|
|
3422
|
+
* `role=alert` for `error`), so the host renders one element per toast inside
|
|
3423
|
+
* `regionProps`. No DOM access here: the host renders, the primitive decides.
|
|
3424
|
+
*
|
|
3425
|
+
* The timeout is a floor of practice, not of code: the default is 5000 ms
|
|
3426
|
+
* because both Base UI and React Aria agree on at least five seconds to read a
|
|
3427
|
+
* message; shorter values exist for tests and are the host's judgement.
|
|
3428
|
+
*/
|
|
3429
|
+
export declare const createToast: (options?: ToastOptions) => Toast;
|
|
3040
3430
|
/**
|
|
3041
3431
|
* APG Toolbar on Solid 2: state plus attribute objects, no elements, no
|
|
3042
3432
|
* styling. A faithful transcription of `specs/apg/toolbar.qnt`.
|
package/dist/index.mjs
CHANGED
|
@@ -54,7 +54,7 @@ const toggled = (a, index) => {
|
|
|
54
54
|
else next.add(index);
|
|
55
55
|
return next;
|
|
56
56
|
};
|
|
57
|
-
const sameSet = (a, b) => a.size === b.size && [...a].every((index) => b.has(index));
|
|
57
|
+
const sameSet$1 = (a, b) => a.size === b.size && [...a].every((index) => b.has(index));
|
|
58
58
|
/** The only member, or -1. */
|
|
59
59
|
const sole = (a) => a.size === 1 ? [...a][0] ?? -1 : -1;
|
|
60
60
|
const at = (values, index) => values[index] ?? 0;
|
|
@@ -477,7 +477,7 @@ const createCanvas = (options) => {
|
|
|
477
477
|
annSel: ann,
|
|
478
478
|
annText: prefs.announce ? messages().selection([...ann].sort((a, b) => a - b).map((i) => labelAt(s, i))) : ""
|
|
479
479
|
});
|
|
480
|
-
const announced = (s, sel) => sameSet(sel, s.annSel) ? {
|
|
480
|
+
const announced = (s, sel) => sameSet$1(sel, s.annSel) ? {
|
|
481
481
|
...s,
|
|
482
482
|
sel
|
|
483
483
|
} : announcing({
|
|
@@ -1296,7 +1296,7 @@ const createPlayer = (options) => {
|
|
|
1296
1296
|
const HEADING_LEVELS = 6;
|
|
1297
1297
|
const count = (expanded) => expanded.filter(Boolean).length;
|
|
1298
1298
|
/** Re-establish the mode's constraints, exactly as the spec's `normExpanded`. */
|
|
1299
|
-
const normalize$
|
|
1299
|
+
const normalize$2 = (expanded, multiple, collapsible) => {
|
|
1300
1300
|
if (!collapsible && count(expanded) === 0) return expanded.map((_, index) => index === 0);
|
|
1301
1301
|
if (multiple) return expanded;
|
|
1302
1302
|
const first = expanded.findIndex(Boolean);
|
|
@@ -1328,7 +1328,7 @@ const createAccordion = (options = {}) => {
|
|
|
1328
1328
|
const controlled = () => options.value !== void 0;
|
|
1329
1329
|
const [indices, request] = createControllable({
|
|
1330
1330
|
value: () => options.value,
|
|
1331
|
-
initial: indicesOf(normalize$
|
|
1331
|
+
initial: indicesOf(normalize$2(Array.from({ length: items }, (_, index) => (options.defaultValue ?? []).includes(index)), multiple(), collapsible())),
|
|
1332
1332
|
onChange: () => options.onValueChange
|
|
1333
1333
|
});
|
|
1334
1334
|
const expanded = () => Array.from({ length: items }, (_, index) => indices().includes(index));
|
|
@@ -1361,11 +1361,11 @@ const createAccordion = (options = {}) => {
|
|
|
1361
1361
|
toggle,
|
|
1362
1362
|
setMultiple: (value) => {
|
|
1363
1363
|
setMultipleOverride(value);
|
|
1364
|
-
if (!controlled()) commit(normalize$
|
|
1364
|
+
if (!controlled()) commit(normalize$2(expanded(), value, collapsible()));
|
|
1365
1365
|
},
|
|
1366
1366
|
setCollapsible: (value) => {
|
|
1367
1367
|
setCollapsibleOverride(value);
|
|
1368
|
-
if (!controlled()) commit(normalize$
|
|
1368
|
+
if (!controlled()) commit(normalize$2(expanded(), multiple(), value));
|
|
1369
1369
|
},
|
|
1370
1370
|
setHeadingLevel: (level) => {
|
|
1371
1371
|
setHeadingLevelSignal(Math.min(Math.max(level, 1), HEADING_LEVELS));
|
|
@@ -2584,28 +2584,85 @@ const createDialogModal = (options = {}) => {
|
|
|
2584
2584
|
}
|
|
2585
2585
|
};
|
|
2586
2586
|
};
|
|
2587
|
-
// src/
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2587
|
+
// src/overlay-shell.ts
|
|
2588
|
+
/**
|
|
2589
|
+
* The page-wide half of a modal overlay: one scroll lock, one stack of open
|
|
2590
|
+
* overlays, the tabbables of a panel and the Tab cycle over them. Shared by
|
|
2591
|
+
* `createConfirm` and `createDrawer` so that a confirm opened over a drawer (or
|
|
2592
|
+
* the reverse) takes Escape and Tab first, and the scroll lock outlives the
|
|
2593
|
+
* first of them to close.
|
|
2594
|
+
*/
|
|
2595
|
+
const TABBABLE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
|
|
2596
|
+
/** The visible, tabbable elements of `root`, in document order. */
|
|
2597
|
+
const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
|
|
2598
|
+
/** The first tabbable element of `root`, if any. */
|
|
2599
|
+
const firstFocusable$1 = (root) => tabbables(root)[0];
|
|
2600
|
+
/**
|
|
2601
|
+
* Wraps Tab / Shift+Tab inside `root` and pulls a focus that escaped back in
|
|
2602
|
+
* on the next Tab. With nothing tabbable the panel itself (which is
|
|
2603
|
+
* `tabindex="-1"`) keeps the focus.
|
|
2604
|
+
*/
|
|
2605
|
+
const cycleFocus = (event, root) => {
|
|
2606
|
+
const items = tabbables(root);
|
|
2607
|
+
const first = items[0];
|
|
2608
|
+
const last = items.at(-1);
|
|
2609
|
+
const active = document.activeElement;
|
|
2610
|
+
const inside = root.contains(active);
|
|
2611
|
+
if (first === void 0 || last === void 0) {
|
|
2612
|
+
event.preventDefault();
|
|
2613
|
+
root.focus();
|
|
2614
|
+
} else if (event.shiftKey && (!inside || active === first || active === root)) {
|
|
2615
|
+
event.preventDefault();
|
|
2616
|
+
last.focus();
|
|
2617
|
+
} else if (!event.shiftKey && (!inside || active === last)) {
|
|
2618
|
+
event.preventDefault();
|
|
2619
|
+
first.focus();
|
|
2592
2620
|
}
|
|
2593
2621
|
};
|
|
2594
|
-
const openDialogs = [];
|
|
2595
2622
|
let scrollLocks = 0;
|
|
2596
2623
|
let scrollBefore = "";
|
|
2624
|
+
/**
|
|
2625
|
+
* `overflow: hidden` on the document element, counted: the first holder takes
|
|
2626
|
+
* it, the last to release restores what `overflow` was. The returned release is
|
|
2627
|
+
* idempotent.
|
|
2628
|
+
*/
|
|
2597
2629
|
const lockScroll = () => {
|
|
2598
2630
|
const root = document.documentElement;
|
|
2599
2631
|
if (scrollLocks++ === 0) {
|
|
2600
2632
|
scrollBefore = root.style.overflow;
|
|
2601
2633
|
root.style.overflow = "hidden";
|
|
2602
2634
|
}
|
|
2635
|
+
let released = false;
|
|
2603
2636
|
return () => {
|
|
2637
|
+
if (released) return;
|
|
2638
|
+
released = true;
|
|
2604
2639
|
if (--scrollLocks === 0) root.style.overflow = scrollBefore;
|
|
2605
2640
|
};
|
|
2606
2641
|
};
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2642
|
+
const openOverlays = [];
|
|
2643
|
+
/**
|
|
2644
|
+
* Joins the open overlays; the last one to claim takes Escape and Tab. Returns
|
|
2645
|
+
* the release (idempotent), which may be called in any order.
|
|
2646
|
+
*/
|
|
2647
|
+
const claimOverlay = (self) => {
|
|
2648
|
+
openOverlays.push(self);
|
|
2649
|
+
let claimed = true;
|
|
2650
|
+
return () => {
|
|
2651
|
+
if (!claimed) return;
|
|
2652
|
+
claimed = false;
|
|
2653
|
+
const at = openOverlays.indexOf(self);
|
|
2654
|
+
if (at !== -1) openOverlays.splice(at, 1);
|
|
2655
|
+
};
|
|
2656
|
+
};
|
|
2657
|
+
/** Whether `self` is the overlay that takes Escape and Tab (the one opened last). */
|
|
2658
|
+
const isTopOverlay = (self) => openOverlays.at(-1) === self;
|
|
2659
|
+
// src/confirm.ts
|
|
2660
|
+
var ConfirmPendingError = class extends Error {
|
|
2661
|
+
name = "ConfirmPendingError";
|
|
2662
|
+
constructor() {
|
|
2663
|
+
super("a confirmation is already pending");
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2609
2666
|
/**
|
|
2610
2667
|
* A promise-based confirmation over `createDialogModal({ role: "alertdialog" })`
|
|
2611
2668
|
* (APG Alert Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/).
|
|
@@ -2627,7 +2684,9 @@ const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((
|
|
|
2627
2684
|
* Nesting: confirms from different `createConfirm` calls may be open together;
|
|
2628
2685
|
* only the last one opened gets Escape and Tab (and Escape does not reach a
|
|
2629
2686
|
* dialog it sits in), and the scroll lock is counted: the first takes it, the
|
|
2630
|
-
* last to close restores what `overflow` was.
|
|
2687
|
+
* last to close restores what `overflow` was. `createDrawer` claims the same
|
|
2688
|
+
* page-wide stack and lock, so a confirm over a drawer (or the reverse) nests
|
|
2689
|
+
* the same way. One confirm still has one request.
|
|
2631
2690
|
*
|
|
2632
2691
|
* One request at a time: a second `confirm()` while one is pending is rejected,
|
|
2633
2692
|
* not queued. Queued, a double click on "Delete" would ask twice and a stale
|
|
@@ -2661,7 +2720,7 @@ const createConfirm = (options = {}) => {
|
|
|
2661
2720
|
});
|
|
2662
2721
|
const self = Symbol(base);
|
|
2663
2722
|
const onDocumentKeyDown = (event) => {
|
|
2664
|
-
if (
|
|
2723
|
+
if (!isTopOverlay(self)) return;
|
|
2665
2724
|
if (event.key === "Escape") {
|
|
2666
2725
|
event.preventDefault();
|
|
2667
2726
|
event.stopPropagation();
|
|
@@ -2670,31 +2729,17 @@ const createConfirm = (options = {}) => {
|
|
|
2670
2729
|
}
|
|
2671
2730
|
const root = document.getElementById(dialog.dialogProps.id);
|
|
2672
2731
|
if (event.key !== "Tab" || root === null) return;
|
|
2673
|
-
|
|
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
|
-
}
|
|
2732
|
+
cycleFocus(event, root);
|
|
2688
2733
|
};
|
|
2689
2734
|
createEffect(() => dialog.open(), (isOpen) => {
|
|
2690
2735
|
if (!isOpen) return;
|
|
2691
2736
|
document.getElementById(cancelId)?.focus();
|
|
2692
2737
|
const unlock = lockScroll();
|
|
2693
|
-
|
|
2738
|
+
const release = claimOverlay(self);
|
|
2694
2739
|
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
2695
2740
|
return () => {
|
|
2696
2741
|
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
2697
|
-
|
|
2742
|
+
release();
|
|
2698
2743
|
unlock();
|
|
2699
2744
|
};
|
|
2700
2745
|
});
|
|
@@ -2752,6 +2797,148 @@ const createConfirm = (options = {}) => {
|
|
|
2752
2797
|
}
|
|
2753
2798
|
};
|
|
2754
2799
|
};
|
|
2800
|
+
// src/drawer.ts
|
|
2801
|
+
/**
|
|
2802
|
+
* The shell for a drawer / bottom sheet: an edge-anchored panel over the
|
|
2803
|
+
* Dialog (Modal) semantics (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
|
2804
|
+
* with the same shell as `createConfirm` — focus into the panel on open and back
|
|
2805
|
+
* to the trigger on close, Tab cycling while modal, Escape read from the
|
|
2806
|
+
* document (so it closes wherever focus is), a counted scroll lock and an
|
|
2807
|
+
* outside dismissal.
|
|
2808
|
+
*
|
|
2809
|
+
* **Compose, do not add a sheet primitive.** [docs/bottom-sheet.md](../docs/bottom-sheet.md)
|
|
2810
|
+
* settled it: the semantics are Dialog (Modal), the position, the slide and the
|
|
2811
|
+
* snap points are the host's CSS (native scroll snap), and the panel that both
|
|
2812
|
+
* need is this shell. `side` and `modal` are the whole presentation API: a sheet
|
|
2813
|
+
* is `{ side: "bottom", modal: true }`, a side rail is `{ side: "right" }` and a
|
|
2814
|
+
* non-modal rail is `{ side: "right", modal: false }`.
|
|
2815
|
+
*
|
|
2816
|
+
* **No drag-to-dismiss.** A drag is not a dialog interaction, it is a scroll
|
|
2817
|
+
* snap the platform runs off the main thread, and it belongs to the host's
|
|
2818
|
+
* scroll-snap CSS (see bottom-sheet.md: `scroll-snap-type: y mandatory`, snap
|
|
2819
|
+
* markers, `scrollsnapchange`). Mirroring a native snap in JS would be the
|
|
2820
|
+
* gesture re-implementation that the platform research forbids, and a pointer
|
|
2821
|
+
* gesture would exist on top of the snap that already moved the panel. So the
|
|
2822
|
+
* handle is decoration (`aria-hidden`, no pointer handlers): the ways out are
|
|
2823
|
+
* the backdrop, Escape, the close control the host puts in the panel, and the
|
|
2824
|
+
* trigger. Swipe-to-dismiss is a separate decision with its own oracle.
|
|
2825
|
+
*
|
|
2826
|
+
* The panel is edge-anchored, so there is **no `attach`**: nothing is measured
|
|
2827
|
+
* and no placement is computed. Focus and Tab find the panel through
|
|
2828
|
+
* `contentProps.id`, like `createConfirm` does, so the host only spreads
|
|
2829
|
+
* attributes onto its own elements.
|
|
2830
|
+
*
|
|
2831
|
+
* Not checked against a Quint spec: the dialog is, and this is its shell, so
|
|
2832
|
+
* `test/drawer.browser.test.tsx` is the oracle. A pointerdown outside the panel
|
|
2833
|
+
* closes it when `dismissable` (that is the backdrop click too, and the only
|
|
2834
|
+
* dismissal a non-modal drawer has); closing returns focus to the trigger.
|
|
2835
|
+
*/
|
|
2836
|
+
const createDrawer = (options = {}) => {
|
|
2837
|
+
const base = options.id ?? createUniqueId();
|
|
2838
|
+
const triggerId = `${base}-trigger`;
|
|
2839
|
+
const contentId = `${base}-content`;
|
|
2840
|
+
const backdropId = `${base}-backdrop`;
|
|
2841
|
+
const self = Symbol(base);
|
|
2842
|
+
let wasOpen = false;
|
|
2843
|
+
const isModal = () => options.modal ?? true;
|
|
2844
|
+
const dismissable = () => options.dismissable ?? true;
|
|
2845
|
+
const content = () => document.getElementById(contentId);
|
|
2846
|
+
const trigger = () => document.getElementById(triggerId);
|
|
2847
|
+
const [open, requestOpen] = createControllable({
|
|
2848
|
+
value: () => options.open,
|
|
2849
|
+
initial: options.defaultOpen ?? false,
|
|
2850
|
+
onChange: () => options.onOpenChange
|
|
2851
|
+
});
|
|
2852
|
+
const onDocumentKeyDown = (event) => {
|
|
2853
|
+
if (!isTopOverlay(self) || !open()) return;
|
|
2854
|
+
if (event.key === "Escape") {
|
|
2855
|
+
event.preventDefault();
|
|
2856
|
+
event.stopPropagation();
|
|
2857
|
+
requestOpen(false);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (event.key !== "Tab" || !isModal()) return;
|
|
2861
|
+
const panel = content();
|
|
2862
|
+
if (panel !== null) cycleFocus(event, panel);
|
|
2863
|
+
};
|
|
2864
|
+
createEffect(() => open(), (isOpen) => {
|
|
2865
|
+
if (isOpen) {
|
|
2866
|
+
wasOpen = true;
|
|
2867
|
+
const panel = content();
|
|
2868
|
+
if (panel !== null) (firstFocusable$1(panel) ?? panel).focus();
|
|
2869
|
+
const release = claimOverlay(self);
|
|
2870
|
+
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
2871
|
+
return () => {
|
|
2872
|
+
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
2873
|
+
release();
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
if (wasOpen) {
|
|
2877
|
+
wasOpen = false;
|
|
2878
|
+
const back = trigger();
|
|
2879
|
+
if (back !== null && back.isConnected) back.focus();
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
createEffect(() => open() && isModal(), (locked) => {
|
|
2883
|
+
if (locked) return lockScroll();
|
|
2884
|
+
});
|
|
2885
|
+
const onPointerDown = (event) => {
|
|
2886
|
+
if (!open() || !dismissable()) return;
|
|
2887
|
+
const target = event.target;
|
|
2888
|
+
if (!(target instanceof Node)) return;
|
|
2889
|
+
if (content()?.contains(target) === true || trigger()?.contains(target) === true) return;
|
|
2890
|
+
requestOpen(false);
|
|
2891
|
+
};
|
|
2892
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2893
|
+
onCleanup(() => document.removeEventListener("pointerdown", onPointerDown, true));
|
|
2894
|
+
return {
|
|
2895
|
+
open,
|
|
2896
|
+
side: () => options.side ?? "right",
|
|
2897
|
+
triggerProps: {
|
|
2898
|
+
id: triggerId,
|
|
2899
|
+
"aria-haspopup": "dialog",
|
|
2900
|
+
get "aria-expanded"() {
|
|
2901
|
+
return open() ? "true" : "false";
|
|
2902
|
+
},
|
|
2903
|
+
"aria-controls": contentId,
|
|
2904
|
+
onClick: () => requestOpen(!open()),
|
|
2905
|
+
onKeyDown: (event) => {
|
|
2906
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
2907
|
+
event.preventDefault();
|
|
2908
|
+
requestOpen(true);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
},
|
|
2912
|
+
backdropProps: {
|
|
2913
|
+
id: backdropId,
|
|
2914
|
+
get hidden() {
|
|
2915
|
+
return !open() || !isModal();
|
|
2916
|
+
},
|
|
2917
|
+
onClick: () => {
|
|
2918
|
+
if (dismissable()) requestOpen(false);
|
|
2919
|
+
}
|
|
2920
|
+
},
|
|
2921
|
+
contentProps: {
|
|
2922
|
+
id: contentId,
|
|
2923
|
+
role: "dialog",
|
|
2924
|
+
get "aria-modal"() {
|
|
2925
|
+
return isModal() && open() ? "true" : void 0;
|
|
2926
|
+
},
|
|
2927
|
+
"aria-labelledby": options.labelledby,
|
|
2928
|
+
tabindex: -1,
|
|
2929
|
+
get hidden() {
|
|
2930
|
+
return !open();
|
|
2931
|
+
},
|
|
2932
|
+
onKeyDown: (event) => {
|
|
2933
|
+
if (event.key === "Escape" && open()) {
|
|
2934
|
+
event.preventDefault();
|
|
2935
|
+
requestOpen(false);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
handleProps: { "aria-hidden": "true" }
|
|
2940
|
+
};
|
|
2941
|
+
};
|
|
2755
2942
|
// src/feed.ts
|
|
2756
2943
|
/**
|
|
2757
2944
|
* APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
|
|
@@ -4982,7 +5169,7 @@ const createRadioGroup = (options) => {
|
|
|
4982
5169
|
};
|
|
4983
5170
|
// src/resizable-panels.ts
|
|
4984
5171
|
const DEFAULT_MIN = 10;
|
|
4985
|
-
const DEFAULT_MAX = 80;
|
|
5172
|
+
const DEFAULT_MAX$1 = 80;
|
|
4986
5173
|
const DEFAULT_STEP = 10;
|
|
4987
5174
|
const FULL = 100;
|
|
4988
5175
|
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
@@ -5052,7 +5239,7 @@ const createResizablePanels = (options) => {
|
|
|
5052
5239
|
const minOf = (index) => panes()[index]?.min ?? DEFAULT_MIN;
|
|
5053
5240
|
/** The smallest committed size (a collapsible pane may sit at collapsedSize). */
|
|
5054
5241
|
const lowOf = (index) => panes()[index]?.collapsible === true ? collapsedSize(index) : minOf(index);
|
|
5055
|
-
const highOf = (index) => panes()[index]?.max ?? DEFAULT_MAX;
|
|
5242
|
+
const highOf = (index) => panes()[index]?.max ?? DEFAULT_MAX$1;
|
|
5056
5243
|
/** Clamp every pane, then push the residual into the panes in order. */
|
|
5057
5244
|
const fit = (raw) => {
|
|
5058
5245
|
const next = panes().map((_, index) => clamp(round2(raw[index] ?? FULL / Math.max(count(), 1)), lowOf(index), highOf(index)));
|
|
@@ -5702,6 +5889,37 @@ const createSpinbutton = (options = {}) => {
|
|
|
5702
5889
|
}
|
|
5703
5890
|
};
|
|
5704
5891
|
};
|
|
5892
|
+
// src/status.ts
|
|
5893
|
+
/**
|
|
5894
|
+
* APG Alert's polite sibling: one `role=status` live region whose text the host
|
|
5895
|
+
* replaces ("Link copied", "Saved"). It is not a queue: a second message shows
|
|
5896
|
+
* in place of the first, so a burst reads as the last state, not as N alerts.
|
|
5897
|
+
* The region is rendered once and stays mounted, which is what makes a change
|
|
5898
|
+
* of its text announced.
|
|
5899
|
+
*
|
|
5900
|
+
* A status is never for errors: an error that needs attention now is
|
|
5901
|
+
* `createAlert` (`role=alert`, assertive), and a message with a title, a
|
|
5902
|
+
* description and a timeout is `createToast`.
|
|
5903
|
+
*/
|
|
5904
|
+
const createStatus = (options = {}) => {
|
|
5905
|
+
const id = options.id ?? createUniqueId();
|
|
5906
|
+
const [message, setMessage] = createSignal();
|
|
5907
|
+
return {
|
|
5908
|
+
message,
|
|
5909
|
+
show: (text) => {
|
|
5910
|
+
setMessage(text);
|
|
5911
|
+
},
|
|
5912
|
+
clear: () => {
|
|
5913
|
+
setMessage(void 0);
|
|
5914
|
+
},
|
|
5915
|
+
statusProps: {
|
|
5916
|
+
id,
|
|
5917
|
+
role: "status",
|
|
5918
|
+
"aria-live": "polite",
|
|
5919
|
+
"aria-atomic": "true"
|
|
5920
|
+
}
|
|
5921
|
+
};
|
|
5922
|
+
};
|
|
5705
5923
|
// src/switch.ts
|
|
5706
5924
|
/**
|
|
5707
5925
|
* APG Switch (https://www.w3.org/WAI/ARIA/apg/patterns/switch/): a binary on/off
|
|
@@ -5791,6 +6009,171 @@ const createSwitch = (options = {}) => {
|
|
|
5791
6009
|
}
|
|
5792
6010
|
};
|
|
5793
6011
|
};
|
|
6012
|
+
// src/table.ts
|
|
6013
|
+
const normalize$1 = (ids) => [...new Set(ids)];
|
|
6014
|
+
const sameSet = (a, b) => a.length === b.length && a.every((id) => b.includes(id));
|
|
6015
|
+
const sameSort = (a, b) => a === null || b === null ? a === b : a.key === b.key && a.direction === b.direction;
|
|
6016
|
+
const isToggleKey = (event) => event.key === " " || event.key === "Spacebar" || event.key === "Enter";
|
|
6017
|
+
/**
|
|
6018
|
+
* Every option is read when an attribute is read, so pass props/getters, never a
|
|
6019
|
+
* destructured snapshot. `id` is captured once: it is the base of the caption id.
|
|
6020
|
+
*/
|
|
6021
|
+
const createTable = (options) => {
|
|
6022
|
+
const base = options.id ?? createUniqueId();
|
|
6023
|
+
const selectionMode = () => options.selection ?? "none";
|
|
6024
|
+
const selectable = () => selectionMode() !== "none";
|
|
6025
|
+
const columnOf = (key) => options.columns.find((column) => column.key === key);
|
|
6026
|
+
const isSortable = (key) => columnOf(key)?.sortable !== false;
|
|
6027
|
+
const [rawSort, requestSort] = createControllable({
|
|
6028
|
+
value: () => options.sort,
|
|
6029
|
+
initial: options.defaultSort ?? null,
|
|
6030
|
+
onChange: () => (next) => options.onSortChange?.(next ?? void 0),
|
|
6031
|
+
same: sameSort
|
|
6032
|
+
});
|
|
6033
|
+
const sort = () => rawSort() ?? void 0;
|
|
6034
|
+
const [rawSelected, requestSelection] = createControllable({
|
|
6035
|
+
value: () => options.selected,
|
|
6036
|
+
initial: normalize$1(options.defaultSelected ?? []),
|
|
6037
|
+
onChange: () => options.onSelectionChange,
|
|
6038
|
+
same: sameSet
|
|
6039
|
+
});
|
|
6040
|
+
const selected = () => normalize$1(rawSelected());
|
|
6041
|
+
const isSelected = (id) => selected().includes(id);
|
|
6042
|
+
const nextSort = (key) => {
|
|
6043
|
+
const current = rawSort();
|
|
6044
|
+
if (current === null || current.key !== key) return {
|
|
6045
|
+
key,
|
|
6046
|
+
direction: "ascending"
|
|
6047
|
+
};
|
|
6048
|
+
if (current.direction === "ascending") return {
|
|
6049
|
+
key,
|
|
6050
|
+
direction: "descending"
|
|
6051
|
+
};
|
|
6052
|
+
return (options.sortCycle ?? "tri-state") === "toggle" ? {
|
|
6053
|
+
key,
|
|
6054
|
+
direction: "ascending"
|
|
6055
|
+
} : null;
|
|
6056
|
+
};
|
|
6057
|
+
const toggleRow = (id) => {
|
|
6058
|
+
const mode = selectionMode();
|
|
6059
|
+
if (mode === "none") return;
|
|
6060
|
+
const current = selected();
|
|
6061
|
+
const has = current.includes(id);
|
|
6062
|
+
requestSelection(mode === "single" ? has ? [] : [id] : has ? current.filter((value) => value !== id) : [...current, id]);
|
|
6063
|
+
};
|
|
6064
|
+
const rows = () => options.rows;
|
|
6065
|
+
const allSelected = () => {
|
|
6066
|
+
const every = rows();
|
|
6067
|
+
return every.length > 0 && every.every(isSelected);
|
|
6068
|
+
};
|
|
6069
|
+
const someSelected = () => rows().some(isSelected);
|
|
6070
|
+
const toggleAll = () => {
|
|
6071
|
+
if (selectionMode() !== "multiple") return;
|
|
6072
|
+
const current = selected();
|
|
6073
|
+
if (allSelected()) {
|
|
6074
|
+
requestSelection(current.filter((id) => !rows().includes(id)));
|
|
6075
|
+
return;
|
|
6076
|
+
}
|
|
6077
|
+
requestSelection(normalize$1([...current, ...rows()]));
|
|
6078
|
+
};
|
|
6079
|
+
const keyedToggle = (toggle) => (event) => {
|
|
6080
|
+
if (!isToggleKey(event)) return;
|
|
6081
|
+
event.preventDefault();
|
|
6082
|
+
toggle();
|
|
6083
|
+
};
|
|
6084
|
+
const selectAll = () => {
|
|
6085
|
+
if (selectionMode() !== "multiple") return;
|
|
6086
|
+
return {
|
|
6087
|
+
role: "checkbox",
|
|
6088
|
+
get "aria-checked"() {
|
|
6089
|
+
return allSelected() ? "true" : someSelected() ? "mixed" : "false";
|
|
6090
|
+
},
|
|
6091
|
+
get "aria-label"() {
|
|
6092
|
+
return options.labels?.selectAll ?? "Select all rows";
|
|
6093
|
+
},
|
|
6094
|
+
tabindex: 0,
|
|
6095
|
+
onClick: () => {
|
|
6096
|
+
toggleAll();
|
|
6097
|
+
},
|
|
6098
|
+
onKeyDown: keyedToggle(toggleAll)
|
|
6099
|
+
};
|
|
6100
|
+
};
|
|
6101
|
+
const rowCheckbox = (id) => {
|
|
6102
|
+
if (!selectable()) return;
|
|
6103
|
+
return {
|
|
6104
|
+
role: "checkbox",
|
|
6105
|
+
get "aria-checked"() {
|
|
6106
|
+
return isSelected(id) ? "true" : "false";
|
|
6107
|
+
},
|
|
6108
|
+
get "aria-label"() {
|
|
6109
|
+
return options.labels?.selectRow?.(id) ?? `Select row ${id}`;
|
|
6110
|
+
},
|
|
6111
|
+
tabindex: 0,
|
|
6112
|
+
onClick: (event) => {
|
|
6113
|
+
event.stopPropagation();
|
|
6114
|
+
toggleRow(id);
|
|
6115
|
+
},
|
|
6116
|
+
onKeyDown: keyedToggle(() => toggleRow(id))
|
|
6117
|
+
};
|
|
6118
|
+
};
|
|
6119
|
+
return {
|
|
6120
|
+
selectionMode,
|
|
6121
|
+
sort,
|
|
6122
|
+
selected,
|
|
6123
|
+
isSelected,
|
|
6124
|
+
tableProps: {
|
|
6125
|
+
get "aria-label"() {
|
|
6126
|
+
return options.labels?.table;
|
|
6127
|
+
},
|
|
6128
|
+
get "aria-rowcount"() {
|
|
6129
|
+
return options.rowCount;
|
|
6130
|
+
},
|
|
6131
|
+
get "aria-colcount"() {
|
|
6132
|
+
return options.columnCount;
|
|
6133
|
+
}
|
|
6134
|
+
},
|
|
6135
|
+
captionProps: { id: `${base}-caption` },
|
|
6136
|
+
headerCellProps: (key) => ({
|
|
6137
|
+
scope: "col",
|
|
6138
|
+
get "aria-sort"() {
|
|
6139
|
+
const current = rawSort();
|
|
6140
|
+
return current !== null && current.key === key && isSortable(key) ? current.direction : void 0;
|
|
6141
|
+
},
|
|
6142
|
+
get abbr() {
|
|
6143
|
+
return columnOf(key)?.abbr;
|
|
6144
|
+
}
|
|
6145
|
+
}),
|
|
6146
|
+
headerButtonProps: (key) => {
|
|
6147
|
+
if (!isSortable(key)) return;
|
|
6148
|
+
return {
|
|
6149
|
+
type: "button",
|
|
6150
|
+
onClick: () => {
|
|
6151
|
+
requestSort(nextSort(key));
|
|
6152
|
+
}
|
|
6153
|
+
};
|
|
6154
|
+
},
|
|
6155
|
+
rowProps: (id) => ({
|
|
6156
|
+
get "aria-selected"() {
|
|
6157
|
+
if (!selectable()) return;
|
|
6158
|
+
return isSelected(id) ? "true" : "false";
|
|
6159
|
+
},
|
|
6160
|
+
get onClick() {
|
|
6161
|
+
return selectable() ? () => toggleRow(id) : void 0;
|
|
6162
|
+
}
|
|
6163
|
+
}),
|
|
6164
|
+
cellProps: (_rowId, key) => ({ get "aria-colindex"() {
|
|
6165
|
+
if (options.columnCount === void 0) return;
|
|
6166
|
+
const index = options.columns.findIndex((column) => column.key === key);
|
|
6167
|
+
return index < 0 ? void 0 : index + 1;
|
|
6168
|
+
} }),
|
|
6169
|
+
get selectAllProps() {
|
|
6170
|
+
return selectAll();
|
|
6171
|
+
},
|
|
6172
|
+
rowCheckboxProps: (id) => rowCheckbox(id),
|
|
6173
|
+
toggleAll,
|
|
6174
|
+
toggleRow
|
|
6175
|
+
};
|
|
6176
|
+
};
|
|
5794
6177
|
// src/textbox.ts
|
|
5795
6178
|
const control = (field, options) => ({
|
|
5796
6179
|
...field.controlProps,
|
|
@@ -6248,6 +6631,143 @@ const createTimeline = (options = {}) => {
|
|
|
6248
6631
|
}
|
|
6249
6632
|
};
|
|
6250
6633
|
};
|
|
6634
|
+
// src/toast.ts
|
|
6635
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
6636
|
+
const DEFAULT_MAX = 3;
|
|
6637
|
+
const DEFAULT_REGION_LABEL = "Notifications";
|
|
6638
|
+
/**
|
|
6639
|
+
* A toast queue: one timeout per toast, a ceiling that drops the oldest, and a
|
|
6640
|
+
* countdown that pauses on hover and focus and resumes from where it stopped.
|
|
6641
|
+
* A toast announces itself through its own live region (`role=status`, or
|
|
6642
|
+
* `role=alert` for `error`), so the host renders one element per toast inside
|
|
6643
|
+
* `regionProps`. No DOM access here: the host renders, the primitive decides.
|
|
6644
|
+
*
|
|
6645
|
+
* The timeout is a floor of practice, not of code: the default is 5000 ms
|
|
6646
|
+
* because both Base UI and React Aria agree on at least five seconds to read a
|
|
6647
|
+
* message; shorter values exist for tests and are the host's judgement.
|
|
6648
|
+
*/
|
|
6649
|
+
const createToast = (options = {}) => {
|
|
6650
|
+
const [version, setVersion] = createSignal(0);
|
|
6651
|
+
let items = [];
|
|
6652
|
+
const timers = /* @__PURE__ */ new Map();
|
|
6653
|
+
const toasts = () => {
|
|
6654
|
+
version();
|
|
6655
|
+
return items;
|
|
6656
|
+
};
|
|
6657
|
+
const commit = (next) => {
|
|
6658
|
+
items = [...next];
|
|
6659
|
+
setVersion((count) => count + 1);
|
|
6660
|
+
};
|
|
6661
|
+
const clear = (id) => {
|
|
6662
|
+
const countdown = timers.get(id);
|
|
6663
|
+
if (countdown === void 0) return;
|
|
6664
|
+
if (countdown.handle !== void 0) clearTimeout(countdown.handle);
|
|
6665
|
+
timers.delete(id);
|
|
6666
|
+
};
|
|
6667
|
+
const dismiss = (id) => {
|
|
6668
|
+
if (!items.some((item) => item.id === id)) return;
|
|
6669
|
+
clear(id);
|
|
6670
|
+
commit(items.filter((item) => item.id !== id));
|
|
6671
|
+
options.onDismiss?.(id);
|
|
6672
|
+
};
|
|
6673
|
+
const dismissAll = () => {
|
|
6674
|
+
const ids = items.map((item) => item.id);
|
|
6675
|
+
if (ids.length === 0) return;
|
|
6676
|
+
for (const id of ids) clear(id);
|
|
6677
|
+
commit([]);
|
|
6678
|
+
for (const id of ids) options.onDismiss?.(id);
|
|
6679
|
+
};
|
|
6680
|
+
const arm = (id, countdown) => {
|
|
6681
|
+
countdown.handle = setTimeout(() => dismiss(id), countdown.remaining);
|
|
6682
|
+
};
|
|
6683
|
+
const pause = (id) => {
|
|
6684
|
+
const countdown = timers.get(id);
|
|
6685
|
+
if (countdown === void 0 || countdown.handle === void 0) return;
|
|
6686
|
+
clearTimeout(countdown.handle);
|
|
6687
|
+
countdown.handle = void 0;
|
|
6688
|
+
countdown.remaining = Math.max(0, countdown.deadline - Date.now());
|
|
6689
|
+
};
|
|
6690
|
+
const paused = (countdown) => countdown.focused || countdown.hovered && (options.pauseOnHover ?? true);
|
|
6691
|
+
const resume = (id) => {
|
|
6692
|
+
const countdown = timers.get(id);
|
|
6693
|
+
if (countdown === void 0 || countdown.handle !== void 0 || paused(countdown)) return;
|
|
6694
|
+
countdown.deadline = Date.now() + countdown.remaining;
|
|
6695
|
+
arm(id, countdown);
|
|
6696
|
+
};
|
|
6697
|
+
const sync = (id, countdown) => {
|
|
6698
|
+
if (paused(countdown)) pause(id);
|
|
6699
|
+
else resume(id);
|
|
6700
|
+
};
|
|
6701
|
+
const show = (input) => {
|
|
6702
|
+
const item = {
|
|
6703
|
+
id: createUniqueId(),
|
|
6704
|
+
title: input.title,
|
|
6705
|
+
description: input.description,
|
|
6706
|
+
tone: input.tone ?? "info"
|
|
6707
|
+
};
|
|
6708
|
+
const limit = Math.max(1, options.max ?? DEFAULT_MAX);
|
|
6709
|
+
const next = [...items, item];
|
|
6710
|
+
const dropped = [];
|
|
6711
|
+
while (next.length > limit) {
|
|
6712
|
+
const oldest = next.shift();
|
|
6713
|
+
if (oldest !== void 0) dropped.push(oldest.id);
|
|
6714
|
+
}
|
|
6715
|
+
commit(next);
|
|
6716
|
+
for (const id of dropped) {
|
|
6717
|
+
clear(id);
|
|
6718
|
+
options.onDismiss?.(id);
|
|
6719
|
+
}
|
|
6720
|
+
const duration = Math.max(0, options.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
6721
|
+
const countdown = {
|
|
6722
|
+
handle: void 0,
|
|
6723
|
+
deadline: Date.now() + duration,
|
|
6724
|
+
remaining: duration,
|
|
6725
|
+
hovered: false,
|
|
6726
|
+
focused: false
|
|
6727
|
+
};
|
|
6728
|
+
timers.set(item.id, countdown);
|
|
6729
|
+
arm(item.id, countdown);
|
|
6730
|
+
return item.id;
|
|
6731
|
+
};
|
|
6732
|
+
const hover = (id, next) => {
|
|
6733
|
+
const countdown = timers.get(id);
|
|
6734
|
+
if (countdown === void 0) return;
|
|
6735
|
+
countdown.hovered = next;
|
|
6736
|
+
sync(id, countdown);
|
|
6737
|
+
};
|
|
6738
|
+
const focus = (id, next) => {
|
|
6739
|
+
const countdown = timers.get(id);
|
|
6740
|
+
if (countdown === void 0) return;
|
|
6741
|
+
countdown.focused = next;
|
|
6742
|
+
sync(id, countdown);
|
|
6743
|
+
};
|
|
6744
|
+
if (getOwner() !== null) onCleanup(() => {
|
|
6745
|
+
for (const countdown of timers.values()) if (countdown.handle !== void 0) clearTimeout(countdown.handle);
|
|
6746
|
+
timers.clear();
|
|
6747
|
+
});
|
|
6748
|
+
return {
|
|
6749
|
+
toasts,
|
|
6750
|
+
show,
|
|
6751
|
+
dismiss,
|
|
6752
|
+
dismissAll,
|
|
6753
|
+
regionProps: {
|
|
6754
|
+
role: "region",
|
|
6755
|
+
get "aria-label"() {
|
|
6756
|
+
return options.labels?.region ?? DEFAULT_REGION_LABEL;
|
|
6757
|
+
}
|
|
6758
|
+
},
|
|
6759
|
+
toastProps: (id) => ({
|
|
6760
|
+
get role() {
|
|
6761
|
+
return items.find((item) => item.id === id)?.tone === "error" ? "alert" : "status";
|
|
6762
|
+
},
|
|
6763
|
+
"aria-atomic": "true",
|
|
6764
|
+
onMouseEnter: () => hover(id, true),
|
|
6765
|
+
onMouseLeave: () => hover(id, false),
|
|
6766
|
+
onFocusIn: () => focus(id, true),
|
|
6767
|
+
onFocusOut: () => focus(id, false)
|
|
6768
|
+
})
|
|
6769
|
+
};
|
|
6770
|
+
};
|
|
6251
6771
|
// src/toolbar.ts
|
|
6252
6772
|
const AXIS = (o) => o === "horizontal" ? "inline" : "block";
|
|
6253
6773
|
const delta = (axis, key) => {
|
|
@@ -7841,4 +8361,4 @@ const createRearrangeableListbox = (options) => {
|
|
|
7841
8361
|
})
|
|
7842
8362
|
};
|
|
7843
8363
|
};
|
|
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 };
|
|
8364
|
+
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, createDrawer, createFeed, createField, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, createTable, createTabs, createTextArea, createTextInput, createTimeline, createToast, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cssints/headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|