@cssints/headless 0.19.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/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
- * Behaviour is checked against `specs/apg/checkbox-group.qnt` by the bridge.
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"() {
@@ -2506,81 +2629,481 @@ const createCombobox = (options) => {
2506
2629
  }
2507
2630
  }
2508
2631
  };
2509
- };
2510
- // src/dialog-modal.ts
2511
- /**
2512
- * APG Dialog (Modal) on Solid 2. `modal` adds `aria-modal`. Focus moves into
2513
- * the dialog on open and is the host's to restore on close (the fixture does,
2514
- * for dismiss / Escape). The full stack/focus-trap machine lives in
2515
- * `specs/apg/dialog-modal.qnt`; the generated model is the oracle in tests, not
2516
- * loaded here.
2517
- *
2518
- * Request/commit (map ticket `rt1p`): uncontrolled by default (`defaultOpen`);
2519
- * pass `value` + `onOpenChange` to own the open state. `openDialog` / `close`
2520
- * are requests the host may ignore.
2521
- */
2522
- const createDialogModal = (options = {}) => {
2523
- const base = options.id ?? createUniqueId();
2524
- const modal = options.modal ?? true;
2525
- const role = options.role ?? "dialog";
2526
- const [open, requestOpen] = createControllable({
2527
- value: () => options.value,
2528
- initial: options.defaultOpen ?? false,
2529
- onChange: () => options.onOpenChange
2632
+ };
2633
+ // src/dialog-modal.ts
2634
+ /**
2635
+ * APG Dialog (Modal) on Solid 2. `modal` adds `aria-modal`. Focus moves into
2636
+ * the dialog on open and is the host's to restore on close (the fixture does,
2637
+ * for dismiss / Escape). The full stack/focus-trap machine lives in
2638
+ * `specs/apg/dialog-modal.qnt`; the generated model is the oracle in tests, not
2639
+ * loaded here.
2640
+ *
2641
+ * Request/commit (map ticket `rt1p`): uncontrolled by default (`defaultOpen`);
2642
+ * pass `value` + `onOpenChange` to own the open state. `openDialog` / `close`
2643
+ * are requests the host may ignore.
2644
+ */
2645
+ const createDialogModal = (options = {}) => {
2646
+ const base = options.id ?? createUniqueId();
2647
+ const modal = options.modal ?? true;
2648
+ const role = options.role ?? "dialog";
2649
+ const [open, requestOpen] = createControllable({
2650
+ value: () => options.value,
2651
+ initial: options.defaultOpen ?? false,
2652
+ onChange: () => options.onOpenChange
2653
+ });
2654
+ const openDialog = () => requestOpen(true);
2655
+ const close = () => requestOpen(false);
2656
+ return {
2657
+ open,
2658
+ openDialog,
2659
+ close,
2660
+ triggerProps: {
2661
+ id: `${base}-trigger`,
2662
+ "aria-haspopup": "dialog",
2663
+ get "aria-expanded"() {
2664
+ return open() ? "true" : "false";
2665
+ },
2666
+ "aria-controls": `${base}-dialog`,
2667
+ onClick: () => openDialog(),
2668
+ onKeyDown: (event) => {
2669
+ if (event.key === "Enter" || event.key === " ") {
2670
+ event.preventDefault();
2671
+ openDialog();
2672
+ } else if (event.key === "Escape" && open()) close();
2673
+ }
2674
+ },
2675
+ dialogProps: {
2676
+ id: `${base}-dialog`,
2677
+ role,
2678
+ get "aria-modal"() {
2679
+ return modal && open() ? "true" : void 0;
2680
+ },
2681
+ "aria-labelledby": options.labelledby,
2682
+ "aria-describedby": options.describedby ?? (role === "alertdialog" ? "description" : void 0),
2683
+ tabindex: -1,
2684
+ get hidden() {
2685
+ return !open();
2686
+ },
2687
+ onKeyDown: (event) => {
2688
+ if (event.key === "Escape" && !options.keyboardDismissDisabled) {
2689
+ event.preventDefault();
2690
+ close();
2691
+ }
2692
+ }
2693
+ },
2694
+ backdropProps: {
2695
+ id: `${base}-backdrop`,
2696
+ get hidden() {
2697
+ return !open();
2698
+ },
2699
+ onClick: () => {
2700
+ if (options.dismissable ?? true) close();
2701
+ }
2702
+ },
2703
+ dismissProps: {
2704
+ id: `${base}-dismiss`,
2705
+ type: "button",
2706
+ onClick: () => close()
2707
+ }
2708
+ };
2709
+ };
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`);
2749
+ }
2750
+ return chord;
2751
+ };
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);
2755
+ /**
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).
2763
+ */
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
+ }
2811
+ };
2812
+ };
2813
+ // src/status.ts
2814
+ /**
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
2530
3039
  });
2531
- const openDialog = () => requestOpen(true);
2532
- const close = () => requestOpen(false);
2533
3040
  return {
2534
- open,
2535
- openDialog,
2536
- close,
2537
- triggerProps: {
2538
- id: `${base}-trigger`,
2539
- "aria-haspopup": "dialog",
2540
- get "aria-expanded"() {
2541
- return open() ? "true" : "false";
2542
- },
2543
- "aria-controls": `${base}-dialog`,
2544
- onClick: () => openDialog(),
2545
- onKeyDown: (event) => {
2546
- if (event.key === "Enter" || event.key === " ") {
2547
- event.preventDefault();
2548
- openDialog();
2549
- } else if (event.key === "Escape" && open()) close();
2550
- }
2551
- },
2552
- dialogProps: {
2553
- id: `${base}-dialog`,
2554
- role,
2555
- get "aria-modal"() {
2556
- return modal && open() ? "true" : void 0;
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();
2557
3056
  },
2558
- "aria-labelledby": options.labelledby,
2559
- "aria-describedby": options.describedby ?? (role === "alertdialog" ? "description" : void 0),
2560
- tabindex: -1,
2561
- get hidden() {
2562
- return !open();
3057
+ "aria-controls": listId,
3058
+ get "aria-activedescendant"() {
3059
+ const current = results()[active()];
3060
+ return current === void 0 ? void 0 : optionId(current.id);
2563
3061
  },
2564
- onKeyDown: (event) => {
2565
- if (event.key === "Escape" && !options.keyboardDismissDisabled) {
2566
- event.preventDefault();
2567
- close();
2568
- }
2569
- }
3062
+ onKeyDown
2570
3063
  },
2571
- backdropProps: {
2572
- id: `${base}-backdrop`,
2573
- get hidden() {
2574
- return !open();
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";
2575
3075
  },
3076
+ tabindex: -1,
2576
3077
  onClick: () => {
2577
- if (options.dismissable ?? true) close();
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);
2578
3083
  }
2579
- },
2580
- dismissProps: {
2581
- id: `${base}-dismiss`,
2582
- type: "button",
2583
- onClick: () => close()
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
+ };
2584
3107
  }
2585
3108
  };
2586
3109
  };
@@ -3233,90 +3756,6 @@ const createFeed = (options = {}) => {
3233
3756
  }
3234
3757
  };
3235
3758
  };
3236
- // src/field.ts
3237
- const text = (value) => value === void 0 || value === "" ? void 0 : value;
3238
- const external = (value) => (value === void 0 ? [] : typeof value === "string" ? [value] : value).filter((id) => id !== "");
3239
- /**
3240
- * The ARIA contract of a form field: a label, a control, a hint and an error.
3241
- * There is no APG pattern for it (APG has no "form field"; the nearest is the
3242
- * label and description rules of the accessible name computation), so the
3243
- * contract comes from WAI-ARIA 1.2 (`aria-describedby`, `aria-invalid`,
3244
- * `aria-required`) and HTML (`label[for]`, `required`), and from the
3245
- * majority of Base UI Field, react-aria TextField and ariakit FormControl,
3246
- * which list the description and the error message together. It is not checked
3247
- * against a Quint spec; `test/field.browser.test.tsx` is its oracle.
3248
- *
3249
- * Decisions: the error does not replace the hint, both are listed, hint first
3250
- * (the hint is what tells the user how to fix the error); an error implies
3251
- * invalid and `invalid: false` overrides it; a hidden message is never
3252
- * referenced, so `aria-describedby` has no dangling id. The error element is
3253
- * `role="alert"` only while it is shown (a hidden element announces nothing, and
3254
- * one that appears is announced); the hint is never an alert.
3255
- *
3256
- * `describedby` lists host-owned ids after the field's own: one refusal that
3257
- * belongs to two controls is one form-level element (with its own `role`), named
3258
- * by both. The field cannot know whether that element is shown: the host
3259
- * passes the id only while it is.
3260
- *
3261
- * Options are read when an attribute is read: pass getters, never destructure.
3262
- */
3263
- const createField = (options = {}) => {
3264
- const base = options.id ?? createUniqueId();
3265
- const controlId = `${base}-control`;
3266
- const labelId = `${base}-label`;
3267
- const hintId = `${base}-hint`;
3268
- const errorId = `${base}-error`;
3269
- const hint = () => text(options.hint);
3270
- const message = () => text(options.error) ?? (typeof options.invalid === "string" ? text(options.invalid) : void 0);
3271
- const invalid = () => options.invalid === false ? false : options.invalid === true || message() !== void 0;
3272
- const error = () => invalid() ? message() : void 0;
3273
- const describedIds = () => [
3274
- ...hint() === void 0 ? [] : [hintId],
3275
- ...error() === void 0 ? [] : [errorId],
3276
- ...external(options.describedby)
3277
- ];
3278
- return {
3279
- invalid,
3280
- hint,
3281
- error,
3282
- describedIds,
3283
- controlProps: {
3284
- id: controlId,
3285
- get "aria-describedby"() {
3286
- const ids = describedIds();
3287
- return ids.length === 0 ? void 0 : ids.join(" ");
3288
- },
3289
- get "aria-invalid"() {
3290
- return invalid() ? "true" : void 0;
3291
- },
3292
- get "aria-required"() {
3293
- return options.required === true ? "true" : void 0;
3294
- },
3295
- get required() {
3296
- return options.required === true ? true : void 0;
3297
- }
3298
- },
3299
- labelProps: {
3300
- id: labelId,
3301
- for: controlId
3302
- },
3303
- hintProps: {
3304
- id: hintId,
3305
- get hidden() {
3306
- return hint() === void 0;
3307
- }
3308
- },
3309
- errorProps: {
3310
- id: errorId,
3311
- get hidden() {
3312
- return error() === void 0;
3313
- },
3314
- get role() {
3315
- return error() === void 0 ? void 0 : "alert";
3316
- }
3317
- }
3318
- };
3319
- };
3320
3759
  // src/pagination.ts
3321
3760
  const clamp$2 = (value, min, max) => Math.min(Math.max(value, min), max);
3322
3761
  /**
@@ -4289,107 +4728,219 @@ const createGrid = (options = {}) => {
4289
4728
  if (!flag) requestSort(-1);
4290
4729
  }
4291
4730
  };
4292
- };
4293
- // src/hotkeys.ts
4294
- const MODIFIERS = /* @__PURE__ */ new Set([
4295
- "mod",
4296
- "ctrl",
4297
- "alt",
4298
- "shift",
4299
- "meta"
4300
- ]);
4301
- const SHIFTED = /* @__PURE__ */ new Set("~!@#$%^&*()_+{}|:\"<>?");
4302
- const detectsApple = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
4303
- const parse = (keys, isApple) => {
4304
- const parts = keys.toLowerCase().split("+");
4305
- const key = parts.pop() ?? "";
4306
- if (key === "" || MODIFIERS.has(key)) throw new TypeError(`hotkeys: "${keys}" ends with no key`);
4307
- const chord = {
4308
- ctrl: false,
4309
- meta: false,
4310
- alt: false,
4311
- shift: SHIFTED.has(key),
4312
- key
4313
- };
4314
- for (const part of parts) switch (part) {
4315
- case "mod":
4316
- if (isApple) chord.meta = true;
4317
- else chord.ctrl = true;
4318
- break;
4319
- case "ctrl":
4320
- chord.ctrl = true;
4321
- break;
4322
- case "meta":
4323
- chord.meta = true;
4324
- break;
4325
- case "alt":
4326
- chord.alt = true;
4327
- break;
4328
- case "shift":
4329
- chord.shift = true;
4330
- break;
4331
- default: throw new TypeError(`hotkeys: "${part}" in "${keys}" is not a modifier`);
4332
- }
4333
- return chord;
4334
- };
4335
- 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;
4336
- const editable = (target) => target instanceof HTMLElement && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
4337
- const inScope = (scope, target) => scope === void 0 || target instanceof Node && scope.contains(target);
4338
- /**
4339
- * One `keydown` listener in the capture phase, one list of chords. A chord
4340
- * fires when its modifiers match exactly, its `when` holds, its scope contains
4341
- * the event's target, and the focus is not in a text field (unless
4342
- * `allowInEditable`). The most recently bound match is the only one that fires,
4343
- * so a modal's `"/"` overrides the page's and unbinding it brings the page's
4344
- * back; `preventDefault` is on by default for the bindings whose chord the
4345
- * browser would otherwise act on (Cmd/Ctrl+S, Cmd/Ctrl+K).
4731
+ };
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;
4747
+ };
4748
+ /**
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.
4346
4778
  */
4347
- const createHotkeys = (options = {}) => {
4348
- const isApple = options.isApple ?? detectsApple();
4349
- const bindings = [];
4350
- let listening = false;
4351
- let disposed = false;
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);
4352
4807
  const onKeyDown = (event) => {
4353
- for (let i = bindings.length - 1; i >= 0; i--) {
4354
- const entry = bindings[i];
4355
- if (entry === void 0) continue;
4356
- const { chord, binding } = entry;
4357
- if (!matches(chord, event) || binding.when?.() === false) continue;
4358
- if (!inScope(binding.scope, event.target)) continue;
4359
- if (binding.allowInEditable !== true && editable(event.target)) continue;
4360
- if (binding.preventDefault !== false) event.preventDefault();
4361
- binding.onTrigger(event);
4362
- return;
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;
4363
4830
  }
4831
+ event.preventDefault();
4364
4832
  };
4365
- const stop = () => {
4366
- if (!listening) return;
4367
- listening = false;
4368
- document.removeEventListener("keydown", onKeyDown, true);
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";
4369
4843
  };
4370
- return {
4371
- bind: (binding) => {
4372
- if (disposed) return () => {};
4373
- const entry = {
4374
- chord: parse(binding.keys, isApple),
4375
- binding
4376
- };
4377
- bindings.push(entry);
4378
- if (!listening) {
4379
- listening = true;
4380
- document.addEventListener("keydown", onKeyDown, true);
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;
4381
4880
  }
4382
- return () => {
4383
- const at = bindings.indexOf(entry);
4384
- if (at === -1) return;
4385
- bindings.splice(at, 1);
4386
- if (bindings.length === 0) stop();
4387
- };
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
4388
4939
  },
4389
- dispose: () => {
4390
- disposed = true;
4391
- bindings.length = 0;
4392
- stop();
4940
+ valueProps: {
4941
+ id: `${base}-value`,
4942
+ role: "status",
4943
+ "aria-live": "polite"
4393
4944
  }
4394
4945
  };
4395
4946
  };
@@ -4820,9 +5371,11 @@ const createMenu = (options) => {
4820
5371
  const disabled = new Set(options.disabled ?? []);
4821
5372
  for (const [index, item] of items.entries()) if (item.disabled) disabled.add(index);
4822
5373
  const held = (options.triggerMode ?? "outside") === "held";
4823
- let level = 0;
4824
- let index = -1;
4825
- let open = options.defaultOpen ?? false;
5374
+ const [isOpen, requestOpen] = createControllable({
5375
+ value: () => options.open,
5376
+ initial: options.defaultOpen === true,
5377
+ onChange: () => options.onOpenChange
5378
+ });
4826
5379
  let checked = new Set(options.defaultChecked ?? []);
4827
5380
  const variantChoice = () => {
4828
5381
  if (options.variant === "mixed") return 1;
@@ -4858,57 +5411,80 @@ const createMenu = (options) => {
4858
5411
  version();
4859
5412
  return read();
4860
5413
  };
4861
- if (open) {
4862
- level = 2;
4863
- index = 0;
4864
- } else if (held) level = 1;
5414
+ let closed = held ? 1 : 0;
5415
+ let index = 0;
5416
+ let seenOpen = isOpen();
4865
5417
  const isDisabled = (i) => disabled.has(i);
4866
- const setFocus = (nextLevel, nextIndex) => {
4867
- level = nextLevel;
4868
- index = nextIndex;
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;
4869
5435
  bump();
4870
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
+ };
4871
5457
  const openOn = (target) => {
4872
- level = 2;
4873
- index = target;
4874
- open = true;
4875
- bump();
4876
- options.onOpenChange?.(true);
5458
+ move(true, target, 1);
4877
5459
  };
4878
5460
  const closeToTrigger = () => {
4879
- level = 1;
4880
- index = -1;
4881
- open = false;
4882
- bump();
4883
- options.onOpenChange?.(false);
5461
+ move(false, -1, 1);
4884
5462
  };
4885
5463
  const focusTrigger = () => {
4886
- if (level === 0) setFocus(1, -1);
5464
+ if (level() === 0) {
5465
+ closed = 1;
5466
+ bump();
5467
+ }
4887
5468
  };
4888
5469
  const leave = () => {
4889
- const wasOpen = open;
4890
- level = held ? 1 : 0;
4891
- index = -1;
4892
- open = false;
4893
- bump();
4894
- if (wasOpen) options.onOpenChange?.(false);
5470
+ move(false, -1, held ? 1 : 0);
4895
5471
  };
4896
5472
  const clickTrigger = () => {
4897
- if (open) closeToTrigger();
5473
+ if (isOpen()) closeToTrigger();
4898
5474
  else openOn(0);
4899
5475
  };
4900
5476
  const openMenu = () => {
4901
- if (!open) openOn(0);
5477
+ if (!isOpen()) openOn(0);
4902
5478
  };
4903
5479
  const closeMenu = () => {
4904
- if (open) closeToTrigger();
5480
+ if (isOpen()) closeToTrigger();
4905
5481
  };
4906
5482
  const activate = (close) => {
4907
- if (level === 1) {
5483
+ if (level() === 1) {
4908
5484
  openOn(0);
4909
5485
  return;
4910
5486
  }
4911
- if (level !== 2 || index < 0 || isDisabled(index)) return;
5487
+ if (level() !== 2 || index < 0 || isDisabled(index)) return;
4912
5488
  const item = items[index];
4913
5489
  if (item === void 0) return;
4914
5490
  if (item.kind === "checkbox") {
@@ -4949,23 +5525,23 @@ const createMenu = (options) => {
4949
5525
  activate(false);
4950
5526
  break;
4951
5527
  case "ArrowDown":
4952
- if (level === 1) openOn(0);
4953
- else if (level === 2) setFocus(2, wrapNext(index));
5528
+ if (level() === 1) openOn(0);
5529
+ else if (level() === 2) focusItem(wrapNext(focusedIndex()));
4954
5530
  break;
4955
5531
  case "ArrowUp":
4956
- if (level === 1) openOn(count - 1);
4957
- else if (level === 2) setFocus(2, wrapPrev(index));
5532
+ if (level() === 1) openOn(count - 1);
5533
+ else if (level() === 2) focusItem(wrapPrev(focusedIndex()));
4958
5534
  break;
4959
5535
  case "Home":
4960
- if (level === 2) setFocus(2, 0);
5536
+ if (level() === 2) focusItem(0);
4961
5537
  break;
4962
5538
  case "End":
4963
- if (level === 2) setFocus(2, count - 1);
5539
+ if (level() === 2) focusItem(count - 1);
4964
5540
  break;
4965
5541
  case "Escape":
4966
- if (level === 2) closeToTrigger();
5542
+ if (level() === 2) closeToTrigger();
4967
5543
  break;
4968
- case "Tab": if (level !== 0) leave();
5544
+ case "Tab": if (level() !== 0) leave();
4969
5545
  }
4970
5546
  };
4971
5547
  const search = (from, text) => {
@@ -4976,36 +5552,36 @@ const createMenu = (options) => {
4976
5552
  }
4977
5553
  };
4978
5554
  const type = (text) => {
4979
- if (!open || text.length === 0) return;
5555
+ if (!isOpen() || text.length === 0) return;
4980
5556
  buffer += text;
4981
5557
  if (bufferTimer !== void 0) clearTimeout(bufferTimer);
4982
5558
  bufferTimer = setTimeout(() => {
4983
5559
  buffer = "";
4984
5560
  }, prefs.typeaheadDelayMs);
4985
- const match = search(index >= 0 ? index : 0, buffer);
4986
- if (match !== void 0) setFocus(2, match);
5561
+ const from = focusedIndex() >= 0 ? focusedIndex() : 0;
5562
+ const match = search(from, buffer);
5563
+ if (match !== void 0) focusItem(match);
4987
5564
  };
4988
5565
  const codeOf = (i) => i % 2 === 0 ? 1 : 2;
4989
5566
  const typeaheadCode = (code) => {
4990
- if (!open) return;
4991
- const from = index >= 0 ? index : 0;
5567
+ if (!isOpen()) return;
5568
+ const from = focusedIndex() >= 0 ? focusedIndex() : 0;
4992
5569
  for (let offset = 1; offset < count; offset += 1) {
4993
5570
  const candidate = (from + offset) % count;
4994
5571
  if (codeOf(candidate) === code) {
4995
- setFocus(2, candidate);
5572
+ focusItem(candidate);
4996
5573
  return;
4997
5574
  }
4998
5575
  }
4999
5576
  };
5000
5577
  const setFocusItem = (i) => {
5001
5578
  if (i < 0 || i >= count) return;
5002
- open = true;
5003
- setFocus(2, i);
5579
+ move(true, i, 1);
5004
5580
  };
5005
5581
  return {
5006
- open: track(() => open),
5007
- focusLevel: track(() => level),
5008
- focusIndex: track(() => index),
5582
+ open: track(isOpen),
5583
+ focusLevel: track(level),
5584
+ focusIndex: track(focusedIndex),
5009
5585
  items,
5010
5586
  checked: track(() => [...currentChecked()].sort((a, b) => a - b)),
5011
5587
  choice: track(() => currentChoice()),
@@ -5024,7 +5600,7 @@ const createMenu = (options) => {
5024
5600
  "aria-haspopup": "menu",
5025
5601
  get "aria-expanded"() {
5026
5602
  version();
5027
- return open ? "true" : "false";
5603
+ return isOpen() ? "true" : "false";
5028
5604
  },
5029
5605
  "aria-controls": menuId,
5030
5606
  get disabled() {
@@ -5046,7 +5622,7 @@ const createMenu = (options) => {
5046
5622
  role: "menu",
5047
5623
  get hidden() {
5048
5624
  version();
5049
- return !open;
5625
+ return !isOpen();
5050
5626
  },
5051
5627
  onKeyDown: (event) => {
5052
5628
  if (event.key === "Tab" || event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") event.preventDefault();
@@ -5072,7 +5648,7 @@ const createMenu = (options) => {
5072
5648
  },
5073
5649
  get tabindex() {
5074
5650
  version();
5075
- return level === 2 && index === i ? 0 : -1;
5651
+ return level() === 2 && index === i ? 0 : -1;
5076
5652
  },
5077
5653
  onClick: () => {
5078
5654
  if (isDisabled(i)) return;
@@ -6530,37 +7106,6 @@ const createSpinbutton = (options = {}) => {
6530
7106
  }
6531
7107
  };
6532
7108
  };
6533
- // src/status.ts
6534
- /**
6535
- * APG Alert's polite sibling: one `role=status` live region whose text the host
6536
- * replaces ("Link copied", "Saved"). It is not a queue: a second message shows
6537
- * in place of the first, so a burst reads as the last state, not as N alerts.
6538
- * The region is rendered once and stays mounted, which is what makes a change
6539
- * of its text announced.
6540
- *
6541
- * A status is never for errors: an error that needs attention now is
6542
- * `createAlert` (`role=alert`, assertive), and a message with a title, a
6543
- * description and a timeout is `createToast`.
6544
- */
6545
- const createStatus = (options = {}) => {
6546
- const id = options.id ?? createUniqueId();
6547
- const [message, setMessage] = createSignal();
6548
- return {
6549
- message,
6550
- show: (text) => {
6551
- setMessage(text);
6552
- },
6553
- clear: () => {
6554
- setMessage(void 0);
6555
- },
6556
- statusProps: {
6557
- id,
6558
- role: "status",
6559
- "aria-live": "polite",
6560
- "aria-atomic": "true"
6561
- }
6562
- };
6563
- };
6564
7109
  // src/switch.ts
6565
7110
  /**
6566
7111
  * APG Switch (https://www.w3.org/WAI/ARIA/apg/patterns/switch/): a binary on/off
@@ -6815,90 +7360,6 @@ const createTable = (options) => {
6815
7360
  toggleRow
6816
7361
  };
6817
7362
  };
6818
- // src/textbox.ts
6819
- const control = (field, options) => ({
6820
- ...field.controlProps,
6821
- get type() {
6822
- return options.type ?? "text";
6823
- },
6824
- get autoComplete() {
6825
- return options.autoComplete;
6826
- },
6827
- get spellcheck() {
6828
- if (options.spellCheck === void 0) return;
6829
- return options.spellCheck ? "true" : "false";
6830
- },
6831
- get autocapitalize() {
6832
- return options.autoCapitalize;
6833
- },
6834
- get inputmode() {
6835
- return options.inputMode;
6836
- },
6837
- get maxlength() {
6838
- return options.maxLength;
6839
- },
6840
- get readOnly() {
6841
- return options.readOnly === true ? true : void 0;
6842
- },
6843
- get disabled() {
6844
- return options.disabled === true ? true : void 0;
6845
- },
6846
- get name() {
6847
- return options.name;
6848
- },
6849
- get dir() {
6850
- return options.dir;
6851
- },
6852
- get placeholder() {
6853
- return options.placeholder;
6854
- },
6855
- onInput: (event) => {
6856
- options.onValueChange?.(event.currentTarget.value);
6857
- }
6858
- });
6859
- /**
6860
- * A text input with the field contract attached: label, hint, error (announced),
6861
- * `aria-describedby` and `aria-invalid` come from `createField`; this adds the
6862
- * props a host forgets — autofill tokens, spellcheck, inputmode, maxlength, the
6863
- * disabled/readOnly pair. Platform: native (`input` is its own APG-equivalent;
6864
- * no primitive re-implements text editing, which is why there is no key
6865
- * handling here).
6866
- *
6867
- * Both `required` and `aria-required` are emitted: the native attribute is what
6868
- * the browser enforces, the ARIA one is what a custom widget reading the same
6869
- * props needs. A host that validates itself passes `noValidate` on the form
6870
- * rather than dropping the attribute.
6871
- */
6872
- const createTextInput = (options = {}) => {
6873
- const field = createField(options);
6874
- return {
6875
- ...field,
6876
- controlProps: control(field, options)
6877
- };
6878
- };
6879
- /**
6880
- * The same contract for a textarea, plus rows, wrap and resize. `resize` is a
6881
- * style because CSS has no attribute for it; `none` is the value a layout that
6882
- * owns its own height wants.
6883
- */
6884
- const createTextArea = (options = {}) => {
6885
- const field = createField(options);
6886
- return {
6887
- ...field,
6888
- controlProps: {
6889
- ...control(field, options),
6890
- get rows() {
6891
- return options.rows;
6892
- },
6893
- get wrap() {
6894
- return options.wrap;
6895
- },
6896
- get style() {
6897
- return options.resize === void 0 ? void 0 : { resize: options.resize };
6898
- }
6899
- }
6900
- };
6901
- };
6902
7363
  // src/tabs.ts
6903
7364
  /**
6904
7365
  * APG Tabs (https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): state plus prop
@@ -9274,4 +9735,4 @@ const createRearrangeableListbox = (options) => {
9274
9735
  })
9275
9736
  };
9276
9737
  };
9277
- export { ConfirmPendingError, DEFAULT_PREFS, DRAG_PX, GRID, NO_POS, SNAP_PX, TONE, TREEVIEW_NODES, TWEEN_TICKS, WORLD_BOUND, YES_POS, ZMAX, ZMIN, ZONE, cellPart, createAccordion, createAlert, createAlertDialog, createBreadcrumb, createButton, createCanvas, createCarousel, createCheckbox, createCheckboxGroup, createCombobox, createConfirm, createDialogModal, createDisclosure, createDrawer, createDropZone, createFeed, createField, createGrid, createHotkeys, createLink, createListbox, createMenu, createMenubar, createMeter, createPagination, createPanZoom, createPlayer, createPopover, createRadioGroup, createRearrangeableListbox, createResizablePanels, createScrollspy, createSlider, createSliderMultiThumb, createSpinbutton, createStatus, createSwitch, createTable, createTabs, createTextArea, createTextInput, createTimeline, createToast, createToolbar, createTooltipGroup, createTreegrid, createTreeview, createWindowSplitter, gridCandidates, headerPart, packSelection, packSort, rowPart, treegridCellPart, treegridFocusTargetPart, treegridRowPart, withPrefs };
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 };