@stevederico/skateboard-ui 4.1.0 → 4.5.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/ui/drawer.js CHANGED
@@ -4,7 +4,9 @@ import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
5
  import { Slot, mergeRefs, resolveRender } from "./slot.js";
6
6
  import { useControllableState } from "./use-controllable-state.js";
7
+ import { useDialogLabelling } from "./use-labelling.js";
7
8
  import { usePresence } from "./use-presence.js";
9
+ import { useScrollLock } from "./use-scroll-lock.js";
8
10
  // Drag thresholds and animation timing ported from vaul (MIT — Emil Kowalski).
9
11
  // https://github.com/emilkowalski/vaul/blob/main/src/constants.ts
10
12
  const VELOCITY_THRESHOLD = 0.4;
@@ -51,9 +53,17 @@ function Drawer({ open, defaultOpen = false, onOpenChange, children }) {
51
53
  defaultValue: defaultOpen,
52
54
  onChange: onOpenChange,
53
55
  });
54
- const titleId = React.useId();
55
- const descriptionId = React.useId();
56
- return (_jsx(DrawerContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
56
+ const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
57
+ return (_jsx(DrawerContext.Provider, { value: {
58
+ open: isOpen,
59
+ setOpen,
60
+ titleId,
61
+ descriptionId,
62
+ registerTitle,
63
+ registerDescription,
64
+ labelledBy,
65
+ describedBy,
66
+ }, children: children }));
57
67
  }
58
68
  function DrawerTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
59
69
  const { open, setOpen } = useDrawer();
@@ -84,7 +94,7 @@ function DrawerOverlay() {
84
94
  return null;
85
95
  }
86
96
  function DrawerContent({ className, children, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, ...props }) {
87
- const { open, setOpen, titleId, descriptionId } = useDrawer();
97
+ const { open, setOpen, labelledBy, describedBy } = useDrawer();
88
98
  const ref = React.useRef(null);
89
99
  const pointerDownOnBackdrop = React.useRef(false);
90
100
  const [mounted, presenceRef] = usePresence(open);
@@ -103,15 +113,11 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
103
113
  else if (!mounted && node.open)
104
114
  node.close();
105
115
  }, [mounted]);
106
- React.useEffect(() => {
107
- if (!mounted)
108
- return;
109
- const prev = document.body.style.overflow;
110
- document.body.style.overflow = "hidden";
111
- return () => {
112
- document.body.style.overflow = prev;
113
- };
114
- }, [mounted]);
116
+ // Reentrant, reference-counted body scroll lock: when drawers stack with other
117
+ // overlays, the inline overflow effect would let an inner overlay's cleanup
118
+ // unlock scroll while an outer overlay is still open. useScrollLock shares one
119
+ // lock so only the last overlay to close restores the original overflow.
120
+ useScrollLock(mounted);
115
121
  // Clear any drag-leftover inline transform whenever we (re)open.
116
122
  React.useEffect(() => {
117
123
  if (open && ref.current) {
@@ -207,7 +213,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
207
213
  };
208
214
  if (!mounted)
209
215
  return null;
210
- return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
216
+ return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
211
217
  e.preventDefault();
212
218
  setOpen(false);
213
219
  }, onClose: () => setOpen(false), onClick: (e) => {
@@ -233,11 +239,17 @@ function DrawerFooter({ className, ...props }) {
233
239
  return (_jsx("div", { "data-slot": "drawer-footer", className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
234
240
  }
235
241
  function DrawerTitle({ className, ...props }) {
236
- const { titleId } = useDrawer();
242
+ const { titleId, registerTitle } = useDrawer();
243
+ // Register on mount so the container knows a title exists and can wire
244
+ // aria-labelledby; the returned cleanup unregisters on unmount.
245
+ React.useEffect(registerTitle, [registerTitle]);
237
246
  return (_jsx("h2", { id: titleId, "data-slot": "drawer-title", className: cn("font-medium text-foreground", className), ...props }));
238
247
  }
239
248
  function DrawerDescription({ className, ...props }) {
240
- const { descriptionId } = useDrawer();
249
+ const { descriptionId, registerDescription } = useDrawer();
250
+ // Register on mount so the container knows a description exists and can wire
251
+ // aria-describedby; the returned cleanup unregisters on unmount.
252
+ React.useEffect(registerDescription, [registerDescription]);
241
253
  return (_jsx("p", { id: descriptionId, "data-slot": "drawer-description", className: cn("text-sm text-muted-foreground", className), ...props }));
242
254
  }
243
255
  export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };
@@ -8,6 +8,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
10
  import { useTypeahead } from "./use-typeahead.js";
11
+ import { usePointerMoved } from "./use-pointer-moved.js";
11
12
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
12
13
  const MenuContext = React.createContext(null);
13
14
  function useMenu() {
@@ -43,17 +44,20 @@ function DropdownMenu({ open, defaultOpen = false, onOpenChange, children, }) {
43
44
  });
44
45
  const triggerRef = React.useRef(null);
45
46
  const contentId = React.useId();
47
+ const triggerId = React.useId();
46
48
  const layers = React.useRef(new Set());
47
- return (_jsx(MenuContext.Provider, { value: { open: isOpen, setOpen, triggerRef, contentId, layers }, children: children }));
49
+ return (_jsx(MenuContext.Provider, { value: { open: isOpen, setOpen, triggerRef, contentId, triggerId, layers }, children: children }));
48
50
  }
49
51
  function DropdownMenuPortal({ children }) {
50
52
  return _jsx(_Fragment, { children: children });
51
53
  }
52
54
  function DropdownMenuTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, onKeyDown, ...props }) {
53
- const { open, setOpen, triggerRef, contentId } = useMenu();
55
+ const { open, setOpen, triggerRef, contentId, triggerId } = useMenu();
54
56
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
55
57
  const Comp = useSlot ? Slot : "button";
56
- return (_jsx(Comp, { ref: triggerRef, type: useSlot ? undefined : "button", "data-slot": "dropdown-menu-trigger", "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", onClick: (e) => {
58
+ // Give the trigger a stable id so the menu can name itself via aria-labelledby.
59
+ // Honor a consumer-supplied id when present so we don't clobber their wiring.
60
+ return (_jsx(Comp, { ref: triggerRef, type: useSlot ? undefined : "button", id: props.id ?? triggerId, "data-slot": "dropdown-menu-trigger", "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", onClick: (e) => {
57
61
  onClick?.(e);
58
62
  if (!e.defaultPrevented)
59
63
  setOpen(!open);
@@ -68,7 +72,7 @@ function DropdownMenuTrigger({ asChild = false, render, nativeButton: _nativeBut
68
72
  }, ...props, children: slotChild }));
69
73
  }
70
74
  function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom", sideOffset = 4, className, children, onKeyDown, ...props }) {
71
- const { open, setOpen, triggerRef, contentId, layers } = useMenu();
75
+ const { open, setOpen, triggerRef, contentId, triggerId, layers } = useMenu();
72
76
  const [mounted, presenceRef] = usePresence(open);
73
77
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
74
78
  side,
@@ -78,6 +82,7 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
78
82
  });
79
83
  const containerRef = React.useRef(null);
80
84
  const { onTypeaheadKeyDown } = useTypeahead();
85
+ const hasPointerMoved = usePointerMoved();
81
86
  React.useEffect(() => {
82
87
  const node = containerRef.current;
83
88
  if (!node)
@@ -128,7 +133,7 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
128
133
  }, [open, setOpen, triggerRef, layers]);
129
134
  if (!mounted)
130
135
  return null;
131
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "menu", "aria-orientation": "vertical", tabIndex: -1, "data-slot": "dropdown-menu-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
136
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "menu", "aria-labelledby": triggerRef.current?.id ?? triggerId, "aria-orientation": "vertical", tabIndex: -1, "data-slot": "dropdown-menu-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
132
137
  position: "fixed",
133
138
  left: pos?.x ?? 0,
134
139
  top: pos?.y ?? 0,
@@ -136,6 +141,10 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
136
141
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
137
142
  ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
138
143
  }, onPointerMove: (e) => {
144
+ // Ignore synthetic pointermoves fired when keyboard nav scrolls the list
145
+ // under a stationary pointer — they'd yank focus off the keyboard target.
146
+ if (!hasPointerMoved(e))
147
+ return;
139
148
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
140
149
  if (item && item !== document.activeElement)
141
150
  item.focus();
@@ -186,8 +195,11 @@ function DropdownMenuLabel({ className, inset, ...props }) {
186
195
  return (_jsx("div", { "data-slot": "dropdown-menu-label", "data-inset": inset ? "" : undefined, className: cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className), ...props }));
187
196
  }
188
197
  function DropdownMenuItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
189
- const { setOpen } = useMenu();
190
- return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "dropdown-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn("group/dropdown-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 not-data-[variant=destructive]: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 data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
198
+ const { setOpen, triggerRef } = useMenu();
199
+ return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "dropdown-menu-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn(
200
+ // focus-visible ring: focus:bg-accent alone is ~1.12:1 against the popover
201
+ // bg (below WCAG 3:1), so add a keyboard-only ring for a visible focus target.
202
+ "group/dropdown-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 not-data-[variant=destructive]: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 data-[variant=destructive]:*:[svg]:text-destructive", className), onClick: (e) => {
191
203
  if (disabled)
192
204
  return;
193
205
  onClick?.(e);
@@ -195,11 +207,16 @@ function DropdownMenuItem({ className, inset, variant = "default", disabled, onS
195
207
  return;
196
208
  onSelect?.();
197
209
  setOpen(false);
210
+ // Selecting a plain item closes the menu; without this, focus is left on
211
+ // <body> instead of returning to the trigger (Escape already restores it).
212
+ triggerRef.current?.focus();
198
213
  }, ...props }));
199
214
  }
200
215
  const RadioGroupContext = React.createContext({});
201
216
  function DropdownMenuCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
202
- return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "dropdown-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 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) => {
217
+ return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "dropdown-menu-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
218
+ // focus-visible ring: keyboard-only focus target (focus:bg-accent is below WCAG 3:1).
219
+ "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 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) => {
203
220
  if (disabled)
204
221
  return;
205
222
  onClick?.(e);
@@ -213,7 +230,9 @@ function DropdownMenuRadioGroup({ value, onValueChange, ...props }) {
213
230
  function DropdownMenuRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
214
231
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
215
232
  const checked = groupValue === value;
216
- return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "dropdown-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 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) => {
233
+ return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "dropdown-menu-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn(
234
+ // focus-visible ring: keyboard-only focus target (focus:bg-accent is below WCAG 3:1).
235
+ "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 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) => {
217
236
  if (disabled)
218
237
  return;
219
238
  onClick?.(e);
@@ -237,11 +256,14 @@ function useSub() {
237
256
  function DropdownMenuSub({ defaultOpen = false, children, }) {
238
257
  const [open, setOpen] = React.useState(defaultOpen);
239
258
  const triggerRef = React.useRef(null);
240
- return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef }, children: children }));
259
+ const subContentId = React.useId();
260
+ return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
241
261
  }
242
262
  function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
243
- const { open, setOpen, triggerRef } = useSub();
244
- return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn("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-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) => {
263
+ const { open, setOpen, triggerRef, subContentId } = useSub();
264
+ return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
265
+ // focus-visible ring: keyboard-only focus target (focus:bg-accent is below WCAG 3:1).
266
+ "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-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) => {
245
267
  onMouseEnter?.(e);
246
268
  setOpen(true);
247
269
  }, onClick: (e) => {
@@ -259,7 +281,7 @@ function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown
259
281
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
260
282
  }
261
283
  function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
262
- const { open, setOpen, triggerRef } = useSub();
284
+ const { open, setOpen, triggerRef, subContentId } = useSub();
263
285
  const { layers } = useMenu();
264
286
  const [mounted, presenceRef] = usePresence(open);
265
287
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -270,6 +292,7 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
270
292
  });
271
293
  const containerRef = React.useRef(null);
272
294
  const { onTypeaheadKeyDown } = useTypeahead();
295
+ const hasPointerMoved = usePointerMoved();
273
296
  React.useEffect(() => {
274
297
  const node = containerRef.current;
275
298
  if (!node)
@@ -301,13 +324,20 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
301
324
  }, [open]);
302
325
  if (!mounted)
303
326
  return null;
304
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), role: "menu", tabIndex: -1, "data-slot": "dropdown-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
327
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef),
328
+ // id matches the sub-trigger's aria-controls so AT can follow the
329
+ // disclosure relationship from the trigger to this open submenu panel.
330
+ id: subContentId, role: "menu", tabIndex: -1, "data-slot": "dropdown-menu-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
305
331
  position: "fixed",
306
332
  left: pos?.x ?? 0,
307
333
  top: pos?.y ?? 0,
308
334
  visibility: pos ? "visible" : "hidden",
309
335
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
310
336
  }, onPointerMove: (e) => {
337
+ // Ignore synthetic pointermoves fired when keyboard nav scrolls the list
338
+ // under a stationary pointer — they'd yank focus off the keyboard target.
339
+ if (!hasPointerMoved(e))
340
+ return;
311
341
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
312
342
  if (item && item !== document.activeElement)
313
343
  item.focus();
@@ -7,6 +7,7 @@ import { Portal } from "./portal.js";
7
7
  import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
9
  import { useTypeahead } from "./use-typeahead.js";
10
+ import { usePointerMoved } from "./use-pointer-moved.js";
10
11
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
11
12
  const MenubarRootContext = React.createContext(null);
12
13
  function useMenubarRoot() {
@@ -52,6 +53,7 @@ function MenubarMenu({ value: valueProp, children, }) {
52
53
  const value = valueProp ?? generatedId;
53
54
  const triggerRef = React.useRef(null);
54
55
  const contentId = React.useId();
56
+ const triggerId = React.useId();
55
57
  const layers = React.useRef(new Set());
56
58
  const open = root.value === value;
57
59
  const setOpen = React.useCallback((next) => {
@@ -65,7 +67,7 @@ function MenubarMenu({ value: valueProp, children, }) {
65
67
  root.setActive(false);
66
68
  }
67
69
  }, [root, value]);
68
- return (_jsx(MenuContext.Provider, { value: { value, open, setOpen, triggerRef, contentId, layers }, children: children }));
70
+ return (_jsx(MenuContext.Provider, { value: { value, open, setOpen, triggerRef, contentId, triggerId, layers }, children: children }));
69
71
  }
70
72
  function MenubarPortal({ children }) {
71
73
  return _jsx(_Fragment, { children: children });
@@ -75,10 +77,13 @@ function MenubarGroup({ className, ...props }) {
75
77
  }
76
78
  function MenubarTrigger({ asChild = false, render, nativeButton: _nativeButton, children, className, onClick, onKeyDown, onMouseEnter, ...props }) {
77
79
  const root = useMenubarRoot();
78
- const { value, open, setOpen, triggerRef, contentId } = useMenu();
80
+ const { value, open, setOpen, triggerRef, contentId, triggerId } = useMenu();
79
81
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
80
82
  const Comp = useSlot ? Slot : "button";
81
- return (_jsx(Comp, { ref: triggerRef, type: useSlot ? undefined : "button", "data-slot": "menubar-trigger", "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", className: cn("cursor-pointer flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted", className), onClick: (e) => {
83
+ return (_jsx(Comp, { ref: triggerRef, type: useSlot ? undefined : "button", "data-slot": "menubar-trigger",
84
+ // a11y: give the trigger a stable id so its menu can reference it via
85
+ // aria-labelledby; a consumer-supplied id in ...props overrides this.
86
+ id: triggerId, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", className: cn("cursor-pointer flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted", className), onClick: (e) => {
82
87
  onClick?.(e);
83
88
  if (!e.defaultPrevented)
84
89
  setOpen(!open);
@@ -117,7 +122,7 @@ function MenubarTrigger({ asChild = false, render, nativeButton: _nativeButton,
117
122
  }
118
123
  function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", sideOffset = 8, className, children, onKeyDown, ...props }) {
119
124
  const root = useMenubarRoot();
120
- const { open, setOpen, triggerRef, contentId, layers } = useMenu();
125
+ const { open, setOpen, triggerRef, contentId, triggerId, layers } = useMenu();
121
126
  const [mounted, presenceRef] = usePresence(open);
122
127
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
123
128
  side,
@@ -127,6 +132,7 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
127
132
  });
128
133
  const containerRef = React.useRef(null);
129
134
  const { onTypeaheadKeyDown } = useTypeahead();
135
+ const hasPointerMoved = usePointerMoved();
130
136
  React.useEffect(() => {
131
137
  const node = containerRef.current;
132
138
  if (!node)
@@ -177,13 +183,18 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
177
183
  }, [open, setOpen, triggerRef, layers]);
178
184
  if (!mounted)
179
185
  return null;
180
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "menu", "aria-orientation": "vertical", tabIndex: -1, "data-slot": "menubar-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
186
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: contentId, role: "menu", "aria-labelledby": triggerRef.current?.id ?? triggerId, "aria-orientation": "vertical", tabIndex: -1, "data-slot": "menubar-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, style: {
181
187
  position: "fixed",
182
188
  left: pos?.x ?? 0,
183
189
  top: pos?.y ?? 0,
184
190
  visibility: pos ? "visible" : "hidden",
185
191
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
186
192
  }, onPointerMove: (e) => {
193
+ // a11y: ignore synthetic pointermove events fired when keyboard nav
194
+ // scrolls the focused item into view — those report unchanged
195
+ // coordinates and would otherwise steal focus from the keyboard target.
196
+ if (!hasPointerMoved(e))
197
+ return;
187
198
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
188
199
  if (item && item !== document.activeElement)
189
200
  item.focus();
@@ -247,8 +258,8 @@ function MenubarLabel({ className, inset, ...props }) {
247
258
  return (_jsx("div", { "data-slot": "menubar-label", "data-inset": inset ? "" : undefined, className: cn("px-2 py-1.5 text-sm font-medium data-inset:pl-8", className), ...props }));
248
259
  }
249
260
  function MenubarItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
250
- const { setOpen } = useMenu();
251
- return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "menubar-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn("group/menubar-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 not-data-[variant=destructive]: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 data-[variant=destructive]:*:[svg]:text-destructive!", className), onClick: (e) => {
261
+ const { setOpen, triggerRef } = useMenu();
262
+ return (_jsx("div", { role: "menuitem", tabIndex: -1, "aria-disabled": disabled || undefined, "data-slot": "menubar-item", "data-inset": inset ? "" : undefined, "data-variant": variant, "data-disabled": disabled ? "" : undefined, className: cn("group/menubar-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 not-data-[variant=destructive]: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 data-[variant=destructive]:*:[svg]:text-destructive!", className), onClick: (e) => {
252
263
  if (disabled)
253
264
  return;
254
265
  onClick?.(e);
@@ -256,11 +267,15 @@ function MenubarItem({ className, inset, variant = "default", disabled, onSelect
256
267
  return;
257
268
  onSelect?.();
258
269
  setOpen(false);
270
+ // a11y: selecting an item closes the menu, which would otherwise drop
271
+ // focus to <body>; restore it to the trigger so keyboard users keep
272
+ // their place in the bar.
273
+ triggerRef.current?.focus();
259
274
  }, ...props }));
260
275
  }
261
276
  const RadioGroupContext = React.createContext({});
262
277
  function MenubarCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
263
- return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "menubar-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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) => {
278
+ return (_jsxs("div", { role: "menuitemcheckbox", tabIndex: -1, "aria-checked": !!checked, "aria-disabled": disabled || undefined, "data-slot": "menubar-checkbox-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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) => {
264
279
  if (disabled)
265
280
  return;
266
281
  onClick?.(e);
@@ -274,7 +289,7 @@ function MenubarRadioGroup({ value, onValueChange, ...props }) {
274
289
  function MenubarRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
275
290
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
276
291
  const checked = groupValue === value;
277
- return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "menubar-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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) => {
292
+ return (_jsxs("div", { role: "menuitemradio", tabIndex: -1, "aria-checked": checked, "aria-disabled": disabled || undefined, "data-slot": "menubar-radio-item", "data-disabled": disabled ? "" : undefined, "data-inset": inset ? "" : undefined, className: cn("relative flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground 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) => {
278
293
  if (disabled)
279
294
  return;
280
295
  onClick?.(e);
@@ -298,11 +313,12 @@ function useSub() {
298
313
  function MenubarSub({ defaultOpen = false, children, }) {
299
314
  const [open, setOpen] = React.useState(defaultOpen);
300
315
  const triggerRef = React.useRef(null);
301
- return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef }, children: children }));
316
+ const subContentId = React.useId();
317
+ return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
302
318
  }
303
319
  function MenubarSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
304
- const { open, setOpen, triggerRef } = useSub();
305
- return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "data-slot": "menubar-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn("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-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) => {
320
+ const { open, setOpen, triggerRef, subContentId } = useSub();
321
+ return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "menubar-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn("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-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) => {
306
322
  onMouseEnter?.(e);
307
323
  setOpen(true);
308
324
  }, onClick: (e) => {
@@ -320,7 +336,7 @@ function MenubarSubTrigger({ className, inset, children, onClick, onKeyDown, onM
320
336
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
321
337
  }
322
338
  function MenubarSubContent({ className, children, onKeyDown, ...props }) {
323
- const { open, setOpen, triggerRef } = useSub();
339
+ const { open, setOpen, triggerRef, subContentId } = useSub();
324
340
  const { layers } = useMenu();
325
341
  const [mounted, presenceRef] = usePresence(open);
326
342
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -330,6 +346,7 @@ function MenubarSubContent({ className, children, onKeyDown, ...props }) {
330
346
  alignOffset: -3,
331
347
  });
332
348
  const containerRef = React.useRef(null);
349
+ const hasPointerMoved = usePointerMoved();
333
350
  React.useEffect(() => {
334
351
  const node = containerRef.current;
335
352
  if (!node)
@@ -361,13 +378,17 @@ function MenubarSubContent({ className, children, onKeyDown, ...props }) {
361
378
  }, [open]);
362
379
  if (!mounted)
363
380
  return null;
364
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), role: "menu", tabIndex: -1, "data-slot": "menubar-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
381
+ return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, containerRef), id: subContentId, role: "menu", tabIndex: -1, "data-slot": "menubar-sub-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? "right", style: {
365
382
  position: "fixed",
366
383
  left: pos?.x ?? 0,
367
384
  top: pos?.y ?? 0,
368
385
  visibility: pos ? "visible" : "hidden",
369
386
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
370
387
  }, onPointerMove: (e) => {
388
+ // Ignore synthetic same-coordinate pointermoves (from keyboard-nav
389
+ // scroll) so they don't steal focus off the keyboard target.
390
+ if (!hasPointerMoved(e))
391
+ return;
371
392
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
372
393
  if (item && item !== document.activeElement)
373
394
  item.focus();
@@ -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();
@@ -38,7 +51,7 @@ function PopoverTrigger({ asChild = false, render, nativeButton: _nativeButton,
38
51
  // Tabbable descendants, in DOM order, for focus-in and the Tab trap.
39
52
  const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
40
53
  function PopoverContent({ className, style, ref, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, onKeyDown, ...props }) {
41
- const { open, setOpen, triggerRef, contentId } = usePopover();
54
+ const { open, setOpen, triggerRef, contentId, labelledBy, describedBy } = usePopover();
42
55
  const [mounted, presenceRef] = usePresence(open);
43
56
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
44
57
  side,
@@ -81,7 +94,7 @@ function PopoverContent({ className, style, ref, align = "center", alignOffset =
81
94
  }, [open, triggerRef]);
82
95
  if (!mounted)
83
96
  return null;
84
- return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, contentRef, ref), id: contentId, role: "dialog", 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) => {
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) => {
85
98
  onKeyDown?.(e);
86
99
  if (e.defaultPrevented || e.key !== "Tab")
87
100
  return;
@@ -118,9 +131,15 @@ function PopoverHeader({ className, ...props }) {
118
131
  return (_jsx("div", { "data-slot": "popover-header", className: cn("flex flex-col gap-1 text-sm", className), ...props }));
119
132
  }
120
133
  function PopoverTitle({ className, ...props }) {
121
- 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 }));
122
138
  }
123
139
  function PopoverDescription({ className, ...props }) {
124
- 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 }));
125
144
  }
126
145
  export { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription, };
@@ -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
@@ -8,6 +8,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
10
  import { useTypeahead } from "./use-typeahead.js";
11
+ import { usePointerMoved } from "./use-pointer-moved.js";
11
12
  import { ChevronDownIcon, CheckIcon } from "../icons/index.js";
12
13
  const SelectContext = React.createContext(null);
13
14
  function useSelect() {
@@ -29,6 +30,7 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
29
30
  });
30
31
  const triggerRef = React.useRef(null);
31
32
  const contentId = React.useId();
33
+ const triggerId = React.useId();
32
34
  const [labels, setLabels] = React.useState({});
33
35
  const registerLabel = React.useCallback((v, label) => setLabels((prev) => ({ ...prev, [v]: label })), []);
34
36
  const unregisterLabel = React.useCallback((v) => setLabels((prev) => {
@@ -47,6 +49,7 @@ function Select({ value, defaultValue, onValueChange, open, defaultOpen = false,
47
49
  setValue,
48
50
  triggerRef,
49
51
  contentId,
52
+ triggerId,
50
53
  labels,
51
54
  registerLabel,
52
55
  unregisterLabel,
@@ -60,9 +63,13 @@ function SelectValue({ className, placeholder, ...props }) {
60
63
  const label = value !== undefined ? labels[value] : undefined;
61
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 }));
62
65
  }
63
- function SelectTrigger({ className, size = "default", children, onClick, onKeyDown, ...props }) {
64
- const { open, setOpen, value, triggerRef, contentId } = useSelect();
65
- 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) => {
66
73
  onClick?.(e);
67
74
  if (!e.defaultPrevented)
68
75
  setOpen(!open);
@@ -80,7 +87,7 @@ function optionsOf(container) {
80
87
  return Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
81
88
  }
82
89
  function SelectContent({ className, children, side = "bottom", sideOffset = 4, align = "center", alignOffset = 0, onKeyDown, ...props }) {
83
- const { open, setOpen, value, triggerRef, contentId } = useSelect();
90
+ const { open, setOpen, value, triggerRef, contentId, triggerId } = useSelect();
84
91
  const [mounted, presenceRef] = usePresence(open);
85
92
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
86
93
  side,
@@ -90,6 +97,7 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
90
97
  });
91
98
  const containerRef = React.useRef(null);
92
99
  const { onTypeaheadKeyDown } = useTypeahead();
100
+ const hasPointerMoved = usePointerMoved();
93
101
  // Focus the selected (or first) option once the popup is actually visible.
94
102
  const didFocus = React.useRef(false);
95
103
  React.useEffect(() => {
@@ -136,7 +144,11 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
136
144
  return (_jsx("div", { hidden: true, "aria-hidden": true, "data-slot": "select-content-registry", children: children }));
137
145
  }
138
146
  const triggerWidth = triggerRef.current?.offsetWidth;
139
- 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: {
140
152
  position: "fixed",
141
153
  left: pos?.x ?? 0,
142
154
  top: pos?.y ?? 0,
@@ -145,6 +157,11 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
145
157
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
146
158
  ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
147
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;
148
165
  const opt = e.target.closest('[role="option"]:not([data-disabled])');
149
166
  if (opt && opt !== document.activeElement)
150
167
  opt.focus();
@@ -205,7 +222,11 @@ function SelectItem({ className, children, value, disabled, onClick, ...props })
205
222
  React.useEffect(() => {
206
223
  registerLabel(value, children);
207
224
  }, [value, children, registerLabel]);
208
- 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) => {
209
230
  if (disabled)
210
231
  return;
211
232
  onClick?.(e);