@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.
@@ -63,15 +63,56 @@ function NavigationMenuList({ className, ...props }) {
63
63
  }
64
64
  function NavigationMenuItem({ className, ...props }) {
65
65
  const value = React.useId();
66
+ // One id + content ref per item so multiple triggers/panels don't collide
67
+ // when wiring aria-controls and focusing into the right portaled panel.
68
+ const contentId = React.useId();
66
69
  const triggerRef = React.useRef(null);
67
- return (_jsx(NavigationMenuItemContext.Provider, { value: { value, triggerRef }, children: _jsx("li", { "data-slot": "navigation-menu-item", className: cn("relative", className), ...props }) }));
70
+ const contentRef = React.useRef(null);
71
+ return (_jsx(NavigationMenuItemContext.Provider, { value: { value, triggerRef, contentId, contentRef }, children: _jsx("li", { "data-slot": "navigation-menu-item", className: cn("relative", className), ...props }) }));
68
72
  }
73
+ // Tabbable descendants, in DOM order, used to move focus into the open content
74
+ // panel. Kept local (not imported) so this file stays self-contained.
75
+ const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
69
76
  const navigationMenuTriggerStyle = cva("cursor-pointer group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted");
70
- function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, ...props }) {
77
+ function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, onKeyDown, ...props }) {
71
78
  const { value, setValue, open } = useNavigationMenu();
72
- const { value: itemValue, triggerRef } = useNavigationMenuItem();
79
+ const { value: itemValue, triggerRef, contentId, contentRef } = useNavigationMenuItem();
73
80
  const isOpen = value === itemValue;
74
- return (_jsxs("button", { ref: triggerRef, type: "button", "data-slot": "navigation-menu-trigger", "aria-expanded": isOpen, "data-state": isOpen ? "open" : "closed", "data-popup-open": isOpen ? "" : undefined, "data-open": isOpen ? "" : undefined, className: cn(navigationMenuTriggerStyle(), "group", className), onMouseEnter: (e) => {
81
+ /**
82
+ * Move keyboard focus into the open content panel so its links are
83
+ * reachable. The panel is portaled and opens on focus/hover, but without
84
+ * this a keyboard user can never enter it (WCAG 2.1.1 keyboard trap-out).
85
+ * ArrowDown (and Enter) open if needed, then focus the first focusable child.
86
+ */
87
+ const handleKeyDown = (e) => {
88
+ onKeyDown?.(e);
89
+ if (e.defaultPrevented)
90
+ return;
91
+ if (e.key !== "ArrowDown" && e.key !== "Enter")
92
+ return;
93
+ // Enter still toggles via onClick; only intercept it to enter an open panel.
94
+ if (e.key === "Enter" && !isOpen)
95
+ return;
96
+ e.preventDefault();
97
+ open(itemValue);
98
+ // The panel mounts this tick and is positioned a frame or two later (it
99
+ // starts visibility:hidden until useFloating measures it, and focus() on a
100
+ // hidden node is a no-op). Retry across a few frames until it is visible,
101
+ // bounded so a panel that never opens can't spin forever.
102
+ let frames = 0;
103
+ const focusFirst = () => {
104
+ const node = contentRef.current;
105
+ if (!node || getComputedStyle(node).visibility === "hidden") {
106
+ if (frames++ < 20)
107
+ requestAnimationFrame(focusFirst);
108
+ return;
109
+ }
110
+ const first = node.querySelector(FOCUSABLE_SELECTOR);
111
+ (first ?? node).focus();
112
+ };
113
+ requestAnimationFrame(focusFirst);
114
+ };
115
+ return (_jsxs("button", { ref: triggerRef, type: "button", "data-slot": "navigation-menu-trigger", "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": isOpen ? contentId : undefined, "data-state": isOpen ? "open" : "closed", "data-popup-open": isOpen ? "" : undefined, "data-open": isOpen ? "" : undefined, className: cn(navigationMenuTriggerStyle(), "group", className), onMouseEnter: (e) => {
75
116
  onMouseEnter?.(e);
76
117
  if (!e.defaultPrevented)
77
118
  open(itemValue);
@@ -83,11 +124,11 @@ function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onC
83
124
  onClick?.(e);
84
125
  if (!e.defaultPrevented)
85
126
  setValue(isOpen ? null : itemValue);
86
- }, ...props, children: [children, " ", _jsx(ChevronDownIcon, { className: "relative top-px ml-1 size-3 transition duration-300 group-data-open:rotate-180", "aria-hidden": "true" })] }));
127
+ }, onKeyDown: handleKeyDown, ...props, children: [children, " ", _jsx(ChevronDownIcon, { className: "relative top-px ml-1 size-3 transition duration-300 group-data-open:rotate-180", "aria-hidden": "true" })] }));
87
128
  }
88
- function NavigationMenuContent({ className, children, onMouseEnter, ...props }) {
89
- const { value, cancelClose, scheduleClose } = useNavigationMenu();
90
- const { value: itemValue, triggerRef } = useNavigationMenuItem();
129
+ function NavigationMenuContent({ className, children, onMouseEnter, onKeyDown, ...props }) {
130
+ const { value, setValue, cancelClose, scheduleClose } = useNavigationMenu();
131
+ const { value: itemValue, triggerRef, contentId, contentRef } = useNavigationMenuItem();
91
132
  const isOpen = value === itemValue;
92
133
  const [mounted, presenceRef] = usePresence(isOpen);
93
134
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -99,16 +140,44 @@ function NavigationMenuContent({ className, children, onMouseEnter, ...props })
99
140
  const setRef = React.useCallback((node) => {
100
141
  floatingRef.current = node;
101
142
  presenceRef.current = node;
102
- }, [floatingRef, presenceRef]);
143
+ contentRef.current = node;
144
+ }, [floatingRef, presenceRef, contentRef]);
145
+ /**
146
+ * Restore focus to the trigger whenever the panel closes while focus is still
147
+ * inside it (root-level Escape, mouse-leave timeout, etc.). Without this the
148
+ * panel unmounts and keyboard focus is stranded on a detached node (a11y).
149
+ * If focus already moved elsewhere (e.g. an outside click), leave it alone.
150
+ */
151
+ React.useEffect(() => {
152
+ if (isOpen)
153
+ return;
154
+ const node = contentRef.current;
155
+ const trigger = triggerRef.current;
156
+ if (trigger && node && node.contains(document.activeElement))
157
+ trigger.focus();
158
+ }, [isOpen, contentRef, triggerRef]);
159
+ /**
160
+ * Escape closes the menu and returns focus to the trigger. The root already
161
+ * listens for Escape to clear the value, but only this panel knows to restore
162
+ * focus — otherwise keyboard focus would be stranded on a now-unmounted node.
163
+ */
164
+ const handleKeyDown = (e) => {
165
+ onKeyDown?.(e);
166
+ if (e.defaultPrevented || e.key !== "Escape")
167
+ return;
168
+ e.preventDefault();
169
+ setValue(null);
170
+ triggerRef.current?.focus();
171
+ };
103
172
  if (!mounted)
104
173
  return null;
105
- return (_jsx(Portal, { children: _jsx("div", { ref: setRef, "data-slot": "navigation-menu-content", "data-state": isOpen ? "open" : "closed", "data-open": isOpen ? "" : undefined, "data-closed": isOpen ? undefined : "", "data-side": pos?.side ?? "bottom", style: {
174
+ return (_jsx(Portal, { children: _jsx("div", { ref: setRef, id: contentId, role: "menu", tabIndex: -1, "data-slot": "navigation-menu-content", "data-state": isOpen ? "open" : "closed", "data-open": isOpen ? "" : undefined, "data-closed": isOpen ? undefined : "", "data-side": pos?.side ?? "bottom", style: {
106
175
  position: "fixed",
107
176
  left: pos?.x ?? 0,
108
177
  top: pos?.y ?? 0,
109
178
  visibility: pos ? "visible" : "hidden",
110
179
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
111
- }, onMouseEnter: (e) => {
180
+ }, onKeyDown: handleKeyDown, onMouseEnter: (e) => {
112
181
  onMouseEnter?.(e);
113
182
  cancelClose();
114
183
  }, onMouseLeave: scheduleClose, className: cn("z-50 w-auto origin-(--transform-origin) rounded-md bg-popover p-2 text-popover-foreground shadow ring-1 ring-foreground/10 duration-200 outline-none 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 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", className), ...props, children: children }) }));
@@ -18,7 +18,7 @@ export interface PopoverContentProps extends React.ComponentProps<"div"> {
18
18
  sideOffset?: number;
19
19
  alignOffset?: number;
20
20
  }
21
- declare function PopoverContent({ className, style, ref, align, alignOffset, side, sideOffset, ...props }: PopoverContentProps): React.JSX.Element | null;
21
+ declare function PopoverContent({ className, style, ref, align, alignOffset, side, sideOffset, onKeyDown, ...props }: PopoverContentProps): React.JSX.Element | null;
22
22
  declare function PopoverHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
23
23
  declare function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">): React.JSX.Element;
24
24
  declare function PopoverDescription({ className, ...props }: React.ComponentProps<"p">): React.JSX.Element;
@@ -8,6 +8,7 @@ import { useFloating } from "./use-floating.js";
8
8
  import { useDismiss } from "./use-dismiss.js";
9
9
  import { usePresence } from "./use-presence.js";
10
10
  import { useControllableState } from "./use-controllable-state.js";
11
+ import { useDialogLabelling } from "./use-labelling.js";
11
12
  const PopoverContext = React.createContext(null);
12
13
  function usePopover() {
13
14
  const ctx = React.useContext(PopoverContext);
@@ -23,7 +24,19 @@ function Popover({ open, defaultOpen = false, onOpenChange, children }) {
23
24
  });
24
25
  const triggerRef = React.useRef(null);
25
26
  const contentId = React.useId();
26
- return (_jsx(PopoverContext.Provider, { value: { open: isOpen, setOpen, triggerRef, contentId }, children: children }));
27
+ const labelling = useDialogLabelling();
28
+ return (_jsx(PopoverContext.Provider, { value: {
29
+ open: isOpen,
30
+ setOpen,
31
+ triggerRef,
32
+ contentId,
33
+ titleId: labelling.titleId,
34
+ descriptionId: labelling.descriptionId,
35
+ registerTitle: labelling.registerTitle,
36
+ registerDescription: labelling.registerDescription,
37
+ labelledBy: labelling.labelledBy,
38
+ describedBy: labelling.describedBy,
39
+ }, children: children }));
27
40
  }
28
41
  function PopoverTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
29
42
  const { open, setOpen, triggerRef, contentId } = usePopover();
@@ -35,8 +48,10 @@ function PopoverTrigger({ asChild = false, render, nativeButton: _nativeButton,
35
48
  setOpen(!open);
36
49
  }, ...props, children: slotChild }));
37
50
  }
38
- function PopoverContent({ className, style, ref, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, ...props }) {
39
- const { open, setOpen, triggerRef, contentId } = usePopover();
51
+ // Tabbable descendants, in DOM order, for focus-in and the Tab trap.
52
+ const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
53
+ function PopoverContent({ className, style, ref, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, onKeyDown, ...props }) {
54
+ const { open, setOpen, triggerRef, contentId, labelledBy, describedBy } = usePopover();
40
55
  const [mounted, presenceRef] = usePresence(open);
41
56
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
42
57
  side,
@@ -44,10 +59,66 @@ function PopoverContent({ className, style, ref, align = "center", alignOffset =
44
59
  sideOffset,
45
60
  alignOffset,
46
61
  });
62
+ const contentRef = React.useRef(null);
47
63
  useDismiss(open, () => setOpen(false), [triggerRef, floatingRef]);
64
+ // Move focus into the popover when it opens and restore it to the trigger on
65
+ // close — a role="dialog" must manage focus (native <dialog> does this for
66
+ // free; this portaled div does not).
67
+ React.useEffect(() => {
68
+ if (!open)
69
+ return;
70
+ const trigger = triggerRef.current;
71
+ let raf = 0;
72
+ // Retry across frames until the content is mounted AND visible: presence
73
+ // mounts it a tick after `open` flips, and it stays visibility:hidden until
74
+ // useFloating positions it — focus() on a missing or hidden node is a silent
75
+ // no-op. The effect cleanup cancels this when the popover closes.
76
+ const focusIn = () => {
77
+ const node = contentRef.current;
78
+ if (!node || getComputedStyle(node).visibility === "hidden") {
79
+ raf = requestAnimationFrame(focusIn);
80
+ return;
81
+ }
82
+ const first = node.querySelector(FOCUSABLE_SELECTOR);
83
+ (first ?? node).focus();
84
+ };
85
+ raf = requestAnimationFrame(focusIn);
86
+ return () => {
87
+ cancelAnimationFrame(raf);
88
+ // Only reclaim focus if it is still inside the popover (an outside click
89
+ // already moved it elsewhere — don't yank it back).
90
+ const node = contentRef.current;
91
+ if (trigger && node && node.contains(document.activeElement))
92
+ trigger.focus();
93
+ };
94
+ }, [open, triggerRef]);
48
95
  if (!mounted)
49
96
  return null;
50
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, ref), id: contentId, role: "dialog", "data-slot": "popover-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
97
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, contentRef, ref), id: contentId, role: "dialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, tabIndex: -1, "data-slot": "popover-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, onKeyDown: (e) => {
98
+ onKeyDown?.(e);
99
+ if (e.defaultPrevented || e.key !== "Tab")
100
+ return;
101
+ const node = contentRef.current;
102
+ if (!node)
103
+ return;
104
+ const focusables = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null || el === document.activeElement);
105
+ if (focusables.length === 0) {
106
+ e.preventDefault();
107
+ node.focus();
108
+ return;
109
+ }
110
+ const first = focusables[0];
111
+ const last = focusables[focusables.length - 1];
112
+ const activeEl = document.activeElement;
113
+ if (e.shiftKey && (activeEl === first || activeEl === node)) {
114
+ e.preventDefault();
115
+ last.focus();
116
+ }
117
+ else if (!e.shiftKey && activeEl === last) {
118
+ e.preventDefault();
119
+ first.focus();
120
+ }
121
+ }, style: {
51
122
  ...style,
52
123
  position: "fixed",
53
124
  left: pos?.x ?? 0,
@@ -60,9 +131,15 @@ function PopoverHeader({ className, ...props }) {
60
131
  return (_jsx("div", { "data-slot": "popover-header", className: cn("flex flex-col gap-1 text-sm", className), ...props }));
61
132
  }
62
133
  function PopoverTitle({ className, ...props }) {
63
- return (_jsx("h2", { "data-slot": "popover-title", className: cn("font-medium", className), ...props }));
134
+ // Register this title's id so PopoverContent can point aria-labelledby at it.
135
+ const { titleId, registerTitle } = usePopover();
136
+ React.useEffect(registerTitle, [registerTitle]);
137
+ return (_jsx("h2", { id: titleId, "data-slot": "popover-title", className: cn("font-medium", className), ...props }));
64
138
  }
65
139
  function PopoverDescription({ className, ...props }) {
66
- return (_jsx("p", { "data-slot": "popover-description", className: cn("text-muted-foreground", className), ...props }));
140
+ // Register this description's id so PopoverContent can point aria-describedby at it.
141
+ const { descriptionId, registerDescription } = usePopover();
142
+ React.useEffect(registerDescription, [registerDescription]);
143
+ return (_jsx("p", { id: descriptionId, "data-slot": "popover-description", className: cn("text-muted-foreground", className), ...props }));
67
144
  }
68
145
  export { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, };
@@ -13,5 +13,5 @@ declare function RadioGroup({ className, value, defaultValue, onValueChange, nam
13
13
  export interface RadioGroupItemProps extends Omit<React.ComponentProps<"button">, "value"> {
14
14
  value: string;
15
15
  }
16
- declare function RadioGroupItem({ className, value, onClick, disabled, ...props }: RadioGroupItemProps): React.JSX.Element;
16
+ declare function RadioGroupItem({ className, value, onClick, disabled, ref, ...props }: RadioGroupItemProps): React.JSX.Element;
17
17
  export { RadioGroup, RadioGroupItem };
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, 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 { mergeRefs } from "./slot.js";
5
6
  import { useControllableState } from "./use-controllable-state.js";
6
7
  const RadioGroupContext = React.createContext(null);
7
8
  function useRadioGroup() {
@@ -39,13 +40,24 @@ function RadioGroup({ className, value, defaultValue, onValueChange, name, onKey
39
40
  radios[next].click();
40
41
  }, ...props }), name ? (_jsx("input", { type: "hidden", name: name, value: selected ?? "" })) : null] }));
41
42
  }
42
- function RadioGroupItem({ className, value, onClick, disabled, ...props }) {
43
+ function RadioGroupItem({ className, value, onClick, disabled, ref, ...props }) {
43
44
  const { value: selected, setValue } = useRadioGroup();
44
45
  const checked = selected === value;
45
- // Roving tab index: the checked radio is tabbable; if none checked, the first
46
- // item is reachable (handled by Base UI semantics via tabIndex below).
47
- const tabbable = checked || selected === undefined;
48
- return (_jsx("button", { type: "button", role: "radio", "aria-checked": checked, disabled: disabled, tabIndex: tabbable ? 0 : -1, "data-slot": "radio-group-item", "data-state": checked ? "checked" : "unchecked", "data-checked": checked ? "" : undefined, className: cn("cursor-pointer group/radio-group-item peer relative flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border border-input outline-none 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) => {
46
+ const btnRef = React.useRef(null);
47
+ // Roving tab index: the checked radio is the single tab stop. When nothing is
48
+ // checked, only the first enabled radio is tabbable — making every radio
49
+ // tabbable would put multiple stops in Tab order, breaking the radiogroup
50
+ // pattern.
51
+ const [isFirstEnabled, setIsFirstEnabled] = React.useState(false);
52
+ React.useLayoutEffect(() => {
53
+ if (selected !== undefined)
54
+ return;
55
+ const el = btnRef.current;
56
+ const first = el?.parentElement?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
57
+ setIsFirstEnabled(first === el);
58
+ });
59
+ const tabbable = checked || (selected === undefined && isFirstEnabled);
60
+ return (_jsx("button", { ref: mergeRefs(btnRef, ref), type: "button", role: "radio", "aria-checked": checked, disabled: disabled, tabIndex: tabbable ? 0 : -1, "data-slot": "radio-group-item", "data-state": checked ? "checked" : "unchecked", "data-checked": checked ? "" : undefined, className: cn("cursor-pointer group/radio-group-item peer relative flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border border-input outline-none 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) => {
49
61
  onClick?.(e);
50
62
  if (!e.defaultPrevented)
51
63
  setValue(value);
@@ -14,7 +14,7 @@ declare function SelectGroup({ className, ...props }: React.ComponentProps<"div"
14
14
  declare function SelectValue({ className, placeholder, ...props }: React.ComponentProps<"span"> & {
15
15
  placeholder?: React.ReactNode;
16
16
  }): React.JSX.Element;
17
- declare function SelectTrigger({ className, size, children, onClick, onKeyDown, ...props }: React.ComponentProps<"button"> & {
17
+ declare function SelectTrigger({ className, size, children, id, onClick, onKeyDown, ...props }: React.ComponentProps<"button"> & {
18
18
  size?: "sm" | "default";
19
19
  }): React.JSX.Element;
20
20
  export interface SelectContentProps extends React.ComponentProps<"div"> {
package/dist/ui/select.js CHANGED
@@ -7,6 +7,8 @@ import { Portal } from "./portal.js";
7
7
  import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
9
  import { useControllableState } from "./use-controllable-state.js";
10
+ import { useTypeahead } from "./use-typeahead.js";
11
+ import { usePointerMoved } from "./use-pointer-moved.js";
10
12
  import { ChevronDownIcon, CheckIcon } from "../icons/index.js";
11
13
  const SelectContext = React.createContext(null);
12
14
  function useSelect() {
@@ -28,6 +30,7 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
28
30
  });
29
31
  const triggerRef = React.useRef(null);
30
32
  const contentId = React.useId();
33
+ const triggerId = React.useId();
31
34
  const [labels, setLabels] = React.useState({});
32
35
  const registerLabel = React.useCallback((v, label) => setLabels((prev) => ({ ...prev, [v]: label })), []);
33
36
  const unregisterLabel = React.useCallback((v) => setLabels((prev) => {
@@ -46,6 +49,7 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
46
49
  setValue,
47
50
  triggerRef,
48
51
  contentId,
52
+ triggerId,
49
53
  labels,
50
54
  registerLabel,
51
55
  unregisterLabel,
@@ -59,9 +63,13 @@ function SelectValue({ className, placeholder, ...props }) {
59
63
  const label = value !== undefined ? labels[value] : undefined;
60
64
  return (_jsx("span", { "data-slot": "select-value", "data-placeholder": value === undefined ? "" : undefined, className: cn("flex flex-1 text-left", className), ...props, children: label ?? placeholder ?? null }));
61
65
  }
62
- function SelectTrigger({ className, size = "default", children, onClick, onKeyDown, ...props }) {
63
- const { open, setOpen, value, triggerRef, contentId } = useSelect();
64
- return (_jsxs("button", { ref: triggerRef, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-slot": "select-trigger", "data-size": size, "data-state": open ? "open" : "closed", "data-placeholder": value === undefined ? "" : undefined, className: cn("flex w-fit cursor-pointer items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none 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 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
66
+ function SelectTrigger({ className, size = "default", children, id, onClick, onKeyDown, ...props }) {
67
+ const { open, setOpen, value, triggerRef, contentId, triggerId } = useSelect();
68
+ // Give the trigger a stable id so the listbox can point back at it via
69
+ // aria-labelledby — naming the popup with its combobox. Honor a consumer id
70
+ // when supplied so an existing label association isn't clobbered.
71
+ const resolvedTriggerId = id ?? triggerId;
72
+ return (_jsxs("button", { ref: triggerRef, type: "button", id: resolvedTriggerId, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-slot": "select-trigger", "data-size": size, "data-state": open ? "open" : "closed", "data-placeholder": value === undefined ? "" : undefined, className: cn("flex w-fit cursor-pointer items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none 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 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
65
73
  onClick?.(e);
66
74
  if (!e.defaultPrevented)
67
75
  setOpen(!open);
@@ -79,7 +87,7 @@ function optionsOf(container) {
79
87
  return Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
80
88
  }
81
89
  function SelectContent({ className, children, side = "bottom", sideOffset = 4, align = "center", alignOffset = 0, onKeyDown, ...props }) {
82
- const { open, setOpen, value, triggerRef, contentId } = useSelect();
90
+ const { open, setOpen, value, triggerRef, contentId, triggerId } = useSelect();
83
91
  const [mounted, presenceRef] = usePresence(open);
84
92
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
85
93
  side,
@@ -88,6 +96,8 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
88
96
  alignOffset,
89
97
  });
90
98
  const containerRef = React.useRef(null);
99
+ const { onTypeaheadKeyDown } = useTypeahead();
100
+ const hasPointerMoved = usePointerMoved();
91
101
  // Focus the selected (or first) option once the popup is actually visible.
92
102
  const didFocus = React.useRef(false);
93
103
  React.useEffect(() => {
@@ -134,14 +144,24 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
134
144
  return (_jsx("div", { hidden: true, "aria-hidden": true, "data-slot": "select-content-registry", children: children }));
135
145
  }
136
146
  const triggerWidth = triggerRef.current?.offsetWidth;
137
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "listbox", tabIndex: -1, "data-slot": "select-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
147
+ // Name the listbox after its combobox trigger for screen readers. Prefer the
148
+ // trigger's live DOM id (which reflects any consumer-supplied id) and fall
149
+ // back to the context-generated triggerId.
150
+ const labelledById = triggerRef.current?.id || triggerId;
151
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "listbox", "aria-labelledby": labelledById, tabIndex: -1, "data-slot": "select-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
138
152
  position: "fixed",
139
153
  left: pos?.x ?? 0,
140
154
  top: pos?.y ?? 0,
141
155
  minWidth: triggerWidth,
142
156
  visibility: pos ? "visible" : "hidden",
143
157
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
158
+ ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
144
159
  }, onPointerMove: (e) => {
160
+ // Ignore synthetic pointermoves fired by keyboard arrow scrolling
161
+ // (same coords) — otherwise pointer focus yanks focus off the
162
+ // keyboard-navigated option right after it scrolls into view.
163
+ if (!hasPointerMoved(e))
164
+ return;
145
165
  const opt = e.target.closest('[role="option"]:not([data-disabled])');
146
166
  if (opt && opt !== document.activeElement)
147
167
  opt.focus();
@@ -180,6 +200,13 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
180
200
  active.click();
181
201
  }
182
202
  }
203
+ else {
204
+ const match = onTypeaheadKeyDown(e, opts, idx);
205
+ if (match) {
206
+ e.preventDefault();
207
+ match.focus();
208
+ }
209
+ }
183
210
  }, className: cn("relative isolate 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 data-[side=bottom]:slide-in-from-top-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 }) }));
184
211
  }
185
212
  function SelectLabel({ className, ...props }) {
@@ -195,7 +222,11 @@ function SelectItem({ className, children, value, disabled, onClick, ...props })
195
222
  React.useEffect(() => {
196
223
  registerLabel(value, children);
197
224
  }, [value, children, registerLabel]);
198
- return (_jsxs("div", { role: "option", tabIndex: -1, "aria-selected": selected, "aria-disabled": disabled || undefined, "data-slot": "select-item", "data-value": value, "data-disabled": disabled ? "" : undefined, className: cn("relative flex w-full 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-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
225
+ return (_jsxs("div", { role: "option", tabIndex: -1, "aria-selected": selected, "aria-disabled": disabled || undefined, "data-slot": "select-item", "data-value": value, "data-disabled": disabled ? "" : undefined, className: cn(
226
+ // focus-visible ring gives keyboard users a clear focus indicator;
227
+ // focus:bg-accent alone is a weak signal and fails when accent contrast
228
+ // is low, so pair it with a visible ring for arrow-key navigation.
229
+ "relative flex w-full 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-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onClick: (e) => {
199
230
  if (disabled)
200
231
  return;
201
232
  onClick?.(e);
package/dist/ui/sheet.js CHANGED
@@ -3,10 +3,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { Button } from "./button.js";
6
- import { Slot, resolveRender } from "./slot.js";
6
+ import { Slot, mergeRefs, resolveRender } from "./slot.js";
7
7
  import { XIcon } from "../icons/index.js";
8
8
  import { useControllableState } from "./use-controllable-state.js";
9
9
  import { usePresence } from "./use-presence.js";
10
+ import { useScrollLock } from "./use-scroll-lock.js";
11
+ import { useDialogLabelling } from "./use-labelling.js";
10
12
  const SheetContext = React.createContext(null);
11
13
  function useSheet() {
12
14
  const ctx = React.useContext(SheetContext);
@@ -22,9 +24,20 @@ function Sheet({ open, defaultOpen = false, onOpenChange, children }) {
22
24
  defaultValue: defaultOpen,
23
25
  onChange: onOpenChange,
24
26
  });
25
- const titleId = React.useId();
26
- const descriptionId = React.useId();
27
- return (_jsx(SheetContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
27
+ // Conditional labelling: only advertise aria-labelledby/aria-describedby when a
28
+ // Title/Description part is actually rendered, so the dialog never points at a
29
+ // missing element (a dangling IDREF that screen readers announce as broken).
30
+ const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
31
+ return (_jsx(SheetContext.Provider, { value: {
32
+ open: isOpen,
33
+ setOpen,
34
+ titleId,
35
+ descriptionId,
36
+ registerTitle,
37
+ registerDescription,
38
+ labelledBy,
39
+ describedBy,
40
+ }, children: children }));
28
41
  }
29
42
  function SheetTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
30
43
  const { open, setOpen } = useSheet();
@@ -67,10 +80,10 @@ const SIDE_OUT = {
67
80
  bottom: "slide-out-to-bottom-52",
68
81
  };
69
82
  function SheetContent({ className, children, side = "right", showCloseButton = true, ...props }) {
70
- const { open, setOpen, titleId, descriptionId } = useSheet();
83
+ const { open, setOpen, labelledBy, describedBy } = useSheet();
71
84
  const ref = React.useRef(null);
72
85
  const pointerDownOnBackdrop = React.useRef(false);
73
- const [mounted] = usePresence(open);
86
+ const [mounted, presenceRef] = usePresence(open);
74
87
  React.useEffect(() => {
75
88
  const node = ref.current;
76
89
  if (!node)
@@ -80,18 +93,12 @@ function SheetContent({ className, children, side = "right", showCloseButton = t
80
93
  else if (!mounted && node.open)
81
94
  node.close();
82
95
  }, [mounted]);
83
- React.useEffect(() => {
84
- if (!mounted)
85
- return;
86
- const prev = document.body.style.overflow;
87
- document.body.style.overflow = "hidden";
88
- return () => {
89
- document.body.style.overflow = prev;
90
- };
91
- }, [mounted]);
96
+ // Reentrant, reference-counted scroll lock so stacked overlays don't unlock the
97
+ // body while another is still open (a plain per-instance effect would).
98
+ useScrollLock(mounted);
92
99
  if (!mounted)
93
100
  return null;
94
- return (_jsxs("dialog", { ...props, ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
101
+ return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
95
102
  e.preventDefault();
96
103
  setOpen(false);
97
104
  }, onClose: () => setOpen(false), onPointerDown: (e) => {
@@ -111,11 +118,15 @@ function SheetFooter({ className, ...props }) {
111
118
  return (_jsx("div", { "data-slot": "sheet-footer", className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
112
119
  }
113
120
  function SheetTitle({ className, ...props }) {
114
- const { titleId } = useSheet();
121
+ const { titleId, registerTitle } = useSheet();
122
+ // Register so the dialog only sets aria-labelledby while this title is mounted.
123
+ React.useEffect(registerTitle, [registerTitle]);
115
124
  return (_jsx("h2", { id: titleId, "data-slot": "sheet-title", className: cn("font-medium text-foreground", className), ...props }));
116
125
  }
117
126
  function SheetDescription({ className, ...props }) {
118
- const { descriptionId } = useSheet();
127
+ const { descriptionId, registerDescription } = useSheet();
128
+ // Register so the dialog only sets aria-describedby while this description is mounted.
129
+ React.useEffect(registerDescription, [registerDescription]);
119
130
  return (_jsx("p", { id: descriptionId, "data-slot": "sheet-description", className: cn("text-sm text-muted-foreground", className), ...props }));
120
131
  }
121
132
  export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, };
@@ -8,11 +8,12 @@ export interface SliderProps extends Omit<React.ComponentProps<"div">, "defaultV
8
8
  step?: number;
9
9
  orientation?: "horizontal" | "vertical";
10
10
  disabled?: boolean;
11
+ name?: string;
11
12
  }
12
13
  /**
13
14
  * Self-contained slider with pointer drag and keyboard control. Supports one or
14
15
  * more thumbs (the nearest thumb to the pointer is the one that moves). Track,
15
16
  * range, and thumbs mirror the shadcn structure/classes.
16
17
  */
17
- declare function Slider({ className, value, defaultValue, onValueChange, min, max, step, orientation, disabled, ...props }: SliderProps): React.JSX.Element;
18
+ declare function Slider({ className, value, defaultValue, onValueChange, min, max, step, orientation, disabled, name, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }: SliderProps): React.JSX.Element;
18
19
  export { Slider };
package/dist/ui/slider.js CHANGED
@@ -9,7 +9,10 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
9
9
  * more thumbs (the nearest thumb to the pointer is the one that moves). Track,
10
10
  * range, and thumbs mirror the shadcn structure/classes.
11
11
  */
12
- function Slider({ className, value, defaultValue, onValueChange, min = 0, max = 100, step = 1, orientation = "horizontal", disabled = false, ...props }) {
12
+ function Slider({ className, value, defaultValue, onValueChange, min = 0, max = 100, step = 1, orientation = "horizontal", disabled = false, name,
13
+ // Pull the accessible-name props off so they don't land on the outer wrapper
14
+ // div; the thumb (role="slider") is the element that needs them.
15
+ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props }) {
13
16
  const [values, setValues] = useControllableState({
14
17
  value,
15
18
  defaultValue: defaultValue ?? [min],
@@ -85,10 +88,12 @@ function Slider({ className, value, defaultValue, onValueChange, min = 0, max =
85
88
  };
86
89
  const rangeStart = values.length > 1 ? Math.min(...values.map(percent)) : 0;
87
90
  const rangeEnd = Math.max(...values.map(percent));
88
- return (_jsx("div", { "data-slot": "slider", "data-orientation": orientation, "data-disabled": disabled ? "" : undefined, className: cn("data-horizontal:w-full data-vertical:h-full", className), ...props, children: _jsxs("div", { "data-orientation": orientation, onPointerDown: onPointerDown, className: "relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col", children: [_jsx("div", { ref: trackRef, "data-slot": "slider-track", "data-orientation": orientation, className: "relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5", children: _jsx("div", { "data-slot": "slider-range", "data-orientation": orientation, className: "absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full", style: vertical
89
- ? { bottom: `${rangeStart}%`, height: `${rangeEnd - rangeStart}%` }
90
- : { left: `${rangeStart}%`, width: `${rangeEnd - rangeStart}%` } }) }), values.map((v, index) => (_jsx("span", { role: "slider", tabIndex: disabled ? -1 : 0, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": v, "aria-orientation": orientation, "aria-disabled": disabled || undefined, "data-slot": "slider-thumb", onKeyDown: onThumbKeyDown(index), className: "absolute block size-4 shrink-0 -translate-x-1/2 cursor-pointer rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-vertical:translate-x-0 data-vertical:-translate-y-1/2", "data-orientation": orientation, style: vertical
91
- ? { bottom: `${percent(v)}%` }
92
- : { left: `${percent(v)}%` } }, index)))] }) }));
91
+ return (_jsxs("div", { "data-slot": "slider", "data-orientation": orientation, "data-disabled": disabled ? "" : undefined, className: cn("data-horizontal:w-full data-vertical:h-full", className), ...props, children: [_jsxs("div", { "data-orientation": orientation, onPointerDown: onPointerDown, className: "relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col", children: [_jsx("div", { ref: trackRef, "data-slot": "slider-track", "data-orientation": orientation, className: "relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5", children: _jsx("div", { "data-slot": "slider-range", "data-orientation": orientation, className: "absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full", style: vertical
92
+ ? { bottom: `${rangeStart}%`, height: `${rangeEnd - rangeStart}%` }
93
+ : { left: `${rangeStart}%`, width: `${rangeEnd - rangeStart}%` } }) }), values.map((v, index) => (_jsx("span", { role: "slider", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": v, "aria-orientation": orientation, "aria-disabled": disabled || undefined, "data-slot": "slider-thumb", "data-disabled": disabled ? "" : undefined, onKeyDown: onThumbKeyDown(index), className: "absolute block size-4 shrink-0 -translate-x-1/2 cursor-pointer rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 data-vertical:translate-x-0 data-vertical:-translate-y-1/2", "data-orientation": orientation, style: vertical
94
+ ? { bottom: `${percent(v)}%` }
95
+ : { left: `${percent(v)}%` } }, index)))] }), name
96
+ ? values.map((v, i) => (_jsx("input", { type: "hidden", name: name, value: v }, i)))
97
+ : null] }));
93
98
  }
94
99
  export { Slider };
package/dist/ui/tabs.d.ts CHANGED
@@ -21,7 +21,7 @@ declare function TabsList({ className, variant, onKeyDown, ...props }: React.Com
21
21
  export interface TabsTriggerProps extends Omit<React.ComponentProps<"button">, "value"> {
22
22
  value: string;
23
23
  }
24
- declare function TabsTrigger({ className, value, onClick, ...props }: TabsTriggerProps): React.JSX.Element;
24
+ declare function TabsTrigger({ className, value, onClick, ref, ...props }: TabsTriggerProps): React.JSX.Element;
25
25
  export interface TabsContentProps extends Omit<React.ComponentProps<"div">, "value"> {
26
26
  value: string;
27
27
  }