@cssints/headless 0.15.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 +191 -1
- package/dist/index.d.mts +531 -1
- package/dist/index.mjs +850 -34
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -54,7 +54,7 @@ const toggled = (a, index) => {
|
|
|
54
54
|
else next.add(index);
|
|
55
55
|
return next;
|
|
56
56
|
};
|
|
57
|
-
const sameSet = (a, b) => a.size === b.size && [...a].every((index) => b.has(index));
|
|
57
|
+
const sameSet$1 = (a, b) => a.size === b.size && [...a].every((index) => b.has(index));
|
|
58
58
|
/** The only member, or -1. */
|
|
59
59
|
const sole = (a) => a.size === 1 ? [...a][0] ?? -1 : -1;
|
|
60
60
|
const at = (values, index) => values[index] ?? 0;
|
|
@@ -477,7 +477,7 @@ const createCanvas = (options) => {
|
|
|
477
477
|
annSel: ann,
|
|
478
478
|
annText: prefs.announce ? messages().selection([...ann].sort((a, b) => a - b).map((i) => labelAt(s, i))) : ""
|
|
479
479
|
});
|
|
480
|
-
const announced = (s, sel) => sameSet(sel, s.annSel) ? {
|
|
480
|
+
const announced = (s, sel) => sameSet$1(sel, s.annSel) ? {
|
|
481
481
|
...s,
|
|
482
482
|
sel
|
|
483
483
|
} : announcing({
|
|
@@ -1296,7 +1296,7 @@ const createPlayer = (options) => {
|
|
|
1296
1296
|
const HEADING_LEVELS = 6;
|
|
1297
1297
|
const count = (expanded) => expanded.filter(Boolean).length;
|
|
1298
1298
|
/** Re-establish the mode's constraints, exactly as the spec's `normExpanded`. */
|
|
1299
|
-
const normalize$
|
|
1299
|
+
const normalize$2 = (expanded, multiple, collapsible) => {
|
|
1300
1300
|
if (!collapsible && count(expanded) === 0) return expanded.map((_, index) => index === 0);
|
|
1301
1301
|
if (multiple) return expanded;
|
|
1302
1302
|
const first = expanded.findIndex(Boolean);
|
|
@@ -1328,7 +1328,7 @@ const createAccordion = (options = {}) => {
|
|
|
1328
1328
|
const controlled = () => options.value !== void 0;
|
|
1329
1329
|
const [indices, request] = createControllable({
|
|
1330
1330
|
value: () => options.value,
|
|
1331
|
-
initial: indicesOf(normalize$
|
|
1331
|
+
initial: indicesOf(normalize$2(Array.from({ length: items }, (_, index) => (options.defaultValue ?? []).includes(index)), multiple(), collapsible())),
|
|
1332
1332
|
onChange: () => options.onValueChange
|
|
1333
1333
|
});
|
|
1334
1334
|
const expanded = () => Array.from({ length: items }, (_, index) => indices().includes(index));
|
|
@@ -1361,11 +1361,11 @@ const createAccordion = (options = {}) => {
|
|
|
1361
1361
|
toggle,
|
|
1362
1362
|
setMultiple: (value) => {
|
|
1363
1363
|
setMultipleOverride(value);
|
|
1364
|
-
if (!controlled()) commit(normalize$
|
|
1364
|
+
if (!controlled()) commit(normalize$2(expanded(), value, collapsible()));
|
|
1365
1365
|
},
|
|
1366
1366
|
setCollapsible: (value) => {
|
|
1367
1367
|
setCollapsibleOverride(value);
|
|
1368
|
-
if (!controlled()) commit(normalize$
|
|
1368
|
+
if (!controlled()) commit(normalize$2(expanded(), multiple(), value));
|
|
1369
1369
|
},
|
|
1370
1370
|
setHeadingLevel: (level) => {
|
|
1371
1371
|
setHeadingLevelSignal(Math.min(Math.max(level, 1), HEADING_LEVELS));
|
|
@@ -2584,28 +2584,85 @@ const createDialogModal = (options = {}) => {
|
|
|
2584
2584
|
}
|
|
2585
2585
|
};
|
|
2586
2586
|
};
|
|
2587
|
-
// src/
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2587
|
+
// src/overlay-shell.ts
|
|
2588
|
+
/**
|
|
2589
|
+
* The page-wide half of a modal overlay: one scroll lock, one stack of open
|
|
2590
|
+
* overlays, the tabbables of a panel and the Tab cycle over them. Shared by
|
|
2591
|
+
* `createConfirm` and `createDrawer` so that a confirm opened over a drawer (or
|
|
2592
|
+
* the reverse) takes Escape and Tab first, and the scroll lock outlives the
|
|
2593
|
+
* first of them to close.
|
|
2594
|
+
*/
|
|
2595
|
+
const TABBABLE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
|
|
2596
|
+
/** The visible, tabbable elements of `root`, in document order. */
|
|
2597
|
+
const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
|
|
2598
|
+
/** The first tabbable element of `root`, if any. */
|
|
2599
|
+
const firstFocusable$1 = (root) => tabbables(root)[0];
|
|
2600
|
+
/**
|
|
2601
|
+
* Wraps Tab / Shift+Tab inside `root` and pulls a focus that escaped back in
|
|
2602
|
+
* on the next Tab. With nothing tabbable the panel itself (which is
|
|
2603
|
+
* `tabindex="-1"`) keeps the focus.
|
|
2604
|
+
*/
|
|
2605
|
+
const cycleFocus = (event, root) => {
|
|
2606
|
+
const items = tabbables(root);
|
|
2607
|
+
const first = items[0];
|
|
2608
|
+
const last = items.at(-1);
|
|
2609
|
+
const active = document.activeElement;
|
|
2610
|
+
const inside = root.contains(active);
|
|
2611
|
+
if (first === void 0 || last === void 0) {
|
|
2612
|
+
event.preventDefault();
|
|
2613
|
+
root.focus();
|
|
2614
|
+
} else if (event.shiftKey && (!inside || active === first || active === root)) {
|
|
2615
|
+
event.preventDefault();
|
|
2616
|
+
last.focus();
|
|
2617
|
+
} else if (!event.shiftKey && (!inside || active === last)) {
|
|
2618
|
+
event.preventDefault();
|
|
2619
|
+
first.focus();
|
|
2592
2620
|
}
|
|
2593
2621
|
};
|
|
2594
|
-
const openDialogs = [];
|
|
2595
2622
|
let scrollLocks = 0;
|
|
2596
2623
|
let scrollBefore = "";
|
|
2624
|
+
/**
|
|
2625
|
+
* `overflow: hidden` on the document element, counted: the first holder takes
|
|
2626
|
+
* it, the last to release restores what `overflow` was. The returned release is
|
|
2627
|
+
* idempotent.
|
|
2628
|
+
*/
|
|
2597
2629
|
const lockScroll = () => {
|
|
2598
2630
|
const root = document.documentElement;
|
|
2599
2631
|
if (scrollLocks++ === 0) {
|
|
2600
2632
|
scrollBefore = root.style.overflow;
|
|
2601
2633
|
root.style.overflow = "hidden";
|
|
2602
2634
|
}
|
|
2635
|
+
let released = false;
|
|
2603
2636
|
return () => {
|
|
2637
|
+
if (released) return;
|
|
2638
|
+
released = true;
|
|
2604
2639
|
if (--scrollLocks === 0) root.style.overflow = scrollBefore;
|
|
2605
2640
|
};
|
|
2606
2641
|
};
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2642
|
+
const openOverlays = [];
|
|
2643
|
+
/**
|
|
2644
|
+
* Joins the open overlays; the last one to claim takes Escape and Tab. Returns
|
|
2645
|
+
* the release (idempotent), which may be called in any order.
|
|
2646
|
+
*/
|
|
2647
|
+
const claimOverlay = (self) => {
|
|
2648
|
+
openOverlays.push(self);
|
|
2649
|
+
let claimed = true;
|
|
2650
|
+
return () => {
|
|
2651
|
+
if (!claimed) return;
|
|
2652
|
+
claimed = false;
|
|
2653
|
+
const at = openOverlays.indexOf(self);
|
|
2654
|
+
if (at !== -1) openOverlays.splice(at, 1);
|
|
2655
|
+
};
|
|
2656
|
+
};
|
|
2657
|
+
/** Whether `self` is the overlay that takes Escape and Tab (the one opened last). */
|
|
2658
|
+
const isTopOverlay = (self) => openOverlays.at(-1) === self;
|
|
2659
|
+
// src/confirm.ts
|
|
2660
|
+
var ConfirmPendingError = class extends Error {
|
|
2661
|
+
name = "ConfirmPendingError";
|
|
2662
|
+
constructor() {
|
|
2663
|
+
super("a confirmation is already pending");
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2609
2666
|
/**
|
|
2610
2667
|
* A promise-based confirmation over `createDialogModal({ role: "alertdialog" })`
|
|
2611
2668
|
* (APG Alert Dialog: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/).
|
|
@@ -2627,7 +2684,9 @@ const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((
|
|
|
2627
2684
|
* Nesting: confirms from different `createConfirm` calls may be open together;
|
|
2628
2685
|
* only the last one opened gets Escape and Tab (and Escape does not reach a
|
|
2629
2686
|
* dialog it sits in), and the scroll lock is counted: the first takes it, the
|
|
2630
|
-
* last to close restores what `overflow` was.
|
|
2687
|
+
* last to close restores what `overflow` was. `createDrawer` claims the same
|
|
2688
|
+
* page-wide stack and lock, so a confirm over a drawer (or the reverse) nests
|
|
2689
|
+
* the same way. One confirm still has one request.
|
|
2631
2690
|
*
|
|
2632
2691
|
* One request at a time: a second `confirm()` while one is pending is rejected,
|
|
2633
2692
|
* not queued. Queued, a double click on "Delete" would ask twice and a stale
|
|
@@ -2661,7 +2720,7 @@ const createConfirm = (options = {}) => {
|
|
|
2661
2720
|
});
|
|
2662
2721
|
const self = Symbol(base);
|
|
2663
2722
|
const onDocumentKeyDown = (event) => {
|
|
2664
|
-
if (
|
|
2723
|
+
if (!isTopOverlay(self)) return;
|
|
2665
2724
|
if (event.key === "Escape") {
|
|
2666
2725
|
event.preventDefault();
|
|
2667
2726
|
event.stopPropagation();
|
|
@@ -2670,31 +2729,17 @@ const createConfirm = (options = {}) => {
|
|
|
2670
2729
|
}
|
|
2671
2730
|
const root = document.getElementById(dialog.dialogProps.id);
|
|
2672
2731
|
if (event.key !== "Tab" || root === null) return;
|
|
2673
|
-
|
|
2674
|
-
const first = items[0];
|
|
2675
|
-
const last = items.at(-1);
|
|
2676
|
-
const active = document.activeElement;
|
|
2677
|
-
const inside = root.contains(active);
|
|
2678
|
-
if (first === void 0 || last === void 0) {
|
|
2679
|
-
event.preventDefault();
|
|
2680
|
-
root.focus();
|
|
2681
|
-
} else if (event.shiftKey && (!inside || active === first || active === root)) {
|
|
2682
|
-
event.preventDefault();
|
|
2683
|
-
last.focus();
|
|
2684
|
-
} else if (!event.shiftKey && (!inside || active === last)) {
|
|
2685
|
-
event.preventDefault();
|
|
2686
|
-
first.focus();
|
|
2687
|
-
}
|
|
2732
|
+
cycleFocus(event, root);
|
|
2688
2733
|
};
|
|
2689
2734
|
createEffect(() => dialog.open(), (isOpen) => {
|
|
2690
2735
|
if (!isOpen) return;
|
|
2691
2736
|
document.getElementById(cancelId)?.focus();
|
|
2692
2737
|
const unlock = lockScroll();
|
|
2693
|
-
|
|
2738
|
+
const release = claimOverlay(self);
|
|
2694
2739
|
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
2695
2740
|
return () => {
|
|
2696
2741
|
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
2697
|
-
|
|
2742
|
+
release();
|
|
2698
2743
|
unlock();
|
|
2699
2744
|
};
|
|
2700
2745
|
});
|
|
@@ -2752,6 +2797,148 @@ const createConfirm = (options = {}) => {
|
|
|
2752
2797
|
}
|
|
2753
2798
|
};
|
|
2754
2799
|
};
|
|
2800
|
+
// src/drawer.ts
|
|
2801
|
+
/**
|
|
2802
|
+
* The shell for a drawer / bottom sheet: an edge-anchored panel over the
|
|
2803
|
+
* Dialog (Modal) semantics (https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
|
|
2804
|
+
* with the same shell as `createConfirm` — focus into the panel on open and back
|
|
2805
|
+
* to the trigger on close, Tab cycling while modal, Escape read from the
|
|
2806
|
+
* document (so it closes wherever focus is), a counted scroll lock and an
|
|
2807
|
+
* outside dismissal.
|
|
2808
|
+
*
|
|
2809
|
+
* **Compose, do not add a sheet primitive.** [docs/bottom-sheet.md](../docs/bottom-sheet.md)
|
|
2810
|
+
* settled it: the semantics are Dialog (Modal), the position, the slide and the
|
|
2811
|
+
* snap points are the host's CSS (native scroll snap), and the panel that both
|
|
2812
|
+
* need is this shell. `side` and `modal` are the whole presentation API: a sheet
|
|
2813
|
+
* is `{ side: "bottom", modal: true }`, a side rail is `{ side: "right" }` and a
|
|
2814
|
+
* non-modal rail is `{ side: "right", modal: false }`.
|
|
2815
|
+
*
|
|
2816
|
+
* **No drag-to-dismiss.** A drag is not a dialog interaction, it is a scroll
|
|
2817
|
+
* snap the platform runs off the main thread, and it belongs to the host's
|
|
2818
|
+
* scroll-snap CSS (see bottom-sheet.md: `scroll-snap-type: y mandatory`, snap
|
|
2819
|
+
* markers, `scrollsnapchange`). Mirroring a native snap in JS would be the
|
|
2820
|
+
* gesture re-implementation that the platform research forbids, and a pointer
|
|
2821
|
+
* gesture would exist on top of the snap that already moved the panel. So the
|
|
2822
|
+
* handle is decoration (`aria-hidden`, no pointer handlers): the ways out are
|
|
2823
|
+
* the backdrop, Escape, the close control the host puts in the panel, and the
|
|
2824
|
+
* trigger. Swipe-to-dismiss is a separate decision with its own oracle.
|
|
2825
|
+
*
|
|
2826
|
+
* The panel is edge-anchored, so there is **no `attach`**: nothing is measured
|
|
2827
|
+
* and no placement is computed. Focus and Tab find the panel through
|
|
2828
|
+
* `contentProps.id`, like `createConfirm` does, so the host only spreads
|
|
2829
|
+
* attributes onto its own elements.
|
|
2830
|
+
*
|
|
2831
|
+
* Not checked against a Quint spec: the dialog is, and this is its shell, so
|
|
2832
|
+
* `test/drawer.browser.test.tsx` is the oracle. A pointerdown outside the panel
|
|
2833
|
+
* closes it when `dismissable` (that is the backdrop click too, and the only
|
|
2834
|
+
* dismissal a non-modal drawer has); closing returns focus to the trigger.
|
|
2835
|
+
*/
|
|
2836
|
+
const createDrawer = (options = {}) => {
|
|
2837
|
+
const base = options.id ?? createUniqueId();
|
|
2838
|
+
const triggerId = `${base}-trigger`;
|
|
2839
|
+
const contentId = `${base}-content`;
|
|
2840
|
+
const backdropId = `${base}-backdrop`;
|
|
2841
|
+
const self = Symbol(base);
|
|
2842
|
+
let wasOpen = false;
|
|
2843
|
+
const isModal = () => options.modal ?? true;
|
|
2844
|
+
const dismissable = () => options.dismissable ?? true;
|
|
2845
|
+
const content = () => document.getElementById(contentId);
|
|
2846
|
+
const trigger = () => document.getElementById(triggerId);
|
|
2847
|
+
const [open, requestOpen] = createControllable({
|
|
2848
|
+
value: () => options.open,
|
|
2849
|
+
initial: options.defaultOpen ?? false,
|
|
2850
|
+
onChange: () => options.onOpenChange
|
|
2851
|
+
});
|
|
2852
|
+
const onDocumentKeyDown = (event) => {
|
|
2853
|
+
if (!isTopOverlay(self) || !open()) return;
|
|
2854
|
+
if (event.key === "Escape") {
|
|
2855
|
+
event.preventDefault();
|
|
2856
|
+
event.stopPropagation();
|
|
2857
|
+
requestOpen(false);
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (event.key !== "Tab" || !isModal()) return;
|
|
2861
|
+
const panel = content();
|
|
2862
|
+
if (panel !== null) cycleFocus(event, panel);
|
|
2863
|
+
};
|
|
2864
|
+
createEffect(() => open(), (isOpen) => {
|
|
2865
|
+
if (isOpen) {
|
|
2866
|
+
wasOpen = true;
|
|
2867
|
+
const panel = content();
|
|
2868
|
+
if (panel !== null) (firstFocusable$1(panel) ?? panel).focus();
|
|
2869
|
+
const release = claimOverlay(self);
|
|
2870
|
+
document.addEventListener("keydown", onDocumentKeyDown, true);
|
|
2871
|
+
return () => {
|
|
2872
|
+
document.removeEventListener("keydown", onDocumentKeyDown, true);
|
|
2873
|
+
release();
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
if (wasOpen) {
|
|
2877
|
+
wasOpen = false;
|
|
2878
|
+
const back = trigger();
|
|
2879
|
+
if (back !== null && back.isConnected) back.focus();
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2882
|
+
createEffect(() => open() && isModal(), (locked) => {
|
|
2883
|
+
if (locked) return lockScroll();
|
|
2884
|
+
});
|
|
2885
|
+
const onPointerDown = (event) => {
|
|
2886
|
+
if (!open() || !dismissable()) return;
|
|
2887
|
+
const target = event.target;
|
|
2888
|
+
if (!(target instanceof Node)) return;
|
|
2889
|
+
if (content()?.contains(target) === true || trigger()?.contains(target) === true) return;
|
|
2890
|
+
requestOpen(false);
|
|
2891
|
+
};
|
|
2892
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
2893
|
+
onCleanup(() => document.removeEventListener("pointerdown", onPointerDown, true));
|
|
2894
|
+
return {
|
|
2895
|
+
open,
|
|
2896
|
+
side: () => options.side ?? "right",
|
|
2897
|
+
triggerProps: {
|
|
2898
|
+
id: triggerId,
|
|
2899
|
+
"aria-haspopup": "dialog",
|
|
2900
|
+
get "aria-expanded"() {
|
|
2901
|
+
return open() ? "true" : "false";
|
|
2902
|
+
},
|
|
2903
|
+
"aria-controls": contentId,
|
|
2904
|
+
onClick: () => requestOpen(!open()),
|
|
2905
|
+
onKeyDown: (event) => {
|
|
2906
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
2907
|
+
event.preventDefault();
|
|
2908
|
+
requestOpen(true);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
},
|
|
2912
|
+
backdropProps: {
|
|
2913
|
+
id: backdropId,
|
|
2914
|
+
get hidden() {
|
|
2915
|
+
return !open() || !isModal();
|
|
2916
|
+
},
|
|
2917
|
+
onClick: () => {
|
|
2918
|
+
if (dismissable()) requestOpen(false);
|
|
2919
|
+
}
|
|
2920
|
+
},
|
|
2921
|
+
contentProps: {
|
|
2922
|
+
id: contentId,
|
|
2923
|
+
role: "dialog",
|
|
2924
|
+
get "aria-modal"() {
|
|
2925
|
+
return isModal() && open() ? "true" : void 0;
|
|
2926
|
+
},
|
|
2927
|
+
"aria-labelledby": options.labelledby,
|
|
2928
|
+
tabindex: -1,
|
|
2929
|
+
get hidden() {
|
|
2930
|
+
return !open();
|
|
2931
|
+
},
|
|
2932
|
+
onKeyDown: (event) => {
|
|
2933
|
+
if (event.key === "Escape" && open()) {
|
|
2934
|
+
event.preventDefault();
|
|
2935
|
+
requestOpen(false);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
handleProps: { "aria-hidden": "true" }
|
|
2940
|
+
};
|
|
2941
|
+
};
|
|
2755
2942
|
// src/feed.ts
|
|
2756
2943
|
/**
|
|
2757
2944
|
* APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
|
|
@@ -3079,6 +3266,304 @@ const createPagination = (options) => {
|
|
|
3079
3266
|
}
|
|
3080
3267
|
};
|
|
3081
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
|
+
};
|
|
3082
3567
|
// src/popover.ts
|
|
3083
3568
|
const FLIPPED = {
|
|
3084
3569
|
"bottom-start": "top-start",
|
|
@@ -3646,6 +4131,109 @@ const createGrid = (options = {}) => {
|
|
|
3646
4131
|
}
|
|
3647
4132
|
};
|
|
3648
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
|
+
};
|
|
3649
4237
|
// src/link.ts
|
|
3650
4238
|
/**
|
|
3651
4239
|
* APG Link (https://www.w3.org/WAI/ARIA/apg/patterns/link/): a native `<a href>`
|
|
@@ -5262,6 +5850,69 @@ const createResizablePanels = (options) => {
|
|
|
5262
5850
|
separatorProps
|
|
5263
5851
|
};
|
|
5264
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
|
+
};
|
|
5265
5916
|
// src/slider.ts
|
|
5266
5917
|
const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "high";
|
|
5267
5918
|
/**
|
|
@@ -5822,6 +6473,171 @@ const createSwitch = (options = {}) => {
|
|
|
5822
6473
|
}
|
|
5823
6474
|
};
|
|
5824
6475
|
};
|
|
6476
|
+
// src/table.ts
|
|
6477
|
+
const normalize$1 = (ids) => [...new Set(ids)];
|
|
6478
|
+
const sameSet = (a, b) => a.length === b.length && a.every((id) => b.includes(id));
|
|
6479
|
+
const sameSort = (a, b) => a === null || b === null ? a === b : a.key === b.key && a.direction === b.direction;
|
|
6480
|
+
const isToggleKey = (event) => event.key === " " || event.key === "Spacebar" || event.key === "Enter";
|
|
6481
|
+
/**
|
|
6482
|
+
* Every option is read when an attribute is read, so pass props/getters, never a
|
|
6483
|
+
* destructured snapshot. `id` is captured once: it is the base of the caption id.
|
|
6484
|
+
*/
|
|
6485
|
+
const createTable = (options) => {
|
|
6486
|
+
const base = options.id ?? createUniqueId();
|
|
6487
|
+
const selectionMode = () => options.selection ?? "none";
|
|
6488
|
+
const selectable = () => selectionMode() !== "none";
|
|
6489
|
+
const columnOf = (key) => options.columns.find((column) => column.key === key);
|
|
6490
|
+
const isSortable = (key) => columnOf(key)?.sortable !== false;
|
|
6491
|
+
const [rawSort, requestSort] = createControllable({
|
|
6492
|
+
value: () => options.sort,
|
|
6493
|
+
initial: options.defaultSort ?? null,
|
|
6494
|
+
onChange: () => (next) => options.onSortChange?.(next ?? void 0),
|
|
6495
|
+
same: sameSort
|
|
6496
|
+
});
|
|
6497
|
+
const sort = () => rawSort() ?? void 0;
|
|
6498
|
+
const [rawSelected, requestSelection] = createControllable({
|
|
6499
|
+
value: () => options.selected,
|
|
6500
|
+
initial: normalize$1(options.defaultSelected ?? []),
|
|
6501
|
+
onChange: () => options.onSelectionChange,
|
|
6502
|
+
same: sameSet
|
|
6503
|
+
});
|
|
6504
|
+
const selected = () => normalize$1(rawSelected());
|
|
6505
|
+
const isSelected = (id) => selected().includes(id);
|
|
6506
|
+
const nextSort = (key) => {
|
|
6507
|
+
const current = rawSort();
|
|
6508
|
+
if (current === null || current.key !== key) return {
|
|
6509
|
+
key,
|
|
6510
|
+
direction: "ascending"
|
|
6511
|
+
};
|
|
6512
|
+
if (current.direction === "ascending") return {
|
|
6513
|
+
key,
|
|
6514
|
+
direction: "descending"
|
|
6515
|
+
};
|
|
6516
|
+
return (options.sortCycle ?? "tri-state") === "toggle" ? {
|
|
6517
|
+
key,
|
|
6518
|
+
direction: "ascending"
|
|
6519
|
+
} : null;
|
|
6520
|
+
};
|
|
6521
|
+
const toggleRow = (id) => {
|
|
6522
|
+
const mode = selectionMode();
|
|
6523
|
+
if (mode === "none") return;
|
|
6524
|
+
const current = selected();
|
|
6525
|
+
const has = current.includes(id);
|
|
6526
|
+
requestSelection(mode === "single" ? has ? [] : [id] : has ? current.filter((value) => value !== id) : [...current, id]);
|
|
6527
|
+
};
|
|
6528
|
+
const rows = () => options.rows;
|
|
6529
|
+
const allSelected = () => {
|
|
6530
|
+
const every = rows();
|
|
6531
|
+
return every.length > 0 && every.every(isSelected);
|
|
6532
|
+
};
|
|
6533
|
+
const someSelected = () => rows().some(isSelected);
|
|
6534
|
+
const toggleAll = () => {
|
|
6535
|
+
if (selectionMode() !== "multiple") return;
|
|
6536
|
+
const current = selected();
|
|
6537
|
+
if (allSelected()) {
|
|
6538
|
+
requestSelection(current.filter((id) => !rows().includes(id)));
|
|
6539
|
+
return;
|
|
6540
|
+
}
|
|
6541
|
+
requestSelection(normalize$1([...current, ...rows()]));
|
|
6542
|
+
};
|
|
6543
|
+
const keyedToggle = (toggle) => (event) => {
|
|
6544
|
+
if (!isToggleKey(event)) return;
|
|
6545
|
+
event.preventDefault();
|
|
6546
|
+
toggle();
|
|
6547
|
+
};
|
|
6548
|
+
const selectAll = () => {
|
|
6549
|
+
if (selectionMode() !== "multiple") return;
|
|
6550
|
+
return {
|
|
6551
|
+
role: "checkbox",
|
|
6552
|
+
get "aria-checked"() {
|
|
6553
|
+
return allSelected() ? "true" : someSelected() ? "mixed" : "false";
|
|
6554
|
+
},
|
|
6555
|
+
get "aria-label"() {
|
|
6556
|
+
return options.labels?.selectAll ?? "Select all rows";
|
|
6557
|
+
},
|
|
6558
|
+
tabindex: 0,
|
|
6559
|
+
onClick: () => {
|
|
6560
|
+
toggleAll();
|
|
6561
|
+
},
|
|
6562
|
+
onKeyDown: keyedToggle(toggleAll)
|
|
6563
|
+
};
|
|
6564
|
+
};
|
|
6565
|
+
const rowCheckbox = (id) => {
|
|
6566
|
+
if (!selectable()) return;
|
|
6567
|
+
return {
|
|
6568
|
+
role: "checkbox",
|
|
6569
|
+
get "aria-checked"() {
|
|
6570
|
+
return isSelected(id) ? "true" : "false";
|
|
6571
|
+
},
|
|
6572
|
+
get "aria-label"() {
|
|
6573
|
+
return options.labels?.selectRow?.(id) ?? `Select row ${id}`;
|
|
6574
|
+
},
|
|
6575
|
+
tabindex: 0,
|
|
6576
|
+
onClick: (event) => {
|
|
6577
|
+
event.stopPropagation();
|
|
6578
|
+
toggleRow(id);
|
|
6579
|
+
},
|
|
6580
|
+
onKeyDown: keyedToggle(() => toggleRow(id))
|
|
6581
|
+
};
|
|
6582
|
+
};
|
|
6583
|
+
return {
|
|
6584
|
+
selectionMode,
|
|
6585
|
+
sort,
|
|
6586
|
+
selected,
|
|
6587
|
+
isSelected,
|
|
6588
|
+
tableProps: {
|
|
6589
|
+
get "aria-label"() {
|
|
6590
|
+
return options.labels?.table;
|
|
6591
|
+
},
|
|
6592
|
+
get "aria-rowcount"() {
|
|
6593
|
+
return options.rowCount;
|
|
6594
|
+
},
|
|
6595
|
+
get "aria-colcount"() {
|
|
6596
|
+
return options.columnCount;
|
|
6597
|
+
}
|
|
6598
|
+
},
|
|
6599
|
+
captionProps: { id: `${base}-caption` },
|
|
6600
|
+
headerCellProps: (key) => ({
|
|
6601
|
+
scope: "col",
|
|
6602
|
+
get "aria-sort"() {
|
|
6603
|
+
const current = rawSort();
|
|
6604
|
+
return current !== null && current.key === key && isSortable(key) ? current.direction : void 0;
|
|
6605
|
+
},
|
|
6606
|
+
get abbr() {
|
|
6607
|
+
return columnOf(key)?.abbr;
|
|
6608
|
+
}
|
|
6609
|
+
}),
|
|
6610
|
+
headerButtonProps: (key) => {
|
|
6611
|
+
if (!isSortable(key)) return;
|
|
6612
|
+
return {
|
|
6613
|
+
type: "button",
|
|
6614
|
+
onClick: () => {
|
|
6615
|
+
requestSort(nextSort(key));
|
|
6616
|
+
}
|
|
6617
|
+
};
|
|
6618
|
+
},
|
|
6619
|
+
rowProps: (id) => ({
|
|
6620
|
+
get "aria-selected"() {
|
|
6621
|
+
if (!selectable()) return;
|
|
6622
|
+
return isSelected(id) ? "true" : "false";
|
|
6623
|
+
},
|
|
6624
|
+
get onClick() {
|
|
6625
|
+
return selectable() ? () => toggleRow(id) : void 0;
|
|
6626
|
+
}
|
|
6627
|
+
}),
|
|
6628
|
+
cellProps: (_rowId, key) => ({ get "aria-colindex"() {
|
|
6629
|
+
if (options.columnCount === void 0) return;
|
|
6630
|
+
const index = options.columns.findIndex((column) => column.key === key);
|
|
6631
|
+
return index < 0 ? void 0 : index + 1;
|
|
6632
|
+
} }),
|
|
6633
|
+
get selectAllProps() {
|
|
6634
|
+
return selectAll();
|
|
6635
|
+
},
|
|
6636
|
+
rowCheckboxProps: (id) => rowCheckbox(id),
|
|
6637
|
+
toggleAll,
|
|
6638
|
+
toggleRow
|
|
6639
|
+
};
|
|
6640
|
+
};
|
|
5825
6641
|
// src/textbox.ts
|
|
5826
6642
|
const control = (field, options) => ({
|
|
5827
6643
|
...field.controlProps,
|
|
@@ -8009,4 +8825,4 @@ const createRearrangeableListbox = (options) => {
|
|
|
8009
8825
|
})
|
|
8010
8826
|
};
|
|
8011
8827
|
};
|
|
8012
|
-
export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createFeed, createField, createGrid, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, 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 };
|