@stevederico/skateboard-ui 4.0.0 → 4.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # CHANGELOG
2
2
 
3
+ 4.3.0
4
+
5
+ Submenu aria-controls wiring
6
+ Menu labelled by trigger
7
+ Tooltip dismiss on outside tap
8
+ Checkbox indeterminate mixed state
9
+ Replace catch any narrowing
10
+ Match DOMException error names
11
+ Fix relative-time date casts
12
+ Add a11y regression tests
13
+
14
+ 4.2.0
15
+
16
+ Restore menu focus on select
17
+ Guard menu pointer focus-steal
18
+ Add menu keyboard focus ring
19
+ Conditional dialog aria labelling
20
+ Reentrant overlay scroll lock
21
+ Name popover dialog
22
+ Slider name and form
23
+ Fix dead slider classes
24
+ Add a11y regression tests
25
+
26
+ 4.1.0
27
+
28
+ Add menu/select typeahead
29
+ Fix popover focus management
30
+ Fix navigation-menu keyboard entry
31
+ Fix Command combobox ARIA
32
+ Fix tabs first tab-stop
33
+ Fix radio single tab-stop
34
+ Set menu available-height
35
+ Fix sheet/drawer exit animations
36
+ Reset floating position on close
37
+ Add a11y regression tests
38
+
3
39
  4.0.0
4
40
 
5
41
  Self-contained ui/ component tier (47 components, zero base-ui)
@@ -10,6 +10,9 @@ function useCommand() {
10
10
  export function Command({ className, children, ...props }) {
11
11
  const [filter, setFilter] = useState('');
12
12
  const [selectedId, setSelectedId] = useState(null);
13
+ // Stable base id shared by the listbox container and every option, so the
14
+ // input's aria-controls / aria-activedescendant can reference them.
15
+ const listboxId = useId();
13
16
  const itemsRef = useRef(new Map()); // id -> { value, onSelect, disabled }
14
17
  const register = useCallback((id, meta) => {
15
18
  itemsRef.current.set(id, meta);
@@ -55,12 +58,18 @@ export function Command({ className, children, ...props }) {
55
58
  }, [selectedId]);
56
59
  const isVisible = useCallback((id) => visibleIds().includes(id), [visibleIds]);
57
60
  const visibleCount = visibleIds().length;
58
- const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, register, unregister, isVisible, visibleCount, move, triggerSelected }), [filter, selectedId, register, unregister, isVisible, visibleCount, move, triggerSelected]);
59
- return (_jsx(CommandContext.Provider, { value: value, children: _jsx("div", { role: "combobox", "aria-expanded": "true", className: className, ...props, children: children }) }));
61
+ const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected }), [filter, selectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected]);
62
+ return (_jsx(CommandContext.Provider, { value: value, children: _jsx("div", { className: className, ...props, children: children }) }));
60
63
  }
61
64
  export function CommandInput({ className, placeholder, value: controlledValue, onValueChange, ...props }) {
62
- const { filter, setFilter, move, triggerSelected } = useCommand();
65
+ const { filter, setFilter, move, triggerSelected, selectedId, listboxId, visibleCount } = useCommand();
63
66
  const value = controlledValue ?? filter;
67
+ // The listbox is considered "shown"/expanded whenever it has visible options.
68
+ const isExpanded = visibleCount > 0;
69
+ // aria-activedescendant points at the DOM id of the active option (selectedId
70
+ // is rendered verbatim as each CommandItem's id). Undefined when nothing is
71
+ // active, so screen readers don't reference a missing element.
72
+ const activeDescendant = isExpanded && selectedId ? selectedId : undefined;
64
73
  const handleChange = (e) => {
65
74
  const v = e.target.value;
66
75
  setFilter(v);
@@ -80,10 +89,16 @@ export function CommandInput({ className, placeholder, value: controlledValue, o
80
89
  triggerSelected();
81
90
  }
82
91
  };
83
- return (_jsx("input", { role: "searchbox", autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
92
+ return (
93
+ // The input is the combobox. Real DOM focus stays here during arrow
94
+ // navigation; only aria-activedescendant moves so screen readers announce
95
+ // the active option (WAI-ARIA combobox + activedescendant pattern).
96
+ _jsx("input", { role: "combobox", "aria-autocomplete": "list", "aria-expanded": isExpanded, "aria-controls": listboxId, "aria-activedescendant": activeDescendant, autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
84
97
  }
85
98
  export function CommandList({ className, children, ...props }) {
86
- return (_jsx("div", { role: "listbox", className: className, ...props, children: children }));
99
+ const { listboxId } = useCommand();
100
+ // id matches the input's aria-controls so the combobox owns this listbox.
101
+ return (_jsx("div", { role: "listbox", id: listboxId, className: className, ...props, children: children }));
87
102
  }
88
103
  export function CommandEmpty({ className, children, ...props }) {
89
104
  const { visibleCount } = useCommand();
@@ -95,23 +110,34 @@ export function CommandGroup({ className, heading, children, ...props }) {
95
110
  return (_jsxs("div", { role: "group", className: className, ...props, children: [heading && (_jsx("div", { "cmdk-group-heading": "", role: "presentation", children: heading })), children] }));
96
111
  }
97
112
  export function CommandItem({ className, value, onSelect, disabled = false, children, ...props }) {
113
+ // This id is both the value tracked in selectedId and the option's DOM id,
114
+ // which is what the input's aria-activedescendant references.
98
115
  const id = useId();
99
116
  const { register, unregister, selectedId, setSelectedId, isVisible } = useCommand();
117
+ const optionRef = useRef(null);
100
118
  // Resolve a usable value string — fall back to children text if no value prop
101
119
  const resolvedValue = value ?? (typeof children === 'string' ? children : id);
102
120
  useEffect(() => {
103
121
  register(id, { value: String(resolvedValue), onSelect, disabled });
104
122
  return () => unregister(id);
105
123
  }, [id, resolvedValue, onSelect, disabled, register, unregister]);
124
+ const isSelected = selectedId === id;
125
+ // Focus stays on the input under the activedescendant model, so scroll the
126
+ // active option into view manually when it becomes active.
127
+ useEffect(() => {
128
+ if (isSelected)
129
+ optionRef.current?.scrollIntoView({ block: 'nearest' });
130
+ }, [isSelected]);
106
131
  if (!isVisible(id))
107
132
  return null;
108
- const isSelected = selectedId === id;
109
133
  const handleClick = () => {
110
134
  if (disabled)
111
135
  return;
112
136
  onSelect?.(String(resolvedValue));
113
137
  };
114
- return (_jsx("div", { role: "option", "aria-selected": isSelected, "data-selected": isSelected ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, onMouseEnter: () => !disabled && setSelectedId(id), onClick: handleClick, className: className, ...props, children: children }));
138
+ return (_jsx("div", {
139
+ // Stable DOM id targeted by aria-activedescendant when this option is active.
140
+ id: id, ref: optionRef, role: "option", "aria-selected": isSelected, "data-selected": isSelected ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, onMouseEnter: () => !disabled && setSelectedId(id), onClick: handleClick, className: className, ...props, children: children }));
115
141
  }
116
142
  export function CommandSeparator({ className, ...props }) {
117
143
  return _jsx("div", { role: "separator", className: className, ...props });
@@ -43,7 +43,7 @@ function isLocalStorageAvailable() {
43
43
  return true;
44
44
  }
45
45
  catch (e) {
46
- console.warn('localStorage not available:', e.message);
46
+ console.warn('localStorage not available:', e instanceof Error ? e.message : String(e));
47
47
  return false;
48
48
  }
49
49
  }
@@ -58,7 +58,7 @@ function safeLSSetItem(key, value) {
58
58
  return true;
59
59
  }
60
60
  catch (error) {
61
- console.error('localStorage setItem error:', error.message);
61
+ console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
62
62
  return false;
63
63
  }
64
64
  }
@@ -69,7 +69,7 @@ function safeLSGetItem(key) {
69
69
  return localStorage.getItem(key);
70
70
  }
71
71
  catch (error) {
72
- console.error('localStorage getItem error:', error.message);
72
+ console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
73
73
  return null;
74
74
  }
75
75
  }
@@ -81,7 +81,7 @@ function safeLSRemoveItem(key) {
81
81
  return true;
82
82
  }
83
83
  catch (error) {
84
- console.error('localStorage removeItem error:', error.message);
84
+ console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
85
85
  return false;
86
86
  }
87
87
  }
@@ -99,7 +99,7 @@ export function ContextProvider({ children, constants }) {
99
99
  return JSON.parse(storedUser);
100
100
  }
101
101
  catch (e) {
102
- console.error('Error parsing user data:', e.message);
102
+ console.error('Error parsing user data:', e instanceof Error ? e.message : String(e));
103
103
  return null;
104
104
  }
105
105
  };
@@ -126,7 +126,7 @@ export function ContextProvider({ children, constants }) {
126
126
  }
127
127
  }
128
128
  catch (error) {
129
- console.error('Error setting user:', error.message);
129
+ console.error('Error setting user:', error instanceof Error ? error.message : String(error));
130
130
  }
131
131
  return { ...state, user: action.payload };
132
132
  }
@@ -21,7 +21,7 @@ function isLocalStorageAvailable() {
21
21
  return true;
22
22
  }
23
23
  catch (e) {
24
- console.warn('localStorage not available (private mode or quota exceeded):', e.message);
24
+ console.warn('localStorage not available (private mode or quota exceeded):', e instanceof Error ? e.message : String(e));
25
25
  _localStorageAvailable = false;
26
26
  return false;
27
27
  }
@@ -37,11 +37,13 @@ function safeSetItem(key, value) {
37
37
  return true;
38
38
  }
39
39
  catch (error) {
40
- if (error.name === 'QuotaExceededError') {
40
+ // DOMException (browser storage/abort errors) does not extend Error in
41
+ // the browser, so match either to read `.name` safely without a cast.
42
+ if ((error instanceof DOMException || error instanceof Error) && error.name === 'QuotaExceededError') {
41
43
  console.error('localStorage quota exceeded - data not persisted');
42
44
  }
43
45
  else {
44
- console.error('localStorage setItem error:', error.message);
46
+ console.error('localStorage setItem error:', error instanceof Error ? error.message : String(error));
45
47
  }
46
48
  return false;
47
49
  }
@@ -53,7 +55,7 @@ function safeGetItem(key) {
53
55
  return localStorage.getItem(key);
54
56
  }
55
57
  catch (error) {
56
- console.error('localStorage getItem error:', error.message);
58
+ console.error('localStorage getItem error:', error instanceof Error ? error.message : String(error));
57
59
  return null;
58
60
  }
59
61
  }
@@ -65,7 +67,7 @@ function safeRemoveItem(key) {
65
67
  return true;
66
68
  }
67
69
  catch (error) {
68
- console.error('localStorage removeItem error:', error.message);
70
+ console.error('localStorage removeItem error:', error instanceof Error ? error.message : String(error));
69
71
  return false;
70
72
  }
71
73
  }
@@ -523,7 +525,7 @@ export async function showUpgradeSheet(upgradeSheetRef) {
523
525
  (!user?.subscription?.expires || user?.subscription?.expires > Math.floor(Date.now() / 1000));
524
526
  }
525
527
  catch (error) {
526
- console.error('Error parsing user data from storage:', error.message);
528
+ console.error('Error parsing user data from storage:', error instanceof Error ? error.message : String(error));
527
529
  subscriber = false;
528
530
  }
529
531
  }
@@ -580,7 +582,7 @@ export function timestampToString(input, format = "DOB") {
580
582
  return date.toLocaleString();
581
583
  case "ago": {
582
584
  const now = new Date();
583
- const diffInSeconds = Math.floor((now - date) / 1000);
585
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
584
586
  const minute = 60, hour = minute * 60, day = hour * 24;
585
587
  const week = day * 7, month = day * 30, year = day * 365;
586
588
  if (diffInSeconds < minute)
@@ -703,10 +705,12 @@ export async function apiRequest(endpoint, options = {}) {
703
705
  });
704
706
  }
705
707
  catch (error) {
706
- if (error.name === 'AbortError') {
708
+ // fetch() aborts throw a DOMException, which does not extend Error in
709
+ // the browser — match either so abort detection still works.
710
+ if ((error instanceof DOMException || error instanceof Error) && error.name === 'AbortError') {
707
711
  throw error; // Re-throw abort errors for useListData to handle
708
712
  }
709
- throw new Error(`Network error: ${error.message}`);
713
+ throw new Error(`Network error: ${error instanceof Error ? error.message : String(error)}`);
710
714
  }
711
715
  finally {
712
716
  clearTimeout(timeoutId);
@@ -787,7 +791,7 @@ export async function apiRequest(endpoint, options = {}) {
787
791
  return await response.json();
788
792
  }
789
793
  catch (error) {
790
- throw new Error(`Invalid JSON response from server: ${error.message}`);
794
+ throw new Error(`Invalid JSON response from server: ${error instanceof Error ? error.message : String(error)}`);
791
795
  }
792
796
  }
793
797
  /**
@@ -861,10 +865,10 @@ export function useListData(endpoint, sortFn = null) {
861
865
  setError(null);
862
866
  }
863
867
  catch (err) {
864
- // Ignore abort errors
865
- if (err.name === 'AbortError')
868
+ // Ignore abort errors (DOMException in the browser, not an Error)
869
+ if ((err instanceof DOMException || err instanceof Error) && err.name === 'AbortError')
866
870
  return;
867
- setError(err.message);
871
+ setError(err instanceof Error ? err.message : String(err));
868
872
  }
869
873
  finally {
870
874
  setLoading(false);
@@ -899,7 +903,7 @@ export function useForm(initialValues, onSubmit) {
899
903
  setValues(initialValues);
900
904
  }
901
905
  catch (err) {
902
- setError(err.message);
906
+ setError(err instanceof Error ? err.message : String(err));
903
907
  }
904
908
  finally {
905
909
  setSubmitting(false);
@@ -5,6 +5,8 @@ import { cn } from "../shadcn/lib/utils.js";
5
5
  import { Button } from "./button.js";
6
6
  import { Slot, resolveRender } from "./slot.js";
7
7
  import { useControllableState } from "./use-controllable-state.js";
8
+ import { useDialogLabelling } from "./use-labelling.js";
9
+ import { useScrollLock } from "./use-scroll-lock.js";
8
10
  const AlertDialogContext = React.createContext(null);
9
11
  function useAlertDialog() {
10
12
  const ctx = React.useContext(AlertDialogContext);
@@ -20,9 +22,17 @@ function AlertDialog({ open, defaultOpen = false, onOpenChange, children, }) {
20
22
  defaultValue: defaultOpen,
21
23
  onChange: onOpenChange,
22
24
  });
23
- const titleId = React.useId();
24
- const descriptionId = React.useId();
25
- return (_jsx(AlertDialogContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
25
+ const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy, } = useDialogLabelling();
26
+ return (_jsx(AlertDialogContext.Provider, { value: {
27
+ open: isOpen,
28
+ setOpen,
29
+ titleId,
30
+ descriptionId,
31
+ registerTitle,
32
+ registerDescription,
33
+ labelledBy,
34
+ describedBy,
35
+ }, children: children }));
26
36
  }
27
37
  function AlertDialogTrigger({ className, asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
28
38
  const { open, setOpen } = useAlertDialog();
@@ -41,7 +51,7 @@ function AlertDialogOverlay() {
41
51
  return null;
42
52
  }
43
53
  function AlertDialogContent({ className, size = "default", children, ...props }) {
44
- const { open, setOpen, titleId, descriptionId } = useAlertDialog();
54
+ const { open, setOpen, labelledBy, describedBy } = useAlertDialog();
45
55
  const ref = React.useRef(null);
46
56
  React.useEffect(() => {
47
57
  const node = ref.current;
@@ -52,16 +62,10 @@ function AlertDialogContent({ className, size = "default", children, ...props })
52
62
  else if (!open && node.open)
53
63
  node.close();
54
64
  }, [open]);
55
- React.useEffect(() => {
56
- if (!open)
57
- return;
58
- const prev = document.body.style.overflow;
59
- document.body.style.overflow = "hidden";
60
- return () => {
61
- document.body.style.overflow = prev;
62
- };
63
- }, [open]);
64
- return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
65
+ // Lock background scroll while open via the shared reentrant helper so stacked
66
+ // overlays don't unlock each other when one closes.
67
+ useScrollLock(open);
68
+ return (_jsx("dialog", { ref: ref, role: "alertdialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "alert-dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
65
69
  e.preventDefault();
66
70
  setOpen(false);
67
71
  }, onClose: () => setOpen(false), className: "m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsx("div", { "data-slot": "alert-dialog-content", "data-size": size, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("group/alert-dialog-content relative grid w-full gap-6 rounded-xl bg-background p-6 text-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: children })) : null }));
@@ -76,11 +80,15 @@ function AlertDialogMedia({ className, ...props }) {
76
80
  return (_jsx("div", { "data-slot": "alert-dialog-media", className: cn("mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", className), ...props }));
77
81
  }
78
82
  function AlertDialogTitle({ className, ...props }) {
79
- const { titleId } = useAlertDialog();
83
+ const { titleId, registerTitle } = useAlertDialog();
84
+ // Signal that a title exists so the content wires `aria-labelledby` to it.
85
+ React.useEffect(registerTitle, [registerTitle]);
80
86
  return (_jsx("h2", { id: titleId, "data-slot": "alert-dialog-title", className: cn("text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className), ...props }));
81
87
  }
82
88
  function AlertDialogDescription({ className, ...props }) {
83
- const { descriptionId } = useAlertDialog();
89
+ const { descriptionId, registerDescription } = useAlertDialog();
90
+ // Signal that a description exists so the content wires `aria-describedby`.
91
+ React.useEffect(registerDescription, [registerDescription]);
84
92
  return (_jsx("p", { id: descriptionId, "data-slot": "alert-dialog-description", className: cn("text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
85
93
  }
86
94
  function AlertDialogAction({ className, onClick, ...props }) {
@@ -1,7 +1,9 @@
1
1
  import * as React from "react";
2
- export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onChange" | "value"> {
3
- checked?: boolean;
4
- defaultChecked?: boolean;
2
+ /** Checked state of a checkbox: a real boolean, or the tri-state "mixed" value. */
3
+ type CheckedState = boolean | "indeterminate";
4
+ export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onChange" | "value" | "checked" | "defaultChecked"> {
5
+ checked?: CheckedState;
6
+ defaultChecked?: CheckedState;
5
7
  onCheckedChange?: (checked: boolean) => void;
6
8
  /** When set, a hidden checkbox of this name mirrors the state for form posts. */
7
9
  name?: string;
@@ -9,9 +11,10 @@ export interface CheckboxProps extends Omit<React.ComponentProps<"button">, "onC
9
11
  }
10
12
  /**
11
13
  * Self-contained checkbox: a `role="checkbox"` button with controlled/
12
- * uncontrolled checked state. Emits `data-checked`/`data-unchecked`/
13
- * `data-disabled` so the shared classes apply. Pass `name` to include a
14
- * hidden checkbox for forms.
14
+ * uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
15
+ * value for incoming controlled/default state. Emits `data-checked`/
16
+ * `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
17
+ * apply. Pass `name` to include a hidden checkbox for forms.
15
18
  */
16
19
  declare function Checkbox({ className, checked, defaultChecked, onCheckedChange, disabled, name, value, onClick, ...props }: CheckboxProps): React.JSX.Element;
17
20
  export { Checkbox };
@@ -2,24 +2,36 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
- import { CheckIcon } from "../icons/index.js";
5
+ import { CheckIcon, MinusIcon } from "../icons/index.js";
6
6
  import { useControllableState } from "./use-controllable-state.js";
7
7
  /**
8
8
  * Self-contained checkbox: a `role="checkbox"` button with controlled/
9
- * uncontrolled checked state. Emits `data-checked`/`data-unchecked`/
10
- * `data-disabled` so the shared classes apply. Pass `name` to include a
11
- * hidden checkbox for forms.
9
+ * uncontrolled checked state. Supports a tri-state `"indeterminate"` (mixed)
10
+ * value for incoming controlled/default state. Emits `data-checked`/
11
+ * `data-unchecked`/`data-indeterminate`/`data-disabled` so the shared classes
12
+ * apply. Pass `name` to include a hidden checkbox for forms.
12
13
  */
13
14
  function Checkbox({ className, checked, defaultChecked = false, onCheckedChange, disabled, name, value = "on", onClick, ...props }) {
15
+ // The hook tracks the full tri-state so an incoming "indeterminate" survives;
16
+ // onCheckedChange is fired separately in onClick where the value is always a
17
+ // boolean, keeping the public callback signature unchanged.
14
18
  const [on, setOn] = useControllableState({
15
19
  value: checked,
16
20
  defaultValue: defaultChecked,
17
- onChange: onCheckedChange,
18
21
  });
19
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", role: "checkbox", "aria-checked": on, disabled: disabled, "data-slot": "checkbox", "data-state": on ? "checked" : "unchecked", "data-checked": on ? "" : undefined, "data-unchecked": on ? undefined : "", "data-disabled": disabled ? "" : undefined, className: cn("cursor-pointer peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", className), onClick: (e) => {
22
+ const isIndeterminate = on === "indeterminate";
23
+ // a11y: a tri-state checkbox must report "mixed" so assistive tech announces
24
+ // the partial selection instead of a plain checked/unchecked state.
25
+ const ariaChecked = isIndeterminate ? "mixed" : on;
26
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", role: "checkbox", "aria-checked": ariaChecked, disabled: disabled, "data-slot": "checkbox", "data-state": isIndeterminate ? "indeterminate" : on ? "checked" : "unchecked", "data-checked": on === true || isIndeterminate ? "" : undefined, "data-unchecked": on === false ? "" : undefined, "data-indeterminate": isIndeterminate ? "" : undefined, "data-disabled": disabled ? "" : undefined, className: cn("cursor-pointer peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", className), onClick: (e) => {
20
27
  onClick?.(e);
21
- if (!e.defaultPrevented)
22
- setOn(!on);
23
- }, ...props, children: on ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-checked": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) : null }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": true, tabIndex: -1, name: name, value: value, checked: on, disabled: disabled, readOnly: true, className: "sr-only" })) : null] }));
28
+ if (e.defaultPrevented)
29
+ return;
30
+ // A user click always resolves to a real boolean: indeterminate and
31
+ // unchecked both become checked, checked becomes unchecked.
32
+ const next = on === true ? false : true;
33
+ setOn(next);
34
+ onCheckedChange?.(next);
35
+ }, ...props, children: on === true ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-checked": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) : isIndeterminate ? (_jsx("span", { "data-slot": "checkbox-indicator", "data-indeterminate": "", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(MinusIcon, {}) })) : null }), name ? (_jsx("input", { type: "checkbox", "aria-hidden": true, tabIndex: -1, name: name, value: value, checked: on === true, disabled: disabled, readOnly: true, className: "sr-only" })) : null] }));
24
36
  }
25
37
  export { Checkbox };
@@ -6,6 +6,8 @@ import { Slot, mergeRefs, resolveRender } from "./slot.js";
6
6
  import { Portal } from "./portal.js";
7
7
  import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
+ import { useTypeahead } from "./use-typeahead.js";
10
+ import { usePointerMoved } from "./use-pointer-moved.js";
9
11
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
10
12
  const MenuContext = React.createContext(null);
11
13
  function useMenu() {
@@ -63,8 +65,14 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
63
65
  const { open, setOpen, point, contentId, layers } = useMenu();
64
66
  const [mounted, presenceRef] = usePresence(open);
65
67
  const containerRef = React.useRef(null);
68
+ const { onTypeaheadKeyDown } = useTypeahead();
69
+ // Pointer-move focus follows the cursor, but keyboard arrow nav scrolls the
70
+ // list and fires a synthetic pointermove at the same coordinates — guard it
71
+ // so scrolling doesn't yank focus off the keyboard target.
72
+ const hasPointerMoved = usePointerMoved();
66
73
  // Position the menu at the cursor, clamped into the viewport once the
67
- // popup has measurable dimensions.
74
+ // popup has measurable dimensions. availableHeight caps the menu so long
75
+ // lists scroll inside the viewport instead of overflowing it.
68
76
  const [pos, setPos] = React.useState(null);
69
77
  React.useLayoutEffect(() => {
70
78
  if (!mounted) {
@@ -94,7 +102,9 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
94
102
  y = Math.max(PAD, point.y - h);
95
103
  x = Math.max(PAD, Math.min(x, vw - w - PAD));
96
104
  y = Math.max(PAD, Math.min(y, vh - h - PAD));
97
- setPos({ x, y });
105
+ // Space from the menu's top to the bottom of the viewport, minus padding.
106
+ const availableHeight = Math.max(0, vh - y - PAD);
107
+ setPos({ x, y, availableHeight });
98
108
  };
99
109
  measure();
100
110
  window.addEventListener("scroll", measure, true);
@@ -159,7 +169,10 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
159
169
  top: pos?.y ?? point.y,
160
170
  visibility: pos ? "visible" : "hidden",
161
171
  ["--transform-origin"]: "top left",
172
+ ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
162
173
  }, onPointerMove: (e) => {
174
+ if (!hasPointerMoved(e))
175
+ return;
163
176
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
164
177
  if (item && item !== document.activeElement)
165
178
  item.focus();
@@ -190,6 +203,16 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
190
203
  active.click();
191
204
  }
192
205
  }
206
+ else {
207
+ // Printable-character typeahead: jump to the matching item.
208
+ const items = itemsOf(c);
209
+ const idx = items.indexOf(document.activeElement);
210
+ const match = onTypeaheadKeyDown(e, items, idx);
211
+ if (match) {
212
+ e.preventDefault();
213
+ match.focus();
214
+ }
215
+ }
193
216
  }, className: cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: children }) }));
194
217
  }
195
218
  function ContextMenuGroup({ className, ...props }) {
@@ -200,7 +223,10 @@ function ContextMenuLabel({ className, inset, ...props }) {
200
223
  }
201
224
  function ContextMenuItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
202
225
  const { setOpen } = useMenu();
203
- return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn("group/context-menu-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
226
+ return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "context-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn(
227
+ // focus-visible ring gives keyboard roving focus a visible indicator
228
+ // (outline-hidden removes the native one); pointer focus stays ring-free.
229
+ "group/context-menu-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
204
230
  if (disabled)
205
231
  return;
206
232
  onClick?.(e);
@@ -212,7 +238,9 @@ function ContextMenuItem({ className, inset, variant = "default", disabled, onSe
212
238
  }
213
239
  const RadioGroupContext = React.createContext({});
214
240
  function ContextMenuCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
215
- return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
241
+ return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
242
+ // focus-visible ring gives keyboard roving focus a visible indicator.
243
+ "relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
216
244
  if (disabled)
217
245
  return;
218
246
  onClick?.(e);
@@ -226,7 +254,9 @@ function ContextMenuRadioGroup({ value, onValueChange, ...props }) {
226
254
  function ContextMenuRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
227
255
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
228
256
  const checked = groupValue === value;
229
- return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
257
+ return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "context-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
258
+ // focus-visible ring gives keyboard roving focus a visible indicator.
259
+ "relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
230
260
  if (disabled)
231
261
  return;
232
262
  onClick?.(e);
@@ -250,11 +280,14 @@ function useSub() {
250
280
  function ContextMenuSub({ defaultOpen = false, children, }) {
251
281
  const [open, setOpen] = React.useState(defaultOpen);
252
282
  const triggerRef = React.useRef(null);
253
- return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef }, children: children }));
283
+ const subContentId = React.useId();
284
+ return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
254
285
  }
255
286
  function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
256
- const { open, setOpen, triggerRef } = useSub();
257
- return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn("flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
287
+ const { open, setOpen, triggerRef, subContentId } = useSub();
288
+ return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "context-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
289
+ // focus-visible ring gives keyboard roving focus a visible indicator.
290
+ "flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
258
291
  onMouseEnter?.(e);
259
292
  setOpen(true);
260
293
  }, onClick: (e) => {
@@ -272,7 +305,7 @@ function ContextMenuSubTrigger({ className, inset, children, onClick, onKeyDown,
272
305
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
273
306
  }
274
307
  function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
275
- const { open, setOpen, triggerRef } = useSub();
308
+ const { open, setOpen, triggerRef, subContentId } = useSub();
276
309
  const { layers } = useMenu();
277
310
  const [mounted, presenceRef] = usePresence(open);
278
311
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -282,6 +315,7 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
282
315
  alignOffset: -3,
283
316
  });
284
317
  const containerRef = React.useRef(null);
318
+ const { onTypeaheadKeyDown } = useTypeahead();
285
319
  React.useEffect(() => {
286
320
  const node = containerRef.current;
287
321
  if (!node)
@@ -313,7 +347,10 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
313
347
  }, [open]);
314
348
  if (!mounted)
315
349
  return null;
316
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), role: "menu", tabIndex: -1, "data-slot": "context-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
350
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef),
351
+ // Matches the SubTrigger's aria-controls target so the trigger->submenu
352
+ // relationship is exposed to assistive tech.
353
+ id: subContentId, role: "menu", tabIndex: -1, "data-slot": "context-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
317
354
  position: "fixed",
318
355
  left: pos?.x ?? 0,
319
356
  top: pos?.y ?? 0,
@@ -340,6 +377,16 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
340
377
  active.click();
341
378
  }
342
379
  }
380
+ else {
381
+ // Printable-character typeahead: jump to the matching item.
382
+ const items = itemsOf(c);
383
+ const idx = items.indexOf(document.activeElement);
384
+ const match = onTypeaheadKeyDown(e, items, idx);
385
+ if (match) {
386
+ e.preventDefault();
387
+ match.focus();
388
+ }
389
+ }
343
390
  }, className: cn("z-50 min-w-[96px] origin-(--transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: children }) }));
344
391
  }
345
392
  export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };