@cssints/headless 0.18.0 → 0.20.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 +190 -2
- package/dist/index.d.mts +768 -236
- package/dist/index.mjs +1417 -507
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -2051,6 +2051,90 @@ const createCheckbox = (options = {}) => {
|
|
|
2051
2051
|
}
|
|
2052
2052
|
};
|
|
2053
2053
|
};
|
|
2054
|
+
// src/field.ts
|
|
2055
|
+
const text = (value) => value === void 0 || value === "" ? void 0 : value;
|
|
2056
|
+
const external = (value) => (value === void 0 ? [] : typeof value === "string" ? [value] : value).filter((id) => id !== "");
|
|
2057
|
+
/**
|
|
2058
|
+
* The ARIA contract of a form field: a label, a control, a hint and an error.
|
|
2059
|
+
* There is no APG pattern for it (APG has no "form field"; the nearest is the
|
|
2060
|
+
* label and description rules of the accessible name computation), so the
|
|
2061
|
+
* contract comes from WAI-ARIA 1.2 (`aria-describedby`, `aria-invalid`,
|
|
2062
|
+
* `aria-required`) and HTML (`label[for]`, `required`), and from the
|
|
2063
|
+
* majority of Base UI Field, react-aria TextField and ariakit FormControl,
|
|
2064
|
+
* which list the description and the error message together. It is not checked
|
|
2065
|
+
* against a Quint spec; `test/field.browser.test.tsx` is its oracle.
|
|
2066
|
+
*
|
|
2067
|
+
* Decisions: the error does not replace the hint, both are listed, hint first
|
|
2068
|
+
* (the hint is what tells the user how to fix the error); an error implies
|
|
2069
|
+
* invalid and `invalid: false` overrides it; a hidden message is never
|
|
2070
|
+
* referenced, so `aria-describedby` has no dangling id. The error element is
|
|
2071
|
+
* `role="alert"` only while it is shown (a hidden element announces nothing, and
|
|
2072
|
+
* one that appears is announced); the hint is never an alert.
|
|
2073
|
+
*
|
|
2074
|
+
* `describedby` lists host-owned ids after the field's own: one refusal that
|
|
2075
|
+
* belongs to two controls is one form-level element (with its own `role`), named
|
|
2076
|
+
* by both. The field cannot know whether that element is shown: the host
|
|
2077
|
+
* passes the id only while it is.
|
|
2078
|
+
*
|
|
2079
|
+
* Options are read when an attribute is read: pass getters, never destructure.
|
|
2080
|
+
*/
|
|
2081
|
+
const createField = (options = {}) => {
|
|
2082
|
+
const base = options.id ?? createUniqueId();
|
|
2083
|
+
const controlId = `${base}-control`;
|
|
2084
|
+
const labelId = `${base}-label`;
|
|
2085
|
+
const hintId = `${base}-hint`;
|
|
2086
|
+
const errorId = `${base}-error`;
|
|
2087
|
+
const hint = () => text(options.hint);
|
|
2088
|
+
const message = () => text(options.error) ?? (typeof options.invalid === "string" ? text(options.invalid) : void 0);
|
|
2089
|
+
const invalid = () => options.invalid === false ? false : options.invalid === true || message() !== void 0;
|
|
2090
|
+
const error = () => invalid() ? message() : void 0;
|
|
2091
|
+
const describedIds = () => [
|
|
2092
|
+
...hint() === void 0 ? [] : [hintId],
|
|
2093
|
+
...error() === void 0 ? [] : [errorId],
|
|
2094
|
+
...external(options.describedby)
|
|
2095
|
+
];
|
|
2096
|
+
return {
|
|
2097
|
+
invalid,
|
|
2098
|
+
hint,
|
|
2099
|
+
error,
|
|
2100
|
+
describedIds,
|
|
2101
|
+
controlProps: {
|
|
2102
|
+
id: controlId,
|
|
2103
|
+
get "aria-describedby"() {
|
|
2104
|
+
const ids = describedIds();
|
|
2105
|
+
return ids.length === 0 ? void 0 : ids.join(" ");
|
|
2106
|
+
},
|
|
2107
|
+
get "aria-invalid"() {
|
|
2108
|
+
return invalid() ? "true" : void 0;
|
|
2109
|
+
},
|
|
2110
|
+
get "aria-required"() {
|
|
2111
|
+
return options.required === true ? "true" : void 0;
|
|
2112
|
+
},
|
|
2113
|
+
get required() {
|
|
2114
|
+
return options.required === true ? true : void 0;
|
|
2115
|
+
}
|
|
2116
|
+
},
|
|
2117
|
+
labelProps: {
|
|
2118
|
+
id: labelId,
|
|
2119
|
+
for: controlId
|
|
2120
|
+
},
|
|
2121
|
+
hintProps: {
|
|
2122
|
+
id: hintId,
|
|
2123
|
+
get hidden() {
|
|
2124
|
+
return hint() === void 0;
|
|
2125
|
+
}
|
|
2126
|
+
},
|
|
2127
|
+
errorProps: {
|
|
2128
|
+
id: errorId,
|
|
2129
|
+
get hidden() {
|
|
2130
|
+
return error() === void 0;
|
|
2131
|
+
},
|
|
2132
|
+
get role() {
|
|
2133
|
+
return error() === void 0 ? void 0 : "alert";
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
};
|
|
2054
2138
|
// src/checkbox-group.ts
|
|
2055
2139
|
const CHILDREN = [
|
|
2056
2140
|
"c0",
|
|
@@ -2089,7 +2173,17 @@ const nextFocus = (focus) => {
|
|
|
2089
2173
|
* pass `value` + `onValueChange` to own the checked subset. The checked set is
|
|
2090
2174
|
* packed internally so it rides the scalar `createControllable`.
|
|
2091
2175
|
*
|
|
2092
|
-
*
|
|
2176
|
+
* The name, the description and the error are `createField`'s contract on the
|
|
2177
|
+
* `role="group"` element (`aria-label` / `aria-labelledby`, `aria-describedby`,
|
|
2178
|
+
* `aria-invalid`, an error element that is `role="alert"` while it is shown), so
|
|
2179
|
+
* a group reads like the fields around it. The group is not a `<label for>`
|
|
2180
|
+
* target: a host names a visible label element's id with `labelledby`, or passes
|
|
2181
|
+
* `label` without one.
|
|
2182
|
+
*
|
|
2183
|
+
* Behaviour is checked against `specs/apg/checkbox-group.qnt` by the bridge. That
|
|
2184
|
+
* model leaves labelling out on purpose ("shift+Tab, accessible names,
|
|
2185
|
+
* fieldset/legend or role=group labelling" in its header), so the field contract
|
|
2186
|
+
* is checked by plain assertions in `test/checkbox-group.browser.test.tsx`.
|
|
2093
2187
|
*/
|
|
2094
2188
|
const createCheckboxGroup = (options) => {
|
|
2095
2189
|
const values = options.values;
|
|
@@ -2100,6 +2194,18 @@ const createCheckboxGroup = (options) => {
|
|
|
2100
2194
|
initial: packedOf(options.defaultValue ?? []),
|
|
2101
2195
|
onChange: () => (next) => options.onValueChange?.(labelsOf(next))
|
|
2102
2196
|
});
|
|
2197
|
+
const field = createField({
|
|
2198
|
+
id: options.id,
|
|
2199
|
+
get invalid() {
|
|
2200
|
+
return options.invalid;
|
|
2201
|
+
},
|
|
2202
|
+
get error() {
|
|
2203
|
+
return options.error;
|
|
2204
|
+
},
|
|
2205
|
+
get describedby() {
|
|
2206
|
+
return options.describedby;
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2103
2209
|
const [memory, setMemory] = createSignal(normalizeChildren([]));
|
|
2104
2210
|
const [remember, setRememberSignal] = createSignal(options.remember === true);
|
|
2105
2211
|
const [focused, setFocused] = createSignal("none");
|
|
@@ -2157,6 +2263,9 @@ const createCheckboxGroup = (options) => {
|
|
|
2157
2263
|
focused,
|
|
2158
2264
|
parentChecked,
|
|
2159
2265
|
checkedValues: () => selectedValues(children()),
|
|
2266
|
+
error: field.error,
|
|
2267
|
+
invalid: field.invalid,
|
|
2268
|
+
describedIds: field.describedIds,
|
|
2160
2269
|
click: (target) => {
|
|
2161
2270
|
activate(target);
|
|
2162
2271
|
},
|
|
@@ -2179,7 +2288,20 @@ const createCheckboxGroup = (options) => {
|
|
|
2179
2288
|
requestPacked(packed(normalizeChildren(next)));
|
|
2180
2289
|
},
|
|
2181
2290
|
groupProps: {
|
|
2291
|
+
id: field.controlProps.id,
|
|
2182
2292
|
role: "group",
|
|
2293
|
+
get "aria-label"() {
|
|
2294
|
+
return options.label;
|
|
2295
|
+
},
|
|
2296
|
+
get "aria-labelledby"() {
|
|
2297
|
+
return options.labelledby;
|
|
2298
|
+
},
|
|
2299
|
+
get "aria-describedby"() {
|
|
2300
|
+
return field.controlProps["aria-describedby"];
|
|
2301
|
+
},
|
|
2302
|
+
get "aria-invalid"() {
|
|
2303
|
+
return field.controlProps["aria-invalid"];
|
|
2304
|
+
},
|
|
2183
2305
|
onKeyDown: (event) => {
|
|
2184
2306
|
if (event.key === "Tab") {
|
|
2185
2307
|
event.preventDefault();
|
|
@@ -2190,6 +2312,7 @@ const createCheckboxGroup = (options) => {
|
|
|
2190
2312
|
}
|
|
2191
2313
|
}
|
|
2192
2314
|
},
|
|
2315
|
+
errorProps: field.errorProps,
|
|
2193
2316
|
parentProps: {
|
|
2194
2317
|
role: "checkbox",
|
|
2195
2318
|
get "aria-checked"() {
|
|
@@ -2584,72 +2707,472 @@ const createDialogModal = (options = {}) => {
|
|
|
2584
2707
|
}
|
|
2585
2708
|
};
|
|
2586
2709
|
};
|
|
2587
|
-
// src/
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
const
|
|
2596
|
-
|
|
2597
|
-
const
|
|
2598
|
-
|
|
2599
|
-
const
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
const
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2710
|
+
// src/hotkeys.ts
|
|
2711
|
+
const MODIFIERS = /* @__PURE__ */ new Set([
|
|
2712
|
+
"mod",
|
|
2713
|
+
"ctrl",
|
|
2714
|
+
"alt",
|
|
2715
|
+
"shift",
|
|
2716
|
+
"meta"
|
|
2717
|
+
]);
|
|
2718
|
+
const SHIFTED = /* @__PURE__ */ new Set("~!@#$%^&*()_+{}|:\"<>?");
|
|
2719
|
+
const detectsApple = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
|
|
2720
|
+
const parse = (keys, isApple) => {
|
|
2721
|
+
const parts = keys.toLowerCase().split("+");
|
|
2722
|
+
const key = parts.pop() ?? "";
|
|
2723
|
+
if (key === "" || MODIFIERS.has(key)) throw new TypeError(`hotkeys: "${keys}" ends with no key`);
|
|
2724
|
+
const chord = {
|
|
2725
|
+
ctrl: false,
|
|
2726
|
+
meta: false,
|
|
2727
|
+
alt: false,
|
|
2728
|
+
shift: SHIFTED.has(key),
|
|
2729
|
+
key
|
|
2730
|
+
};
|
|
2731
|
+
for (const part of parts) switch (part) {
|
|
2732
|
+
case "mod":
|
|
2733
|
+
if (isApple) chord.meta = true;
|
|
2734
|
+
else chord.ctrl = true;
|
|
2735
|
+
break;
|
|
2736
|
+
case "ctrl":
|
|
2737
|
+
chord.ctrl = true;
|
|
2738
|
+
break;
|
|
2739
|
+
case "meta":
|
|
2740
|
+
chord.meta = true;
|
|
2741
|
+
break;
|
|
2742
|
+
case "alt":
|
|
2743
|
+
chord.alt = true;
|
|
2744
|
+
break;
|
|
2745
|
+
case "shift":
|
|
2746
|
+
chord.shift = true;
|
|
2747
|
+
break;
|
|
2748
|
+
default: throw new TypeError(`hotkeys: "${part}" in "${keys}" is not a modifier`);
|
|
2620
2749
|
}
|
|
2750
|
+
return chord;
|
|
2621
2751
|
};
|
|
2622
|
-
|
|
2623
|
-
|
|
2752
|
+
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;
|
|
2753
|
+
const editable = (target) => target instanceof HTMLElement && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
|
|
2754
|
+
const inScope = (scope, target) => scope === void 0 || target instanceof Node && scope.contains(target);
|
|
2624
2755
|
/**
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2756
|
+
* One `keydown` listener in the capture phase, one list of chords. A chord
|
|
2757
|
+
* fires when its modifiers match exactly, its `when` holds, its scope contains
|
|
2758
|
+
* the event's target, and the focus is not in a text field (unless
|
|
2759
|
+
* `allowInEditable`). The most recently bound match is the only one that fires,
|
|
2760
|
+
* so a modal's `"/"` overrides the page's and unbinding it brings the page's
|
|
2761
|
+
* back; `preventDefault` is on by default for the bindings whose chord the
|
|
2762
|
+
* browser would otherwise act on (Cmd/Ctrl+S, Cmd/Ctrl+K).
|
|
2628
2763
|
*/
|
|
2629
|
-
const
|
|
2630
|
-
const
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2764
|
+
const createHotkeys = (options = {}) => {
|
|
2765
|
+
const isApple = options.isApple ?? detectsApple();
|
|
2766
|
+
const bindings = [];
|
|
2767
|
+
let listening = false;
|
|
2768
|
+
let disposed = false;
|
|
2769
|
+
const onKeyDown = (event) => {
|
|
2770
|
+
for (let i = bindings.length - 1; i >= 0; i--) {
|
|
2771
|
+
const entry = bindings[i];
|
|
2772
|
+
if (entry === void 0) continue;
|
|
2773
|
+
const { chord, binding } = entry;
|
|
2774
|
+
if (!matches(chord, event) || binding.when?.() === false) continue;
|
|
2775
|
+
if (!inScope(binding.scope, event.target)) continue;
|
|
2776
|
+
if (binding.allowInEditable !== true && editable(event.target)) continue;
|
|
2777
|
+
if (binding.preventDefault !== false) event.preventDefault();
|
|
2778
|
+
binding.onTrigger(event);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
const stop = () => {
|
|
2783
|
+
if (!listening) return;
|
|
2784
|
+
listening = false;
|
|
2785
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
2786
|
+
};
|
|
2787
|
+
return {
|
|
2788
|
+
bind: (binding) => {
|
|
2789
|
+
if (disposed) return () => {};
|
|
2790
|
+
const entry = {
|
|
2791
|
+
chord: parse(binding.keys, isApple),
|
|
2792
|
+
binding
|
|
2793
|
+
};
|
|
2794
|
+
bindings.push(entry);
|
|
2795
|
+
if (!listening) {
|
|
2796
|
+
listening = true;
|
|
2797
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
2798
|
+
}
|
|
2799
|
+
return () => {
|
|
2800
|
+
const at = bindings.indexOf(entry);
|
|
2801
|
+
if (at === -1) return;
|
|
2802
|
+
bindings.splice(at, 1);
|
|
2803
|
+
if (bindings.length === 0) stop();
|
|
2804
|
+
};
|
|
2805
|
+
},
|
|
2806
|
+
dispose: () => {
|
|
2807
|
+
disposed = true;
|
|
2808
|
+
bindings.length = 0;
|
|
2809
|
+
stop();
|
|
2810
|
+
}
|
|
2640
2811
|
};
|
|
2641
2812
|
};
|
|
2642
|
-
|
|
2813
|
+
// src/status.ts
|
|
2643
2814
|
/**
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2815
|
+
* APG Alert's polite sibling: one `role=status` live region whose text the host
|
|
2816
|
+
* replaces ("Link copied", "Saved"). It is not a queue: a second message shows
|
|
2817
|
+
* in place of the first, so a burst reads as the last state, not as N alerts.
|
|
2818
|
+
* The region is rendered once and stays mounted, which is what makes a change
|
|
2819
|
+
* of its text announced.
|
|
2820
|
+
*
|
|
2821
|
+
* A status is never for errors: an error that needs attention now is
|
|
2822
|
+
* `createAlert` (`role=alert`, assertive), and a message with a title, a
|
|
2823
|
+
* description and a timeout is `createToast`.
|
|
2824
|
+
*/
|
|
2825
|
+
const createStatus = (options = {}) => {
|
|
2826
|
+
const id = options.id ?? createUniqueId();
|
|
2827
|
+
const [message, setMessage] = createSignal();
|
|
2828
|
+
return {
|
|
2829
|
+
message,
|
|
2830
|
+
show: (text) => {
|
|
2831
|
+
setMessage(text);
|
|
2832
|
+
},
|
|
2833
|
+
clear: () => {
|
|
2834
|
+
setMessage(void 0);
|
|
2835
|
+
},
|
|
2836
|
+
statusProps: {
|
|
2837
|
+
id,
|
|
2838
|
+
role: "status",
|
|
2839
|
+
"aria-live": "polite",
|
|
2840
|
+
"aria-atomic": "true"
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
};
|
|
2844
|
+
// src/textbox.ts
|
|
2845
|
+
const control = (field, options) => ({
|
|
2846
|
+
...field.controlProps,
|
|
2847
|
+
get type() {
|
|
2848
|
+
return options.type ?? "text";
|
|
2849
|
+
},
|
|
2850
|
+
get autoComplete() {
|
|
2851
|
+
return options.autoComplete;
|
|
2852
|
+
},
|
|
2853
|
+
get spellcheck() {
|
|
2854
|
+
if (options.spellCheck === void 0) return;
|
|
2855
|
+
return options.spellCheck ? "true" : "false";
|
|
2856
|
+
},
|
|
2857
|
+
get autocapitalize() {
|
|
2858
|
+
return options.autoCapitalize;
|
|
2859
|
+
},
|
|
2860
|
+
get inputmode() {
|
|
2861
|
+
return options.inputMode;
|
|
2862
|
+
},
|
|
2863
|
+
get maxlength() {
|
|
2864
|
+
return options.maxLength;
|
|
2865
|
+
},
|
|
2866
|
+
get readOnly() {
|
|
2867
|
+
return options.readOnly === true ? true : void 0;
|
|
2868
|
+
},
|
|
2869
|
+
get disabled() {
|
|
2870
|
+
return options.disabled === true ? true : void 0;
|
|
2871
|
+
},
|
|
2872
|
+
get name() {
|
|
2873
|
+
return options.name;
|
|
2874
|
+
},
|
|
2875
|
+
get dir() {
|
|
2876
|
+
return options.dir;
|
|
2877
|
+
},
|
|
2878
|
+
get placeholder() {
|
|
2879
|
+
return options.placeholder;
|
|
2880
|
+
},
|
|
2881
|
+
onInput: (event) => {
|
|
2882
|
+
options.onValueChange?.(event.currentTarget.value);
|
|
2883
|
+
}
|
|
2884
|
+
});
|
|
2885
|
+
/**
|
|
2886
|
+
* A text input with the field contract attached: label, hint, error (announced),
|
|
2887
|
+
* `aria-describedby` and `aria-invalid` come from `createField`; this adds the
|
|
2888
|
+
* props a host forgets — autofill tokens, spellcheck, inputmode, maxlength, the
|
|
2889
|
+
* disabled/readOnly pair. Platform: native (`input` is its own APG-equivalent;
|
|
2890
|
+
* no primitive re-implements text editing, which is why there is no key
|
|
2891
|
+
* handling here).
|
|
2892
|
+
*
|
|
2893
|
+
* Both `required` and `aria-required` are emitted: the native attribute is what
|
|
2894
|
+
* the browser enforces, the ARIA one is what a custom widget reading the same
|
|
2895
|
+
* props needs. A host that validates itself passes `noValidate` on the form
|
|
2896
|
+
* rather than dropping the attribute.
|
|
2897
|
+
*/
|
|
2898
|
+
const createTextInput = (options = {}) => {
|
|
2899
|
+
const field = createField(options);
|
|
2900
|
+
return {
|
|
2901
|
+
...field,
|
|
2902
|
+
controlProps: control(field, options)
|
|
2903
|
+
};
|
|
2904
|
+
};
|
|
2905
|
+
/**
|
|
2906
|
+
* The same contract for a textarea, plus rows, wrap and resize. `resize` is a
|
|
2907
|
+
* style because CSS has no attribute for it; `none` is the value a layout that
|
|
2908
|
+
* owns its own height wants.
|
|
2909
|
+
*/
|
|
2910
|
+
const createTextArea = (options = {}) => {
|
|
2911
|
+
const field = createField(options);
|
|
2912
|
+
return {
|
|
2913
|
+
...field,
|
|
2914
|
+
controlProps: {
|
|
2915
|
+
...control(field, options),
|
|
2916
|
+
get rows() {
|
|
2917
|
+
return options.rows;
|
|
2918
|
+
},
|
|
2919
|
+
get wrap() {
|
|
2920
|
+
return options.wrap;
|
|
2921
|
+
},
|
|
2922
|
+
get style() {
|
|
2923
|
+
return options.resize === void 0 ? void 0 : { resize: options.resize };
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
};
|
|
2927
|
+
};
|
|
2928
|
+
// src/command-palette.ts
|
|
2929
|
+
const countLabel = (count) => count === 0 ? "No commands" : count === 1 ? "1 command" : `${count} commands`;
|
|
2930
|
+
/**
|
|
2931
|
+
* A command palette: `createDialogModal` (a modal dialog, announced as one),
|
|
2932
|
+
* `createTextInput` (a `searchbox`, not a combobox — choosing runs a command,
|
|
2933
|
+
* it does not complete the field) and one filtered list. Focus stays in the
|
|
2934
|
+
* field; `aria-activedescendant` names the highlighted option, and ↓ on the
|
|
2935
|
+
* last / ↑ on the first return the highlight to the field. Home/End are menu
|
|
2936
|
+
* keys only while the field is empty, so the caret keeps them while typing.
|
|
2937
|
+
* Filtering is a plain case-insensitive substring match on the label and the
|
|
2938
|
+
* keywords, no fuzzy scoring: a palette is short and stable, and ranking
|
|
2939
|
+
* (recency, scopes) is a policy the host owns.
|
|
2940
|
+
*
|
|
2941
|
+
* The host's: rendering and grouping (the `group` field, headings, the CSS),
|
|
2942
|
+
* async commands (a rejected `run` is the host's to report), and any close
|
|
2943
|
+
* other than Escape / a command. `runActive` closes before it runs.
|
|
2944
|
+
*/
|
|
2945
|
+
const createCommandPalette = (options) => {
|
|
2946
|
+
const corpus = options.commands;
|
|
2947
|
+
const base = createUniqueId();
|
|
2948
|
+
const titleId = `${base}-title`;
|
|
2949
|
+
const listId = `${base}-list`;
|
|
2950
|
+
const optionId = (id) => `${base}-option-${id}`;
|
|
2951
|
+
const labels = {
|
|
2952
|
+
title: options.labels?.title ?? "Command palette",
|
|
2953
|
+
placeholder: options.labels?.placeholder ?? "Type a command",
|
|
2954
|
+
empty: options.labels?.empty ?? "No commands found",
|
|
2955
|
+
results: options.labels?.results ?? countLabel
|
|
2956
|
+
};
|
|
2957
|
+
const filter = (text) => {
|
|
2958
|
+
const needle = text.toLowerCase();
|
|
2959
|
+
return corpus.filter((command) => command.label.toLowerCase().includes(needle) || (command.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(needle))).map(({ id, label, group }) => ({
|
|
2960
|
+
id,
|
|
2961
|
+
label,
|
|
2962
|
+
group
|
|
2963
|
+
}));
|
|
2964
|
+
};
|
|
2965
|
+
const [query, setQueryRaw] = createSignal("");
|
|
2966
|
+
const [active, setActiveRaw] = createSignal(0);
|
|
2967
|
+
const results = () => filter(query());
|
|
2968
|
+
const dialog = createDialogModal({
|
|
2969
|
+
id: base,
|
|
2970
|
+
role: "dialog",
|
|
2971
|
+
labelledby: titleId,
|
|
2972
|
+
value: options.open,
|
|
2973
|
+
onOpenChange: (open) => options.onOpenChange?.(open)
|
|
2974
|
+
});
|
|
2975
|
+
const status = createStatus();
|
|
2976
|
+
const setQuery = (text) => {
|
|
2977
|
+
setQueryRaw(text);
|
|
2978
|
+
setActiveRaw(filter(text).length > 0 ? 0 : -1);
|
|
2979
|
+
};
|
|
2980
|
+
const run = (id) => {
|
|
2981
|
+
const chosen = corpus.find((command) => command.id === id);
|
|
2982
|
+
if (chosen === void 0) return;
|
|
2983
|
+
dialog.close();
|
|
2984
|
+
return chosen.run();
|
|
2985
|
+
};
|
|
2986
|
+
const runActive = () => {
|
|
2987
|
+
const current = results()[active()];
|
|
2988
|
+
return current === void 0 ? void 0 : run(current.id);
|
|
2989
|
+
};
|
|
2990
|
+
const onKeyDown = (event) => {
|
|
2991
|
+
if (event.isComposing) return;
|
|
2992
|
+
const last = results().length - 1;
|
|
2993
|
+
switch (event.key) {
|
|
2994
|
+
case "ArrowDown":
|
|
2995
|
+
event.preventDefault();
|
|
2996
|
+
setActiveRaw(active() < last ? active() + 1 : -1);
|
|
2997
|
+
return;
|
|
2998
|
+
case "ArrowUp":
|
|
2999
|
+
event.preventDefault();
|
|
3000
|
+
setActiveRaw(active() > 0 ? active() - 1 : last);
|
|
3001
|
+
return;
|
|
3002
|
+
case "Home":
|
|
3003
|
+
case "End":
|
|
3004
|
+
if (query() !== "") return;
|
|
3005
|
+
event.preventDefault();
|
|
3006
|
+
setActiveRaw(event.key === "Home" ? last >= 0 ? 0 : -1 : last);
|
|
3007
|
+
return;
|
|
3008
|
+
case "Enter":
|
|
3009
|
+
event.preventDefault();
|
|
3010
|
+
runActive();
|
|
3011
|
+
return;
|
|
3012
|
+
default: return;
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
let returnFocus = null;
|
|
3016
|
+
createEffect(() => dialog.open(), (isOpen) => {
|
|
3017
|
+
if (isOpen) {
|
|
3018
|
+
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
3019
|
+
document.getElementById(`${base}-control`)?.focus();
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
setQueryRaw("");
|
|
3023
|
+
setActiveRaw(0);
|
|
3024
|
+
const back = returnFocus;
|
|
3025
|
+
returnFocus = null;
|
|
3026
|
+
if (back !== null && back.isConnected) back.focus();
|
|
3027
|
+
});
|
|
3028
|
+
createEffect(() => [dialog.open(), results().length], ([isOpen, count]) => {
|
|
3029
|
+
if (isOpen) status.show(labels.results(count));
|
|
3030
|
+
else status.clear();
|
|
3031
|
+
});
|
|
3032
|
+
const input = createTextInput({
|
|
3033
|
+
id: base,
|
|
3034
|
+
type: "text",
|
|
3035
|
+
autoComplete: "off",
|
|
3036
|
+
spellCheck: false,
|
|
3037
|
+
placeholder: labels.placeholder,
|
|
3038
|
+
onValueChange: setQuery
|
|
3039
|
+
});
|
|
3040
|
+
return {
|
|
3041
|
+
open: dialog.open,
|
|
3042
|
+
openPalette: dialog.openDialog,
|
|
3043
|
+
closePalette: dialog.close,
|
|
3044
|
+
query,
|
|
3045
|
+
setQuery,
|
|
3046
|
+
active,
|
|
3047
|
+
setActive: (index) => setActiveRaw(Math.max(-1, Math.min(index, results().length - 1))),
|
|
3048
|
+
results,
|
|
3049
|
+
labels,
|
|
3050
|
+
status: status.message,
|
|
3051
|
+
inputProps: {
|
|
3052
|
+
...input.controlProps,
|
|
3053
|
+
role: "searchbox",
|
|
3054
|
+
get value() {
|
|
3055
|
+
return query();
|
|
3056
|
+
},
|
|
3057
|
+
"aria-controls": listId,
|
|
3058
|
+
get "aria-activedescendant"() {
|
|
3059
|
+
const current = results()[active()];
|
|
3060
|
+
return current === void 0 ? void 0 : optionId(current.id);
|
|
3061
|
+
},
|
|
3062
|
+
onKeyDown
|
|
3063
|
+
},
|
|
3064
|
+
listProps: {
|
|
3065
|
+
id: listId,
|
|
3066
|
+
role: "listbox",
|
|
3067
|
+
"aria-labelledby": titleId,
|
|
3068
|
+
onMouseDown: (event) => event.preventDefault()
|
|
3069
|
+
},
|
|
3070
|
+
optionProps: (id) => ({
|
|
3071
|
+
id: optionId(id),
|
|
3072
|
+
role: "option",
|
|
3073
|
+
get "aria-selected"() {
|
|
3074
|
+
return results()[active()]?.id === id ? "true" : "false";
|
|
3075
|
+
},
|
|
3076
|
+
tabindex: -1,
|
|
3077
|
+
onClick: () => {
|
|
3078
|
+
if (results().some((result) => result.id === id)) run(id);
|
|
3079
|
+
},
|
|
3080
|
+
onMouseEnter: () => {
|
|
3081
|
+
const index = results().findIndex((result) => result.id === id);
|
|
3082
|
+
if (index >= 0) setActiveRaw(index);
|
|
3083
|
+
}
|
|
3084
|
+
}),
|
|
3085
|
+
emptyProps: { get hidden() {
|
|
3086
|
+
return results().length > 0;
|
|
3087
|
+
} },
|
|
3088
|
+
statusProps: status.statusProps,
|
|
3089
|
+
dialogProps: dialog.dialogProps,
|
|
3090
|
+
backdropProps: dialog.backdropProps,
|
|
3091
|
+
titleProps: { id: titleId },
|
|
3092
|
+
runActive,
|
|
3093
|
+
attach: () => {
|
|
3094
|
+
const hotkeys = createHotkeys();
|
|
3095
|
+
const unbind = hotkeys.bind({
|
|
3096
|
+
keys: options.hotkey ?? "mod+k",
|
|
3097
|
+
allowInEditable: true,
|
|
3098
|
+
onTrigger: () => {
|
|
3099
|
+
if (dialog.open()) dialog.close();
|
|
3100
|
+
else dialog.openDialog();
|
|
3101
|
+
}
|
|
3102
|
+
});
|
|
3103
|
+
return () => {
|
|
3104
|
+
unbind();
|
|
3105
|
+
hotkeys.dispose();
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
};
|
|
3110
|
+
// src/overlay-shell.ts
|
|
3111
|
+
/**
|
|
3112
|
+
* The page-wide half of a modal overlay: one scroll lock, one stack of open
|
|
3113
|
+
* overlays, the tabbables of a panel and the Tab cycle over them. Shared by
|
|
3114
|
+
* `createConfirm` and `createDrawer` so that a confirm opened over a drawer (or
|
|
3115
|
+
* the reverse) takes Escape and Tab first, and the scroll lock outlives the
|
|
3116
|
+
* first of them to close.
|
|
3117
|
+
*/
|
|
3118
|
+
const TABBABLE = "a[href], button, input, select, textarea, [tabindex], [contenteditable]";
|
|
3119
|
+
/** The visible, tabbable elements of `root`, in document order. */
|
|
3120
|
+
const tabbables = (root) => Array.from(root.querySelectorAll(TABBABLE)).filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && element.getClientRects().length > 0);
|
|
3121
|
+
/** The first tabbable element of `root`, if any. */
|
|
3122
|
+
const firstFocusable$1 = (root) => tabbables(root)[0];
|
|
3123
|
+
/**
|
|
3124
|
+
* Wraps Tab / Shift+Tab inside `root` and pulls a focus that escaped back in
|
|
3125
|
+
* on the next Tab. With nothing tabbable the panel itself (which is
|
|
3126
|
+
* `tabindex="-1"`) keeps the focus.
|
|
3127
|
+
*/
|
|
3128
|
+
const cycleFocus = (event, root) => {
|
|
3129
|
+
const items = tabbables(root);
|
|
3130
|
+
const first = items[0];
|
|
3131
|
+
const last = items.at(-1);
|
|
3132
|
+
const active = document.activeElement;
|
|
3133
|
+
const inside = root.contains(active);
|
|
3134
|
+
if (first === void 0 || last === void 0) {
|
|
3135
|
+
event.preventDefault();
|
|
3136
|
+
root.focus();
|
|
3137
|
+
} else if (event.shiftKey && (!inside || active === first || active === root)) {
|
|
3138
|
+
event.preventDefault();
|
|
3139
|
+
last.focus();
|
|
3140
|
+
} else if (!event.shiftKey && (!inside || active === last)) {
|
|
3141
|
+
event.preventDefault();
|
|
3142
|
+
first.focus();
|
|
3143
|
+
}
|
|
3144
|
+
};
|
|
3145
|
+
let scrollLocks = 0;
|
|
3146
|
+
let scrollBefore = "";
|
|
3147
|
+
/**
|
|
3148
|
+
* `overflow: hidden` on the document element, counted: the first holder takes
|
|
3149
|
+
* it, the last to release restores what `overflow` was. The returned release is
|
|
3150
|
+
* idempotent.
|
|
3151
|
+
*/
|
|
3152
|
+
const lockScroll = () => {
|
|
3153
|
+
const root = document.documentElement;
|
|
3154
|
+
if (scrollLocks++ === 0) {
|
|
3155
|
+
scrollBefore = root.style.overflow;
|
|
3156
|
+
root.style.overflow = "hidden";
|
|
3157
|
+
}
|
|
3158
|
+
let released = false;
|
|
3159
|
+
return () => {
|
|
3160
|
+
if (released) return;
|
|
3161
|
+
released = true;
|
|
3162
|
+
if (--scrollLocks === 0) root.style.overflow = scrollBefore;
|
|
3163
|
+
};
|
|
3164
|
+
};
|
|
3165
|
+
const openOverlays = [];
|
|
3166
|
+
/**
|
|
3167
|
+
* Joins the open overlays; the last one to claim takes Escape and Tab. Returns
|
|
3168
|
+
* the release (idempotent), which may be called in any order.
|
|
3169
|
+
*/
|
|
3170
|
+
const claimOverlay = (self) => {
|
|
3171
|
+
openOverlays.push(self);
|
|
3172
|
+
let claimed = true;
|
|
3173
|
+
return () => {
|
|
3174
|
+
if (!claimed) return;
|
|
3175
|
+
claimed = false;
|
|
2653
3176
|
const at = openOverlays.indexOf(self);
|
|
2654
3177
|
if (at !== -1) openOverlays.splice(at, 1);
|
|
2655
3178
|
};
|
|
@@ -2939,6 +3462,165 @@ const createDrawer = (options = {}) => {
|
|
|
2939
3462
|
handleProps: { "aria-hidden": "true" }
|
|
2940
3463
|
};
|
|
2941
3464
|
};
|
|
3465
|
+
// src/dropzone.ts
|
|
3466
|
+
const REASON_TEXT = {
|
|
3467
|
+
type: "unsupported type",
|
|
3468
|
+
size: "too large"
|
|
3469
|
+
};
|
|
3470
|
+
const tokensOf = (accept) => (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
3471
|
+
/** The HTML `accept` grammar: a token starting with "." is an extension, else a MIME type, `*` allowed. */
|
|
3472
|
+
const accepts = (accept, file) => {
|
|
3473
|
+
const tokens = tokensOf(accept);
|
|
3474
|
+
if (tokens.length === 0) return true;
|
|
3475
|
+
const name = file.name.toLowerCase();
|
|
3476
|
+
const [fileType = "", fileSubtype = ""] = file.type.toLowerCase().split("/");
|
|
3477
|
+
return tokens.some((token) => {
|
|
3478
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
3479
|
+
const [type = "", subtype = ""] = token.split("/");
|
|
3480
|
+
return type === fileType && (subtype === "*" || subtype === fileSubtype);
|
|
3481
|
+
});
|
|
3482
|
+
};
|
|
3483
|
+
const refusal = (options, file) => {
|
|
3484
|
+
if (!accepts(options.accept, file)) return "type";
|
|
3485
|
+
if (options.maxSize !== void 0 && file.size > options.maxSize) return "size";
|
|
3486
|
+
};
|
|
3487
|
+
const messageOf = (batch) => {
|
|
3488
|
+
if (batch === void 0) return;
|
|
3489
|
+
const files = batch.total === 1 ? "1 file" : `${batch.total} files`;
|
|
3490
|
+
if (batch.rejected.length === 0) return files;
|
|
3491
|
+
const reasons = [...new Set(batch.rejected)].map((reason) => REASON_TEXT[reason]).join(", ");
|
|
3492
|
+
return `${files}, ${batch.rejected.length} rejected: ${reasons}`;
|
|
3493
|
+
};
|
|
3494
|
+
/**
|
|
3495
|
+
* A file drop zone. There is no APG pattern and no Quint spec for one: the
|
|
3496
|
+
* contract is HTML's file input plus WAI-ARIA's live region, and
|
|
3497
|
+
* `test/dropzone.browser.test.tsx` is its oracle.
|
|
3498
|
+
*
|
|
3499
|
+
* Decisions:
|
|
3500
|
+
* - **The zone is never drag-only.** `browseProps` spreads onto a real
|
|
3501
|
+
* `<button type="button">` that clicks the hidden input, so the picker is the
|
|
3502
|
+
* keyboard path (Enter and Space are the platform's) and a pointer user can
|
|
3503
|
+
* click as well as drag. The drop handlers add nothing the button cannot do.
|
|
3504
|
+
* - **Validation happens once per file**, in the order type then size, and a
|
|
3505
|
+
* refused file reaches `onReject` with the reason the host shows. `accept` is
|
|
3506
|
+
* the HTML grammar (`.` extension, MIME type, `*`); `maxSize` is bytes.
|
|
3507
|
+
* - **Dropping a folder or a `DataTransfer` with no files is a no-op**: no
|
|
3508
|
+
* `onFiles`, no `onReject`, no message, and the previous announcement stays.
|
|
3509
|
+
* - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the
|
|
3510
|
+
* state is a depth counter: it clears only when the drag leaves the zone
|
|
3511
|
+
* itself, or on a drop.
|
|
3512
|
+
* - **The zone is `role=group`** so `labels.zone` is exposed; `dragging()` is
|
|
3513
|
+
* the only drag state a skin reads (no `data-dragging`).
|
|
3514
|
+
* - `disabled` marks the zone `aria-disabled`, disables the input natively and
|
|
3515
|
+
* the browse button, and drops every drag before it becomes a batch.
|
|
3516
|
+
*/
|
|
3517
|
+
const createDropZone = (options) => {
|
|
3518
|
+
const id = createUniqueId();
|
|
3519
|
+
const [depth, setDepth] = createSignal(0);
|
|
3520
|
+
const [batch, setBatch] = createSignal();
|
|
3521
|
+
const disabled = () => options.disabled === true;
|
|
3522
|
+
const deliver = (files) => {
|
|
3523
|
+
if (disabled() || files.length === 0) return;
|
|
3524
|
+
const accepted = [];
|
|
3525
|
+
const rejected = [];
|
|
3526
|
+
for (const file of files) {
|
|
3527
|
+
const reason = refusal(options, file);
|
|
3528
|
+
if (reason === void 0) accepted.push(file);
|
|
3529
|
+
else {
|
|
3530
|
+
rejected.push(reason);
|
|
3531
|
+
options.onReject?.({
|
|
3532
|
+
file,
|
|
3533
|
+
reason
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
setBatch({
|
|
3538
|
+
total: files.length,
|
|
3539
|
+
rejected
|
|
3540
|
+
});
|
|
3541
|
+
if (accepted.length > 0) options.onFiles(accepted);
|
|
3542
|
+
};
|
|
3543
|
+
const dropped = (event) => {
|
|
3544
|
+
event.preventDefault();
|
|
3545
|
+
setDepth(0);
|
|
3546
|
+
if (disabled()) return;
|
|
3547
|
+
const transfer = event.dataTransfer;
|
|
3548
|
+
if (transfer === null) return;
|
|
3549
|
+
const items = Array.from(transfer.items).filter((item) => item.kind === "file");
|
|
3550
|
+
const files = Array.from(transfer.files);
|
|
3551
|
+
if (files.length === 0) return;
|
|
3552
|
+
if (items.length > 0 && items.every((item) => item.webkitGetAsEntry?.()?.isDirectory === true)) return;
|
|
3553
|
+
deliver(files);
|
|
3554
|
+
};
|
|
3555
|
+
const picked = (event) => {
|
|
3556
|
+
const input = event.currentTarget;
|
|
3557
|
+
const files = Array.from(input.files ?? []);
|
|
3558
|
+
input.value = "";
|
|
3559
|
+
deliver(files);
|
|
3560
|
+
};
|
|
3561
|
+
const browse = () => {
|
|
3562
|
+
if (disabled()) return;
|
|
3563
|
+
document.getElementById(id)?.click();
|
|
3564
|
+
};
|
|
3565
|
+
return {
|
|
3566
|
+
dragging: () => depth() > 0,
|
|
3567
|
+
message: () => messageOf(batch()),
|
|
3568
|
+
inputProps: {
|
|
3569
|
+
id,
|
|
3570
|
+
type: "file",
|
|
3571
|
+
get accept() {
|
|
3572
|
+
return options.accept;
|
|
3573
|
+
},
|
|
3574
|
+
get multiple() {
|
|
3575
|
+
return options.multiple === true ? true : void 0;
|
|
3576
|
+
},
|
|
3577
|
+
get disabled() {
|
|
3578
|
+
return disabled() ? true : void 0;
|
|
3579
|
+
},
|
|
3580
|
+
hidden: true,
|
|
3581
|
+
onChange: picked
|
|
3582
|
+
},
|
|
3583
|
+
zoneProps: {
|
|
3584
|
+
role: "group",
|
|
3585
|
+
get "aria-label"() {
|
|
3586
|
+
return options.labels?.zone;
|
|
3587
|
+
},
|
|
3588
|
+
get "aria-disabled"() {
|
|
3589
|
+
return disabled() ? "true" : void 0;
|
|
3590
|
+
},
|
|
3591
|
+
onDragEnter: (event) => {
|
|
3592
|
+
event.preventDefault();
|
|
3593
|
+
if (!disabled()) setDepth(depth() + 1);
|
|
3594
|
+
},
|
|
3595
|
+
onDragOver: (event) => {
|
|
3596
|
+
event.preventDefault();
|
|
3597
|
+
},
|
|
3598
|
+
onDragLeave: (event) => {
|
|
3599
|
+
event.preventDefault();
|
|
3600
|
+
if (!disabled()) setDepth(Math.max(0, depth() - 1));
|
|
3601
|
+
},
|
|
3602
|
+
onDrop: dropped
|
|
3603
|
+
},
|
|
3604
|
+
browseProps: {
|
|
3605
|
+
type: "button",
|
|
3606
|
+
get disabled() {
|
|
3607
|
+
return disabled() ? true : void 0;
|
|
3608
|
+
},
|
|
3609
|
+
get "aria-disabled"() {
|
|
3610
|
+
return disabled() ? "true" : void 0;
|
|
3611
|
+
},
|
|
3612
|
+
get "aria-label"() {
|
|
3613
|
+
return options.labels?.browse;
|
|
3614
|
+
},
|
|
3615
|
+
onClick: browse
|
|
3616
|
+
},
|
|
3617
|
+
statusProps: {
|
|
3618
|
+
role: "status",
|
|
3619
|
+
"aria-live": "polite",
|
|
3620
|
+
"aria-atomic": "true"
|
|
3621
|
+
}
|
|
3622
|
+
};
|
|
3623
|
+
};
|
|
2942
3624
|
// src/feed.ts
|
|
2943
3625
|
/**
|
|
2944
3626
|
* APG Feed (https://www.w3.org/WAI/ARIA/apg/patterns/feed/): state plus prop
|
|
@@ -3013,148 +3695,64 @@ const createFeed = (options = {}) => {
|
|
|
3013
3695
|
}
|
|
3014
3696
|
};
|
|
3015
3697
|
return {
|
|
3016
|
-
items,
|
|
3017
|
-
loaded,
|
|
3018
|
-
busy,
|
|
3019
|
-
active,
|
|
3020
|
-
focusArticle,
|
|
3021
|
-
pageDown,
|
|
3022
|
-
pageUp,
|
|
3023
|
-
ctrlEnd: () => setActive("after"),
|
|
3024
|
-
ctrlHome: () => setActive("before"),
|
|
3025
|
-
loadStart: startLoad,
|
|
3026
|
-
loadFinish: (count) => {
|
|
3027
|
-
if (phase() !== "loading") return;
|
|
3028
|
-
const all = total();
|
|
3029
|
-
const remaining = all === null ? count : Math.max(0, all - loaded());
|
|
3030
|
-
const next = loaded() + Math.min(count, all === null ? count : remaining);
|
|
3031
|
-
requestLoaded(next);
|
|
3032
|
-
setPhase("idle");
|
|
3033
|
-
},
|
|
3034
|
-
loadFail: () => {
|
|
3035
|
-
if (phase() === "loading") setPhase("idle");
|
|
3036
|
-
},
|
|
3037
|
-
setSizeMode: (mode) => setSizeMode(mode),
|
|
3038
|
-
setTotalKnown: (known) => setTotalKnown(known),
|
|
3039
|
-
feedProps: {
|
|
3040
|
-
id: base,
|
|
3041
|
-
role: "feed",
|
|
3042
|
-
get "aria-busy"() {
|
|
3043
|
-
return busy() ? "true" : void 0;
|
|
3044
|
-
},
|
|
3045
|
-
"aria-labelledby": options.labelledBy
|
|
3046
|
-
},
|
|
3047
|
-
articleProps: (posinset) => ({
|
|
3048
|
-
id: `${base}-article-${posinset}`,
|
|
3049
|
-
role: "article",
|
|
3050
|
-
"aria-posinset": posinset,
|
|
3051
|
-
get "aria-setsize"() {
|
|
3052
|
-
if (sizeMode() === "loaded") return loaded();
|
|
3053
|
-
const all = total();
|
|
3054
|
-
return totalKnown() && all !== null ? all : -1;
|
|
3055
|
-
},
|
|
3056
|
-
"aria-labelledby": `${base}-article-${posinset}-title`,
|
|
3057
|
-
"aria-describedby": `${base}-article-${posinset}-content`,
|
|
3058
|
-
tabindex: 0,
|
|
3059
|
-
get hidden() {
|
|
3060
|
-
return posinset > loaded();
|
|
3061
|
-
},
|
|
3062
|
-
onFocus: () => focusArticle(posinset),
|
|
3063
|
-
onKeyDown
|
|
3064
|
-
}),
|
|
3065
|
-
beforeProps: {
|
|
3066
|
-
id: `${base}-before`,
|
|
3067
|
-
tabindex: 0,
|
|
3068
|
-
onFocus: () => setActive("before")
|
|
3069
|
-
},
|
|
3070
|
-
afterProps: {
|
|
3071
|
-
id: `${base}-after`,
|
|
3072
|
-
tabindex: 0,
|
|
3073
|
-
onFocus: () => setActive("after")
|
|
3074
|
-
}
|
|
3075
|
-
};
|
|
3076
|
-
};
|
|
3077
|
-
// src/field.ts
|
|
3078
|
-
const text = (value) => value === void 0 || value === "" ? void 0 : value;
|
|
3079
|
-
const external = (value) => (value === void 0 ? [] : typeof value === "string" ? [value] : value).filter((id) => id !== "");
|
|
3080
|
-
/**
|
|
3081
|
-
* The ARIA contract of a form field: a label, a control, a hint and an error.
|
|
3082
|
-
* There is no APG pattern for it (APG has no "form field"; the nearest is the
|
|
3083
|
-
* label and description rules of the accessible name computation), so the
|
|
3084
|
-
* contract comes from WAI-ARIA 1.2 (`aria-describedby`, `aria-invalid`,
|
|
3085
|
-
* `aria-required`) and HTML (`label[for]`, `required`), and from the
|
|
3086
|
-
* majority of Base UI Field, react-aria TextField and ariakit FormControl,
|
|
3087
|
-
* which list the description and the error message together. It is not checked
|
|
3088
|
-
* against a Quint spec; `test/field.browser.test.tsx` is its oracle.
|
|
3089
|
-
*
|
|
3090
|
-
* Decisions: the error does not replace the hint, both are listed, hint first
|
|
3091
|
-
* (the hint is what tells the user how to fix the error); an error implies
|
|
3092
|
-
* invalid and `invalid: false` overrides it; a hidden message is never
|
|
3093
|
-
* referenced, so `aria-describedby` has no dangling id. The error element is
|
|
3094
|
-
* `role="alert"` only while it is shown (a hidden element announces nothing, and
|
|
3095
|
-
* one that appears is announced); the hint is never an alert.
|
|
3096
|
-
*
|
|
3097
|
-
* `describedby` lists host-owned ids after the field's own: one refusal that
|
|
3098
|
-
* belongs to two controls is one form-level element (with its own `role`), named
|
|
3099
|
-
* by both. The field cannot know whether that element is shown: the host
|
|
3100
|
-
* passes the id only while it is.
|
|
3101
|
-
*
|
|
3102
|
-
* Options are read when an attribute is read: pass getters, never destructure.
|
|
3103
|
-
*/
|
|
3104
|
-
const createField = (options = {}) => {
|
|
3105
|
-
const base = options.id ?? createUniqueId();
|
|
3106
|
-
const controlId = `${base}-control`;
|
|
3107
|
-
const labelId = `${base}-label`;
|
|
3108
|
-
const hintId = `${base}-hint`;
|
|
3109
|
-
const errorId = `${base}-error`;
|
|
3110
|
-
const hint = () => text(options.hint);
|
|
3111
|
-
const message = () => text(options.error) ?? (typeof options.invalid === "string" ? text(options.invalid) : void 0);
|
|
3112
|
-
const invalid = () => options.invalid === false ? false : options.invalid === true || message() !== void 0;
|
|
3113
|
-
const error = () => invalid() ? message() : void 0;
|
|
3114
|
-
const describedIds = () => [
|
|
3115
|
-
...hint() === void 0 ? [] : [hintId],
|
|
3116
|
-
...error() === void 0 ? [] : [errorId],
|
|
3117
|
-
...external(options.describedby)
|
|
3118
|
-
];
|
|
3119
|
-
return {
|
|
3120
|
-
invalid,
|
|
3121
|
-
hint,
|
|
3122
|
-
error,
|
|
3123
|
-
describedIds,
|
|
3124
|
-
controlProps: {
|
|
3125
|
-
id: controlId,
|
|
3126
|
-
get "aria-describedby"() {
|
|
3127
|
-
const ids = describedIds();
|
|
3128
|
-
return ids.length === 0 ? void 0 : ids.join(" ");
|
|
3129
|
-
},
|
|
3130
|
-
get "aria-invalid"() {
|
|
3131
|
-
return invalid() ? "true" : void 0;
|
|
3132
|
-
},
|
|
3133
|
-
get "aria-required"() {
|
|
3134
|
-
return options.required === true ? "true" : void 0;
|
|
3135
|
-
},
|
|
3136
|
-
get required() {
|
|
3137
|
-
return options.required === true ? true : void 0;
|
|
3138
|
-
}
|
|
3698
|
+
items,
|
|
3699
|
+
loaded,
|
|
3700
|
+
busy,
|
|
3701
|
+
active,
|
|
3702
|
+
focusArticle,
|
|
3703
|
+
pageDown,
|
|
3704
|
+
pageUp,
|
|
3705
|
+
ctrlEnd: () => setActive("after"),
|
|
3706
|
+
ctrlHome: () => setActive("before"),
|
|
3707
|
+
loadStart: startLoad,
|
|
3708
|
+
loadFinish: (count) => {
|
|
3709
|
+
if (phase() !== "loading") return;
|
|
3710
|
+
const all = total();
|
|
3711
|
+
const remaining = all === null ? count : Math.max(0, all - loaded());
|
|
3712
|
+
const next = loaded() + Math.min(count, all === null ? count : remaining);
|
|
3713
|
+
requestLoaded(next);
|
|
3714
|
+
setPhase("idle");
|
|
3139
3715
|
},
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
for: controlId
|
|
3716
|
+
loadFail: () => {
|
|
3717
|
+
if (phase() === "loading") setPhase("idle");
|
|
3143
3718
|
},
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3719
|
+
setSizeMode: (mode) => setSizeMode(mode),
|
|
3720
|
+
setTotalKnown: (known) => setTotalKnown(known),
|
|
3721
|
+
feedProps: {
|
|
3722
|
+
id: base,
|
|
3723
|
+
role: "feed",
|
|
3724
|
+
get "aria-busy"() {
|
|
3725
|
+
return busy() ? "true" : void 0;
|
|
3726
|
+
},
|
|
3727
|
+
"aria-labelledby": options.labelledBy
|
|
3149
3728
|
},
|
|
3150
|
-
|
|
3151
|
-
id:
|
|
3729
|
+
articleProps: (posinset) => ({
|
|
3730
|
+
id: `${base}-article-${posinset}`,
|
|
3731
|
+
role: "article",
|
|
3732
|
+
"aria-posinset": posinset,
|
|
3733
|
+
get "aria-setsize"() {
|
|
3734
|
+
if (sizeMode() === "loaded") return loaded();
|
|
3735
|
+
const all = total();
|
|
3736
|
+
return totalKnown() && all !== null ? all : -1;
|
|
3737
|
+
},
|
|
3738
|
+
"aria-labelledby": `${base}-article-${posinset}-title`,
|
|
3739
|
+
"aria-describedby": `${base}-article-${posinset}-content`,
|
|
3740
|
+
tabindex: 0,
|
|
3152
3741
|
get hidden() {
|
|
3153
|
-
return
|
|
3742
|
+
return posinset > loaded();
|
|
3154
3743
|
},
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3744
|
+
onFocus: () => focusArticle(posinset),
|
|
3745
|
+
onKeyDown
|
|
3746
|
+
}),
|
|
3747
|
+
beforeProps: {
|
|
3748
|
+
id: `${base}-before`,
|
|
3749
|
+
tabindex: 0,
|
|
3750
|
+
onFocus: () => setActive("before")
|
|
3751
|
+
},
|
|
3752
|
+
afterProps: {
|
|
3753
|
+
id: `${base}-after`,
|
|
3754
|
+
tabindex: 0,
|
|
3755
|
+
onFocus: () => setActive("after")
|
|
3158
3756
|
}
|
|
3159
3757
|
};
|
|
3160
3758
|
};
|
|
@@ -4131,106 +4729,218 @@ const createGrid = (options = {}) => {
|
|
|
4131
4729
|
}
|
|
4132
4730
|
};
|
|
4133
4731
|
};
|
|
4134
|
-
// src/
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
const
|
|
4143
|
-
|
|
4144
|
-
const
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
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;
|
|
4732
|
+
// src/knob.ts
|
|
4733
|
+
/** The pointer must travel this far before the drag picks a mode. */
|
|
4734
|
+
const LOCK_PX = 6;
|
|
4735
|
+
/** A slide is only locked while the other axis stays this small a share of the movement. */
|
|
4736
|
+
const SLIDE_VARIANCE = .35;
|
|
4737
|
+
/** The radius (px) at which a full turn sweeps the whole range; farther out is finer. */
|
|
4738
|
+
const REFERENCE_RADIUS = 100;
|
|
4739
|
+
/** The radius floor of the spin sensitivity, so a grab at the centre is not wildly coarse. */
|
|
4740
|
+
const MIN_SPIN_RADIUS = 25;
|
|
4741
|
+
/** Pixels of slide that move the value by the whole range. */
|
|
4742
|
+
const SLIDE_PX_PER_RANGE = 200;
|
|
4743
|
+
const angleOf = (x, y) => Math.atan2(y, x) * 180 / Math.PI;
|
|
4744
|
+
const angleDelta = (from, to) => {
|
|
4745
|
+
const diff = (to - from) % 360;
|
|
4746
|
+
return diff > 180 ? diff - 360 : diff < -180 ? diff + 360 : diff;
|
|
4175
4747
|
};
|
|
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
4748
|
/**
|
|
4180
|
-
*
|
|
4181
|
-
*
|
|
4182
|
-
*
|
|
4183
|
-
*
|
|
4184
|
-
*
|
|
4185
|
-
*
|
|
4186
|
-
*
|
|
4749
|
+
* A virtual knob after GarageBand's (https://jherrm.github.io/knobs/): one
|
|
4750
|
+
* control turned three ways — spun around its centre, or slid vertically or
|
|
4751
|
+
* horizontally, one gesture locked per drag. Plain Solid signals, no elements,
|
|
4752
|
+
* no styling. **No ARIA role exists for a dial**, so the knob is a `role=slider`
|
|
4753
|
+
* with `aria-valuenow` / `aria-valuetext`; the value the host renders in
|
|
4754
|
+
* `valueProps` is the live announcement (`role=status`), and a numeric field
|
|
4755
|
+
* must sit next to the dial, because a dial is never the only control.
|
|
4756
|
+
*
|
|
4757
|
+
* Request/commit (map ticket `rt1p`): uncontrolled by default (`defaultValue`);
|
|
4758
|
+
* pass `value` + `onValueChange` to own the value. A key, a wheel, a drag and
|
|
4759
|
+
* `setValue`/`stepBy` are requests the host may ignore.
|
|
4760
|
+
*
|
|
4761
|
+
* Gestures (`gesture`, default "both"), decided once on the first meaningful
|
|
4762
|
+
* movement so a drag never switches modes:
|
|
4763
|
+
* - a movement that stays on one axis (the other axis under `SLIDE_VARIANCE` of
|
|
4764
|
+
* it) locks a slide — the reference's "slide in a straight line";
|
|
4765
|
+
* - any other movement is a turn around the centre: the angle the pointer
|
|
4766
|
+
* covers is the value, and the farther from the centre the finer it is;
|
|
4767
|
+
* - `gesture: "spin"` / `"slide"` disable the other.
|
|
4768
|
+
*
|
|
4769
|
+
* A vertical slide turns the knob the way the grabbed side would turn a real
|
|
4770
|
+
* one: down on the right half is clockwise (up in value), down on the left half
|
|
4771
|
+
* counter-clockwise. A horizontal slide uses the same left/right side (the
|
|
4772
|
+
* reference picks top/bottom for this one; one rule for both is simpler to
|
|
4773
|
+
* learn). `wrap` makes the dial continuous instead of clamped.
|
|
4774
|
+
*
|
|
4775
|
+
* ponytail: no momentum/inertia and no `disabled` — the reference has a
|
|
4776
|
+
* deceleration tail, and no host here has asked for one; add inertia in the
|
|
4777
|
+
* host's rAF loop if a look needs it.
|
|
4187
4778
|
*/
|
|
4188
|
-
const
|
|
4189
|
-
const
|
|
4190
|
-
const
|
|
4191
|
-
|
|
4192
|
-
|
|
4779
|
+
const createKnob = (options = {}) => {
|
|
4780
|
+
const min = options.min ?? 0;
|
|
4781
|
+
const max = options.max ?? 100;
|
|
4782
|
+
const step = options.step ?? 1;
|
|
4783
|
+
const span = max - min;
|
|
4784
|
+
const base = options.id ?? createUniqueId();
|
|
4785
|
+
const wrap = () => options.wrap === true;
|
|
4786
|
+
const bound = (next) => {
|
|
4787
|
+
if (span <= 0) return min;
|
|
4788
|
+
if (wrap()) return min + ((next - min) % span + span) % span;
|
|
4789
|
+
return Math.max(min, Math.min(max, next));
|
|
4790
|
+
};
|
|
4791
|
+
const quantize = (next) => {
|
|
4792
|
+
const stepped = step > 0 ? min + Math.round((next - min) / step) * step : next;
|
|
4793
|
+
return bound(Number(stepped.toPrecision(12)));
|
|
4794
|
+
};
|
|
4795
|
+
const [value, request] = createControllable({
|
|
4796
|
+
value: () => options.value,
|
|
4797
|
+
initial: quantize(options.defaultValue ?? min + span / 2),
|
|
4798
|
+
onChange: () => options.onValueChange
|
|
4799
|
+
});
|
|
4800
|
+
const commit = (next) => request(quantize(next));
|
|
4801
|
+
const percentOf = (at) => {
|
|
4802
|
+
if (span <= 0) return 0;
|
|
4803
|
+
const fraction = (at - min) / span;
|
|
4804
|
+
return wrap() ? fraction - Math.floor(fraction) : Math.max(0, Math.min(1, fraction));
|
|
4805
|
+
};
|
|
4806
|
+
const stepBy = (delta) => commit(value() + delta);
|
|
4193
4807
|
const onKeyDown = (event) => {
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4808
|
+
switch (event.key) {
|
|
4809
|
+
case "ArrowUp":
|
|
4810
|
+
case "ArrowRight":
|
|
4811
|
+
stepBy(step);
|
|
4812
|
+
break;
|
|
4813
|
+
case "ArrowDown":
|
|
4814
|
+
case "ArrowLeft":
|
|
4815
|
+
stepBy(-step);
|
|
4816
|
+
break;
|
|
4817
|
+
case "PageUp":
|
|
4818
|
+
stepBy(step * 10);
|
|
4819
|
+
break;
|
|
4820
|
+
case "PageDown":
|
|
4821
|
+
stepBy(-step * 10);
|
|
4822
|
+
break;
|
|
4823
|
+
case "Home":
|
|
4824
|
+
commit(min);
|
|
4825
|
+
break;
|
|
4826
|
+
case "End":
|
|
4827
|
+
commit(max);
|
|
4828
|
+
break;
|
|
4829
|
+
default: return;
|
|
4204
4830
|
}
|
|
4831
|
+
event.preventDefault();
|
|
4205
4832
|
};
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4833
|
+
let drag;
|
|
4834
|
+
const modeFor = (current, event) => {
|
|
4835
|
+
if (options.gesture === "spin") return "spin";
|
|
4836
|
+
const dx = event.clientX - current.fromX;
|
|
4837
|
+
const dy = event.clientY - current.fromY;
|
|
4838
|
+
const vertical = Math.abs(dy);
|
|
4839
|
+
const horizontal = Math.abs(dx);
|
|
4840
|
+
const straight = Math.min(vertical, horizontal) <= Math.max(vertical, horizontal) * SLIDE_VARIANCE;
|
|
4841
|
+
if (options.gesture !== "slide" && !straight) return "spin";
|
|
4842
|
+
return vertical >= horizontal ? "vertical" : "horizontal";
|
|
4210
4843
|
};
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4844
|
+
const pointerDown = (event) => {
|
|
4845
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
4846
|
+
const element = event.currentTarget;
|
|
4847
|
+
if (!(element instanceof Element)) return;
|
|
4848
|
+
try {
|
|
4849
|
+
element.setPointerCapture(event.pointerId);
|
|
4850
|
+
} catch {}
|
|
4851
|
+
const rect = element.getBoundingClientRect();
|
|
4852
|
+
const centreX = rect.left + rect.width / 2;
|
|
4853
|
+
const centreY = rect.top + rect.height / 2;
|
|
4854
|
+
const startAngle = angleOf(event.clientX - centreX, event.clientY - centreY);
|
|
4855
|
+
drag = {
|
|
4856
|
+
pointerId: event.pointerId,
|
|
4857
|
+
centreX,
|
|
4858
|
+
centreY,
|
|
4859
|
+
side: event.clientX >= centreX ? 1 : -1,
|
|
4860
|
+
fromX: event.clientX,
|
|
4861
|
+
fromY: event.clientY,
|
|
4862
|
+
startAngle,
|
|
4863
|
+
raw: value(),
|
|
4864
|
+
mode: void 0,
|
|
4865
|
+
lastAngle: startAngle,
|
|
4866
|
+
lastX: event.clientX,
|
|
4867
|
+
lastY: event.clientY
|
|
4868
|
+
};
|
|
4869
|
+
};
|
|
4870
|
+
const pointerMove = (event) => {
|
|
4871
|
+
const current = drag;
|
|
4872
|
+
if (current === void 0 || current.pointerId !== event.pointerId) return;
|
|
4873
|
+
if (current.mode === void 0) {
|
|
4874
|
+
if (Math.hypot(event.clientX - current.fromX, event.clientY - current.fromY) < LOCK_PX) return;
|
|
4875
|
+
current.mode = modeFor(current, event);
|
|
4876
|
+
if (current.mode === "spin") current.lastAngle = current.startAngle;
|
|
4877
|
+
else {
|
|
4878
|
+
current.lastX = current.fromX;
|
|
4879
|
+
current.lastY = current.fromY;
|
|
4222
4880
|
}
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4881
|
+
}
|
|
4882
|
+
if (current.mode === "spin") {
|
|
4883
|
+
const angle = angleOf(event.clientX - current.centreX, event.clientY - current.centreY);
|
|
4884
|
+
const delta = angleDelta(current.lastAngle, angle);
|
|
4885
|
+
current.lastAngle = angle;
|
|
4886
|
+
const radius = Math.hypot(event.clientX - current.centreX, event.clientY - current.centreY);
|
|
4887
|
+
const perDegree = span / 360 * (REFERENCE_RADIUS / Math.max(radius, MIN_SPIN_RADIUS));
|
|
4888
|
+
current.raw = bound(current.raw + delta * perDegree);
|
|
4889
|
+
} else {
|
|
4890
|
+
const pixels = current.mode === "vertical" ? event.clientY - current.lastY : event.clientX - current.lastX;
|
|
4891
|
+
current.lastX = event.clientX;
|
|
4892
|
+
current.lastY = event.clientY;
|
|
4893
|
+
current.raw = bound(current.raw + pixels * (span / SLIDE_PX_PER_RANGE) * current.side);
|
|
4894
|
+
}
|
|
4895
|
+
commit(current.raw);
|
|
4896
|
+
};
|
|
4897
|
+
const pointerUp = (event) => {
|
|
4898
|
+
const current = drag;
|
|
4899
|
+
if (current === void 0 || current.pointerId !== event.pointerId) return;
|
|
4900
|
+
drag = void 0;
|
|
4901
|
+
const target = event.currentTarget;
|
|
4902
|
+
if (target instanceof Element) try {
|
|
4903
|
+
target.releasePointerCapture(event.pointerId);
|
|
4904
|
+
} catch {}
|
|
4905
|
+
};
|
|
4906
|
+
const onWheel = (event) => {
|
|
4907
|
+
if (event.deltaY === 0) return;
|
|
4908
|
+
event.preventDefault();
|
|
4909
|
+
stepBy(event.deltaY < 0 ? step : -step);
|
|
4910
|
+
};
|
|
4911
|
+
return {
|
|
4912
|
+
value,
|
|
4913
|
+
setValue: commit,
|
|
4914
|
+
percentOf,
|
|
4915
|
+
stepBy,
|
|
4916
|
+
knobProps: {
|
|
4917
|
+
role: "slider",
|
|
4918
|
+
get tabindex() {
|
|
4919
|
+
return 0;
|
|
4920
|
+
},
|
|
4921
|
+
"aria-valuemin": String(min),
|
|
4922
|
+
"aria-valuemax": String(max),
|
|
4923
|
+
get "aria-valuenow"() {
|
|
4924
|
+
return String(value());
|
|
4925
|
+
},
|
|
4926
|
+
get "aria-valuetext"() {
|
|
4927
|
+
const format = options.format;
|
|
4928
|
+
return format === void 0 ? String(value()) : format(value());
|
|
4929
|
+
},
|
|
4930
|
+
get "aria-label"() {
|
|
4931
|
+
return options.labels?.knob ?? "Knob";
|
|
4932
|
+
},
|
|
4933
|
+
onKeyDown,
|
|
4934
|
+
onPointerDown: pointerDown,
|
|
4935
|
+
onPointerMove: pointerMove,
|
|
4936
|
+
onPointerUp: pointerUp,
|
|
4937
|
+
onPointerCancel: pointerUp,
|
|
4938
|
+
onWheel
|
|
4229
4939
|
},
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4940
|
+
valueProps: {
|
|
4941
|
+
id: `${base}-value`,
|
|
4942
|
+
role: "status",
|
|
4943
|
+
"aria-live": "polite"
|
|
4234
4944
|
}
|
|
4235
4945
|
};
|
|
4236
4946
|
};
|
|
@@ -4661,9 +5371,11 @@ const createMenu = (options) => {
|
|
|
4661
5371
|
const disabled = new Set(options.disabled ?? []);
|
|
4662
5372
|
for (const [index, item] of items.entries()) if (item.disabled) disabled.add(index);
|
|
4663
5373
|
const held = (options.triggerMode ?? "outside") === "held";
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
5374
|
+
const [isOpen, requestOpen] = createControllable({
|
|
5375
|
+
value: () => options.open,
|
|
5376
|
+
initial: options.defaultOpen === true,
|
|
5377
|
+
onChange: () => options.onOpenChange
|
|
5378
|
+
});
|
|
4667
5379
|
let checked = new Set(options.defaultChecked ?? []);
|
|
4668
5380
|
const variantChoice = () => {
|
|
4669
5381
|
if (options.variant === "mixed") return 1;
|
|
@@ -4699,57 +5411,80 @@ const createMenu = (options) => {
|
|
|
4699
5411
|
version();
|
|
4700
5412
|
return read();
|
|
4701
5413
|
};
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
} else if (held) level = 1;
|
|
5414
|
+
let closed = held ? 1 : 0;
|
|
5415
|
+
let index = 0;
|
|
5416
|
+
let seenOpen = isOpen();
|
|
4706
5417
|
const isDisabled = (i) => disabled.has(i);
|
|
4707
|
-
const
|
|
4708
|
-
|
|
4709
|
-
|
|
5418
|
+
const sync = () => {
|
|
5419
|
+
const now = isOpen();
|
|
5420
|
+
if (now === seenOpen) return;
|
|
5421
|
+
seenOpen = now;
|
|
5422
|
+
index = now ? 0 : -1;
|
|
5423
|
+
closed = now ? closed : 1;
|
|
5424
|
+
};
|
|
5425
|
+
const level = () => {
|
|
5426
|
+
sync();
|
|
5427
|
+
return isOpen() ? 2 : closed;
|
|
5428
|
+
};
|
|
5429
|
+
const focusedIndex = () => {
|
|
5430
|
+
sync();
|
|
5431
|
+
return index;
|
|
5432
|
+
};
|
|
5433
|
+
const focusItem = (next) => {
|
|
5434
|
+
index = next;
|
|
4710
5435
|
bump();
|
|
4711
5436
|
};
|
|
5437
|
+
const move = (want, nextIndex, nextClosed) => {
|
|
5438
|
+
const apply = () => {
|
|
5439
|
+
seenOpen = isOpen();
|
|
5440
|
+
index = want ? nextIndex : -1;
|
|
5441
|
+
closed = nextClosed;
|
|
5442
|
+
bump();
|
|
5443
|
+
};
|
|
5444
|
+
if (isOpen() === want) {
|
|
5445
|
+
apply();
|
|
5446
|
+
return;
|
|
5447
|
+
}
|
|
5448
|
+
requestOpen(want);
|
|
5449
|
+
if (options.open === void 0) {
|
|
5450
|
+
apply();
|
|
5451
|
+
return;
|
|
5452
|
+
}
|
|
5453
|
+
queueMicrotask(() => {
|
|
5454
|
+
if (isOpen() === want) apply();
|
|
5455
|
+
});
|
|
5456
|
+
};
|
|
4712
5457
|
const openOn = (target) => {
|
|
4713
|
-
|
|
4714
|
-
index = target;
|
|
4715
|
-
open = true;
|
|
4716
|
-
bump();
|
|
4717
|
-
options.onOpenChange?.(true);
|
|
5458
|
+
move(true, target, 1);
|
|
4718
5459
|
};
|
|
4719
5460
|
const closeToTrigger = () => {
|
|
4720
|
-
|
|
4721
|
-
index = -1;
|
|
4722
|
-
open = false;
|
|
4723
|
-
bump();
|
|
4724
|
-
options.onOpenChange?.(false);
|
|
5461
|
+
move(false, -1, 1);
|
|
4725
5462
|
};
|
|
4726
5463
|
const focusTrigger = () => {
|
|
4727
|
-
if (level === 0)
|
|
5464
|
+
if (level() === 0) {
|
|
5465
|
+
closed = 1;
|
|
5466
|
+
bump();
|
|
5467
|
+
}
|
|
4728
5468
|
};
|
|
4729
5469
|
const leave = () => {
|
|
4730
|
-
|
|
4731
|
-
level = held ? 1 : 0;
|
|
4732
|
-
index = -1;
|
|
4733
|
-
open = false;
|
|
4734
|
-
bump();
|
|
4735
|
-
if (wasOpen) options.onOpenChange?.(false);
|
|
5470
|
+
move(false, -1, held ? 1 : 0);
|
|
4736
5471
|
};
|
|
4737
5472
|
const clickTrigger = () => {
|
|
4738
|
-
if (
|
|
5473
|
+
if (isOpen()) closeToTrigger();
|
|
4739
5474
|
else openOn(0);
|
|
4740
5475
|
};
|
|
4741
5476
|
const openMenu = () => {
|
|
4742
|
-
if (!
|
|
5477
|
+
if (!isOpen()) openOn(0);
|
|
4743
5478
|
};
|
|
4744
5479
|
const closeMenu = () => {
|
|
4745
|
-
if (
|
|
5480
|
+
if (isOpen()) closeToTrigger();
|
|
4746
5481
|
};
|
|
4747
5482
|
const activate = (close) => {
|
|
4748
|
-
if (level === 1) {
|
|
5483
|
+
if (level() === 1) {
|
|
4749
5484
|
openOn(0);
|
|
4750
5485
|
return;
|
|
4751
5486
|
}
|
|
4752
|
-
if (level !== 2 || index < 0 || isDisabled(index)) return;
|
|
5487
|
+
if (level() !== 2 || index < 0 || isDisabled(index)) return;
|
|
4753
5488
|
const item = items[index];
|
|
4754
5489
|
if (item === void 0) return;
|
|
4755
5490
|
if (item.kind === "checkbox") {
|
|
@@ -4790,23 +5525,23 @@ const createMenu = (options) => {
|
|
|
4790
5525
|
activate(false);
|
|
4791
5526
|
break;
|
|
4792
5527
|
case "ArrowDown":
|
|
4793
|
-
if (level === 1) openOn(0);
|
|
4794
|
-
else if (level === 2)
|
|
5528
|
+
if (level() === 1) openOn(0);
|
|
5529
|
+
else if (level() === 2) focusItem(wrapNext(focusedIndex()));
|
|
4795
5530
|
break;
|
|
4796
5531
|
case "ArrowUp":
|
|
4797
|
-
if (level === 1) openOn(count - 1);
|
|
4798
|
-
else if (level === 2)
|
|
5532
|
+
if (level() === 1) openOn(count - 1);
|
|
5533
|
+
else if (level() === 2) focusItem(wrapPrev(focusedIndex()));
|
|
4799
5534
|
break;
|
|
4800
5535
|
case "Home":
|
|
4801
|
-
if (level === 2)
|
|
5536
|
+
if (level() === 2) focusItem(0);
|
|
4802
5537
|
break;
|
|
4803
5538
|
case "End":
|
|
4804
|
-
if (level === 2)
|
|
5539
|
+
if (level() === 2) focusItem(count - 1);
|
|
4805
5540
|
break;
|
|
4806
5541
|
case "Escape":
|
|
4807
|
-
if (level === 2) closeToTrigger();
|
|
5542
|
+
if (level() === 2) closeToTrigger();
|
|
4808
5543
|
break;
|
|
4809
|
-
case "Tab": if (level !== 0) leave();
|
|
5544
|
+
case "Tab": if (level() !== 0) leave();
|
|
4810
5545
|
}
|
|
4811
5546
|
};
|
|
4812
5547
|
const search = (from, text) => {
|
|
@@ -4817,36 +5552,36 @@ const createMenu = (options) => {
|
|
|
4817
5552
|
}
|
|
4818
5553
|
};
|
|
4819
5554
|
const type = (text) => {
|
|
4820
|
-
if (!
|
|
5555
|
+
if (!isOpen() || text.length === 0) return;
|
|
4821
5556
|
buffer += text;
|
|
4822
5557
|
if (bufferTimer !== void 0) clearTimeout(bufferTimer);
|
|
4823
5558
|
bufferTimer = setTimeout(() => {
|
|
4824
5559
|
buffer = "";
|
|
4825
5560
|
}, prefs.typeaheadDelayMs);
|
|
4826
|
-
const
|
|
4827
|
-
|
|
5561
|
+
const from = focusedIndex() >= 0 ? focusedIndex() : 0;
|
|
5562
|
+
const match = search(from, buffer);
|
|
5563
|
+
if (match !== void 0) focusItem(match);
|
|
4828
5564
|
};
|
|
4829
5565
|
const codeOf = (i) => i % 2 === 0 ? 1 : 2;
|
|
4830
5566
|
const typeaheadCode = (code) => {
|
|
4831
|
-
if (!
|
|
4832
|
-
const from =
|
|
5567
|
+
if (!isOpen()) return;
|
|
5568
|
+
const from = focusedIndex() >= 0 ? focusedIndex() : 0;
|
|
4833
5569
|
for (let offset = 1; offset < count; offset += 1) {
|
|
4834
5570
|
const candidate = (from + offset) % count;
|
|
4835
5571
|
if (codeOf(candidate) === code) {
|
|
4836
|
-
|
|
5572
|
+
focusItem(candidate);
|
|
4837
5573
|
return;
|
|
4838
5574
|
}
|
|
4839
5575
|
}
|
|
4840
5576
|
};
|
|
4841
5577
|
const setFocusItem = (i) => {
|
|
4842
5578
|
if (i < 0 || i >= count) return;
|
|
4843
|
-
|
|
4844
|
-
setFocus(2, i);
|
|
5579
|
+
move(true, i, 1);
|
|
4845
5580
|
};
|
|
4846
5581
|
return {
|
|
4847
|
-
open: track(
|
|
4848
|
-
focusLevel: track(
|
|
4849
|
-
focusIndex: track(
|
|
5582
|
+
open: track(isOpen),
|
|
5583
|
+
focusLevel: track(level),
|
|
5584
|
+
focusIndex: track(focusedIndex),
|
|
4850
5585
|
items,
|
|
4851
5586
|
checked: track(() => [...currentChecked()].sort((a, b) => a - b)),
|
|
4852
5587
|
choice: track(() => currentChoice()),
|
|
@@ -4865,7 +5600,7 @@ const createMenu = (options) => {
|
|
|
4865
5600
|
"aria-haspopup": "menu",
|
|
4866
5601
|
get "aria-expanded"() {
|
|
4867
5602
|
version();
|
|
4868
|
-
return
|
|
5603
|
+
return isOpen() ? "true" : "false";
|
|
4869
5604
|
},
|
|
4870
5605
|
"aria-controls": menuId,
|
|
4871
5606
|
get disabled() {
|
|
@@ -4887,7 +5622,7 @@ const createMenu = (options) => {
|
|
|
4887
5622
|
role: "menu",
|
|
4888
5623
|
get hidden() {
|
|
4889
5624
|
version();
|
|
4890
|
-
return !
|
|
5625
|
+
return !isOpen();
|
|
4891
5626
|
},
|
|
4892
5627
|
onKeyDown: (event) => {
|
|
4893
5628
|
if (event.key === "Tab" || event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") event.preventDefault();
|
|
@@ -4913,7 +5648,7 @@ const createMenu = (options) => {
|
|
|
4913
5648
|
},
|
|
4914
5649
|
get tabindex() {
|
|
4915
5650
|
version();
|
|
4916
|
-
return level === 2 && index === i ? 0 : -1;
|
|
5651
|
+
return level() === 2 && index === i ? 0 : -1;
|
|
4917
5652
|
},
|
|
4918
5653
|
onClick: () => {
|
|
4919
5654
|
if (isDisabled(i)) return;
|
|
@@ -5930,6 +6665,11 @@ const valueTextFor$1 = (value) => value <= 3 ? "low" : value <= 7 ? "medium" : "
|
|
|
5930
6665
|
* pass `value` + `onValueChange` to own the value. A key or rail press is a
|
|
5931
6666
|
* request the host may ignore.
|
|
5932
6667
|
*
|
|
6668
|
+
* `disabled` is a widget-level extension of the pattern (the spec lists it
|
|
6669
|
+
* under its deliberate omissions): the thumb keeps its tab stop and announces
|
|
6670
|
+
* `aria-disabled`, and every key and rail press is dropped before it becomes a
|
|
6671
|
+
* request.
|
|
6672
|
+
*
|
|
5933
6673
|
* ponytail: dragging (pointer capture/move) is not modelled — the spec
|
|
5934
6674
|
* deliberately only fixes a discrete rail press.
|
|
5935
6675
|
*/
|
|
@@ -5948,6 +6688,7 @@ const createSlider = (options = {}) => {
|
|
|
5948
6688
|
const [focused, setFocused] = createSignal(false);
|
|
5949
6689
|
const orientation = () => options.orientation ?? "horizontal";
|
|
5950
6690
|
const direction = () => options.direction ?? "ltr";
|
|
6691
|
+
const disabled = () => options.disabled === true;
|
|
5951
6692
|
const flipped = () => orientation() === "horizontal" && direction() === "rtl";
|
|
5952
6693
|
const commit = (next) => request(clamp(next));
|
|
5953
6694
|
const delta = (key) => {
|
|
@@ -5962,7 +6703,7 @@ const createSlider = (options = {}) => {
|
|
|
5962
6703
|
}
|
|
5963
6704
|
};
|
|
5964
6705
|
const keyDown = (key) => {
|
|
5965
|
-
if (!focused()) return false;
|
|
6706
|
+
if (!focused() || disabled()) return false;
|
|
5966
6707
|
if (key === "Home") {
|
|
5967
6708
|
commit(min);
|
|
5968
6709
|
return true;
|
|
@@ -5977,6 +6718,7 @@ const createSlider = (options = {}) => {
|
|
|
5977
6718
|
return true;
|
|
5978
6719
|
};
|
|
5979
6720
|
const pointerDown = (position) => {
|
|
6721
|
+
if (disabled()) return;
|
|
5980
6722
|
const steps = Math.round(position * (max - min) / step);
|
|
5981
6723
|
commit(min + steps * step);
|
|
5982
6724
|
setFocused(true);
|
|
@@ -6003,6 +6745,9 @@ const createSlider = (options = {}) => {
|
|
|
6003
6745
|
get "aria-orientation"() {
|
|
6004
6746
|
return orientation();
|
|
6005
6747
|
},
|
|
6748
|
+
get "aria-disabled"() {
|
|
6749
|
+
return disabled() ? "true" : void 0;
|
|
6750
|
+
},
|
|
6006
6751
|
get tabindex() {
|
|
6007
6752
|
return 0;
|
|
6008
6753
|
},
|
|
@@ -6167,6 +6912,12 @@ const createSliderMultiThumb = (options = {}) => {
|
|
|
6167
6912
|
* stepper buttons, Enter and blur are requests the host may ignore; typing and
|
|
6168
6913
|
* Escape change no committed value.
|
|
6169
6914
|
*
|
|
6915
|
+
* `disabled` is a widget-level extension of the pattern (the spec models the
|
|
6916
|
+
* steppers' bound only, and lists this under its deliberate omissions): the
|
|
6917
|
+
* field announces `aria-disabled` and carries the native `disabled`, every key,
|
|
6918
|
+
* typed draft and both steppers are dropped before they become a request, and
|
|
6919
|
+
* neither stepper offers itself.
|
|
6920
|
+
*
|
|
6170
6921
|
* ponytail: commit/revert on Enter/blur/Escape is the common convention, not
|
|
6171
6922
|
* APG text.
|
|
6172
6923
|
*/
|
|
@@ -6187,6 +6938,7 @@ const createSpinbutton = (options = {}) => {
|
|
|
6187
6938
|
const [editing, setEditing] = createSignal(false);
|
|
6188
6939
|
const [draft, setDraft] = createSignal("");
|
|
6189
6940
|
const [wrap, setWrapSignal] = createSignal(options.wrap ?? false);
|
|
6941
|
+
const disabled = () => options.disabled === true;
|
|
6190
6942
|
const draftInRange = (text) => {
|
|
6191
6943
|
const number = Number(text);
|
|
6192
6944
|
return text.trim() !== "" && number >= min && number <= max;
|
|
@@ -6214,7 +6966,7 @@ const createSpinbutton = (options = {}) => {
|
|
|
6214
6966
|
commitValue(next);
|
|
6215
6967
|
};
|
|
6216
6968
|
const keyDown = (key) => {
|
|
6217
|
-
if (!focused()) return false;
|
|
6969
|
+
if (!focused() || disabled()) return false;
|
|
6218
6970
|
switch (key) {
|
|
6219
6971
|
case "ArrowUp":
|
|
6220
6972
|
applyStep(1, step);
|
|
@@ -6246,7 +6998,7 @@ const createSpinbutton = (options = {}) => {
|
|
|
6246
6998
|
}
|
|
6247
6999
|
};
|
|
6248
7000
|
const commit = () => {
|
|
6249
|
-
if (!focused()) return;
|
|
7001
|
+
if (!focused() || disabled()) return;
|
|
6250
7002
|
const next = baseValue();
|
|
6251
7003
|
clearDraft();
|
|
6252
7004
|
commitValue(next);
|
|
@@ -6254,8 +7006,25 @@ const createSpinbutton = (options = {}) => {
|
|
|
6254
7006
|
const revert = () => {
|
|
6255
7007
|
clearDraft();
|
|
6256
7008
|
};
|
|
7009
|
+
const blurField = () => {
|
|
7010
|
+
const next = baseValue();
|
|
7011
|
+
clearDraft();
|
|
7012
|
+
setFocused(false);
|
|
7013
|
+
if (disabled()) return;
|
|
7014
|
+
commitValue(next);
|
|
7015
|
+
};
|
|
7016
|
+
const increment = () => {
|
|
7017
|
+
if (disabled()) return;
|
|
7018
|
+
setFocused(true);
|
|
7019
|
+
applyStep(1, step);
|
|
7020
|
+
};
|
|
7021
|
+
const decrement = () => {
|
|
7022
|
+
if (disabled()) return;
|
|
7023
|
+
setFocused(true);
|
|
7024
|
+
applyStep(-1, step);
|
|
7025
|
+
};
|
|
6257
7026
|
const displayed = () => editing() ? draft() : String(value());
|
|
6258
|
-
const ariaDisabled = (atBound) => !wrap() && atBound ? "true" : void 0;
|
|
7027
|
+
const ariaDisabled = (atBound) => disabled() || !wrap() && atBound ? "true" : void 0;
|
|
6259
7028
|
return {
|
|
6260
7029
|
value,
|
|
6261
7030
|
focused,
|
|
@@ -6264,28 +7033,17 @@ const createSpinbutton = (options = {}) => {
|
|
|
6264
7033
|
displayed,
|
|
6265
7034
|
setValue: commitValue,
|
|
6266
7035
|
focus: () => setFocused(true),
|
|
6267
|
-
blur:
|
|
6268
|
-
const next = baseValue();
|
|
6269
|
-
clearDraft();
|
|
6270
|
-
setFocused(false);
|
|
6271
|
-
commitValue(next);
|
|
6272
|
-
},
|
|
7036
|
+
blur: blurField,
|
|
6273
7037
|
keyDown,
|
|
6274
7038
|
typeInput: (text) => {
|
|
6275
|
-
if (!focused()) return;
|
|
7039
|
+
if (!focused() || disabled()) return;
|
|
6276
7040
|
setEditing(true);
|
|
6277
7041
|
setDraft(text);
|
|
6278
7042
|
},
|
|
6279
7043
|
commit,
|
|
6280
7044
|
revert,
|
|
6281
|
-
increment
|
|
6282
|
-
|
|
6283
|
-
applyStep(1, step);
|
|
6284
|
-
},
|
|
6285
|
-
decrement: () => {
|
|
6286
|
-
setFocused(true);
|
|
6287
|
-
applyStep(-1, step);
|
|
6288
|
-
},
|
|
7045
|
+
increment,
|
|
7046
|
+
decrement,
|
|
6289
7047
|
setWrap: (next) => setWrapSignal(next),
|
|
6290
7048
|
inputProps: {
|
|
6291
7049
|
id: `${base}-input`,
|
|
@@ -6301,6 +7059,12 @@ const createSpinbutton = (options = {}) => {
|
|
|
6301
7059
|
get "aria-invalid"() {
|
|
6302
7060
|
return editing() && !draftInRange(draft()) ? "true" : void 0;
|
|
6303
7061
|
},
|
|
7062
|
+
get "aria-disabled"() {
|
|
7063
|
+
return disabled() ? "true" : void 0;
|
|
7064
|
+
},
|
|
7065
|
+
get disabled() {
|
|
7066
|
+
return disabled();
|
|
7067
|
+
},
|
|
6304
7068
|
get tabindex() {
|
|
6305
7069
|
return 0;
|
|
6306
7070
|
},
|
|
@@ -6308,18 +7072,13 @@ const createSpinbutton = (options = {}) => {
|
|
|
6308
7072
|
return displayed();
|
|
6309
7073
|
},
|
|
6310
7074
|
onFocus: () => setFocused(true),
|
|
6311
|
-
onBlur:
|
|
6312
|
-
const next = baseValue();
|
|
6313
|
-
clearDraft();
|
|
6314
|
-
setFocused(false);
|
|
6315
|
-
commitValue(next);
|
|
6316
|
-
},
|
|
7075
|
+
onBlur: blurField,
|
|
6317
7076
|
onKeyDown: (event) => {
|
|
6318
7077
|
if (keyDown(event.key)) event.preventDefault();
|
|
6319
7078
|
},
|
|
6320
7079
|
onInput: (event) => {
|
|
6321
7080
|
const target = event.currentTarget;
|
|
6322
|
-
if (target !== null) {
|
|
7081
|
+
if (target !== null && !disabled()) {
|
|
6323
7082
|
setEditing(true);
|
|
6324
7083
|
setDraft(target.value);
|
|
6325
7084
|
}
|
|
@@ -6333,10 +7092,7 @@ const createSpinbutton = (options = {}) => {
|
|
|
6333
7092
|
return ariaDisabled(value() === max);
|
|
6334
7093
|
},
|
|
6335
7094
|
onMouseDown: (event) => event.preventDefault(),
|
|
6336
|
-
onClick:
|
|
6337
|
-
setFocused(true);
|
|
6338
|
-
applyStep(1, step);
|
|
6339
|
-
}
|
|
7095
|
+
onClick: increment
|
|
6340
7096
|
},
|
|
6341
7097
|
decrementProps: {
|
|
6342
7098
|
role: "button",
|
|
@@ -6346,41 +7102,7 @@ const createSpinbutton = (options = {}) => {
|
|
|
6346
7102
|
return ariaDisabled(value() === min);
|
|
6347
7103
|
},
|
|
6348
7104
|
onMouseDown: (event) => event.preventDefault(),
|
|
6349
|
-
onClick:
|
|
6350
|
-
setFocused(true);
|
|
6351
|
-
applyStep(-1, step);
|
|
6352
|
-
}
|
|
6353
|
-
}
|
|
6354
|
-
};
|
|
6355
|
-
};
|
|
6356
|
-
// src/status.ts
|
|
6357
|
-
/**
|
|
6358
|
-
* APG Alert's polite sibling: one `role=status` live region whose text the host
|
|
6359
|
-
* replaces ("Link copied", "Saved"). It is not a queue: a second message shows
|
|
6360
|
-
* in place of the first, so a burst reads as the last state, not as N alerts.
|
|
6361
|
-
* The region is rendered once and stays mounted, which is what makes a change
|
|
6362
|
-
* of its text announced.
|
|
6363
|
-
*
|
|
6364
|
-
* A status is never for errors: an error that needs attention now is
|
|
6365
|
-
* `createAlert` (`role=alert`, assertive), and a message with a title, a
|
|
6366
|
-
* description and a timeout is `createToast`.
|
|
6367
|
-
*/
|
|
6368
|
-
const createStatus = (options = {}) => {
|
|
6369
|
-
const id = options.id ?? createUniqueId();
|
|
6370
|
-
const [message, setMessage] = createSignal();
|
|
6371
|
-
return {
|
|
6372
|
-
message,
|
|
6373
|
-
show: (text) => {
|
|
6374
|
-
setMessage(text);
|
|
6375
|
-
},
|
|
6376
|
-
clear: () => {
|
|
6377
|
-
setMessage(void 0);
|
|
6378
|
-
},
|
|
6379
|
-
statusProps: {
|
|
6380
|
-
id,
|
|
6381
|
-
role: "status",
|
|
6382
|
-
"aria-live": "polite",
|
|
6383
|
-
"aria-atomic": "true"
|
|
7105
|
+
onClick: decrement
|
|
6384
7106
|
}
|
|
6385
7107
|
};
|
|
6386
7108
|
};
|
|
@@ -6638,90 +7360,6 @@ const createTable = (options) => {
|
|
|
6638
7360
|
toggleRow
|
|
6639
7361
|
};
|
|
6640
7362
|
};
|
|
6641
|
-
// src/textbox.ts
|
|
6642
|
-
const control = (field, options) => ({
|
|
6643
|
-
...field.controlProps,
|
|
6644
|
-
get type() {
|
|
6645
|
-
return options.type ?? "text";
|
|
6646
|
-
},
|
|
6647
|
-
get autoComplete() {
|
|
6648
|
-
return options.autoComplete;
|
|
6649
|
-
},
|
|
6650
|
-
get spellcheck() {
|
|
6651
|
-
if (options.spellCheck === void 0) return;
|
|
6652
|
-
return options.spellCheck ? "true" : "false";
|
|
6653
|
-
},
|
|
6654
|
-
get autocapitalize() {
|
|
6655
|
-
return options.autoCapitalize;
|
|
6656
|
-
},
|
|
6657
|
-
get inputmode() {
|
|
6658
|
-
return options.inputMode;
|
|
6659
|
-
},
|
|
6660
|
-
get maxlength() {
|
|
6661
|
-
return options.maxLength;
|
|
6662
|
-
},
|
|
6663
|
-
get readOnly() {
|
|
6664
|
-
return options.readOnly === true ? true : void 0;
|
|
6665
|
-
},
|
|
6666
|
-
get disabled() {
|
|
6667
|
-
return options.disabled === true ? true : void 0;
|
|
6668
|
-
},
|
|
6669
|
-
get name() {
|
|
6670
|
-
return options.name;
|
|
6671
|
-
},
|
|
6672
|
-
get dir() {
|
|
6673
|
-
return options.dir;
|
|
6674
|
-
},
|
|
6675
|
-
get placeholder() {
|
|
6676
|
-
return options.placeholder;
|
|
6677
|
-
},
|
|
6678
|
-
onInput: (event) => {
|
|
6679
|
-
options.onValueChange?.(event.currentTarget.value);
|
|
6680
|
-
}
|
|
6681
|
-
});
|
|
6682
|
-
/**
|
|
6683
|
-
* A text input with the field contract attached: label, hint, error (announced),
|
|
6684
|
-
* `aria-describedby` and `aria-invalid` come from `createField`; this adds the
|
|
6685
|
-
* props a host forgets — autofill tokens, spellcheck, inputmode, maxlength, the
|
|
6686
|
-
* disabled/readOnly pair. Platform: native (`input` is its own APG-equivalent;
|
|
6687
|
-
* no primitive re-implements text editing, which is why there is no key
|
|
6688
|
-
* handling here).
|
|
6689
|
-
*
|
|
6690
|
-
* Both `required` and `aria-required` are emitted: the native attribute is what
|
|
6691
|
-
* the browser enforces, the ARIA one is what a custom widget reading the same
|
|
6692
|
-
* props needs. A host that validates itself passes `noValidate` on the form
|
|
6693
|
-
* rather than dropping the attribute.
|
|
6694
|
-
*/
|
|
6695
|
-
const createTextInput = (options = {}) => {
|
|
6696
|
-
const field = createField(options);
|
|
6697
|
-
return {
|
|
6698
|
-
...field,
|
|
6699
|
-
controlProps: control(field, options)
|
|
6700
|
-
};
|
|
6701
|
-
};
|
|
6702
|
-
/**
|
|
6703
|
-
* The same contract for a textarea, plus rows, wrap and resize. `resize` is a
|
|
6704
|
-
* style because CSS has no attribute for it; `none` is the value a layout that
|
|
6705
|
-
* owns its own height wants.
|
|
6706
|
-
*/
|
|
6707
|
-
const createTextArea = (options = {}) => {
|
|
6708
|
-
const field = createField(options);
|
|
6709
|
-
return {
|
|
6710
|
-
...field,
|
|
6711
|
-
controlProps: {
|
|
6712
|
-
...control(field, options),
|
|
6713
|
-
get rows() {
|
|
6714
|
-
return options.rows;
|
|
6715
|
-
},
|
|
6716
|
-
get wrap() {
|
|
6717
|
-
return options.wrap;
|
|
6718
|
-
},
|
|
6719
|
-
get style() {
|
|
6720
|
-
return options.resize === void 0 ? void 0 : { resize: options.resize };
|
|
6721
|
-
}
|
|
6722
|
-
}
|
|
6723
|
-
};
|
|
6724
|
-
};
|
|
6725
7363
|
// src/tabs.ts
|
|
6726
7364
|
/**
|
|
6727
7365
|
* APG Tabs (https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): state plus prop
|
|
@@ -6736,28 +7374,40 @@ const createTextArea = (options = {}) => {
|
|
|
6736
7374
|
* Request/commit (map ticket `rt1p`): uncontrolled by default
|
|
6737
7375
|
* (`defaultSelected`); pass `value` + `onValueChange` to own the selection.
|
|
6738
7376
|
* Programmatic methods (`select`, `setSelected`, `keyDown`) are requests too.
|
|
7377
|
+
*
|
|
7378
|
+
* Close and rename are an **extension** (bean `cssints-gdv2`): the APG spec
|
|
7379
|
+
* models a `deletable` flavour but neither a callback nor a rename, and this
|
|
7380
|
+
* primitive owns no list. `closable` / `renamable` turn on the props; `onClose`
|
|
7381
|
+
* / `onRename` ask the host, which removes or renames. Focus after a close goes
|
|
7382
|
+
* to the tab that slid into the closed one's place, or to the previous tab when
|
|
7383
|
+
* the last one closed; closing the last remaining tab is refused, as the spec
|
|
7384
|
+
* refuses an empty tablist.
|
|
6739
7385
|
*/
|
|
6740
7386
|
const createTabs = (options = {}) => {
|
|
6741
|
-
|
|
7387
|
+
/** The live count: the host shrinks its list after a close, and the options are read lazily. */
|
|
7388
|
+
const count = () => Math.max(1, options.items ?? 4);
|
|
6742
7389
|
const base = options.id ?? createUniqueId();
|
|
6743
7390
|
const disabled = new Set(options.disabled ?? []);
|
|
6744
7391
|
const focusablePanels = new Set(options.focusablePanels ?? []);
|
|
6745
7392
|
const activation = () => options.activation ?? "automatic";
|
|
6746
7393
|
const orientation = () => options.orientation ?? "horizontal";
|
|
7394
|
+
const closable = () => options.closable ?? false;
|
|
7395
|
+
const renamable = () => options.renamable ?? false;
|
|
7396
|
+
const labelOf = (index) => options.label?.(index) ?? `tab ${index + 1}`;
|
|
6747
7397
|
const firstEnabled = () => {
|
|
6748
|
-
for (let index = 0; index <
|
|
7398
|
+
for (let index = 0; index < count(); index += 1) if (!disabled.has(index)) return index;
|
|
6749
7399
|
return 0;
|
|
6750
7400
|
};
|
|
6751
7401
|
const initial = options.defaultSelected ?? firstEnabled();
|
|
6752
7402
|
const [selected, request] = createControllable({
|
|
6753
7403
|
value: () => options.value,
|
|
6754
|
-
initial: initial >= 0 && initial <
|
|
7404
|
+
initial: initial >= 0 && initial < count() && !disabled.has(initial) ? initial : firstEnabled(),
|
|
6755
7405
|
onChange: () => options.onValueChange
|
|
6756
7406
|
});
|
|
6757
7407
|
/** The tab that carries `tabindex=0`: the selected one, or the first enabled. */
|
|
6758
7408
|
const roving = () => disabled.has(selected()) ? firstEnabled() : selected();
|
|
6759
7409
|
const select = (index) => {
|
|
6760
|
-
if (index < 0 || index >=
|
|
7410
|
+
if (index < 0 || index >= count() || disabled.has(index)) return;
|
|
6761
7411
|
request(index);
|
|
6762
7412
|
};
|
|
6763
7413
|
const move = (index) => {
|
|
@@ -6765,14 +7415,59 @@ const createTabs = (options = {}) => {
|
|
|
6765
7415
|
if (activation() === "automatic") select(index);
|
|
6766
7416
|
};
|
|
6767
7417
|
const step = (from, delta) => {
|
|
6768
|
-
for (let offset = 1; offset <=
|
|
6769
|
-
const candidate = ((from + delta * offset) %
|
|
7418
|
+
for (let offset = 1; offset <= count(); offset += 1) {
|
|
7419
|
+
const candidate = ((from + delta * offset) % count() + count()) % count();
|
|
6770
7420
|
if (!disabled.has(candidate)) return candidate;
|
|
6771
7421
|
}
|
|
6772
7422
|
return from;
|
|
6773
7423
|
};
|
|
6774
7424
|
const [focused, setFocused] = createSignal(-1);
|
|
7425
|
+
/**
|
|
7426
|
+
* The tab that takes focus once `onClose(index)`'s removal lands: the tab at
|
|
7427
|
+
* `index` in the shortened list (the following tab slid into it), or the
|
|
7428
|
+
* previous one when the last tab closed.
|
|
7429
|
+
*/
|
|
7430
|
+
const focusAfterClose = (index) => index < count() - 1 ? index : Math.max(0, count() - 2);
|
|
7431
|
+
const close = (index) => {
|
|
7432
|
+
if (!closable() || count() <= 1 || index < 0 || index >= count()) return;
|
|
7433
|
+
const next = focusAfterClose(index);
|
|
7434
|
+
setFocused(next);
|
|
7435
|
+
if (selected() === index) select(next);
|
|
7436
|
+
options.onClose?.(index);
|
|
7437
|
+
};
|
|
7438
|
+
const [editVersion, setEditVersion] = createSignal(0);
|
|
7439
|
+
let editingIndex = null;
|
|
7440
|
+
const editing = () => {
|
|
7441
|
+
editVersion();
|
|
7442
|
+
return editingIndex;
|
|
7443
|
+
};
|
|
7444
|
+
const setEditing = (index) => {
|
|
7445
|
+
editingIndex = index;
|
|
7446
|
+
setEditVersion((version) => version + 1);
|
|
7447
|
+
};
|
|
7448
|
+
const [draft, setDraft] = createSignal("");
|
|
7449
|
+
const begin = (index) => {
|
|
7450
|
+
if (!renamable() || disabled.has(index) || index < 0 || index >= count()) return;
|
|
7451
|
+
setEditing(index);
|
|
7452
|
+
setDraft(options.label?.(index) ?? "");
|
|
7453
|
+
};
|
|
7454
|
+
const cancel = () => {
|
|
7455
|
+
if (editing() === null) return;
|
|
7456
|
+
setEditing(null);
|
|
7457
|
+
setDraft("");
|
|
7458
|
+
};
|
|
7459
|
+
/** Enter / blur commit; an empty label is not a name, so it cancels. */
|
|
7460
|
+
const commit = (label) => {
|
|
7461
|
+
const index = editing();
|
|
7462
|
+
if (index === null) return;
|
|
7463
|
+
setEditing(null);
|
|
7464
|
+
setDraft("");
|
|
7465
|
+
const trimmed = label.trim();
|
|
7466
|
+
if (trimmed === "") return;
|
|
7467
|
+
options.onRename?.(index, trimmed);
|
|
7468
|
+
};
|
|
6775
7469
|
const keyDown = (key) => {
|
|
7470
|
+
if (editing() !== null) return;
|
|
6776
7471
|
const current = focused();
|
|
6777
7472
|
if (current < 0) return;
|
|
6778
7473
|
const forward = orientation() === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
@@ -6788,27 +7483,34 @@ const createTabs = (options = {}) => {
|
|
|
6788
7483
|
move(firstEnabled());
|
|
6789
7484
|
break;
|
|
6790
7485
|
case "End": {
|
|
6791
|
-
let last =
|
|
7486
|
+
let last = count() - 1;
|
|
6792
7487
|
while (last > 0 && disabled.has(last)) last -= 1;
|
|
6793
7488
|
move(last);
|
|
6794
7489
|
break;
|
|
6795
7490
|
}
|
|
6796
7491
|
case "Enter":
|
|
6797
7492
|
case " ":
|
|
6798
|
-
case "Space":
|
|
7493
|
+
case "Space":
|
|
7494
|
+
select(current);
|
|
7495
|
+
break;
|
|
7496
|
+
case "Delete": close(current);
|
|
6799
7497
|
}
|
|
6800
7498
|
};
|
|
6801
7499
|
return {
|
|
6802
|
-
items
|
|
7500
|
+
get items() {
|
|
7501
|
+
return count();
|
|
7502
|
+
},
|
|
6803
7503
|
selected,
|
|
6804
7504
|
focused,
|
|
7505
|
+
editing,
|
|
6805
7506
|
select,
|
|
6806
7507
|
setSelected: (index) => {
|
|
6807
|
-
if (index >= 0 && index <
|
|
7508
|
+
if (index >= 0 && index < count()) request(index);
|
|
6808
7509
|
},
|
|
6809
7510
|
focusTab: (index) => {
|
|
6810
|
-
if (index >= 0 && index <
|
|
7511
|
+
if (index >= 0 && index < count()) setFocused(index);
|
|
6811
7512
|
},
|
|
7513
|
+
focusAfterClose,
|
|
6812
7514
|
keyDown,
|
|
6813
7515
|
tablistProps: {
|
|
6814
7516
|
role: "tablist",
|
|
@@ -6841,6 +7543,12 @@ const createTabs = (options = {}) => {
|
|
|
6841
7543
|
setFocused(index);
|
|
6842
7544
|
},
|
|
6843
7545
|
onKeyDown: (event) => {
|
|
7546
|
+
if (editing() !== null) return;
|
|
7547
|
+
if (event.key === "Delete" && closable()) {
|
|
7548
|
+
event.preventDefault();
|
|
7549
|
+
close(index);
|
|
7550
|
+
return;
|
|
7551
|
+
}
|
|
6844
7552
|
const before = focused();
|
|
6845
7553
|
keyDown(event.key);
|
|
6846
7554
|
if (focused() !== before || event.key === "Enter" || event.key === " ") event.preventDefault();
|
|
@@ -6856,6 +7564,43 @@ const createTabs = (options = {}) => {
|
|
|
6856
7564
|
get hidden() {
|
|
6857
7565
|
return selected() !== index;
|
|
6858
7566
|
}
|
|
7567
|
+
}),
|
|
7568
|
+
closeProps: (index) => ({
|
|
7569
|
+
get "aria-label"() {
|
|
7570
|
+
return `Close ${labelOf(index)}`;
|
|
7571
|
+
},
|
|
7572
|
+
tabindex: -1,
|
|
7573
|
+
onClick: () => close(index)
|
|
7574
|
+
}),
|
|
7575
|
+
renameProps: (index) => ({
|
|
7576
|
+
onDblClick: () => begin(index),
|
|
7577
|
+
editing: () => editing() === index,
|
|
7578
|
+
begin,
|
|
7579
|
+
commit,
|
|
7580
|
+
cancel,
|
|
7581
|
+
inputProps: {
|
|
7582
|
+
type: "text",
|
|
7583
|
+
get value() {
|
|
7584
|
+
return draft();
|
|
7585
|
+
},
|
|
7586
|
+
get "aria-label"() {
|
|
7587
|
+
return `Rename ${labelOf(index)}`;
|
|
7588
|
+
},
|
|
7589
|
+
onInput: (event) => {
|
|
7590
|
+
const target = event.currentTarget;
|
|
7591
|
+
if (target !== null) setDraft(target.value);
|
|
7592
|
+
},
|
|
7593
|
+
onKeyDown: (event) => {
|
|
7594
|
+
if (event.key === "Enter") {
|
|
7595
|
+
event.preventDefault();
|
|
7596
|
+
commit(draft());
|
|
7597
|
+
} else if (event.key === "Escape") {
|
|
7598
|
+
event.preventDefault();
|
|
7599
|
+
cancel();
|
|
7600
|
+
}
|
|
7601
|
+
},
|
|
7602
|
+
onBlur: () => commit(draft())
|
|
7603
|
+
}
|
|
6859
7604
|
})
|
|
6860
7605
|
};
|
|
6861
7606
|
};
|
|
@@ -6936,11 +7681,10 @@ const shrink = (x) => x <= MIN_WIDTH ? x : x - Math.floor(x / 4) < MIN_WIDTH ? M
|
|
|
6936
7681
|
const grow = (x) => x >= DOMAIN_MAX ? x : x + Math.floor(x / 4) > DOMAIN_MAX ? DOMAIN_MAX : x + Math.floor(x / 4);
|
|
6937
7682
|
const clampStart = (start, w) => start < DOMAIN_MIN ? DOMAIN_MIN : start + w > DOMAIN_MAX ? DOMAIN_MAX - w : start;
|
|
6938
7683
|
/**
|
|
6939
|
-
*
|
|
6940
|
-
*
|
|
6941
|
-
* reactive.
|
|
7684
|
+
* The demo tree: five fixed spans, collapsing, zoom. Its oracle is
|
|
7685
|
+
* `specs/apg/timeline.qnt` through the differential walk.
|
|
6942
7686
|
*/
|
|
6943
|
-
const
|
|
7687
|
+
const createDemoTimeline = (options = {}) => {
|
|
6944
7688
|
const initialExpanded = (options.defaultExpanded ?? []).reduce((mask, node) => setBit(mask, node, true), 0);
|
|
6945
7689
|
const [expanded, setExpanded] = createSignal(initialExpanded);
|
|
6946
7690
|
const [selected, requestSelected] = createControllable({
|
|
@@ -7095,6 +7839,172 @@ const createTimeline = (options = {}) => {
|
|
|
7095
7839
|
}
|
|
7096
7840
|
};
|
|
7097
7841
|
};
|
|
7842
|
+
/** A zero-length span still paints: at least half a percent of the track. */
|
|
7843
|
+
const MIN_BAR_WIDTH = .5;
|
|
7844
|
+
/** Aim for about five ticks, whatever the extent. */
|
|
7845
|
+
const TICK_TARGET = 5;
|
|
7846
|
+
const percent = (value) => `${Math.round(value * 1e3) / 1e3}%`;
|
|
7847
|
+
/** 1, 2 or 5 times a power of ten, the step that makes the fewest labels fit. */
|
|
7848
|
+
const niceStep = (span) => {
|
|
7849
|
+
const raw = span / TICK_TARGET;
|
|
7850
|
+
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
|
7851
|
+
const normalized = raw / magnitude;
|
|
7852
|
+
return (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
|
7853
|
+
};
|
|
7854
|
+
const formatTick = (value, step, unit) => {
|
|
7855
|
+
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step)));
|
|
7856
|
+
return `${value.toFixed(decimals)} ${unit}`;
|
|
7857
|
+
};
|
|
7858
|
+
/**
|
|
7859
|
+
* The rows mode: real span data on one axis. Everything hangs off `options.rows`
|
|
7860
|
+
* read at attribute-read time, so the host may pass a getter and a trace may
|
|
7861
|
+
* arrive after mount.
|
|
7862
|
+
*/
|
|
7863
|
+
const createRowsTimeline = (options) => {
|
|
7864
|
+
const rows = () => options.rows;
|
|
7865
|
+
const rowOf = (id) => {
|
|
7866
|
+
const row = rows().find((candidate) => candidate.id === id);
|
|
7867
|
+
if (row === void 0) throw new Error(`createTimeline: no row ${id}`);
|
|
7868
|
+
return row;
|
|
7869
|
+
};
|
|
7870
|
+
const [selectedId, requestSelected] = createControllable({
|
|
7871
|
+
value: () => options.selected,
|
|
7872
|
+
initial: void 0,
|
|
7873
|
+
onChange: () => (id) => {
|
|
7874
|
+
if (id !== void 0) options.onSelect?.(id);
|
|
7875
|
+
}
|
|
7876
|
+
});
|
|
7877
|
+
const [focus, setFocus] = createSignal(void 0);
|
|
7878
|
+
const currentFocus = () => focus() ?? rows()[0]?.id;
|
|
7879
|
+
const select = (id) => {
|
|
7880
|
+
requestSelected(id);
|
|
7881
|
+
};
|
|
7882
|
+
const extent = () => {
|
|
7883
|
+
let start = Number.POSITIVE_INFINITY;
|
|
7884
|
+
let end = Number.NEGATIVE_INFINITY;
|
|
7885
|
+
for (const row of rows()) {
|
|
7886
|
+
start = Math.min(start, row.start);
|
|
7887
|
+
end = Math.max(end, row.end);
|
|
7888
|
+
}
|
|
7889
|
+
return start <= end ? {
|
|
7890
|
+
start,
|
|
7891
|
+
end
|
|
7892
|
+
} : {
|
|
7893
|
+
start: 0,
|
|
7894
|
+
end: 0
|
|
7895
|
+
};
|
|
7896
|
+
};
|
|
7897
|
+
const ticks = () => {
|
|
7898
|
+
if (rows().length === 0) return [];
|
|
7899
|
+
const { start, end } = extent();
|
|
7900
|
+
const unit = options.axis?.unit ?? "ms";
|
|
7901
|
+
const span = end - start;
|
|
7902
|
+
if (!(span > 0)) return [{
|
|
7903
|
+
value: start,
|
|
7904
|
+
left: 0,
|
|
7905
|
+
label: formatTick(start, 1, unit)
|
|
7906
|
+
}];
|
|
7907
|
+
const step = niceStep(span);
|
|
7908
|
+
const out = [];
|
|
7909
|
+
for (let value = Math.ceil(start / step) * step; value <= end; value += step) out.push({
|
|
7910
|
+
value,
|
|
7911
|
+
left: (value - start) / span * 100,
|
|
7912
|
+
label: formatTick(value, step, unit)
|
|
7913
|
+
});
|
|
7914
|
+
return out;
|
|
7915
|
+
};
|
|
7916
|
+
const barStyle = (id) => {
|
|
7917
|
+
const row = rowOf(id);
|
|
7918
|
+
const depth = String(row.depth ?? 0);
|
|
7919
|
+
const { start, end } = extent();
|
|
7920
|
+
const span = end - start;
|
|
7921
|
+
if (!(span > 0)) return {
|
|
7922
|
+
left: "0%",
|
|
7923
|
+
width: percent(MIN_BAR_WIDTH),
|
|
7924
|
+
"--depth": depth
|
|
7925
|
+
};
|
|
7926
|
+
const left = Math.max(0, (row.start - start) / span * 100);
|
|
7927
|
+
const width = Math.max(MIN_BAR_WIDTH, (row.end - row.start) / span * 100);
|
|
7928
|
+
return {
|
|
7929
|
+
left: percent(left),
|
|
7930
|
+
width: percent(width),
|
|
7931
|
+
"--depth": depth
|
|
7932
|
+
};
|
|
7933
|
+
};
|
|
7934
|
+
const onKeyDown = (id, event) => {
|
|
7935
|
+
const list = rows();
|
|
7936
|
+
const index = list.findIndex((row) => row.id === id);
|
|
7937
|
+
switch (event.key) {
|
|
7938
|
+
case "ArrowDown":
|
|
7939
|
+
setFocus(list[Math.min(index + 1, list.length - 1)]?.id);
|
|
7940
|
+
break;
|
|
7941
|
+
case "ArrowUp":
|
|
7942
|
+
setFocus(list[Math.max(index - 1, 0)]?.id);
|
|
7943
|
+
break;
|
|
7944
|
+
case "Home":
|
|
7945
|
+
setFocus(list[0]?.id);
|
|
7946
|
+
break;
|
|
7947
|
+
case "End":
|
|
7948
|
+
setFocus(list[list.length - 1]?.id);
|
|
7949
|
+
break;
|
|
7950
|
+
case "Enter":
|
|
7951
|
+
case " ":
|
|
7952
|
+
select(id);
|
|
7953
|
+
break;
|
|
7954
|
+
default: return;
|
|
7955
|
+
}
|
|
7956
|
+
event.preventDefault();
|
|
7957
|
+
};
|
|
7958
|
+
return {
|
|
7959
|
+
extent,
|
|
7960
|
+
depthOf: (id) => rowOf(id).depth ?? 0,
|
|
7961
|
+
listProps: {
|
|
7962
|
+
role: "list",
|
|
7963
|
+
"aria-label": "Span timeline"
|
|
7964
|
+
},
|
|
7965
|
+
rowProps: (id) => ({
|
|
7966
|
+
role: "listitem",
|
|
7967
|
+
get "aria-level"() {
|
|
7968
|
+
return (rowOf(id).depth ?? 0) + 1;
|
|
7969
|
+
},
|
|
7970
|
+
get "aria-current"() {
|
|
7971
|
+
return selectedId() === id ? "true" : void 0;
|
|
7972
|
+
},
|
|
7973
|
+
get "data-status"() {
|
|
7974
|
+
return rowOf(id).status ?? "unset";
|
|
7975
|
+
},
|
|
7976
|
+
get tabindex() {
|
|
7977
|
+
return currentFocus() === id ? 0 : -1;
|
|
7978
|
+
},
|
|
7979
|
+
onClick: () => {
|
|
7980
|
+
setFocus(id);
|
|
7981
|
+
select(id);
|
|
7982
|
+
},
|
|
7983
|
+
onKeyDown: (event) => onKeyDown(id, event)
|
|
7984
|
+
}),
|
|
7985
|
+
barProps: (id) => ({
|
|
7986
|
+
"aria-hidden": "true",
|
|
7987
|
+
get style() {
|
|
7988
|
+
return barStyle(id);
|
|
7989
|
+
}
|
|
7990
|
+
}),
|
|
7991
|
+
labelProps: (id) => ({ get style() {
|
|
7992
|
+
return { "--depth": String(rowOf(id).depth ?? 0) };
|
|
7993
|
+
} }),
|
|
7994
|
+
rulerProps: {
|
|
7995
|
+
role: "presentation",
|
|
7996
|
+
"aria-hidden": "true",
|
|
7997
|
+
get ticks() {
|
|
7998
|
+
return ticks();
|
|
7999
|
+
}
|
|
8000
|
+
},
|
|
8001
|
+
select,
|
|
8002
|
+
selectedId
|
|
8003
|
+
};
|
|
8004
|
+
};
|
|
8005
|
+
function createTimeline(options = {}) {
|
|
8006
|
+
return "rows" in options ? createRowsTimeline(options) : createDemoTimeline(options);
|
|
8007
|
+
}
|
|
7098
8008
|
// src/toast.ts
|
|
7099
8009
|
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
7100
8010
|
const DEFAULT_MAX = 3;
|
|
@@ -8825,4 +9735,4 @@ const createRearrangeableListbox = (options) => {
|
|
|
8825
9735
|
})
|
|
8826
9736
|
};
|
|
8827
9737
|
};
|
|
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 };
|
|
9738
|
+
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, createCommandPalette, createConfirm, createDialogModal, createDisclosure, createDrawer, createDropZone, createFeed, createField, createGrid, createHotkeys, createKnob, 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 };
|