@cssints/headless 0.17.0 → 0.18.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 +105 -1
- package/dist/index.d.mts +239 -0
- package/dist/index.mjs +465 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ 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, 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.
|
|
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, scrollspy, 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
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, panzoom, 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
70
|
|
|
70
71
|
`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
72
|
|
|
@@ -89,6 +90,41 @@ Membership, sizes and z-order are **not modelled** in `pointer.qnt` (its node se
|
|
|
89
90
|
|
|
90
91
|
**Nested canvases** are two instances the host composes: a `#canvas` node holds a second `createCanvas`, and the inner viewport stops propagation of its own pointer, wheel and key events. The innermost canvas under the pointer owns the event, so a press or zoom inside it never starts an outer gesture or moves the outer camera. A nested canvas is a single tab stop in its parent.
|
|
91
92
|
|
|
93
|
+
## Pan and zoom
|
|
94
|
+
|
|
95
|
+
`createPanZoom` is one image fitted to a viewport: the scale, the offset and the prop objects for the viewport, the image and a live value. **There is no APG pattern for pan-zoom**, so there is no Quint spec and no ported scenarios: the contract is this module's own and `test/panzoom.browser.test.tsx` is its oracle. The case it replaces (kocharin-art-3's `Zoomable`: ~110 lines of pointer maths) had no keyboard path and no ARIA at all.
|
|
96
|
+
|
|
97
|
+
**The accessible contract is three parts, and the pointer is a bonus:** a viewport that takes focus (`role=group`, `tabindex=0`, `aria-roledescription="zoomable image"`, `aria-describedby` the value), keyboard operations that stand in for every gesture (`+` / `=` / `-` zoom by `step`, arrows pan by 24 px, Shift ×4, `0` resets), and a polite live region (`role=status`, `aria-live=polite`, the current `"150%"`). `valueProps` deliberately carries no `hidden`: a `hidden` live region is not announced, so the host hides it visually.
|
|
98
|
+
|
|
99
|
+
**Not `createCanvas`.** The canvas is a camera over an infinite world with nodes, culling, snap and a z-order; a picture is one image in a box. There is no world, no node set and no reason to carry the camera machine.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
const panzoom = createPanZoom({ min: 1, max: 4, step: 0.25 });
|
|
103
|
+
|
|
104
|
+
<div {...panzoom.viewportProps} class="viewport">
|
|
105
|
+
<img {...panzoom.imageProps} src={src} alt={alt} />
|
|
106
|
+
<span {...panzoom.valueProps} class="visually-hidden">
|
|
107
|
+
{panzoom.value()}
|
|
108
|
+
</span>
|
|
109
|
+
</div>;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Gestures.** A drag pans, a second pointer pinches about its midpoint, a double click resets. Pinch is two pointers and the distance between them, not a platform gesture, so it works where `PointerEvent` does. **Wheel is zoom only with `ctrl` / `meta`** — the browser's own pinch arrives as `ctrl+wheel` — and a bare wheel is never `preventDefault`ed, so the page keeps scrolling; a swallowed wheel is the one thing that is never acceptable on a trackpad.
|
|
113
|
+
|
|
114
|
+
**`bounds: "clamp"`** (the default) bounds the offset by half the viewport's scaled overflow, so the image is expected to fill the viewport at `min` (`width: 100%; height: 100%` / `object-fit: cover`). A letterboxed image smaller than its viewport cannot be held gap-free: give it a viewport of its own or `bounds: "free"`.
|
|
115
|
+
|
|
116
|
+
Left to the host: the box (the viewport's size and `overflow: hidden`, `touch-action: none` if a touch drag should pan), the look (a transition on the transform, `transform-origin: 50% 50%` — the default this maths assumes), and the image itself. Not modelled: inertia, rotation, and a keyboard pan at scale `min` (the clamp means there is nothing to pan, so the arrows are inert).
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
const panzoom = createPanZoom({
|
|
120
|
+
scale, // controlled: the host owns the scale
|
|
121
|
+
onScaleChange: setScale,
|
|
122
|
+
onPanChange: (offset) => save({ x: offset.x, y: offset.y }),
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused.
|
|
127
|
+
|
|
92
128
|
## Resizable panels
|
|
93
129
|
|
|
94
130
|
`createResizablePanels` is a group of N panes and N-1 separators: react-resizable-panels' layout on the APG window splitter's keyboard. Its behaviour is `packages/apg-specs/specs/patterns/resizable-panels.qnt` — not an APG pattern, so it lives under `patterns/` — and the DOM is checked against it by the differential walk. The spec is the three-pane instance (10/80 bounds, 10% steps, pane 0 collapsible at 0); the primitive takes N panes and any bounds.
|
|
@@ -382,6 +418,74 @@ const drawer = createDrawer({ side: "right", labelledby: "nav-title" });
|
|
|
382
418
|
- **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
419
|
- **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
420
|
|
|
421
|
+
## Hotkeys
|
|
422
|
+
|
|
423
|
+
`createHotkeys` is a **platform utility, not a widget**: one registry for the chords every app hand-rolls (the docs app's `/` and Cmd/Ctrl+K, the CMS's Cmd/Ctrl+S). **No APG pattern exists for a keyboard registry**, so there is no role, no element, no styling and no Quint spec; `test/hotkeys.browser.test.tsx` is the oracle.
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
const hotkeys = createHotkeys();
|
|
427
|
+
|
|
428
|
+
onSettled(() => {
|
|
429
|
+
hotkeys.bind({
|
|
430
|
+
keys: "mod+k",
|
|
431
|
+
onTrigger: () => searchEl.focus(),
|
|
432
|
+
});
|
|
433
|
+
hotkeys.bind({
|
|
434
|
+
keys: "/",
|
|
435
|
+
when: () => !dialog().open,
|
|
436
|
+
onTrigger: () => searchEl.focus(),
|
|
437
|
+
});
|
|
438
|
+
hotkeys.bind({
|
|
439
|
+
keys: "mod+s",
|
|
440
|
+
scope: editorEl,
|
|
441
|
+
onTrigger: () => save(),
|
|
442
|
+
});
|
|
443
|
+
return () => hotkeys.dispose();
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- **One listener.** `bind` adds a chord to one `keydown` listener on the document in the capture phase (a `scope` only filters the event's target, it does not add a listener), and the last binding to go is the one that removes it; `dispose` unbinds everything and a later `bind` is a no-op. The unbind `bind` returns is idempotent.
|
|
448
|
+
- **Modifiers match exactly.** `mod` is Cmd on macOS and Ctrl elsewhere (`isApple` injects the answer, so a test does not read the runner's OS); `ctrl`, `alt`, `shift` and `meta` are as written. A chord that does not declare Shift does not fire with Shift held. A shifted character implies Shift: `"?"` fires from Shift+/ even without `"shift+?"`, and `"/"` does not fire from it.
|
|
449
|
+
- **A text field keeps its keys.** A binding does not fire while the focus is in an `input`, a `textarea` or a `contenteditable` (an inherited one included) unless it sets `allowInEditable`, which is what Escape wants: it closes a dialog wherever the focus is.
|
|
450
|
+
- **Last bound wins.** Only the most recently bound match fires, so a modal's `"/"` shadows the page's and unbinding it brings the page's back; a `when` that is false makes the next match fire instead, so the gate can close without unbinding.
|
|
451
|
+
- **`preventDefault` is on** for a binding that fires (Cmd/Ctrl+S and Cmd/Ctrl+K would otherwise reach the browser); pass `false` to let the platform act too.
|
|
452
|
+
|
|
453
|
+
Collisions with the browser's own chords: `preventDefault` reaches the page's default action, not the browser's. Cmd/Ctrl+S (Save Page), Cmd/Ctrl+K (the browser's search, in Chrome and Firefox), Cmd/Ctrl+P and `/` (Firefox's quick find) can be cancelled by a page, and a host that binds one is choosing to take it from the browser; Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+L and F11 are reserved and cannot be intercepted, so a chord built on them never fires. `keys` takes any of them the same and says nothing.
|
|
454
|
+
|
|
455
|
+
## Scrollspy
|
|
456
|
+
|
|
457
|
+
`createScrollspy` is a table of contents whose current entry is the last heading to reach the top of the scrollport. **No APG pattern exists for a scrollspy** (the APG has no table-of-contents pattern), so there is no Quint spec and no ported scenarios: the contract is WAI-ARIA — `aria-current="location"` on the current link, exactly one link at a time, `role="list"` / `role="listitem"` for the list, and a named navigation landmark — plus HTML (`#id` targets). `test/scrollspy.browser.test.tsx` is its oracle.
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
const toc = createScrollspy({
|
|
461
|
+
entries, // { id, label } in document order: the ids are the headings'
|
|
462
|
+
offset: 96, // a sticky header's height
|
|
463
|
+
onChange: (id) => track(id),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
onSettled(() => toc.attach()); // returns the teardown
|
|
467
|
+
|
|
468
|
+
<nav {...toc.navProps}>
|
|
469
|
+
<ul {...toc.listProps}>
|
|
470
|
+
<For each={entries}>
|
|
471
|
+
{(entry) => (
|
|
472
|
+
<li {...toc.itemProps(entry.id)}>
|
|
473
|
+
<a {...toc.linkProps(entry.id)}>{entry.label}</a>
|
|
474
|
+
</li>
|
|
475
|
+
)}
|
|
476
|
+
</For>
|
|
477
|
+
</ul>
|
|
478
|
+
</nav>;
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- **A scroll listener, not an `IntersectionObserver`.** "The last heading at or above the offset" is a total order over every heading, and an observer only reports what crossed its band: a band observer has to rebuild the order from its enter/leave state and misreports after a jump (an anchor click, a restored scroll position). The listener is passive, the measurements are reads only (no writes, so no layout thrash), and a docs page has a dozen headings — the same cost as the listener the page already keeps.
|
|
482
|
+
- **`attach()`** adds that one listener to `root()` (the document by default; read once, so attach again after swapping containers), calls it once to sync a page opened mid-scroll, and returns the teardown. Headings are looked up by id on every scroll, so they may render after `attach`; an id with no element is skipped.
|
|
483
|
+
- **`offset`** is px below the scrollport's top edge, not the viewport's, so an inner container plus a sticky header works: `offset` is the header's height inside that container.
|
|
484
|
+
- **Before any scroll the first entry is current**, and it stays current while nothing has passed the line. `active()` is `undefined` only when there are no entries.
|
|
485
|
+
- **Exactly one `aria-current="location"`**, on the link, never on the heading: the heading's own `id` is the target. Skin it with `a[aria-current="location"]`.
|
|
486
|
+
- **Smooth scrolling is the host's.** The links are plain `#id` targets, the primitive never moves the scrollport, so `scroll-behavior: smooth` (and any `prefers-reduced-motion` override) belongs to the page's CSS; there is no scroll helper to reduce.
|
|
487
|
+
- **"On this page" is English.** Spread `navProps` first and override it after: `<nav {...toc.navProps} aria-label={t("onThisPage")}>`.
|
|
488
|
+
|
|
385
489
|
## Commands
|
|
386
490
|
|
|
387
491
|
```sh
|
package/dist/index.d.mts
CHANGED
|
@@ -1757,6 +1757,128 @@ export interface Pagination {
|
|
|
1757
1757
|
* Target size is the skin's job: 24x24 CSS px at AA, 44x44 at AAA.
|
|
1758
1758
|
*/
|
|
1759
1759
|
export declare const createPagination: (options: PaginationOptions) => Pagination;
|
|
1760
|
+
/** A point in the viewport's own coordinate space: `0, 0` is its top-left corner. */
|
|
1761
|
+
export interface PanZoomPoint {
|
|
1762
|
+
readonly x: number;
|
|
1763
|
+
readonly y: number;
|
|
1764
|
+
}
|
|
1765
|
+
export interface PanZoomOptions {
|
|
1766
|
+
/** Smallest scale, and where `reset` returns to. Default 1. */
|
|
1767
|
+
readonly min?: number | undefined;
|
|
1768
|
+
/** Largest scale. Default 4. */
|
|
1769
|
+
readonly max?: number | undefined;
|
|
1770
|
+
/** The `+` / `-` / `zoomIn` / `zoomOut` increment. Default 0.25. */
|
|
1771
|
+
readonly step?: number | undefined;
|
|
1772
|
+
/** Starting scale when uncontrolled; ignored while `scale` is set. */
|
|
1773
|
+
readonly defaultScale?: number | undefined;
|
|
1774
|
+
/** Controlled: the host owns the scale and this is the only thing that changes it. */
|
|
1775
|
+
readonly scale?: number | undefined;
|
|
1776
|
+
/**
|
|
1777
|
+
* A request to change the scale, called when the intent differs from the
|
|
1778
|
+
* current value. Uncontrolled it is accepted; controlled the host may ignore it.
|
|
1779
|
+
*/
|
|
1780
|
+
readonly onScaleChange?: (scale: number) => void;
|
|
1781
|
+
/** The pan changed (a drag, a pinch, an arrow key, a zoom about a point, a reset). */
|
|
1782
|
+
readonly onPanChange?: (offset: PanZoomPoint) => void;
|
|
1783
|
+
/**
|
|
1784
|
+
* `"clamp"` (default) keeps the scaled image covering the viewport: the offset
|
|
1785
|
+
* never opens a gap. `"free"` lets it be dragged out of view.
|
|
1786
|
+
*/
|
|
1787
|
+
readonly bounds?: "clamp" | "free" | undefined;
|
|
1788
|
+
readonly labels?: {
|
|
1789
|
+
readonly viewport?: string;
|
|
1790
|
+
} | undefined;
|
|
1791
|
+
}
|
|
1792
|
+
export interface PanZoomViewportProps {
|
|
1793
|
+
readonly tabindex: number;
|
|
1794
|
+
readonly role: "group";
|
|
1795
|
+
readonly "aria-roledescription": string;
|
|
1796
|
+
readonly "aria-label": string;
|
|
1797
|
+
readonly "aria-describedby": string;
|
|
1798
|
+
readonly onKeyDown: (event: KeyboardEvent) => void;
|
|
1799
|
+
readonly onWheel: (event: WheelEvent) => void;
|
|
1800
|
+
readonly onPointerDown: (event: PointerEvent) => void;
|
|
1801
|
+
readonly onPointerMove: (event: PointerEvent) => void;
|
|
1802
|
+
readonly onPointerUp: (event: PointerEvent) => void;
|
|
1803
|
+
readonly onPointerCancel: (event: PointerEvent) => void;
|
|
1804
|
+
readonly onDblClick: (event: MouseEvent) => void;
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Spread onto the image. `style` is a getter returning a fresh object, so Solid
|
|
1808
|
+
* reads it inside the tracking scope; `draggable: false` keeps the native image
|
|
1809
|
+
* drag from swallowing the pan gesture.
|
|
1810
|
+
*/
|
|
1811
|
+
export interface PanZoomImageProps {
|
|
1812
|
+
readonly draggable: false;
|
|
1813
|
+
readonly style: {
|
|
1814
|
+
readonly transform: string;
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
/** The live value: the current percentage, in a polite status region (`aria-describedby` of the viewport). */
|
|
1818
|
+
export interface PanZoomValueProps {
|
|
1819
|
+
readonly id: string;
|
|
1820
|
+
readonly role: "status";
|
|
1821
|
+
readonly "aria-live": "polite";
|
|
1822
|
+
readonly "aria-atomic": "true";
|
|
1823
|
+
}
|
|
1824
|
+
export interface PanZoom {
|
|
1825
|
+
readonly scale: Accessor<number>;
|
|
1826
|
+
readonly offset: Accessor<PanZoomPoint>;
|
|
1827
|
+
/** Back to `min` and centred. */
|
|
1828
|
+
readonly reset: () => void;
|
|
1829
|
+
readonly zoomIn: () => void;
|
|
1830
|
+
readonly zoomOut: () => void;
|
|
1831
|
+
/** A request; `origin` is the viewport point to keep still (its centre by default). */
|
|
1832
|
+
readonly setScale: (scale: number, origin?: PanZoomPoint) => void;
|
|
1833
|
+
readonly viewportProps: PanZoomViewportProps;
|
|
1834
|
+
readonly imageProps: PanZoomImageProps;
|
|
1835
|
+
readonly valueProps: PanZoomValueProps;
|
|
1836
|
+
/** The human value of `valueProps`: `"150%"`. */
|
|
1837
|
+
readonly value: Accessor<string>;
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Pan and zoom for one image in a viewport: scale, offset and the prop objects
|
|
1841
|
+
* for a focusable viewport, the transformed image and a live value.
|
|
1842
|
+
*
|
|
1843
|
+
* **There is no APG pattern for pan-zoom.** The accessible contract is the three
|
|
1844
|
+
* parts this returns: a viewport that takes focus (`role=group`, `tabindex=0`,
|
|
1845
|
+
* `aria-roledescription="zoomable image"`), keyboard operations that stand in
|
|
1846
|
+
* for every pointer gesture (`+` / `=` / `-` zoom, arrows pan, `0` resets), and a
|
|
1847
|
+
* polite live region (`aria-describedby` of the viewport) that carries the
|
|
1848
|
+
* current scale. The pointer gestures are an addition for mouse and touch, never
|
|
1849
|
+
* the only way in. That contract is this module's own: the case it replaces
|
|
1850
|
+
* (kocharin-art-3's `Zoomable`, ~110 lines of pointer maths) had no keyboard path
|
|
1851
|
+
* and no ARIA at all, and `createCanvas` is the wrong model here — an infinite
|
|
1852
|
+
* canvas moves a camera over a world, while this is one image fitted to its
|
|
1853
|
+
* viewport.
|
|
1854
|
+
*
|
|
1855
|
+
* Wheel is zoom-with-a-modifier only: `ctrl` / `meta` (the browser's own pinch
|
|
1856
|
+
* arrives as `ctrl+wheel`) zooms about the cursor; a bare wheel is never
|
|
1857
|
+
* `preventDefault`ed, so the page keeps scrolling. Trackpad gestures differ per
|
|
1858
|
+
* platform and a swallowed wheel is the one thing that is never acceptable.
|
|
1859
|
+
*
|
|
1860
|
+
* Pointer: a drag pans, a second pointer pinches (about its midpoint), a double
|
|
1861
|
+
* click resets. Capture is best-effort (a synthetic pointer cannot be captured).
|
|
1862
|
+
*
|
|
1863
|
+
* `bounds: "clamp"` bounds the offset by half the viewport's scaled overflow, so
|
|
1864
|
+
* the image is expected to fill the viewport at `min` (the usual
|
|
1865
|
+
* `width: 100%; height: 100%` / `object-fit: cover` box). A letterboxed image
|
|
1866
|
+
* smaller than its viewport cannot be held gap-free; give it a viewport of its
|
|
1867
|
+
* own or `bounds: "free"`.
|
|
1868
|
+
*
|
|
1869
|
+
* Request/commit (`rt1p`): uncontrolled by default (`defaultScale`); pass `scale`
|
|
1870
|
+
* + `onScaleChange` to own the value. The offset has no controlled prop, so a
|
|
1871
|
+
* hosting page that rejects a scale request also gets the offset resolved for the
|
|
1872
|
+
* scale it refused — accept the request, or pass `bounds: "free"` and own the
|
|
1873
|
+
* pan elsewhere.
|
|
1874
|
+
*
|
|
1875
|
+
* The host owns the box and the looks: size the viewport, put `touch-action:
|
|
1876
|
+
* none` on it if a touch drag should pan, centre the image
|
|
1877
|
+
* (`transform-origin: 50% 50%` is the default this maths assumes), and hide the
|
|
1878
|
+
* live region visually rather than with `hidden` (a `hidden` live region is not
|
|
1879
|
+
* announced).
|
|
1880
|
+
*/
|
|
1881
|
+
export declare const createPanZoom: (options?: PanZoomOptions) => PanZoom;
|
|
1760
1882
|
export type PopoverPlacement = "bottom-start" | "bottom" | "bottom-end" | "top-start" | "top" | "top-end";
|
|
1761
1883
|
/** A rect in one space, as `getBoundingClientRect` gives it. */
|
|
1762
1884
|
export interface PopoverRect {
|
|
@@ -2027,6 +2149,53 @@ export declare const packSort: (column: number, dir: GridSortDirection) => numbe
|
|
|
2027
2149
|
* snapshot); `rows`/`cols`/`page` are captured once.
|
|
2028
2150
|
*/
|
|
2029
2151
|
export declare const createGrid: (options?: GridOptions) => Grid;
|
|
2152
|
+
/**
|
|
2153
|
+
* Hotkeys: one registry for the chords every app hand-rolls (the docs app's `/`
|
|
2154
|
+
* and Cmd/Ctrl+K, the CMS's Cmd/Ctrl+S), instead of a `keydown` listener per
|
|
2155
|
+
* screen.
|
|
2156
|
+
*
|
|
2157
|
+
* This is a platform utility, not a widget: there is no APG pattern for a
|
|
2158
|
+
* keyboard registry, so no role, no element, no styling, no Quint spec and no
|
|
2159
|
+
* ported scenario. The oracle is `test/hotkeys.browser.test.tsx` in a real
|
|
2160
|
+
* Chromium.
|
|
2161
|
+
*/
|
|
2162
|
+
export interface HotkeyBinding {
|
|
2163
|
+
/**
|
|
2164
|
+
* A chord: the modifiers `mod` (Cmd on macOS, Ctrl elsewhere), `ctrl`, `alt`,
|
|
2165
|
+
* `shift`, `meta`, then exactly one key (`k`, `/`, `?`, `escape`, `enter`,
|
|
2166
|
+
* `arrowup`), joined with `+`; `"mod+k"`, `"shift+?"`, `"/"`.
|
|
2167
|
+
*/
|
|
2168
|
+
readonly keys: string;
|
|
2169
|
+
readonly onTrigger: (event: KeyboardEvent) => void;
|
|
2170
|
+
/** Gates a binding whose precondition is off: it falls through to the next one. */
|
|
2171
|
+
readonly when?: (() => boolean) | undefined;
|
|
2172
|
+
/** `event.preventDefault()` when the binding fires; default true. */
|
|
2173
|
+
readonly preventDefault?: boolean | undefined;
|
|
2174
|
+
/** Fire while the focus is in an input, a textarea or a contenteditable; default false. */
|
|
2175
|
+
readonly allowInEditable?: boolean | undefined;
|
|
2176
|
+
/** Only events from inside this element: one panel's `"mod+s"` over a page's. */
|
|
2177
|
+
readonly scope?: HTMLElement | undefined;
|
|
2178
|
+
}
|
|
2179
|
+
export interface HotkeysOptions {
|
|
2180
|
+
/** Whether `mod` is Cmd; injected so a test does not read the runner's OS. */
|
|
2181
|
+
readonly isApple?: boolean | undefined;
|
|
2182
|
+
}
|
|
2183
|
+
export interface Hotkeys {
|
|
2184
|
+
/** Registers `binding`; the returned unbind is idempotent. The last bound wins. */
|
|
2185
|
+
readonly bind: (binding: HotkeyBinding) => () => void;
|
|
2186
|
+
/** Unbinds all of them and removes the listener; a later `bind` is a no-op. */
|
|
2187
|
+
readonly dispose: () => void;
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* One `keydown` listener in the capture phase, one list of chords. A chord
|
|
2191
|
+
* fires when its modifiers match exactly, its `when` holds, its scope contains
|
|
2192
|
+
* the event's target, and the focus is not in a text field (unless
|
|
2193
|
+
* `allowInEditable`). The most recently bound match is the only one that fires,
|
|
2194
|
+
* so a modal's `"/"` overrides the page's and unbinding it brings the page's
|
|
2195
|
+
* back; `preventDefault` is on by default for the bindings whose chord the
|
|
2196
|
+
* browser would otherwise act on (Cmd/Ctrl+S, Cmd/Ctrl+K).
|
|
2197
|
+
*/
|
|
2198
|
+
export declare const createHotkeys: (options?: HotkeysOptions) => Hotkeys;
|
|
2030
2199
|
export interface LinkOptions {
|
|
2031
2200
|
readonly href?: string | undefined;
|
|
2032
2201
|
/** Render a native `<a href>` (the default when `href` is set). */
|
|
@@ -2601,6 +2770,76 @@ export interface ResizablePanels {
|
|
|
2601
2770
|
* DOM is checked against it by the differential walk.
|
|
2602
2771
|
*/
|
|
2603
2772
|
export declare const createResizablePanels: (options: ResizablePanelsOptions) => ResizablePanels;
|
|
2773
|
+
export interface ScrollspyEntry {
|
|
2774
|
+
readonly id: string;
|
|
2775
|
+
readonly label: string;
|
|
2776
|
+
}
|
|
2777
|
+
export interface ScrollspyOptions {
|
|
2778
|
+
/** The table of contents, in document order. The heading elements carry these ids. */
|
|
2779
|
+
readonly entries: ReadonlyArray<ScrollspyEntry>;
|
|
2780
|
+
/**
|
|
2781
|
+
* How far below the scrollport's top edge a heading counts as current, in px.
|
|
2782
|
+
* A host with a sticky header passes its height. @default 0
|
|
2783
|
+
*/
|
|
2784
|
+
readonly offset?: number | undefined;
|
|
2785
|
+
/**
|
|
2786
|
+
* The scroll container; the document by default. Read once, when `attach`
|
|
2787
|
+
* runs, so a host that swaps containers attaches again.
|
|
2788
|
+
*/
|
|
2789
|
+
readonly root?: (() => HTMLElement | undefined) | undefined;
|
|
2790
|
+
/** The active id changed. */
|
|
2791
|
+
readonly onChange?: ((id: string | undefined) => void) | undefined;
|
|
2792
|
+
}
|
|
2793
|
+
export interface ScrollspyNavProps {
|
|
2794
|
+
/** The navigation landmark's name; the host overrides it after the spread to translate it. */
|
|
2795
|
+
readonly "aria-label": string;
|
|
2796
|
+
}
|
|
2797
|
+
export interface ScrollspyListProps {
|
|
2798
|
+
readonly role: "list";
|
|
2799
|
+
}
|
|
2800
|
+
export interface ScrollspyItemProps {
|
|
2801
|
+
readonly role: "listitem";
|
|
2802
|
+
}
|
|
2803
|
+
export interface ScrollspyLinkProps {
|
|
2804
|
+
readonly href: string;
|
|
2805
|
+
readonly "aria-current": "location" | undefined;
|
|
2806
|
+
}
|
|
2807
|
+
export interface Scrollspy {
|
|
2808
|
+
/** The current entry's id; `undefined` only when there are no entries. */
|
|
2809
|
+
readonly active: Accessor<string | undefined>;
|
|
2810
|
+
readonly navProps: ScrollspyNavProps;
|
|
2811
|
+
readonly listProps: ScrollspyListProps;
|
|
2812
|
+
readonly itemProps: (id: string) => ScrollspyItemProps;
|
|
2813
|
+
readonly linkProps: (id: string) => ScrollspyLinkProps;
|
|
2814
|
+
/**
|
|
2815
|
+
* DOM wiring: one passive `scroll` listener on the root, which measures the
|
|
2816
|
+
* headings. Call it once mounted and call the teardown it returns on cleanup.
|
|
2817
|
+
*/
|
|
2818
|
+
readonly attach: () => () => void;
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* A scrollspy: a table of contents whose current entry is the last heading that
|
|
2822
|
+
* has reached the top of the scrollport, marked `aria-current="location"` on
|
|
2823
|
+
* its link (and nowhere else: exactly one link is current).
|
|
2824
|
+
*
|
|
2825
|
+
* **No APG pattern exists for a scrollspy**, so there is no Quint spec and no
|
|
2826
|
+
* ported scenarios: the contract is WAI-ARIA (`aria-current="location"`, the
|
|
2827
|
+
* `list` / `listitem` roles, the navigation landmark and its name) and HTML,
|
|
2828
|
+
* and `test/scrollspy.browser.test.tsx` is its oracle.
|
|
2829
|
+
*
|
|
2830
|
+
* **A scroll listener, not an `IntersectionObserver`.** "The last heading at or
|
|
2831
|
+
* above the offset" is a total order over every heading, while an observer only
|
|
2832
|
+
* reports what crossed its band: a band observer has to rebuild the order from
|
|
2833
|
+
* its enter/leave state, and it misreports after a jump (an anchor click, a
|
|
2834
|
+
* restored scroll position). The listener is passive, the measurements are
|
|
2835
|
+
* reads only (no writes, so no layout thrash), and a docs page has a dozen
|
|
2836
|
+
* headings — the same cost as the listener the page already keeps.
|
|
2837
|
+
*
|
|
2838
|
+
* Smooth scrolling is the host's (`scroll-behavior: smooth`): the links are
|
|
2839
|
+
* plain `#id` targets and nothing here moves the scrollport, so no
|
|
2840
|
+
* `prefers-reduced-motion` branch is needed either.
|
|
2841
|
+
*/
|
|
2842
|
+
export declare const createScrollspy: (options: ScrollspyOptions) => Scrollspy;
|
|
2604
2843
|
export type SliderOrientation = "horizontal" | "vertical";
|
|
2605
2844
|
export type SliderDirection = "ltr" | "rtl";
|
|
2606
2845
|
export interface SliderOptions {
|
package/dist/index.mjs
CHANGED
|
@@ -3266,6 +3266,304 @@ const createPagination = (options) => {
|
|
|
3266
3266
|
}
|
|
3267
3267
|
};
|
|
3268
3268
|
};
|
|
3269
|
+
// src/panzoom.ts
|
|
3270
|
+
/** Arrow-key pan distance, in CSS px; Shift multiplies it. */
|
|
3271
|
+
const PAN_STEP = 24;
|
|
3272
|
+
const PAN_SHIFT = 4;
|
|
3273
|
+
/** A ctrl/meta wheel notch. */
|
|
3274
|
+
const WHEEL_FACTOR = 1.1;
|
|
3275
|
+
const axis = (value, limit) => Math.min(limit, Math.max(-limit, value));
|
|
3276
|
+
/**
|
|
3277
|
+
* Pan and zoom for one image in a viewport: scale, offset and the prop objects
|
|
3278
|
+
* for a focusable viewport, the transformed image and a live value.
|
|
3279
|
+
*
|
|
3280
|
+
* **There is no APG pattern for pan-zoom.** The accessible contract is the three
|
|
3281
|
+
* parts this returns: a viewport that takes focus (`role=group`, `tabindex=0`,
|
|
3282
|
+
* `aria-roledescription="zoomable image"`), keyboard operations that stand in
|
|
3283
|
+
* for every pointer gesture (`+` / `=` / `-` zoom, arrows pan, `0` resets), and a
|
|
3284
|
+
* polite live region (`aria-describedby` of the viewport) that carries the
|
|
3285
|
+
* current scale. The pointer gestures are an addition for mouse and touch, never
|
|
3286
|
+
* the only way in. That contract is this module's own: the case it replaces
|
|
3287
|
+
* (kocharin-art-3's `Zoomable`, ~110 lines of pointer maths) had no keyboard path
|
|
3288
|
+
* and no ARIA at all, and `createCanvas` is the wrong model here — an infinite
|
|
3289
|
+
* canvas moves a camera over a world, while this is one image fitted to its
|
|
3290
|
+
* viewport.
|
|
3291
|
+
*
|
|
3292
|
+
* Wheel is zoom-with-a-modifier only: `ctrl` / `meta` (the browser's own pinch
|
|
3293
|
+
* arrives as `ctrl+wheel`) zooms about the cursor; a bare wheel is never
|
|
3294
|
+
* `preventDefault`ed, so the page keeps scrolling. Trackpad gestures differ per
|
|
3295
|
+
* platform and a swallowed wheel is the one thing that is never acceptable.
|
|
3296
|
+
*
|
|
3297
|
+
* Pointer: a drag pans, a second pointer pinches (about its midpoint), a double
|
|
3298
|
+
* click resets. Capture is best-effort (a synthetic pointer cannot be captured).
|
|
3299
|
+
*
|
|
3300
|
+
* `bounds: "clamp"` bounds the offset by half the viewport's scaled overflow, so
|
|
3301
|
+
* the image is expected to fill the viewport at `min` (the usual
|
|
3302
|
+
* `width: 100%; height: 100%` / `object-fit: cover` box). A letterboxed image
|
|
3303
|
+
* smaller than its viewport cannot be held gap-free; give it a viewport of its
|
|
3304
|
+
* own or `bounds: "free"`.
|
|
3305
|
+
*
|
|
3306
|
+
* Request/commit (`rt1p`): uncontrolled by default (`defaultScale`); pass `scale`
|
|
3307
|
+
* + `onScaleChange` to own the value. The offset has no controlled prop, so a
|
|
3308
|
+
* hosting page that rejects a scale request also gets the offset resolved for the
|
|
3309
|
+
* scale it refused — accept the request, or pass `bounds: "free"` and own the
|
|
3310
|
+
* pan elsewhere.
|
|
3311
|
+
*
|
|
3312
|
+
* The host owns the box and the looks: size the viewport, put `touch-action:
|
|
3313
|
+
* none` on it if a touch drag should pan, centre the image
|
|
3314
|
+
* (`transform-origin: 50% 50%` is the default this maths assumes), and hide the
|
|
3315
|
+
* live region visually rather than with `hidden` (a `hidden` live region is not
|
|
3316
|
+
* announced).
|
|
3317
|
+
*/
|
|
3318
|
+
const createPanZoom = (options = {}) => {
|
|
3319
|
+
const min = options.min ?? 1;
|
|
3320
|
+
const max = options.max ?? 4;
|
|
3321
|
+
const step = options.step ?? .25;
|
|
3322
|
+
const bounds = () => options.bounds ?? "clamp";
|
|
3323
|
+
const valueId = createUniqueId();
|
|
3324
|
+
const clamp = (next) => Math.min(max, Math.max(min, next));
|
|
3325
|
+
const [scale, requestScale] = createControllable({
|
|
3326
|
+
value: () => options.scale,
|
|
3327
|
+
initial: clamp(options.defaultScale ?? min),
|
|
3328
|
+
onChange: () => options.onScaleChange
|
|
3329
|
+
});
|
|
3330
|
+
const [version, setVersion] = createSignal(0);
|
|
3331
|
+
let last = {
|
|
3332
|
+
x: 0,
|
|
3333
|
+
y: 0
|
|
3334
|
+
};
|
|
3335
|
+
const offset = () => {
|
|
3336
|
+
version();
|
|
3337
|
+
return last;
|
|
3338
|
+
};
|
|
3339
|
+
const setOffset = (next) => {
|
|
3340
|
+
last = next;
|
|
3341
|
+
setVersion((count) => count + 1);
|
|
3342
|
+
};
|
|
3343
|
+
let box = {
|
|
3344
|
+
left: 0,
|
|
3345
|
+
top: 0,
|
|
3346
|
+
width: 0,
|
|
3347
|
+
height: 0
|
|
3348
|
+
};
|
|
3349
|
+
const measure = (event) => {
|
|
3350
|
+
if (!(event.currentTarget instanceof Element)) return;
|
|
3351
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
3352
|
+
box = {
|
|
3353
|
+
left: rect.left,
|
|
3354
|
+
top: rect.top,
|
|
3355
|
+
width: rect.width,
|
|
3356
|
+
height: rect.height
|
|
3357
|
+
};
|
|
3358
|
+
};
|
|
3359
|
+
const limits = (at) => ({
|
|
3360
|
+
x: Math.max(0, box.width * (at - 1) / 2),
|
|
3361
|
+
y: Math.max(0, box.height * (at - 1) / 2)
|
|
3362
|
+
});
|
|
3363
|
+
const move = (next, at = scale()) => {
|
|
3364
|
+
const allowed = limits(at);
|
|
3365
|
+
const target = bounds() === "free" ? next : {
|
|
3366
|
+
x: axis(next.x, allowed.x),
|
|
3367
|
+
y: axis(next.y, allowed.y)
|
|
3368
|
+
};
|
|
3369
|
+
const previous = offset();
|
|
3370
|
+
if (target.x === previous.x && target.y === previous.y) return;
|
|
3371
|
+
setOffset(target);
|
|
3372
|
+
options.onPanChange?.(target);
|
|
3373
|
+
};
|
|
3374
|
+
const setScale = (next, origin) => {
|
|
3375
|
+
const before = scale();
|
|
3376
|
+
const target = clamp(next);
|
|
3377
|
+
if (target === before) return;
|
|
3378
|
+
requestScale(target);
|
|
3379
|
+
const at = origin ?? {
|
|
3380
|
+
x: box.width / 2,
|
|
3381
|
+
y: box.height / 2
|
|
3382
|
+
};
|
|
3383
|
+
const o = {
|
|
3384
|
+
x: at.x - box.width / 2,
|
|
3385
|
+
y: at.y - box.height / 2
|
|
3386
|
+
};
|
|
3387
|
+
const ratio = before > 0 ? target / before : 1;
|
|
3388
|
+
const current = offset();
|
|
3389
|
+
move({
|
|
3390
|
+
x: o.x - (o.x - current.x) * ratio,
|
|
3391
|
+
y: o.y - (o.y - current.y) * ratio
|
|
3392
|
+
}, target);
|
|
3393
|
+
};
|
|
3394
|
+
const zoomIn = () => setScale(scale() + step);
|
|
3395
|
+
const zoomOut = () => setScale(scale() - step);
|
|
3396
|
+
const reset = () => {
|
|
3397
|
+
requestScale(min);
|
|
3398
|
+
move({
|
|
3399
|
+
x: 0,
|
|
3400
|
+
y: 0
|
|
3401
|
+
}, min);
|
|
3402
|
+
};
|
|
3403
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
3404
|
+
let drag;
|
|
3405
|
+
let pinch;
|
|
3406
|
+
const distance = () => {
|
|
3407
|
+
const [a, b] = [...pointers.values()];
|
|
3408
|
+
return a === void 0 || b === void 0 ? 0 : Math.hypot(a.x - b.x, a.y - b.y);
|
|
3409
|
+
};
|
|
3410
|
+
const midpoint = () => {
|
|
3411
|
+
const [a, b] = [...pointers.values()];
|
|
3412
|
+
if (a === void 0 || b === void 0) return {
|
|
3413
|
+
x: box.left + box.width / 2,
|
|
3414
|
+
y: box.top + box.height / 2
|
|
3415
|
+
};
|
|
3416
|
+
return {
|
|
3417
|
+
x: (a.x + b.x) / 2,
|
|
3418
|
+
y: (a.y + b.y) / 2
|
|
3419
|
+
};
|
|
3420
|
+
};
|
|
3421
|
+
const local = (point) => ({
|
|
3422
|
+
x: point.x - box.left,
|
|
3423
|
+
y: point.y - box.top
|
|
3424
|
+
});
|
|
3425
|
+
const pointerDown = (event) => {
|
|
3426
|
+
measure(event);
|
|
3427
|
+
if (event.button !== 0) return;
|
|
3428
|
+
try {
|
|
3429
|
+
if (event.currentTarget instanceof Element) event.currentTarget.setPointerCapture(event.pointerId);
|
|
3430
|
+
} catch {}
|
|
3431
|
+
const at = {
|
|
3432
|
+
x: event.clientX,
|
|
3433
|
+
y: event.clientY
|
|
3434
|
+
};
|
|
3435
|
+
pointers.set(event.pointerId, at);
|
|
3436
|
+
drag = {
|
|
3437
|
+
at,
|
|
3438
|
+
from: offset()
|
|
3439
|
+
};
|
|
3440
|
+
if (pointers.size === 2) pinch = {
|
|
3441
|
+
distance: distance(),
|
|
3442
|
+
scale: scale()
|
|
3443
|
+
};
|
|
3444
|
+
};
|
|
3445
|
+
const pointerMove = (event) => {
|
|
3446
|
+
if (!pointers.has(event.pointerId)) return;
|
|
3447
|
+
pointers.set(event.pointerId, {
|
|
3448
|
+
x: event.clientX,
|
|
3449
|
+
y: event.clientY
|
|
3450
|
+
});
|
|
3451
|
+
if (pointers.size === 2 && pinch !== void 0) {
|
|
3452
|
+
if (pinch.distance > 0) setScale(pinch.scale * (distance() / pinch.distance), local(midpoint()));
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
if (drag !== void 0) move({
|
|
3456
|
+
x: drag.from.x + event.clientX - drag.at.x,
|
|
3457
|
+
y: drag.from.y + event.clientY - drag.at.y
|
|
3458
|
+
});
|
|
3459
|
+
};
|
|
3460
|
+
const pointerUp = (event) => {
|
|
3461
|
+
pointers.delete(event.pointerId);
|
|
3462
|
+
pinch = void 0;
|
|
3463
|
+
const rest = [...pointers.values()][0];
|
|
3464
|
+
drag = rest === void 0 ? void 0 : {
|
|
3465
|
+
at: rest,
|
|
3466
|
+
from: offset()
|
|
3467
|
+
};
|
|
3468
|
+
};
|
|
3469
|
+
const wheel = (event) => {
|
|
3470
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
3471
|
+
event.preventDefault();
|
|
3472
|
+
measure(event);
|
|
3473
|
+
const factor = event.deltaY < 0 ? WHEEL_FACTOR : 1 / WHEEL_FACTOR;
|
|
3474
|
+
setScale(scale() * factor, {
|
|
3475
|
+
x: event.clientX - box.left,
|
|
3476
|
+
y: event.clientY - box.top
|
|
3477
|
+
});
|
|
3478
|
+
};
|
|
3479
|
+
const keyDown = (event) => {
|
|
3480
|
+
measure(event);
|
|
3481
|
+
const by = PAN_STEP * (event.shiftKey ? PAN_SHIFT : 1);
|
|
3482
|
+
const current = offset();
|
|
3483
|
+
switch (event.key) {
|
|
3484
|
+
case "+":
|
|
3485
|
+
case "=":
|
|
3486
|
+
zoomIn();
|
|
3487
|
+
break;
|
|
3488
|
+
case "-":
|
|
3489
|
+
case "_":
|
|
3490
|
+
zoomOut();
|
|
3491
|
+
break;
|
|
3492
|
+
case "0":
|
|
3493
|
+
reset();
|
|
3494
|
+
break;
|
|
3495
|
+
case "ArrowLeft":
|
|
3496
|
+
move({
|
|
3497
|
+
x: current.x - by,
|
|
3498
|
+
y: current.y
|
|
3499
|
+
});
|
|
3500
|
+
break;
|
|
3501
|
+
case "ArrowRight":
|
|
3502
|
+
move({
|
|
3503
|
+
x: current.x + by,
|
|
3504
|
+
y: current.y
|
|
3505
|
+
});
|
|
3506
|
+
break;
|
|
3507
|
+
case "ArrowUp":
|
|
3508
|
+
move({
|
|
3509
|
+
x: current.x,
|
|
3510
|
+
y: current.y - by
|
|
3511
|
+
});
|
|
3512
|
+
break;
|
|
3513
|
+
case "ArrowDown":
|
|
3514
|
+
move({
|
|
3515
|
+
x: current.x,
|
|
3516
|
+
y: current.y + by
|
|
3517
|
+
});
|
|
3518
|
+
break;
|
|
3519
|
+
default: return;
|
|
3520
|
+
}
|
|
3521
|
+
event.preventDefault();
|
|
3522
|
+
};
|
|
3523
|
+
return {
|
|
3524
|
+
scale,
|
|
3525
|
+
offset,
|
|
3526
|
+
reset,
|
|
3527
|
+
zoomIn,
|
|
3528
|
+
zoomOut,
|
|
3529
|
+
setScale,
|
|
3530
|
+
value: () => `${Math.round(scale() * 100)}%`,
|
|
3531
|
+
viewportProps: {
|
|
3532
|
+
tabindex: 0,
|
|
3533
|
+
role: "group",
|
|
3534
|
+
"aria-roledescription": "zoomable image",
|
|
3535
|
+
get "aria-label"() {
|
|
3536
|
+
return options.labels?.viewport ?? "Image";
|
|
3537
|
+
},
|
|
3538
|
+
get "aria-describedby"() {
|
|
3539
|
+
return valueId;
|
|
3540
|
+
},
|
|
3541
|
+
onKeyDown: keyDown,
|
|
3542
|
+
onWheel: wheel,
|
|
3543
|
+
onPointerDown: pointerDown,
|
|
3544
|
+
onPointerMove: pointerMove,
|
|
3545
|
+
onPointerUp: pointerUp,
|
|
3546
|
+
onPointerCancel: pointerUp,
|
|
3547
|
+
onDblClick: (event) => {
|
|
3548
|
+
measure(event);
|
|
3549
|
+
reset();
|
|
3550
|
+
}
|
|
3551
|
+
},
|
|
3552
|
+
imageProps: {
|
|
3553
|
+
draggable: false,
|
|
3554
|
+
get style() {
|
|
3555
|
+
const at = offset();
|
|
3556
|
+
return { transform: `translate(${at.x}px, ${at.y}px) scale(${scale()})` };
|
|
3557
|
+
}
|
|
3558
|
+
},
|
|
3559
|
+
valueProps: {
|
|
3560
|
+
id: valueId,
|
|
3561
|
+
role: "status",
|
|
3562
|
+
"aria-live": "polite",
|
|
3563
|
+
"aria-atomic": "true"
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
};
|
|
3269
3567
|
// src/popover.ts
|
|
3270
3568
|
const FLIPPED = {
|
|
3271
3569
|
"bottom-start": "top-start",
|
|
@@ -3833,6 +4131,109 @@ const createGrid = (options = {}) => {
|
|
|
3833
4131
|
}
|
|
3834
4132
|
};
|
|
3835
4133
|
};
|
|
4134
|
+
// src/hotkeys.ts
|
|
4135
|
+
const MODIFIERS = /* @__PURE__ */ new Set([
|
|
4136
|
+
"mod",
|
|
4137
|
+
"ctrl",
|
|
4138
|
+
"alt",
|
|
4139
|
+
"shift",
|
|
4140
|
+
"meta"
|
|
4141
|
+
]);
|
|
4142
|
+
const SHIFTED = /* @__PURE__ */ new Set("~!@#$%^&*()_+{}|:\"<>?");
|
|
4143
|
+
const detectsApple = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
|
4144
|
+
const parse = (keys, isApple) => {
|
|
4145
|
+
const parts = keys.toLowerCase().split("+");
|
|
4146
|
+
const key = parts.pop() ?? "";
|
|
4147
|
+
if (key === "" || MODIFIERS.has(key)) throw new TypeError(`hotkeys: "${keys}" ends with no key`);
|
|
4148
|
+
const chord = {
|
|
4149
|
+
ctrl: false,
|
|
4150
|
+
meta: false,
|
|
4151
|
+
alt: false,
|
|
4152
|
+
shift: SHIFTED.has(key),
|
|
4153
|
+
key
|
|
4154
|
+
};
|
|
4155
|
+
for (const part of parts) switch (part) {
|
|
4156
|
+
case "mod":
|
|
4157
|
+
if (isApple) chord.meta = true;
|
|
4158
|
+
else chord.ctrl = true;
|
|
4159
|
+
break;
|
|
4160
|
+
case "ctrl":
|
|
4161
|
+
chord.ctrl = true;
|
|
4162
|
+
break;
|
|
4163
|
+
case "meta":
|
|
4164
|
+
chord.meta = true;
|
|
4165
|
+
break;
|
|
4166
|
+
case "alt":
|
|
4167
|
+
chord.alt = true;
|
|
4168
|
+
break;
|
|
4169
|
+
case "shift":
|
|
4170
|
+
chord.shift = true;
|
|
4171
|
+
break;
|
|
4172
|
+
default: throw new TypeError(`hotkeys: "${part}" in "${keys}" is not a modifier`);
|
|
4173
|
+
}
|
|
4174
|
+
return chord;
|
|
4175
|
+
};
|
|
4176
|
+
const matches = (chord, event) => event.key.toLowerCase() === chord.key && event.ctrlKey === chord.ctrl && event.metaKey === chord.meta && event.altKey === chord.alt && event.shiftKey === chord.shift;
|
|
4177
|
+
const editable = (target) => target instanceof HTMLElement && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
|
|
4178
|
+
const inScope = (scope, target) => scope === void 0 || target instanceof Node && scope.contains(target);
|
|
4179
|
+
/**
|
|
4180
|
+
* One `keydown` listener in the capture phase, one list of chords. A chord
|
|
4181
|
+
* fires when its modifiers match exactly, its `when` holds, its scope contains
|
|
4182
|
+
* the event's target, and the focus is not in a text field (unless
|
|
4183
|
+
* `allowInEditable`). The most recently bound match is the only one that fires,
|
|
4184
|
+
* so a modal's `"/"` overrides the page's and unbinding it brings the page's
|
|
4185
|
+
* back; `preventDefault` is on by default for the bindings whose chord the
|
|
4186
|
+
* browser would otherwise act on (Cmd/Ctrl+S, Cmd/Ctrl+K).
|
|
4187
|
+
*/
|
|
4188
|
+
const createHotkeys = (options = {}) => {
|
|
4189
|
+
const isApple = options.isApple ?? detectsApple();
|
|
4190
|
+
const bindings = [];
|
|
4191
|
+
let listening = false;
|
|
4192
|
+
let disposed = false;
|
|
4193
|
+
const onKeyDown = (event) => {
|
|
4194
|
+
for (let i = bindings.length - 1; i >= 0; i--) {
|
|
4195
|
+
const entry = bindings[i];
|
|
4196
|
+
if (entry === void 0) continue;
|
|
4197
|
+
const { chord, binding } = entry;
|
|
4198
|
+
if (!matches(chord, event) || binding.when?.() === false) continue;
|
|
4199
|
+
if (!inScope(binding.scope, event.target)) continue;
|
|
4200
|
+
if (binding.allowInEditable !== true && editable(event.target)) continue;
|
|
4201
|
+
if (binding.preventDefault !== false) event.preventDefault();
|
|
4202
|
+
binding.onTrigger(event);
|
|
4203
|
+
return;
|
|
4204
|
+
}
|
|
4205
|
+
};
|
|
4206
|
+
const stop = () => {
|
|
4207
|
+
if (!listening) return;
|
|
4208
|
+
listening = false;
|
|
4209
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
4210
|
+
};
|
|
4211
|
+
return {
|
|
4212
|
+
bind: (binding) => {
|
|
4213
|
+
if (disposed) return () => {};
|
|
4214
|
+
const entry = {
|
|
4215
|
+
chord: parse(binding.keys, isApple),
|
|
4216
|
+
binding
|
|
4217
|
+
};
|
|
4218
|
+
bindings.push(entry);
|
|
4219
|
+
if (!listening) {
|
|
4220
|
+
listening = true;
|
|
4221
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
4222
|
+
}
|
|
4223
|
+
return () => {
|
|
4224
|
+
const at = bindings.indexOf(entry);
|
|
4225
|
+
if (at === -1) return;
|
|
4226
|
+
bindings.splice(at, 1);
|
|
4227
|
+
if (bindings.length === 0) stop();
|
|
4228
|
+
};
|
|
4229
|
+
},
|
|
4230
|
+
dispose: () => {
|
|
4231
|
+
disposed = true;
|
|
4232
|
+
bindings.length = 0;
|
|
4233
|
+
stop();
|
|
4234
|
+
}
|
|
4235
|
+
};
|
|
4236
|
+
};
|
|
3836
4237
|
// src/link.ts
|
|
3837
4238
|
/**
|
|
3838
4239
|
* APG Link (https://www.w3.org/WAI/ARIA/apg/patterns/link/): a native `<a href>`
|
|
@@ -5449,6 +5850,69 @@ const createResizablePanels = (options) => {
|
|
|
5449
5850
|
separatorProps
|
|
5450
5851
|
};
|
|
5451
5852
|
};
|
|
5853
|
+
// src/scrollspy.ts
|
|
5854
|
+
/**
|
|
5855
|
+
* A scrollspy: a table of contents whose current entry is the last heading that
|
|
5856
|
+
* has reached the top of the scrollport, marked `aria-current="location"` on
|
|
5857
|
+
* its link (and nowhere else: exactly one link is current).
|
|
5858
|
+
*
|
|
5859
|
+
* **No APG pattern exists for a scrollspy**, so there is no Quint spec and no
|
|
5860
|
+
* ported scenarios: the contract is WAI-ARIA (`aria-current="location"`, the
|
|
5861
|
+
* `list` / `listitem` roles, the navigation landmark and its name) and HTML,
|
|
5862
|
+
* and `test/scrollspy.browser.test.tsx` is its oracle.
|
|
5863
|
+
*
|
|
5864
|
+
* **A scroll listener, not an `IntersectionObserver`.** "The last heading at or
|
|
5865
|
+
* above the offset" is a total order over every heading, while an observer only
|
|
5866
|
+
* reports what crossed its band: a band observer has to rebuild the order from
|
|
5867
|
+
* its enter/leave state, and it misreports after a jump (an anchor click, a
|
|
5868
|
+
* restored scroll position). The listener is passive, the measurements are
|
|
5869
|
+
* reads only (no writes, so no layout thrash), and a docs page has a dozen
|
|
5870
|
+
* headings — the same cost as the listener the page already keeps.
|
|
5871
|
+
*
|
|
5872
|
+
* Smooth scrolling is the host's (`scroll-behavior: smooth`): the links are
|
|
5873
|
+
* plain `#id` targets and nothing here moves the scrollport, so no
|
|
5874
|
+
* `prefers-reduced-motion` branch is needed either.
|
|
5875
|
+
*/
|
|
5876
|
+
const createScrollspy = (options) => {
|
|
5877
|
+
const offset = options.offset ?? 0;
|
|
5878
|
+
const first = options.entries[0]?.id;
|
|
5879
|
+
const [active, setActive] = createSignal(first);
|
|
5880
|
+
const at = () => {
|
|
5881
|
+
const container = options.root?.()?.getBoundingClientRect().top ?? 0;
|
|
5882
|
+
let id = first;
|
|
5883
|
+
for (const entry of options.entries) {
|
|
5884
|
+
const element = document.getElementById(entry.id);
|
|
5885
|
+
if (element === null) continue;
|
|
5886
|
+
if (element.getBoundingClientRect().top - container <= offset) id = entry.id;
|
|
5887
|
+
}
|
|
5888
|
+
return id;
|
|
5889
|
+
};
|
|
5890
|
+
const commit = () => {
|
|
5891
|
+
const next = at();
|
|
5892
|
+
if (next === active()) return;
|
|
5893
|
+
setActive(next);
|
|
5894
|
+
options.onChange?.(next);
|
|
5895
|
+
};
|
|
5896
|
+
return {
|
|
5897
|
+
active,
|
|
5898
|
+
navProps: { "aria-label": "On this page" },
|
|
5899
|
+
listProps: { role: "list" },
|
|
5900
|
+
itemProps: () => ({ role: "listitem" }),
|
|
5901
|
+
linkProps: (id) => ({
|
|
5902
|
+
href: `#${id}`,
|
|
5903
|
+
get "aria-current"() {
|
|
5904
|
+
return id === active() ? "location" : void 0;
|
|
5905
|
+
}
|
|
5906
|
+
}),
|
|
5907
|
+
attach: () => {
|
|
5908
|
+
const target = options.root?.() ?? document;
|
|
5909
|
+
const listener = () => commit();
|
|
5910
|
+
target.addEventListener("scroll", listener, { passive: true });
|
|
5911
|
+
commit();
|
|
5912
|
+
return () => target.removeEventListener("scroll", listener);
|
|
5913
|
+
}
|
|
5914
|
+
};
|
|
5915
|
+
};
|
|
5452
5916
|
// src/slider.ts
|
|
5453
5917
|
const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "high";
|
|
5454
5918
|
/**
|
|
@@ -8361,4 +8825,4 @@ const createRearrangeableListbox = (options) => {
|
|
|
8361
8825
|
})
|
|
8362
8826
|
};
|
|
8363
8827
|
};
|
|
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 };
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cssints/headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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": {
|