@cssints/headless 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,6 +206,36 @@ const email = createTextInput({ type: "email", autoComplete: "email", inputMode:
206
206
  - **`disabled` vs `readOnly`.** Disabled is out of the tab order and not submitted; readOnly is focusable, selectable and submitted. A field the user must read before signing is readOnly, not disabled.
207
207
  - `createTextArea` adds `rows`, `wrap` and `resize` (`resize` is a style, since CSS has no attribute for it; `none` is what a layout that owns its own height wants).
208
208
 
209
+ ## Drop zone
210
+
211
+ `createDropZone` is a file drop target on a **hidden native `<input type="file">`**: the picker, the `accept` filter and `multiple` are the platform's, and the primitive adds a drag target, the validation and a status line. **There is no APG pattern for a drop zone**, so there is no Quint spec: `test/dropzone.browser.test.tsx` is its oracle. The same primitive replaces the bare file inputs of cms-admin's image field and kocharin's dashboard upload.
212
+
213
+ ```tsx
214
+ const upload = createDropZone({
215
+ accept: "image/*",
216
+ multiple: true,
217
+ maxSize: 5 * 1024 * 1024,
218
+ labels: { zone: "Images", browse: "Choose images" },
219
+ onFiles: (files) => void add(files),
220
+ onReject: ({ file, reason }) => toast(`${file.name}: ${reason}`),
221
+ });
222
+
223
+ <div {...upload.zoneProps}>
224
+ <p>Drop images here{upload.dragging() ? " (release to add)" : ""}</p>
225
+ <input {...upload.inputProps} />
226
+ <button {...upload.browseProps}>{upload.message() ?? "Choose images"}</button>
227
+ <p {...upload.statusProps}>{upload.message()}</p>
228
+ </div>;
229
+ ```
230
+
231
+ - **The zone is never drag-only.** `browseProps` goes on a real `<button type="button">` that clicks the hidden input, so the picker is the keyboard path (Enter and Space are the platform's) and a pointer user can click as well as drag. The browse button's text is the host's; `labels.browse` is its accessible name when that text is an icon.
232
+ - **Render the hidden input and the status region always.** `inputProps` carries `hidden`, so it is out of the tab order and the accessibility tree but still the thing the button clicks; the `role="status"` region announces a change of text, which only happens if it was already mounted.
233
+ - **Validation happens once per file**, type then size: `accept` is the HTML grammar (`.png`, `image/png`, `image/*`), `maxSize` is bytes. A refused file reaches `onReject` with `reason: "type" | "size"`; the accepted ones reach `onFiles` in one batch, and a batch with nothing accepted calls neither.
234
+ - **`message()` is a default sentence**, "2 files, 1 rejected: too large", for the status region; the words are the host's, the primitive only tracks the counts of the last batch. It is `undefined` before the first file.
235
+ - **A folder or an empty `DataTransfer` is a no-op**: no callback, no rejection, and the previous announcement stays.
236
+ - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the state is a depth counter: `dragging()` clears only when the drag leaves the zone itself, or on a drop. `dragging()` is the styling hook — the primitive writes no `data-*`.
237
+ - **`disabled`** marks the zone `aria-disabled`, disables the input natively and the browse button, and drops every drag before it becomes a batch. Options are read when an attribute is read, so a host may flip it on a mounted zone.
238
+
209
239
  ## Pagination
210
240
 
211
241
  `createPagination` is a navigation landmark over a page list. **No APG pattern exists**; the requirements are TetraLogical's (accessible-components-library v1.0.0), and `test/pagination.browser.test.tsx` is the oracle.
@@ -289,6 +319,87 @@ const table = createTable({
289
319
  - **`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.
290
320
  - Solid 2 arrives through delegated events; a host that reads the DOM right after a click waits a tick.
291
321
 
322
+ ## Timeline
323
+
324
+ `createTimeline` has two modes over one axis. Without `rows` it renders the package's demo tree of five fixed spans (collapsing, a zoom window, the differential walk against `specs/apg/timeline.qnt`); with `rows` it takes real span data and lays it out as a trace waterfall.
325
+
326
+ ```tsx
327
+ const timeline = createTimeline({
328
+ rows: spans, // { id, label, start, end, depth?, status? }, in the host's display order
329
+ axis: { unit: "ms" },
330
+ selected: selected(), // controlled; omit it and the primitive keeps the value
331
+ onSelect: (id) => setSelected(id),
332
+ });
333
+
334
+ <ul {...timeline.listProps}>
335
+ {spans.map((span) => (
336
+ <li {...timeline.rowProps(span.id)}>
337
+ <span {...timeline.labelProps(span.id)}>{span.label}</span>
338
+ <div class="track">
339
+ <div {...timeline.barProps(span.id)} />
340
+ </div>
341
+ </li>
342
+ ))}
343
+ </ul>
344
+ <div {...{ role: timeline.rulerProps.role }}>
345
+ {timeline.rulerProps.ticks.map((tick) => (
346
+ <span style={{ left: `${tick.left}%` }}>{tick.label}</span>
347
+ ))}
348
+ </div>;
349
+ ```
350
+
351
+ - **A waterfall is a list, not an APG widget.** There is no trace/waterfall pattern, and the rows are neither a tree (nothing collapses) nor a grid (nothing is edited). The rows mode is `role="list"` / `role="listitem"` (see `listProps` / `rowProps`) with `aria-level` = `depth + 1`, so the nesting a flat DOM cannot show is still announced; the selected row is `aria-current="true"`, which every element may carry — `aria-selected` is not allowed on a `listitem` and a waterfall is not a selection control. The demo tree keeps `role="tree"` / `treeitem`, because it does collapse.
352
+ - **The extent is the rows'.** `extent()` is the smallest `start` and the largest `end`, so a nested span is positioned and sized as a percentage of the whole trace, not of its parent. `barProps(id).style` is `left` / `width` in percent of that extent. A zero-length span (a marker, `start === end`) still paints: every bar has a **0.5% floor** (2 px on a 400 px track), the floor the o11y dashboard hand-rolled before this mode existed.
353
+ - **Depth is a custom property**, `--depth` on `barProps` and `labelProps`, so a narrow track keeps its indentation: `padding-inline-start: calc(var(--depth) * 0.75rem)`. Nesting the label's DOM would steal width from the bar.
354
+ - **The ruler is derived.** `rulerProps.ticks` is the extent stepped by 1, 2 or 5 times a power of ten (about five ticks), each `{ value, left, label }` with the label in the `axis.unit` (default `ms`). The ruler itself is decorative (`role="presentation"`, `aria-hidden="true"`); render the ticks as children.
355
+ - **Selection and keys.** A row click selects it and moves the tab stop; ArrowUp / ArrowDown / Home / End move a roving tab stop, and Enter or Space selects the row that has DOM focus — the host moves the real focus after an arrow key, as the demo fixture does. `selected` + `onSelect` make it controlled (`selected` is a request the host may ignore); `select(id)` is the programmatic request.
356
+ - `status` is carried as `data-status` on the row for the host's colour, and `label` never leaves the host's data: the primitive does not invent the row's text.
357
+
358
+ ## Tabs
359
+
360
+ `createTabs` is the APG tablist: roving tabindex, wrapping arrows, Home/End, automatic or manual activation, disabled tabs, and a selection that is a request (`value` + `onValueChange`) or uncontrolled (`defaultSelected`). The plain widget is checked against `specs/apg/tabs.qnt` by the scenario bridge and the differential walk in `test/tabs.browser.test.tsx`.
361
+
362
+ The docs playground's file tabs add and close files and rename them, so `createTabs` also takes `closable` and `renamable` — both off by default, so the plain widget is unchanged. **The primitive never takes the list:** it asks (`onClose`, `onRename`), the host removes or renames. `label(index)` is the tab's name: it names the close and rename buttons and seeds the rename draft, and it is read lazily.
363
+
364
+ ```tsx
365
+ const tabs = createTabs({
366
+ get items() {
367
+ return files().length;
368
+ }, // the host's list; it shrinks after a close
369
+ get closable() {
370
+ return true;
371
+ },
372
+ get renamable() {
373
+ return true;
374
+ },
375
+ label: (index) => files()[index], // "Close index.ts", the rename draft
376
+ onClose: (index) => setFiles((all) => all.filter((_, at) => at !== index)),
377
+ onRename: (index, label) =>
378
+ setFiles((all) => all.map((name, at) => (at === index ? label : name))),
379
+ });
380
+
381
+ <div {...tabs.tablistProps}>
382
+ {files().map((name, index) => (
383
+ <div {...tabs.tabProps(index)} onDblClick={tabs.renameProps(index).onDblClick}>
384
+ {tabs.renameProps(index).editing() ? (
385
+ <input
386
+ {...tabs.renameProps(index).inputProps}
387
+ ref={(element) => queueMicrotask(() => element.focus())}
388
+ />
389
+ ) : (
390
+ name
391
+ )}
392
+ </div>
393
+ ))}
394
+ {/* ... the close button of each tab, and the panels */}
395
+ </div>;
396
+ ```
397
+
398
+ - **Close.** `closeProps(index)` is the tab's close button: `aria-label` "Close <label>" and `tabindex=-1`, because a tablist's tab stops are its tabs; the keyboard path is Delete on the focused tab. Focus after a close goes to the tab that slid into the closed one's place, or to the previous tab when the last one closed: `focusAfterClose(index)` is that index in the shortened list, and the host focuses it after its removal lands. Closing the last remaining tab is refused, as the spec refuses an empty tablist; a closed tab that was selected asks for the tab that takes the focus.
399
+ - **Rename.** A double click begins; the tab renders `inputProps` instead of its label. Enter commits, Escape reverts, blur commits; a label that trims to nothing cancels. An `<input>` cannot live in a `<button>`, so the tab becomes a `role="tab"` element while it is editing. While editing the input owns the keys: the tablist's roving does not move, and an arrow key leaves the caret alone. `begin(index)` is the programmatic start (a tab the host has just added: append it, then `focusTab(index)` and `begin(index)`).
400
+ - **State.** `editing()` is the tab being renamed, `null` when none. `items` is read from the option on every read, so the bounds, the roving tab stop and `firstEnabled` follow a host that shrinks its list.
401
+ - The rename and the two callbacks are the extension's own: the spec models a `deletable` flavour (Delete, focus to the next tab, the last refused) but no rename and no callbacks, so `test/tabs-close.browser.test.tsx` is the oracle for all of it.
402
+
292
403
  ## Confirm
293
404
 
294
405
  `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).
package/dist/index.d.mts CHANGED
@@ -1497,6 +1497,106 @@ export interface Drawer {
1497
1497
  * dismissal a non-modal drawer has); closing returns focus to the trigger.
1498
1498
  */
1499
1499
  export declare const createDrawer: (options?: DrawerOptions) => Drawer;
1500
+ /** Why a file was refused: the `accept` grammar, or `maxSize`. */
1501
+ export type DropZoneRejectReason = "type" | "size";
1502
+ export interface DropZoneReject {
1503
+ readonly file: File;
1504
+ readonly reason: DropZoneRejectReason;
1505
+ }
1506
+ export interface DropZoneLabels {
1507
+ /** The zone's accessible name (`aria-label` on the `role=group` zone). */
1508
+ readonly zone?: string | undefined;
1509
+ /** The browse button's accessible name; render it visibly, or use it for an icon-only button. */
1510
+ readonly browse?: string | undefined;
1511
+ }
1512
+ export interface DropZoneOptions {
1513
+ /** The HTML `accept` grammar: extensions (`.png`) and MIME types, wildcards included (`image/*`). */
1514
+ readonly accept?: string | undefined;
1515
+ readonly multiple?: boolean | undefined;
1516
+ /** Bytes; a larger file is rejected with `"size"`. */
1517
+ readonly maxSize?: number | undefined;
1518
+ readonly disabled?: boolean | undefined;
1519
+ /** The files that passed, one call per batch; not called when the batch has none. */
1520
+ readonly onFiles: (files: File[]) => void;
1521
+ /** Each refused file, once, with the reason the host shows. */
1522
+ readonly onReject?: ((reject: DropZoneReject) => void) | undefined;
1523
+ readonly labels?: DropZoneLabels | undefined;
1524
+ }
1525
+ /** Spread onto the hidden `<input type=file>`; render it, always. */
1526
+ export interface DropZoneInputProps {
1527
+ /** The id the browse button clicks; generated. */
1528
+ readonly id: string;
1529
+ readonly type: "file";
1530
+ readonly accept: string | undefined;
1531
+ readonly multiple: true | undefined;
1532
+ readonly disabled: true | undefined;
1533
+ readonly hidden: true;
1534
+ readonly onChange: (event: {
1535
+ currentTarget: HTMLInputElement;
1536
+ }) => void;
1537
+ }
1538
+ /** Spread onto the drop target (any element). `dragging()` is the styling hook. */
1539
+ export interface DropZoneZoneProps {
1540
+ readonly role: "group";
1541
+ readonly "aria-label": string | undefined;
1542
+ readonly "aria-disabled": "true" | undefined;
1543
+ readonly onDragEnter: (event: DragEvent) => void;
1544
+ readonly onDragOver: (event: DragEvent) => void;
1545
+ readonly onDragLeave: (event: DragEvent) => void;
1546
+ readonly onDrop: (event: DragEvent) => void;
1547
+ }
1548
+ /** Spread onto a real `<button type="button">`: Enter and Space are the platform's. */
1549
+ export interface DropZoneBrowseProps {
1550
+ readonly type: "button";
1551
+ readonly disabled: true | undefined;
1552
+ readonly "aria-disabled": "true" | undefined;
1553
+ readonly "aria-label": string | undefined;
1554
+ readonly onClick: () => void;
1555
+ }
1556
+ /** Spread onto the live region; render it once, always. */
1557
+ export interface DropZoneStatusProps {
1558
+ readonly role: "status";
1559
+ readonly "aria-live": "polite";
1560
+ readonly "aria-atomic": "true";
1561
+ }
1562
+ export interface DropZone {
1563
+ /** A drag is over the zone; `dragleave` on a child does not clear it. */
1564
+ readonly dragging: Accessor<boolean>;
1565
+ /**
1566
+ * A default sentence for the last batch ("2 files, 1 rejected: too large"),
1567
+ * or `undefined` before one arrived. The host may write its own words from
1568
+ * the same counts.
1569
+ */
1570
+ readonly message: Accessor<string | undefined>;
1571
+ readonly inputProps: DropZoneInputProps;
1572
+ readonly zoneProps: DropZoneZoneProps;
1573
+ readonly browseProps: DropZoneBrowseProps;
1574
+ readonly statusProps: DropZoneStatusProps;
1575
+ }
1576
+ /**
1577
+ * A file drop zone. There is no APG pattern and no Quint spec for one: the
1578
+ * contract is HTML's file input plus WAI-ARIA's live region, and
1579
+ * `test/dropzone.browser.test.tsx` is its oracle.
1580
+ *
1581
+ * Decisions:
1582
+ * - **The zone is never drag-only.** `browseProps` spreads onto a real
1583
+ * `<button type="button">` that clicks the hidden input, so the picker is the
1584
+ * keyboard path (Enter and Space are the platform's) and a pointer user can
1585
+ * click as well as drag. The drop handlers add nothing the button cannot do.
1586
+ * - **Validation happens once per file**, in the order type then size, and a
1587
+ * refused file reaches `onReject` with the reason the host shows. `accept` is
1588
+ * the HTML grammar (`.` extension, MIME type, `*`); `maxSize` is bytes.
1589
+ * - **Dropping a folder or a `DataTransfer` with no files is a no-op**: no
1590
+ * `onFiles`, no `onReject`, no message, and the previous announcement stays.
1591
+ * - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the
1592
+ * state is a depth counter: it clears only when the drag leaves the zone
1593
+ * itself, or on a drop.
1594
+ * - **The zone is `role=group`** so `labels.zone` is exposed; `dragging()` is
1595
+ * the only drag state a skin reads (no `data-dragging`).
1596
+ * - `disabled` marks the zone `aria-disabled`, disables the input natively and
1597
+ * the browse button, and drops every drag before it becomes a batch.
1598
+ */
1599
+ export declare const createDropZone: (options: DropZoneOptions) => DropZone;
1500
1600
  /**
1501
1601
  * Options are read lazily (getters); pass a component's `props`.
1502
1602
  */
@@ -2854,6 +2954,11 @@ export interface SliderOptions {
2854
2954
  readonly defaultValue?: number;
2855
2955
  readonly orientation?: SliderOrientation;
2856
2956
  readonly direction?: SliderDirection;
2957
+ /**
2958
+ * aria-disabled: the thumb stays the tab stop (the APG pattern's focus
2959
+ * contract) but a key or a rail press changes nothing.
2960
+ */
2961
+ readonly disabled?: boolean;
2857
2962
  /** Base for the generated thumb id. */
2858
2963
  readonly id?: string;
2859
2964
  /**
@@ -2870,6 +2975,7 @@ export interface SliderThumbProps {
2870
2975
  readonly "aria-valuemax": string;
2871
2976
  readonly "aria-valuetext": string;
2872
2977
  readonly "aria-orientation": SliderOrientation;
2978
+ readonly "aria-disabled": "true" | undefined;
2873
2979
  readonly tabindex: number;
2874
2980
  readonly onFocus: () => void;
2875
2981
  readonly onBlur: () => void;
@@ -2907,6 +3013,11 @@ export interface Slider {
2907
3013
  * pass `value` + `onValueChange` to own the value. A key or rail press is a
2908
3014
  * request the host may ignore.
2909
3015
  *
3016
+ * `disabled` is a widget-level extension of the pattern (the spec lists it
3017
+ * under its deliberate omissions): the thumb keeps its tab stop and announces
3018
+ * `aria-disabled`, and every key and rail press is dropped before it becomes a
3019
+ * request.
3020
+ *
2910
3021
  * ponytail: dragging (pointer capture/move) is not modelled — the spec
2911
3022
  * deliberately only fixes a discrete rail press.
2912
3023
  */
@@ -2991,6 +3102,11 @@ export interface SpinbuttonOptions {
2991
3102
  readonly defaultValue?: number;
2992
3103
  /** Wrap around at the bounds instead of clamping. */
2993
3104
  readonly wrap?: boolean;
3105
+ /**
3106
+ * aria-disabled plus the native `disabled` on the text field: keys, typing
3107
+ * and both steppers change nothing, and both steppers announce `aria-disabled`.
3108
+ */
3109
+ readonly disabled?: boolean;
2994
3110
  /** Base for the generated input / button ids. */
2995
3111
  readonly id?: string;
2996
3112
  /**
@@ -3007,6 +3123,8 @@ export interface SpinbuttonInputProps {
3007
3123
  readonly "aria-valuemax": string;
3008
3124
  readonly "aria-valuetext": string;
3009
3125
  readonly "aria-invalid": "true" | undefined;
3126
+ readonly "aria-disabled": "true" | undefined;
3127
+ readonly disabled: boolean;
3010
3128
  readonly tabindex: number;
3011
3129
  readonly value: string;
3012
3130
  readonly onFocus: () => void;
@@ -3062,6 +3180,12 @@ export interface Spinbutton {
3062
3180
  * stepper buttons, Enter and blur are requests the host may ignore; typing and
3063
3181
  * Escape change no committed value.
3064
3182
  *
3183
+ * `disabled` is a widget-level extension of the pattern (the spec models the
3184
+ * steppers' bound only, and lists this under its deliberate omissions): the
3185
+ * field announces `aria-disabled` and carries the native `disabled`, every key,
3186
+ * typed draft and both steppers are dropped before they become a request, and
3187
+ * neither stepper offers itself.
3188
+ *
3065
3189
  * ponytail: commit/revert on Enter/blur/Escape is the common convention, not
3066
3190
  * APG text.
3067
3191
  */
@@ -3433,6 +3557,26 @@ export interface TabsOptions {
3433
3557
  * current value. Uncontrolled it is accepted; controlled the host may ignore it.
3434
3558
  */
3435
3559
  readonly onValueChange?: (index: number) => void;
3560
+ /**
3561
+ * Close buttons and the Delete key (default false): the host renders
3562
+ * `closeProps` and removes the tab from `onClose`. The last tab is never
3563
+ * asked to close.
3564
+ */
3565
+ readonly closable?: boolean | undefined;
3566
+ /**
3567
+ * Inline rename (default false): a double click on a tab starts it, Enter /
3568
+ * blur commit and Escape reverts; the host renames from `onRename`.
3569
+ */
3570
+ readonly renamable?: boolean | undefined;
3571
+ /**
3572
+ * A tab's label: the close / rename button names and the starting draft of a
3573
+ * rename, read lazily (a getter is fine) so a rename follows.
3574
+ */
3575
+ readonly label?: ((index: number) => string | undefined) | undefined;
3576
+ /** A request to remove a tab; the primitive does not own the list. */
3577
+ readonly onClose?: ((index: number) => void) | undefined;
3578
+ /** A request to rename a tab with a non-empty label. */
3579
+ readonly onRename?: ((index: number, label: string) => void) | undefined;
3436
3580
  }
3437
3581
  /** Spread onto the element with `role=tab` (a native `<button>`). */
3438
3582
  export interface TabProps {
@@ -3460,21 +3604,63 @@ export interface TabpanelProps {
3460
3604
  readonly tabindex: 0 | undefined;
3461
3605
  readonly hidden: boolean;
3462
3606
  }
3607
+ /**
3608
+ * Spread onto the tab's close button. `tabindex=-1` keeps it out of the
3609
+ * tablist's tab order (a tablist's stops are its tabs); the Delete key is the
3610
+ * keyboard path.
3611
+ */
3612
+ export interface TabCloseProps {
3613
+ readonly "aria-label": string;
3614
+ readonly tabindex: -1;
3615
+ readonly onClick: () => void;
3616
+ }
3617
+ /** Spread onto the inline `<input>` a renaming tab renders instead of its label. */
3618
+ export interface TabRenameInputProps {
3619
+ readonly type: "text";
3620
+ readonly value: string;
3621
+ readonly "aria-label": string;
3622
+ readonly onInput: (event: InputEvent) => void;
3623
+ readonly onKeyDown: (event: KeyboardEvent) => void;
3624
+ readonly onBlur: () => void;
3625
+ }
3626
+ /** The rename part of one tab: put `onDblClick` on the tab, `inputProps` in it. */
3627
+ export interface TabRenameProps {
3628
+ readonly onDblClick: () => void;
3629
+ /** Whether this tab is the one being renamed. */
3630
+ readonly editing: Accessor<boolean>;
3631
+ /** Start renaming `index` (e.g. a tab the host has just added). */
3632
+ readonly begin: (index: number) => void;
3633
+ /** Commit `label` to the tab being edited; an empty label cancels. */
3634
+ readonly commit: (label: string) => void;
3635
+ readonly cancel: () => void;
3636
+ readonly inputProps: TabRenameInputProps;
3637
+ }
3463
3638
  export interface Tabs {
3639
+ /** The tab count; read from `items` on every read, so a host that shrinks its list stays in step. */
3464
3640
  readonly items: number;
3465
3641
  readonly selected: Accessor<number>;
3466
3642
  /** Tab holding (or last holding) DOM focus; -1 when focus is outside. */
3467
3643
  readonly focused: Accessor<number>;
3644
+ /** The tab being renamed inline, `null` when none. */
3645
+ readonly editing: Accessor<number | null>;
3468
3646
  /** Programmatic selection request; ignores disabled tabs. */
3469
3647
  readonly select: (index: number) => void;
3470
3648
  /** Programmatic selection request (may point at a disabled tab, like base-ui). */
3471
3649
  readonly setSelected: (index: number) => void;
3472
3650
  /** Programmatic focus (does not select). */
3473
3651
  readonly focusTab: (index: number) => void;
3652
+ /**
3653
+ * The tab that takes focus once `onClose(index)`'s removal lands: the tab
3654
+ * that slid into `index`, or the previous one when the last tab closed. Call
3655
+ * it before the host removes the tab.
3656
+ */
3657
+ readonly focusAfterClose: (index: number) => number;
3474
3658
  readonly keyDown: (key: string) => void;
3475
3659
  readonly tablistProps: TablistProps;
3476
3660
  readonly tabProps: (index: number) => TabProps;
3477
3661
  readonly tabpanelProps: (index: number) => TabpanelProps;
3662
+ readonly closeProps: (index: number) => TabCloseProps;
3663
+ readonly renameProps: (index: number) => TabRenameProps;
3478
3664
  }
3479
3665
  /**
3480
3666
  * APG Tabs (https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): state plus prop
@@ -3489,6 +3675,14 @@ export interface Tabs {
3489
3675
  * Request/commit (map ticket `rt1p`): uncontrolled by default
3490
3676
  * (`defaultSelected`); pass `value` + `onValueChange` to own the selection.
3491
3677
  * Programmatic methods (`select`, `setSelected`, `keyDown`) are requests too.
3678
+ *
3679
+ * Close and rename are an **extension** (bean `cssints-gdv2`): the APG spec
3680
+ * models a `deletable` flavour but neither a callback nor a rename, and this
3681
+ * primitive owns no list. `closable` / `renamable` turn on the props; `onClose`
3682
+ * / `onRename` ask the host, which removes or renames. Focus after a close goes
3683
+ * to the tab that slid into the closed one's place, or to the previous tab when
3684
+ * the last one closed; closing the last remaining tab is refused, as the spec
3685
+ * refuses an empty tablist.
3492
3686
  */
3493
3687
  export declare const createTabs: (options?: TabsOptions) => Tabs;
3494
3688
  /**
@@ -3506,10 +3700,16 @@ export declare const createTabs: (options?: TabsOptions) => Tabs;
3506
3700
  * may ignore. The zoom window (`viewStart`/`viewEnd`) is not the controlled
3507
3701
  * value and stays uncontrolled.
3508
3702
  *
3509
- * ponytail: the fixed 5-span tree is a demo fixture; a real consumer owns the
3510
- * span list (layout is theirs). Roving tabindex only — a consumer must call
3511
- * `focus()` on the row whose `tabindex` is 0 after arrow navigation (the tests'
3512
- * fixture does this with an effect).
3703
+ * The fixed 5-span tree is the demo fixture (the differential walk's oracle).
3704
+ * A real consumer passes `rows`: span data on one shared axis, laid out by
3705
+ * `barProps` as percentages of the rows' extent. No roles are borrowed from
3706
+ * APG: a trace waterfall is a list of spans in time, so the rows mode is
3707
+ * `role="list"` / `role="listitem"` (with `aria-level` for the depth) and the
3708
+ * current row is `aria-current="true"`, which every element may carry.
3709
+ *
3710
+ * Roving tabindex only — a consumer must call `focus()` on the row whose
3711
+ * `tabindex` is 0 after arrow navigation (the tests' fixture does this with an
3712
+ * effect; the rows mode's span rows are reached by tabbing to the current one).
3513
3713
  */
3514
3714
  /** One span row: a fraction of the parent span on a shared 0..1 axis. */
3515
3715
  export interface TimelineRow {
@@ -3592,12 +3792,90 @@ export interface Timeline {
3592
3792
  readonly rowProps: (id: number) => TimelineRowProps;
3593
3793
  readonly rulerProps: TimelineRulerProps;
3594
3794
  }
3595
- /**
3596
- * Headless span timeline on Solid 2. The exported accessors are signals; the
3597
- * prop objects are getter-backed, so spreading them keeps every attribute
3598
- * reactive.
3599
- */
3600
- export declare const createTimeline: (options?: TimelineOptions) => Timeline;
3795
+ /** One span of a real trace: a point on a shared axis, labelled, at a depth. */
3796
+ export interface TimelineSpan {
3797
+ readonly id: string;
3798
+ readonly label: string;
3799
+ /** Offset on the axis, in the axis' unit; `end` is the same axis, not a duration. */
3800
+ readonly start: number;
3801
+ readonly end: number;
3802
+ /** Nesting level, 0 for a root span. */
3803
+ readonly depth?: number;
3804
+ readonly status?: "ok" | "error" | "unset";
3805
+ }
3806
+ export interface TimelineAxis {
3807
+ /** The unit the numbers are in; the ruler labels its ticks with it. */
3808
+ readonly unit?: "ms" | "us" | "s";
3809
+ }
3810
+ /** The rows mode: real span data, read lazily like every other option. */
3811
+ export interface TimelineRowsOptions {
3812
+ readonly rows: ReadonlyArray<TimelineSpan>;
3813
+ readonly axis?: TimelineAxis | undefined;
3814
+ /** Controlled: the host owns the selection. */
3815
+ readonly selected?: string | undefined;
3816
+ /** A request to change the selection the host may ignore. */
3817
+ readonly onSelect?: ((id: string) => void) | undefined;
3818
+ }
3819
+ export interface TimelineTick {
3820
+ readonly value: number;
3821
+ readonly left: number;
3822
+ readonly label: string;
3823
+ }
3824
+ export interface TimelineListProps {
3825
+ readonly role: "list";
3826
+ readonly "aria-label": string;
3827
+ }
3828
+ export interface TimelineSpanRowProps {
3829
+ readonly role: "listitem";
3830
+ /** `depth + 1`: the nesting a flat list cannot show in the DOM. */
3831
+ readonly "aria-level": number;
3832
+ readonly "aria-current": "true" | undefined;
3833
+ /** A styling hook: the status the span ended with. */
3834
+ readonly "data-status": "ok" | "error" | "unset";
3835
+ readonly tabindex: number;
3836
+ readonly onClick: () => void;
3837
+ readonly onKeyDown: (event: KeyboardEvent) => void;
3838
+ }
3839
+ export interface TimelineBarProps {
3840
+ readonly "aria-hidden": "true";
3841
+ /** `left` / `width` are percentages of the extent; `--depth` is the row's depth. */
3842
+ readonly style: {
3843
+ readonly left: string;
3844
+ readonly width: string;
3845
+ readonly "--depth": string;
3846
+ };
3847
+ }
3848
+ export interface TimelineLabelProps {
3849
+ /** `--depth`, so the host indents the label (`padding-inline-start: calc(var(--depth) * 1rem)`) instead of nesting it. */
3850
+ readonly style: {
3851
+ readonly "--depth": string;
3852
+ };
3853
+ }
3854
+ export interface TimelineRulerRowsProps {
3855
+ readonly role: "presentation";
3856
+ readonly "aria-hidden": "true";
3857
+ /** Ticks of a nice step across the extent; render them as children. */
3858
+ readonly ticks: ReadonlyArray<TimelineTick>;
3859
+ }
3860
+ export interface TimelineRows {
3861
+ /** `{ start, end }` across the rows; `{ 0, 0 }` when there are none. */
3862
+ readonly extent: () => {
3863
+ readonly start: number;
3864
+ readonly end: number;
3865
+ };
3866
+ readonly depthOf: (id: string) => number;
3867
+ readonly listProps: TimelineListProps;
3868
+ readonly rowProps: (id: string) => TimelineSpanRowProps;
3869
+ readonly barProps: (id: string) => TimelineBarProps;
3870
+ readonly labelProps: (id: string) => TimelineLabelProps;
3871
+ readonly rulerProps: TimelineRulerRowsProps;
3872
+ /** Programmatic selection request; a controlled host may ignore it. */
3873
+ readonly select: (id: string) => void;
3874
+ readonly selectedId: Accessor<string | undefined>;
3875
+ }
3876
+ /** The demo five-span tree (`rows` absent) or real span data (`rows` given). */
3877
+ export declare function createTimeline(options: TimelineRowsOptions): TimelineRows;
3878
+ export declare function createTimeline(options?: TimelineOptions): Timeline;
3601
3879
  /** A toast's urgency; `error` is the only tone that announces assertively. */
3602
3880
  export type ToastTone = "info" | "success" | "error";
3603
3881
  export interface ToastOptions {
package/dist/index.mjs CHANGED
@@ -2939,6 +2939,165 @@ const createDrawer = (options = {}) => {
2939
2939
  handleProps: { "aria-hidden": "true" }
2940
2940
  };
2941
2941
  };
2942
+ // src/dropzone.ts
2943
+ const REASON_TEXT = {
2944
+ type: "unsupported type",
2945
+ size: "too large"
2946
+ };
2947
+ const tokensOf = (accept) => (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
2948
+ /** The HTML `accept` grammar: a token starting with "." is an extension, else a MIME type, `*` allowed. */
2949
+ const accepts = (accept, file) => {
2950
+ const tokens = tokensOf(accept);
2951
+ if (tokens.length === 0) return true;
2952
+ const name = file.name.toLowerCase();
2953
+ const [fileType = "", fileSubtype = ""] = file.type.toLowerCase().split("/");
2954
+ return tokens.some((token) => {
2955
+ if (token.startsWith(".")) return name.endsWith(token);
2956
+ const [type = "", subtype = ""] = token.split("/");
2957
+ return type === fileType && (subtype === "*" || subtype === fileSubtype);
2958
+ });
2959
+ };
2960
+ const refusal = (options, file) => {
2961
+ if (!accepts(options.accept, file)) return "type";
2962
+ if (options.maxSize !== void 0 && file.size > options.maxSize) return "size";
2963
+ };
2964
+ const messageOf = (batch) => {
2965
+ if (batch === void 0) return;
2966
+ const files = batch.total === 1 ? "1 file" : `${batch.total} files`;
2967
+ if (batch.rejected.length === 0) return files;
2968
+ const reasons = [...new Set(batch.rejected)].map((reason) => REASON_TEXT[reason]).join(", ");
2969
+ return `${files}, ${batch.rejected.length} rejected: ${reasons}`;
2970
+ };
2971
+ /**
2972
+ * A file drop zone. There is no APG pattern and no Quint spec for one: the
2973
+ * contract is HTML's file input plus WAI-ARIA's live region, and
2974
+ * `test/dropzone.browser.test.tsx` is its oracle.
2975
+ *
2976
+ * Decisions:
2977
+ * - **The zone is never drag-only.** `browseProps` spreads onto a real
2978
+ * `<button type="button">` that clicks the hidden input, so the picker is the
2979
+ * keyboard path (Enter and Space are the platform's) and a pointer user can
2980
+ * click as well as drag. The drop handlers add nothing the button cannot do.
2981
+ * - **Validation happens once per file**, in the order type then size, and a
2982
+ * refused file reaches `onReject` with the reason the host shows. `accept` is
2983
+ * the HTML grammar (`.` extension, MIME type, `*`); `maxSize` is bytes.
2984
+ * - **Dropping a folder or a `DataTransfer` with no files is a no-op**: no
2985
+ * `onFiles`, no `onReject`, no message, and the previous announcement stays.
2986
+ * - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the
2987
+ * state is a depth counter: it clears only when the drag leaves the zone
2988
+ * itself, or on a drop.
2989
+ * - **The zone is `role=group`** so `labels.zone` is exposed; `dragging()` is
2990
+ * the only drag state a skin reads (no `data-dragging`).
2991
+ * - `disabled` marks the zone `aria-disabled`, disables the input natively and
2992
+ * the browse button, and drops every drag before it becomes a batch.
2993
+ */
2994
+ const createDropZone = (options) => {
2995
+ const id = createUniqueId();
2996
+ const [depth, setDepth] = createSignal(0);
2997
+ const [batch, setBatch] = createSignal();
2998
+ const disabled = () => options.disabled === true;
2999
+ const deliver = (files) => {
3000
+ if (disabled() || files.length === 0) return;
3001
+ const accepted = [];
3002
+ const rejected = [];
3003
+ for (const file of files) {
3004
+ const reason = refusal(options, file);
3005
+ if (reason === void 0) accepted.push(file);
3006
+ else {
3007
+ rejected.push(reason);
3008
+ options.onReject?.({
3009
+ file,
3010
+ reason
3011
+ });
3012
+ }
3013
+ }
3014
+ setBatch({
3015
+ total: files.length,
3016
+ rejected
3017
+ });
3018
+ if (accepted.length > 0) options.onFiles(accepted);
3019
+ };
3020
+ const dropped = (event) => {
3021
+ event.preventDefault();
3022
+ setDepth(0);
3023
+ if (disabled()) return;
3024
+ const transfer = event.dataTransfer;
3025
+ if (transfer === null) return;
3026
+ const items = Array.from(transfer.items).filter((item) => item.kind === "file");
3027
+ const files = Array.from(transfer.files);
3028
+ if (files.length === 0) return;
3029
+ if (items.length > 0 && items.every((item) => item.webkitGetAsEntry?.()?.isDirectory === true)) return;
3030
+ deliver(files);
3031
+ };
3032
+ const picked = (event) => {
3033
+ const input = event.currentTarget;
3034
+ const files = Array.from(input.files ?? []);
3035
+ input.value = "";
3036
+ deliver(files);
3037
+ };
3038
+ const browse = () => {
3039
+ if (disabled()) return;
3040
+ document.getElementById(id)?.click();
3041
+ };
3042
+ return {
3043
+ dragging: () => depth() > 0,
3044
+ message: () => messageOf(batch()),
3045
+ inputProps: {
3046
+ id,
3047
+ type: "file",
3048
+ get accept() {
3049
+ return options.accept;
3050
+ },
3051
+ get multiple() {
3052
+ return options.multiple === true ? true : void 0;
3053
+ },
3054
+ get disabled() {
3055
+ return disabled() ? true : void 0;
3056
+ },
3057
+ hidden: true,
3058
+ onChange: picked
3059
+ },
3060
+ zoneProps: {
3061
+ role: "group",
3062
+ get "aria-label"() {
3063
+ return options.labels?.zone;
3064
+ },
3065
+ get "aria-disabled"() {
3066
+ return disabled() ? "true" : void 0;
3067
+ },
3068
+ onDragEnter: (event) => {
3069
+ event.preventDefault();
3070
+ if (!disabled()) setDepth(depth() + 1);
3071
+ },
3072
+ onDragOver: (event) => {
3073
+ event.preventDefault();
3074
+ },
3075
+ onDragLeave: (event) => {
3076
+ event.preventDefault();
3077
+ if (!disabled()) setDepth(Math.max(0, depth() - 1));
3078
+ },
3079
+ onDrop: dropped
3080
+ },
3081
+ browseProps: {
3082
+ type: "button",
3083
+ get disabled() {
3084
+ return disabled() ? true : void 0;
3085
+ },
3086
+ get "aria-disabled"() {
3087
+ return disabled() ? "true" : void 0;
3088
+ },
3089
+ get "aria-label"() {
3090
+ return options.labels?.browse;
3091
+ },
3092
+ onClick: browse
3093
+ },
3094
+ statusProps: {
3095
+ role: "status",
3096
+ "aria-live": "polite",
3097
+ "aria-atomic": "true"
3098
+ }
3099
+ };
3100
+ };
2942
3101
  // src/feed.ts
2943
3102
  /**
2944
3103
  * APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
@@ -5930,6 +6089,11 @@ const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "
5930
6089
  * pass `value` + `onValueChange` to own the value. A key or rail press is a
5931
6090
  * request the host may ignore.
5932
6091
  *
6092
+ * `disabled` is a widget-level extension of the pattern (the spec lists it
6093
+ * under its deliberate omissions): the thumb keeps its tab stop and announces
6094
+ * `aria-disabled`, and every key and rail press is dropped before it becomes a
6095
+ * request.
6096
+ *
5933
6097
  * ponytail: dragging (pointer capture/move) is not modelled — the spec
5934
6098
  * deliberately only fixes a discrete rail press.
5935
6099
  */
@@ -5948,6 +6112,7 @@ const createSlider = (options = {}) => {
5948
6112
  const [focused, setFocused] = createSignal(false);
5949
6113
  const orientation = () => options.orientation ?? "horizontal";
5950
6114
  const direction = () => options.direction ?? "ltr";
6115
+ const disabled = () => options.disabled === true;
5951
6116
  const flipped = () => orientation() === "horizontal" && direction() === "rtl";
5952
6117
  const commit = (next) => request(clamp(next));
5953
6118
  const delta = (key) => {
@@ -5962,7 +6127,7 @@ const createSlider = (options = {}) => {
5962
6127
  }
5963
6128
  };
5964
6129
  const keyDown = (key) => {
5965
- if (!focused()) return false;
6130
+ if (!focused() || disabled()) return false;
5966
6131
  if (key === "Home") {
5967
6132
  commit(min);
5968
6133
  return true;
@@ -5977,6 +6142,7 @@ const createSlider = (options = {}) => {
5977
6142
  return true;
5978
6143
  };
5979
6144
  const pointerDown = (position) => {
6145
+ if (disabled()) return;
5980
6146
  const steps = Math.round(position * (max - min) / step);
5981
6147
  commit(min + steps * step);
5982
6148
  setFocused(true);
@@ -6003,6 +6169,9 @@ const createSlider = (options = {}) => {
6003
6169
  get "aria-orientation"() {
6004
6170
  return orientation();
6005
6171
  },
6172
+ get "aria-disabled"() {
6173
+ return disabled() ? "true" : void 0;
6174
+ },
6006
6175
  get tabindex() {
6007
6176
  return 0;
6008
6177
  },
@@ -6167,6 +6336,12 @@ const createSliderMultiThumb = (options = {}) => {
6167
6336
  * stepper buttons, Enter and blur are requests the host may ignore; typing and
6168
6337
  * Escape change no committed value.
6169
6338
  *
6339
+ * `disabled` is a widget-level extension of the pattern (the spec models the
6340
+ * steppers' bound only, and lists this under its deliberate omissions): the
6341
+ * field announces `aria-disabled` and carries the native `disabled`, every key,
6342
+ * typed draft and both steppers are dropped before they become a request, and
6343
+ * neither stepper offers itself.
6344
+ *
6170
6345
  * ponytail: commit/revert on Enter/blur/Escape is the common convention, not
6171
6346
  * APG text.
6172
6347
  */
@@ -6187,6 +6362,7 @@ const createSpinbutton = (options = {}) => {
6187
6362
  const [editing, setEditing] = createSignal(false);
6188
6363
  const [draft, setDraft] = createSignal("");
6189
6364
  const [wrap, setWrapSignal] = createSignal(options.wrap ?? false);
6365
+ const disabled = () => options.disabled === true;
6190
6366
  const draftInRange = (text) => {
6191
6367
  const number = Number(text);
6192
6368
  return text.trim() !== "" && number >= min && number <= max;
@@ -6214,7 +6390,7 @@ const createSpinbutton = (options = {}) => {
6214
6390
  commitValue(next);
6215
6391
  };
6216
6392
  const keyDown = (key) => {
6217
- if (!focused()) return false;
6393
+ if (!focused() || disabled()) return false;
6218
6394
  switch (key) {
6219
6395
  case "ArrowUp":
6220
6396
  applyStep(1, step);
@@ -6246,7 +6422,7 @@ const createSpinbutton = (options = {}) => {
6246
6422
  }
6247
6423
  };
6248
6424
  const commit = () => {
6249
- if (!focused()) return;
6425
+ if (!focused() || disabled()) return;
6250
6426
  const next = baseValue();
6251
6427
  clearDraft();
6252
6428
  commitValue(next);
@@ -6254,8 +6430,25 @@ const createSpinbutton = (options = {}) => {
6254
6430
  const revert = () => {
6255
6431
  clearDraft();
6256
6432
  };
6433
+ const blurField = () => {
6434
+ const next = baseValue();
6435
+ clearDraft();
6436
+ setFocused(false);
6437
+ if (disabled()) return;
6438
+ commitValue(next);
6439
+ };
6440
+ const increment = () => {
6441
+ if (disabled()) return;
6442
+ setFocused(true);
6443
+ applyStep(1, step);
6444
+ };
6445
+ const decrement = () => {
6446
+ if (disabled()) return;
6447
+ setFocused(true);
6448
+ applyStep(-1, step);
6449
+ };
6257
6450
  const displayed = () => editing() ? draft() : String(value());
6258
- const ariaDisabled = (atBound) => !wrap() && atBound ? "true" : void 0;
6451
+ const ariaDisabled = (atBound) => disabled() || !wrap() && atBound ? "true" : void 0;
6259
6452
  return {
6260
6453
  value,
6261
6454
  focused,
@@ -6264,28 +6457,17 @@ const createSpinbutton = (options = {}) => {
6264
6457
  displayed,
6265
6458
  setValue: commitValue,
6266
6459
  focus: () => setFocused(true),
6267
- blur: () => {
6268
- const next = baseValue();
6269
- clearDraft();
6270
- setFocused(false);
6271
- commitValue(next);
6272
- },
6460
+ blur: blurField,
6273
6461
  keyDown,
6274
6462
  typeInput: (text) => {
6275
- if (!focused()) return;
6463
+ if (!focused() || disabled()) return;
6276
6464
  setEditing(true);
6277
6465
  setDraft(text);
6278
6466
  },
6279
6467
  commit,
6280
6468
  revert,
6281
- increment: () => {
6282
- setFocused(true);
6283
- applyStep(1, step);
6284
- },
6285
- decrement: () => {
6286
- setFocused(true);
6287
- applyStep(-1, step);
6288
- },
6469
+ increment,
6470
+ decrement,
6289
6471
  setWrap: (next) => setWrapSignal(next),
6290
6472
  inputProps: {
6291
6473
  id: `${base}-input`,
@@ -6301,6 +6483,12 @@ const createSpinbutton = (options = {}) => {
6301
6483
  get "aria-invalid"() {
6302
6484
  return editing() && !draftInRange(draft()) ? "true" : void 0;
6303
6485
  },
6486
+ get "aria-disabled"() {
6487
+ return disabled() ? "true" : void 0;
6488
+ },
6489
+ get disabled() {
6490
+ return disabled();
6491
+ },
6304
6492
  get tabindex() {
6305
6493
  return 0;
6306
6494
  },
@@ -6308,18 +6496,13 @@ const createSpinbutton = (options = {}) => {
6308
6496
  return displayed();
6309
6497
  },
6310
6498
  onFocus: () => setFocused(true),
6311
- onBlur: () => {
6312
- const next = baseValue();
6313
- clearDraft();
6314
- setFocused(false);
6315
- commitValue(next);
6316
- },
6499
+ onBlur: blurField,
6317
6500
  onKeyDown: (event) => {
6318
6501
  if (keyDown(event.key)) event.preventDefault();
6319
6502
  },
6320
6503
  onInput: (event) => {
6321
6504
  const target = event.currentTarget;
6322
- if (target !== null) {
6505
+ if (target !== null && !disabled()) {
6323
6506
  setEditing(true);
6324
6507
  setDraft(target.value);
6325
6508
  }
@@ -6333,10 +6516,7 @@ const createSpinbutton = (options = {}) => {
6333
6516
  return ariaDisabled(value() === max);
6334
6517
  },
6335
6518
  onMouseDown: (event) => event.preventDefault(),
6336
- onClick: () => {
6337
- setFocused(true);
6338
- applyStep(1, step);
6339
- }
6519
+ onClick: increment
6340
6520
  },
6341
6521
  decrementProps: {
6342
6522
  role: "button",
@@ -6346,10 +6526,7 @@ const createSpinbutton = (options = {}) => {
6346
6526
  return ariaDisabled(value() === min);
6347
6527
  },
6348
6528
  onMouseDown: (event) => event.preventDefault(),
6349
- onClick: () => {
6350
- setFocused(true);
6351
- applyStep(-1, step);
6352
- }
6529
+ onClick: decrement
6353
6530
  }
6354
6531
  };
6355
6532
  };
@@ -6736,28 +6913,40 @@ const createTextArea = (options = {}) => {
6736
6913
  * Request/commit (map ticket `rt1p`): uncontrolled by default
6737
6914
  * (`defaultSelected`); pass `value` + `onValueChange` to own the selection.
6738
6915
  * Programmatic methods (`select`, `setSelected`, `keyDown`) are requests too.
6916
+ *
6917
+ * Close and rename are an **extension** (bean `cssints-gdv2`): the APG spec
6918
+ * models a `deletable` flavour but neither a callback nor a rename, and this
6919
+ * primitive owns no list. `closable` / `renamable` turn on the props; `onClose`
6920
+ * / `onRename` ask the host, which removes or renames. Focus after a close goes
6921
+ * to the tab that slid into the closed one's place, or to the previous tab when
6922
+ * the last one closed; closing the last remaining tab is refused, as the spec
6923
+ * refuses an empty tablist.
6739
6924
  */
6740
6925
  const createTabs = (options = {}) => {
6741
- const items = Math.max(1, options.items ?? 4);
6926
+ /** The live count: the host shrinks its list after a close, and the options are read lazily. */
6927
+ const count = () => Math.max(1, options.items ?? 4);
6742
6928
  const base = options.id ?? createUniqueId();
6743
6929
  const disabled = new Set(options.disabled ?? []);
6744
6930
  const focusablePanels = new Set(options.focusablePanels ?? []);
6745
6931
  const activation = () => options.activation ?? "automatic";
6746
6932
  const orientation = () => options.orientation ?? "horizontal";
6933
+ const closable = () => options.closable ?? false;
6934
+ const renamable = () => options.renamable ?? false;
6935
+ const labelOf = (index) => options.label?.(index) ?? `tab ${index + 1}`;
6747
6936
  const firstEnabled = () => {
6748
- for (let index = 0; index < items; index += 1) if (!disabled.has(index)) return index;
6937
+ for (let index = 0; index < count(); index += 1) if (!disabled.has(index)) return index;
6749
6938
  return 0;
6750
6939
  };
6751
6940
  const initial = options.defaultSelected ?? firstEnabled();
6752
6941
  const [selected, request] = createControllable({
6753
6942
  value: () => options.value,
6754
- initial: initial >= 0 && initial < items && !disabled.has(initial) ? initial : firstEnabled(),
6943
+ initial: initial >= 0 && initial < count() && !disabled.has(initial) ? initial : firstEnabled(),
6755
6944
  onChange: () => options.onValueChange
6756
6945
  });
6757
6946
  /** The tab that carries `tabindex=0`: the selected one, or the first enabled. */
6758
6947
  const roving = () => disabled.has(selected()) ? firstEnabled() : selected();
6759
6948
  const select = (index) => {
6760
- if (index < 0 || index >= items || disabled.has(index)) return;
6949
+ if (index < 0 || index >= count() || disabled.has(index)) return;
6761
6950
  request(index);
6762
6951
  };
6763
6952
  const move = (index) => {
@@ -6765,14 +6954,59 @@ const createTabs = (options = {}) => {
6765
6954
  if (activation() === "automatic") select(index);
6766
6955
  };
6767
6956
  const step = (from, delta) => {
6768
- for (let offset = 1; offset <= items; offset += 1) {
6769
- const candidate = ((from + delta * offset) % items + items) % items;
6957
+ for (let offset = 1; offset <= count(); offset += 1) {
6958
+ const candidate = ((from + delta * offset) % count() + count()) % count();
6770
6959
  if (!disabled.has(candidate)) return candidate;
6771
6960
  }
6772
6961
  return from;
6773
6962
  };
6774
6963
  const [focused, setFocused] = createSignal(-1);
6964
+ /**
6965
+ * The tab that takes focus once `onClose(index)`'s removal lands: the tab at
6966
+ * `index` in the shortened list (the following tab slid into it), or the
6967
+ * previous one when the last tab closed.
6968
+ */
6969
+ const focusAfterClose = (index) => index < count() - 1 ? index : Math.max(0, count() - 2);
6970
+ const close = (index) => {
6971
+ if (!closable() || count() <= 1 || index < 0 || index >= count()) return;
6972
+ const next = focusAfterClose(index);
6973
+ setFocused(next);
6974
+ if (selected() === index) select(next);
6975
+ options.onClose?.(index);
6976
+ };
6977
+ const [editVersion, setEditVersion] = createSignal(0);
6978
+ let editingIndex = null;
6979
+ const editing = () => {
6980
+ editVersion();
6981
+ return editingIndex;
6982
+ };
6983
+ const setEditing = (index) => {
6984
+ editingIndex = index;
6985
+ setEditVersion((version) => version + 1);
6986
+ };
6987
+ const [draft, setDraft] = createSignal("");
6988
+ const begin = (index) => {
6989
+ if (!renamable() || disabled.has(index) || index < 0 || index >= count()) return;
6990
+ setEditing(index);
6991
+ setDraft(options.label?.(index) ?? "");
6992
+ };
6993
+ const cancel = () => {
6994
+ if (editing() === null) return;
6995
+ setEditing(null);
6996
+ setDraft("");
6997
+ };
6998
+ /** Enter / blur commit; an empty label is not a name, so it cancels. */
6999
+ const commit = (label) => {
7000
+ const index = editing();
7001
+ if (index === null) return;
7002
+ setEditing(null);
7003
+ setDraft("");
7004
+ const trimmed = label.trim();
7005
+ if (trimmed === "") return;
7006
+ options.onRename?.(index, trimmed);
7007
+ };
6775
7008
  const keyDown = (key) => {
7009
+ if (editing() !== null) return;
6776
7010
  const current = focused();
6777
7011
  if (current < 0) return;
6778
7012
  const forward = orientation() === "horizontal" ? "ArrowRight" : "ArrowDown";
@@ -6788,27 +7022,34 @@ const createTabs = (options = {}) => {
6788
7022
  move(firstEnabled());
6789
7023
  break;
6790
7024
  case "End": {
6791
- let last = items - 1;
7025
+ let last = count() - 1;
6792
7026
  while (last > 0 && disabled.has(last)) last -= 1;
6793
7027
  move(last);
6794
7028
  break;
6795
7029
  }
6796
7030
  case "Enter":
6797
7031
  case " ":
6798
- case "Space": select(current);
7032
+ case "Space":
7033
+ select(current);
7034
+ break;
7035
+ case "Delete": close(current);
6799
7036
  }
6800
7037
  };
6801
7038
  return {
6802
- items,
7039
+ get items() {
7040
+ return count();
7041
+ },
6803
7042
  selected,
6804
7043
  focused,
7044
+ editing,
6805
7045
  select,
6806
7046
  setSelected: (index) => {
6807
- if (index >= 0 && index < items) request(index);
7047
+ if (index >= 0 && index < count()) request(index);
6808
7048
  },
6809
7049
  focusTab: (index) => {
6810
- if (index >= 0 && index < items) setFocused(index);
7050
+ if (index >= 0 && index < count()) setFocused(index);
6811
7051
  },
7052
+ focusAfterClose,
6812
7053
  keyDown,
6813
7054
  tablistProps: {
6814
7055
  role: "tablist",
@@ -6841,6 +7082,12 @@ const createTabs = (options = {}) => {
6841
7082
  setFocused(index);
6842
7083
  },
6843
7084
  onKeyDown: (event) => {
7085
+ if (editing() !== null) return;
7086
+ if (event.key === "Delete" && closable()) {
7087
+ event.preventDefault();
7088
+ close(index);
7089
+ return;
7090
+ }
6844
7091
  const before = focused();
6845
7092
  keyDown(event.key);
6846
7093
  if (focused() !== before || event.key === "Enter" || event.key === " ") event.preventDefault();
@@ -6856,6 +7103,43 @@ const createTabs = (options = {}) => {
6856
7103
  get hidden() {
6857
7104
  return selected() !== index;
6858
7105
  }
7106
+ }),
7107
+ closeProps: (index) => ({
7108
+ get "aria-label"() {
7109
+ return `Close ${labelOf(index)}`;
7110
+ },
7111
+ tabindex: -1,
7112
+ onClick: () => close(index)
7113
+ }),
7114
+ renameProps: (index) => ({
7115
+ onDblClick: () => begin(index),
7116
+ editing: () => editing() === index,
7117
+ begin,
7118
+ commit,
7119
+ cancel,
7120
+ inputProps: {
7121
+ type: "text",
7122
+ get value() {
7123
+ return draft();
7124
+ },
7125
+ get "aria-label"() {
7126
+ return `Rename ${labelOf(index)}`;
7127
+ },
7128
+ onInput: (event) => {
7129
+ const target = event.currentTarget;
7130
+ if (target !== null) setDraft(target.value);
7131
+ },
7132
+ onKeyDown: (event) => {
7133
+ if (event.key === "Enter") {
7134
+ event.preventDefault();
7135
+ commit(draft());
7136
+ } else if (event.key === "Escape") {
7137
+ event.preventDefault();
7138
+ cancel();
7139
+ }
7140
+ },
7141
+ onBlur: () => commit(draft())
7142
+ }
6859
7143
  })
6860
7144
  };
6861
7145
  };
@@ -6936,11 +7220,10 @@ const shrink = (x) => x <= MIN_WIDTH ? x : x - Math.floor(x / 4) < MIN_WIDTH ? M
6936
7220
  const grow = (x) => x >= DOMAIN_MAX ? x : x + Math.floor(x / 4) > DOMAIN_MAX ? DOMAIN_MAX : x + Math.floor(x / 4);
6937
7221
  const clampStart = (start, w) => start < DOMAIN_MIN ? DOMAIN_MIN : start + w > DOMAIN_MAX ? DOMAIN_MAX - w : start;
6938
7222
  /**
6939
- * Headless span timeline on Solid 2. The exported accessors are signals; the
6940
- * prop objects are getter-backed, so spreading them keeps every attribute
6941
- * reactive.
7223
+ * The demo tree: five fixed spans, collapsing, zoom. Its oracle is
7224
+ * `specs/apg/timeline.qnt` through the differential walk.
6942
7225
  */
6943
- const createTimeline = (options = {}) => {
7226
+ const createDemoTimeline = (options = {}) => {
6944
7227
  const initialExpanded = (options.defaultExpanded ?? []).reduce((mask, node) => setBit(mask, node, true), 0);
6945
7228
  const [expanded, setExpanded] = createSignal(initialExpanded);
6946
7229
  const [selected, requestSelected] = createControllable({
@@ -7095,6 +7378,172 @@ const createTimeline = (options = {}) => {
7095
7378
  }
7096
7379
  };
7097
7380
  };
7381
+ /** A zero-length span still paints: at least half a percent of the track. */
7382
+ const MIN_BAR_WIDTH = .5;
7383
+ /** Aim for about five ticks, whatever the extent. */
7384
+ const TICK_TARGET = 5;
7385
+ const percent = (value) => `${Math.round(value * 1e3) / 1e3}%`;
7386
+ /** 1, 2 or 5 times a power of ten, the step that makes the fewest labels fit. */
7387
+ const niceStep = (span) => {
7388
+ const raw = span / TICK_TARGET;
7389
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
7390
+ const normalized = raw / magnitude;
7391
+ return (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
7392
+ };
7393
+ const formatTick = (value, step, unit) => {
7394
+ const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)));
7395
+ return `${value.toFixed(decimals)} ${unit}`;
7396
+ };
7397
+ /**
7398
+ * The rows mode: real span data on one axis. Everything hangs off `options.rows`
7399
+ * read at attribute-read time, so the host may pass a getter and a trace may
7400
+ * arrive after mount.
7401
+ */
7402
+ const createRowsTimeline = (options) => {
7403
+ const rows = () => options.rows;
7404
+ const rowOf = (id) => {
7405
+ const row = rows().find((candidate) => candidate.id === id);
7406
+ if (row === void 0) throw new Error(`createTimeline: no row ${id}`);
7407
+ return row;
7408
+ };
7409
+ const [selectedId, requestSelected] = createControllable({
7410
+ value: () => options.selected,
7411
+ initial: void 0,
7412
+ onChange: () => (id) => {
7413
+ if (id !== void 0) options.onSelect?.(id);
7414
+ }
7415
+ });
7416
+ const [focus, setFocus] = createSignal(void 0);
7417
+ const currentFocus = () => focus() ?? rows()[0]?.id;
7418
+ const select = (id) => {
7419
+ requestSelected(id);
7420
+ };
7421
+ const extent = () => {
7422
+ let start = Number.POSITIVE_INFINITY;
7423
+ let end = Number.NEGATIVE_INFINITY;
7424
+ for (const row of rows()) {
7425
+ start = Math.min(start, row.start);
7426
+ end = Math.max(end, row.end);
7427
+ }
7428
+ return start <= end ? {
7429
+ start,
7430
+ end
7431
+ } : {
7432
+ start: 0,
7433
+ end: 0
7434
+ };
7435
+ };
7436
+ const ticks = () => {
7437
+ if (rows().length === 0) return [];
7438
+ const { start, end } = extent();
7439
+ const unit = options.axis?.unit ?? "ms";
7440
+ const span = end - start;
7441
+ if (!(span > 0)) return [{
7442
+ value: start,
7443
+ left: 0,
7444
+ label: formatTick(start, 1, unit)
7445
+ }];
7446
+ const step = niceStep(span);
7447
+ const out = [];
7448
+ for (let value = Math.ceil(start / step) * step; value <= end; value += step) out.push({
7449
+ value,
7450
+ left: (value - start) / span * 100,
7451
+ label: formatTick(value, step, unit)
7452
+ });
7453
+ return out;
7454
+ };
7455
+ const barStyle = (id) => {
7456
+ const row = rowOf(id);
7457
+ const depth = String(row.depth ?? 0);
7458
+ const { start, end } = extent();
7459
+ const span = end - start;
7460
+ if (!(span > 0)) return {
7461
+ left: "0%",
7462
+ width: percent(MIN_BAR_WIDTH),
7463
+ "--depth": depth
7464
+ };
7465
+ const left = Math.max(0, (row.start - start) / span * 100);
7466
+ const width = Math.max(MIN_BAR_WIDTH, (row.end - row.start) / span * 100);
7467
+ return {
7468
+ left: percent(left),
7469
+ width: percent(width),
7470
+ "--depth": depth
7471
+ };
7472
+ };
7473
+ const onKeyDown = (id, event) => {
7474
+ const list = rows();
7475
+ const index = list.findIndex((row) => row.id === id);
7476
+ switch (event.key) {
7477
+ case "ArrowDown":
7478
+ setFocus(list[Math.min(index + 1, list.length - 1)]?.id);
7479
+ break;
7480
+ case "ArrowUp":
7481
+ setFocus(list[Math.max(index - 1, 0)]?.id);
7482
+ break;
7483
+ case "Home":
7484
+ setFocus(list[0]?.id);
7485
+ break;
7486
+ case "End":
7487
+ setFocus(list[list.length - 1]?.id);
7488
+ break;
7489
+ case "Enter":
7490
+ case " ":
7491
+ select(id);
7492
+ break;
7493
+ default: return;
7494
+ }
7495
+ event.preventDefault();
7496
+ };
7497
+ return {
7498
+ extent,
7499
+ depthOf: (id) => rowOf(id).depth ?? 0,
7500
+ listProps: {
7501
+ role: "list",
7502
+ "aria-label": "Span timeline"
7503
+ },
7504
+ rowProps: (id) => ({
7505
+ role: "listitem",
7506
+ get "aria-level"() {
7507
+ return (rowOf(id).depth ?? 0) + 1;
7508
+ },
7509
+ get "aria-current"() {
7510
+ return selectedId() === id ? "true" : void 0;
7511
+ },
7512
+ get "data-status"() {
7513
+ return rowOf(id).status ?? "unset";
7514
+ },
7515
+ get tabindex() {
7516
+ return currentFocus() === id ? 0 : -1;
7517
+ },
7518
+ onClick: () => {
7519
+ setFocus(id);
7520
+ select(id);
7521
+ },
7522
+ onKeyDown: (event) => onKeyDown(id, event)
7523
+ }),
7524
+ barProps: (id) => ({
7525
+ "aria-hidden": "true",
7526
+ get style() {
7527
+ return barStyle(id);
7528
+ }
7529
+ }),
7530
+ labelProps: (id) => ({ get style() {
7531
+ return { "--depth": String(rowOf(id).depth ?? 0) };
7532
+ } }),
7533
+ rulerProps: {
7534
+ role: "presentation",
7535
+ "aria-hidden": "true",
7536
+ get ticks() {
7537
+ return ticks();
7538
+ }
7539
+ },
7540
+ select,
7541
+ selectedId
7542
+ };
7543
+ };
7544
+ function createTimeline(options = {}) {
7545
+ return "rows" in options ? createRowsTimeline(options) : createDemoTimeline(options);
7546
+ }
7098
7547
  // src/toast.ts
7099
7548
  const DEFAULT_TIMEOUT_MS = 5e3;
7100
7549
  const DEFAULT_MAX = 3;
@@ -8825,4 +9274,4 @@ const createRearrangeableListbox = (options) => {
8825
9274
  })
8826
9275
  };
8827
9276
  };
8828
- 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, createHotkeys, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPanZoom, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createScrollspy, 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 };
9277
+ 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, createDropZone, createFeed, createField, createGrid, createHotkeys, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPanZoom, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createScrollspy, 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.18.0",
3
+ "version": "0.19.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": {