@stevederico/skateboard-ui 4.0.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/dialog.js CHANGED
@@ -6,6 +6,8 @@ import { Button } from "./button.js";
6
6
  import { Slot, resolveRender } from "./slot.js";
7
7
  import { XIcon } from "../icons/index.js";
8
8
  import { useControllableState } from "./use-controllable-state.js";
9
+ import { useDialogLabelling } from "./use-labelling.js";
10
+ import { useScrollLock } from "./use-scroll-lock.js";
9
11
  const DialogContext = React.createContext(null);
10
12
  function useDialog() {
11
13
  const ctx = React.useContext(DialogContext);
@@ -24,9 +26,10 @@ function Dialog({ open, defaultOpen = false, onOpenChange, children }) {
24
26
  defaultValue: defaultOpen,
25
27
  onChange: onOpenChange,
26
28
  });
27
- const titleId = React.useId();
28
- const descriptionId = React.useId();
29
- return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, titleId, descriptionId }, children: children }));
29
+ // Track which labelling parts actually render so DialogContent can reference
30
+ // them only when present, avoiding a dangling aria-labelledby/-describedby IDREF.
31
+ const labelling = useDialogLabelling();
32
+ return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, ...labelling }, children: children }));
30
33
  }
31
34
  function DialogTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
32
35
  const { open, setOpen } = useDialog();
@@ -57,7 +60,7 @@ function DialogOverlay() {
57
60
  return null;
58
61
  }
59
62
  function DialogContent({ className, children, showCloseButton = true, ...props }) {
60
- const { open, setOpen, titleId, descriptionId } = useDialog();
63
+ const { open, setOpen, labelledBy, describedBy } = useDialog();
61
64
  const ref = React.useRef(null);
62
65
  const pointerDownOnBackdrop = React.useRef(false);
63
66
  React.useEffect(() => {
@@ -69,17 +72,11 @@ function DialogContent({ className, children, showCloseButton = true, ...props }
69
72
  else if (!open && node.open)
70
73
  node.close();
71
74
  }, [open]);
72
- // Lock background scroll while open.
73
- React.useEffect(() => {
74
- if (!open)
75
- return;
76
- const prev = document.body.style.overflow;
77
- document.body.style.overflow = "hidden";
78
- return () => {
79
- document.body.style.overflow = prev;
80
- };
81
- }, [open]);
82
- return (_jsx("dialog", { ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
75
+ // Reentrant, reference-counted scroll lock so stacked overlays don't clobber
76
+ // each other's overflow restore (an inner close would otherwise unlock scroll
77
+ // while an outer dialog is still open).
78
+ useScrollLock(open);
79
+ return (_jsx("dialog", { ref: ref, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "dialog-overlay", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
83
80
  e.preventDefault();
84
81
  setOpen(false);
85
82
  }, onClose: () => setOpen(false), onPointerDown: (e) => {
@@ -99,11 +96,15 @@ function DialogFooter({ className, showCloseButton = false, children, ...props }
99
96
  return (_jsxs("div", { "data-slot": "dialog-footer", className: cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className), ...props, children: [children, showCloseButton && (_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "outline", children: "Close" }) }))] }));
100
97
  }
101
98
  function DialogTitle({ className, ...props }) {
102
- const { titleId } = useDialog();
99
+ const { titleId, registerTitle } = useDialog();
100
+ // Register presence so the container wires aria-labelledby only when a title exists.
101
+ React.useEffect(registerTitle, [registerTitle]);
103
102
  return (_jsx("h2", { id: titleId, "data-slot": "dialog-title", className: cn("leading-none font-medium", className), ...props }));
104
103
  }
105
104
  function DialogDescription({ className, ...props }) {
106
- const { descriptionId } = useDialog();
105
+ const { descriptionId, registerDescription } = useDialog();
106
+ // Register presence so the container wires aria-describedby only when a description exists.
107
+ React.useEffect(registerDescription, [registerDescription]);
107
108
  return (_jsx("p", { id: descriptionId, "data-slot": "dialog-description", className: cn("text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", className), ...props }));
108
109
  }
109
110
  export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
package/dist/ui/drawer.js CHANGED
@@ -2,9 +2,11 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { cn } from "../shadcn/lib/utils.js";
5
- import { Slot, resolveRender } from "./slot.js";
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,11 +94,16 @@ 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
- const [mounted] = usePresence(open);
100
+ const [mounted, presenceRef] = usePresence(open);
91
101
  const dragState = React.useRef({ dragging: false, startY: 0, height: 0, startTime: 0 });
102
+ // A drag-dismiss already slides the panel out via an inline transform, so the
103
+ // CSS `animate-out` slide must be suppressed for that path — otherwise its
104
+ // keyframes would override the transform and snap the panel back into view.
105
+ // Programmatic closes (backdrop, Escape, controlled) keep the CSS animation.
106
+ const dismissingByDrag = React.useRef(false);
92
107
  React.useEffect(() => {
93
108
  const node = ref.current;
94
109
  if (!node)
@@ -98,20 +113,17 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
98
113
  else if (!mounted && node.open)
99
114
  node.close();
100
115
  }, [mounted]);
101
- React.useEffect(() => {
102
- if (!mounted)
103
- return;
104
- const prev = document.body.style.overflow;
105
- document.body.style.overflow = "hidden";
106
- return () => {
107
- document.body.style.overflow = prev;
108
- };
109
- }, [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);
110
121
  // Clear any drag-leftover inline transform whenever we (re)open.
111
122
  React.useEffect(() => {
112
123
  if (open && ref.current) {
113
124
  ref.current.style.transition = "";
114
125
  ref.current.style.transform = "";
126
+ dismissingByDrag.current = false;
115
127
  }
116
128
  }, [open]);
117
129
  const snapBack = React.useCallback(() => {
@@ -127,6 +139,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
127
139
  setOpen(false);
128
140
  return;
129
141
  }
142
+ dismissingByDrag.current = true;
130
143
  el.style.transition = TRANSITION;
131
144
  el.style.transform = "translate3d(0, 100%, 0)";
132
145
  window.setTimeout(() => setOpen(false), 500);
@@ -200,7 +213,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
200
213
  };
201
214
  if (!mounted)
202
215
  return null;
203
- return (_jsxs("dialog", { ...props, ref: ref, "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) => {
204
217
  e.preventDefault();
205
218
  setOpen(false);
206
219
  }, onClose: () => setOpen(false), onClick: (e) => {
@@ -215,7 +228,9 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
215
228
  // sizing so the drawer pins to the bottom edge at full width.
216
229
  "fixed inset-x-0 top-auto bottom-0 z-50 m-0 flex max-h-[80vh] w-full max-w-none touch-none flex-col rounded-t-xl border-t border-border bg-background p-0 text-sm outline-none backdrop:bg-black/10 backdrop:transition-opacity backdrop:duration-250 supports-backdrop-filter:backdrop:backdrop-blur-xs", open
217
230
  ? "animate-in fade-in-0 slide-in-from-bottom-52 duration-300"
218
- : "animate-out fade-out-0 slide-out-to-bottom-52 duration-300", className), children: [_jsx("div", { className: "mx-auto mt-4 h-1.5 w-[100px] shrink-0 rounded-full bg-muted" }), children] }));
231
+ : dismissingByDrag.current
232
+ ? ""
233
+ : "animate-out fade-out-0 slide-out-to-bottom-52 duration-300", className), children: [_jsx("div", { className: "mx-auto mt-4 h-1.5 w-[100px] shrink-0 rounded-full bg-muted" }), children] }));
219
234
  }
220
235
  function DrawerHeader({ className, ...props }) {
221
236
  return (_jsx("div", { "data-slot": "drawer-header", className: cn("flex flex-col gap-0.5 p-4 text-center md:gap-1.5 md:text-left", className), ...props }));
@@ -224,11 +239,17 @@ function DrawerFooter({ className, ...props }) {
224
239
  return (_jsx("div", { "data-slot": "drawer-footer", className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
225
240
  }
226
241
  function DrawerTitle({ className, ...props }) {
227
- 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]);
228
246
  return (_jsx("h2", { id: titleId, "data-slot": "drawer-title", className: cn("font-medium text-foreground", className), ...props }));
229
247
  }
230
248
  function DrawerDescription({ className, ...props }) {
231
- 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]);
232
253
  return (_jsx("p", { id: descriptionId, "data-slot": "drawer-description", className: cn("text-sm text-muted-foreground", className), ...props }));
233
254
  }
234
255
  export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };
@@ -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 { ChevronRightIcon, CheckIcon } from "../icons/index.js";
11
13
  const MenuContext = React.createContext(null);
12
14
  function useMenu() {
@@ -42,17 +44,20 @@ function DropdownMenu({ open, defaultOpen = false, onOpenChange, children, }) {
42
44
  });
43
45
  const triggerRef = React.useRef(null);
44
46
  const contentId = React.useId();
47
+ const triggerId = React.useId();
45
48
  const layers = React.useRef(new Set());
46
- 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 }));
47
50
  }
48
51
  function DropdownMenuPortal({ children }) {
49
52
  return _jsx(_Fragment, { children: children });
50
53
  }
51
54
  function DropdownMenuTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, onKeyDown, ...props }) {
52
- const { open, setOpen, triggerRef, contentId } = useMenu();
55
+ const { open, setOpen, triggerRef, contentId, triggerId } = useMenu();
53
56
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
54
57
  const Comp = useSlot ? Slot : "button";
55
- 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) => {
56
61
  onClick?.(e);
57
62
  if (!e.defaultPrevented)
58
63
  setOpen(!open);
@@ -67,7 +72,7 @@ function DropdownMenuTrigger({ asChild = false, render, nativeButton: _nativeBut
67
72
  }, ...props, children: slotChild }));
68
73
  }
69
74
  function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom", sideOffset = 4, className, children, onKeyDown, ...props }) {
70
- const { open, setOpen, triggerRef, contentId, layers } = useMenu();
75
+ const { open, setOpen, triggerRef, contentId, triggerId, layers } = useMenu();
71
76
  const [mounted, presenceRef] = usePresence(open);
72
77
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
73
78
  side,
@@ -76,6 +81,8 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
76
81
  alignOffset,
77
82
  });
78
83
  const containerRef = React.useRef(null);
84
+ const { onTypeaheadKeyDown } = useTypeahead();
85
+ const hasPointerMoved = usePointerMoved();
79
86
  React.useEffect(() => {
80
87
  const node = containerRef.current;
81
88
  if (!node)
@@ -126,13 +133,18 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
126
133
  }, [open, setOpen, triggerRef, layers]);
127
134
  if (!mounted)
128
135
  return null;
129
- 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: {
130
137
  position: "fixed",
131
138
  left: pos?.x ?? 0,
132
139
  top: pos?.y ?? 0,
133
140
  visibility: pos ? "visible" : "hidden",
134
141
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
142
+ ["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
135
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;
136
148
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
137
149
  if (item && item !== document.activeElement)
138
150
  item.focus();
@@ -164,6 +176,16 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
164
176
  active.click();
165
177
  }
166
178
  }
179
+ else {
180
+ // WAI-ARIA typeahead: printable chars jump to the matching item.
181
+ const items = itemsOf(c);
182
+ const idx = items.indexOf(document.activeElement);
183
+ const match = onTypeaheadKeyDown(e, items, idx);
184
+ if (match) {
185
+ e.preventDefault();
186
+ match.focus();
187
+ }
188
+ }
167
189
  }, className: cn("z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=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 }) }));
168
190
  }
169
191
  function DropdownMenuGroup({ className, ...props }) {
@@ -173,8 +195,11 @@ function DropdownMenuLabel({ className, inset, ...props }) {
173
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 }));
174
196
  }
175
197
  function DropdownMenuItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
176
- const { setOpen } = useMenu();
177
- 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) => {
178
203
  if (disabled)
179
204
  return;
180
205
  onClick?.(e);
@@ -182,11 +207,16 @@ function DropdownMenuItem({ className, inset, variant = "default", disabled, onS
182
207
  return;
183
208
  onSelect?.();
184
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();
185
213
  }, ...props }));
186
214
  }
187
215
  const RadioGroupContext = React.createContext({});
188
216
  function DropdownMenuCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
189
- 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) => {
190
220
  if (disabled)
191
221
  return;
192
222
  onClick?.(e);
@@ -200,7 +230,9 @@ function DropdownMenuRadioGroup({ value, onValueChange, ...props }) {
200
230
  function DropdownMenuRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
201
231
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
202
232
  const checked = groupValue === value;
203
- 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) => {
204
236
  if (disabled)
205
237
  return;
206
238
  onClick?.(e);
@@ -224,11 +256,14 @@ function useSub() {
224
256
  function DropdownMenuSub({ defaultOpen = false, children, }) {
225
257
  const [open, setOpen] = React.useState(defaultOpen);
226
258
  const triggerRef = React.useRef(null);
227
- 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 }));
228
261
  }
229
262
  function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
230
- const { open, setOpen, triggerRef } = useSub();
231
- 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) => {
232
267
  onMouseEnter?.(e);
233
268
  setOpen(true);
234
269
  }, onClick: (e) => {
@@ -246,7 +281,7 @@ function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown
246
281
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
247
282
  }
248
283
  function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
249
- const { open, setOpen, triggerRef } = useSub();
284
+ const { open, setOpen, triggerRef, subContentId } = useSub();
250
285
  const { layers } = useMenu();
251
286
  const [mounted, presenceRef] = usePresence(open);
252
287
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -256,6 +291,8 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
256
291
  alignOffset: -3,
257
292
  });
258
293
  const containerRef = React.useRef(null);
294
+ const { onTypeaheadKeyDown } = useTypeahead();
295
+ const hasPointerMoved = usePointerMoved();
259
296
  React.useEffect(() => {
260
297
  const node = containerRef.current;
261
298
  if (!node)
@@ -287,13 +324,20 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
287
324
  }, [open]);
288
325
  if (!mounted)
289
326
  return null;
290
- 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: {
291
331
  position: "fixed",
292
332
  left: pos?.x ?? 0,
293
333
  top: pos?.y ?? 0,
294
334
  visibility: pos ? "visible" : "hidden",
295
335
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
296
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;
297
341
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
298
342
  if (item && item !== document.activeElement)
299
343
  item.focus();
@@ -318,6 +362,16 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
318
362
  active.click();
319
363
  }
320
364
  }
365
+ else {
366
+ // WAI-ARIA typeahead: printable chars jump to the matching item.
367
+ const items = itemsOf(c);
368
+ const idx = items.indexOf(document.activeElement);
369
+ const match = onTypeaheadKeyDown(e, items, idx);
370
+ if (match) {
371
+ e.preventDefault();
372
+ match.focus();
373
+ }
374
+ }
321
375
  }, className: cn("z-50 min-w-[96px] origin-(--transform-origin) overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: children }) }));
322
376
  }
323
377
  export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
@@ -6,6 +6,8 @@ import { Slot, mergeRefs, resolveRender } from "./slot.js";
6
6
  import { Portal } from "./portal.js";
7
7
  import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
+ import { useTypeahead } from "./use-typeahead.js";
10
+ import { usePointerMoved } from "./use-pointer-moved.js";
9
11
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
10
12
  const MenubarRootContext = React.createContext(null);
11
13
  function useMenubarRoot() {
@@ -51,6 +53,7 @@ function MenubarMenu({ value: valueProp, children, }) {
51
53
  const value = valueProp ?? generatedId;
52
54
  const triggerRef = React.useRef(null);
53
55
  const contentId = React.useId();
56
+ const triggerId = React.useId();
54
57
  const layers = React.useRef(new Set());
55
58
  const open = root.value === value;
56
59
  const setOpen = React.useCallback((next) => {
@@ -64,7 +67,7 @@ function MenubarMenu({ value: valueProp, children, }) {
64
67
  root.setActive(false);
65
68
  }
66
69
  }, [root, value]);
67
- 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 }));
68
71
  }
69
72
  function MenubarPortal({ children }) {
70
73
  return _jsx(_Fragment, { children: children });
@@ -74,10 +77,13 @@ function MenubarGroup({ className, ...props }) {
74
77
  }
75
78
  function MenubarTrigger({ asChild = false, render, nativeButton: _nativeButton, children, className, onClick, onKeyDown, onMouseEnter, ...props }) {
76
79
  const root = useMenubarRoot();
77
- const { value, open, setOpen, triggerRef, contentId } = useMenu();
80
+ const { value, open, setOpen, triggerRef, contentId, triggerId } = useMenu();
78
81
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
79
82
  const Comp = useSlot ? Slot : "button";
80
- 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) => {
81
87
  onClick?.(e);
82
88
  if (!e.defaultPrevented)
83
89
  setOpen(!open);
@@ -116,7 +122,7 @@ function MenubarTrigger({ asChild = false, render, nativeButton: _nativeButton,
116
122
  }
117
123
  function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", sideOffset = 8, className, children, onKeyDown, ...props }) {
118
124
  const root = useMenubarRoot();
119
- const { open, setOpen, triggerRef, contentId, layers } = useMenu();
125
+ const { open, setOpen, triggerRef, contentId, triggerId, layers } = useMenu();
120
126
  const [mounted, presenceRef] = usePresence(open);
121
127
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
122
128
  side,
@@ -125,6 +131,8 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
125
131
  alignOffset,
126
132
  });
127
133
  const containerRef = React.useRef(null);
134
+ const { onTypeaheadKeyDown } = useTypeahead();
135
+ const hasPointerMoved = usePointerMoved();
128
136
  React.useEffect(() => {
129
137
  const node = containerRef.current;
130
138
  if (!node)
@@ -175,13 +183,18 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
175
183
  }, [open, setOpen, triggerRef, layers]);
176
184
  if (!mounted)
177
185
  return null;
178
- 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: {
179
187
  position: "fixed",
180
188
  left: pos?.x ?? 0,
181
189
  top: pos?.y ?? 0,
182
190
  visibility: pos ? "visible" : "hidden",
183
191
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
184
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;
185
198
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
186
199
  if (item && item !== document.activeElement)
187
200
  item.focus();
@@ -228,14 +241,25 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
228
241
  active.click();
229
242
  }
230
243
  }
244
+ else {
245
+ // Printable-character typeahead: jump to the menu item whose label
246
+ // matches the typed buffer (WAI-ARIA menu pattern).
247
+ const items = itemsOf(c);
248
+ const idx = items.indexOf(document.activeElement);
249
+ const match = onTypeaheadKeyDown(e, items, idx);
250
+ if (match) {
251
+ e.preventDefault();
252
+ match.focus();
253
+ }
254
+ }
231
255
  }, className: cn("z-50 min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=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 }) }));
232
256
  }
233
257
  function MenubarLabel({ className, inset, ...props }) {
234
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 }));
235
259
  }
236
260
  function MenubarItem({ className, inset, variant = "default", disabled, onSelect, onClick, ...props }) {
237
- const { setOpen } = useMenu();
238
- 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) => {
239
263
  if (disabled)
240
264
  return;
241
265
  onClick?.(e);
@@ -243,11 +267,15 @@ function MenubarItem({ className, inset, variant = "default", disabled, onSelect
243
267
  return;
244
268
  onSelect?.();
245
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();
246
274
  }, ...props }));
247
275
  }
248
276
  const RadioGroupContext = React.createContext({});
249
277
  function MenubarCheckboxItem({ className, children, checked, onCheckedChange, disabled, inset, onClick, ...props }) {
250
- 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) => {
251
279
  if (disabled)
252
280
  return;
253
281
  onClick?.(e);
@@ -261,7 +289,7 @@ function MenubarRadioGroup({ value, onValueChange, ...props }) {
261
289
  function MenubarRadioItem({ className, children, value, disabled, inset, onClick, ...props }) {
262
290
  const { value: groupValue, onValueChange } = React.useContext(RadioGroupContext);
263
291
  const checked = groupValue === value;
264
- 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) => {
265
293
  if (disabled)
266
294
  return;
267
295
  onClick?.(e);
@@ -285,11 +313,12 @@ function useSub() {
285
313
  function MenubarSub({ defaultOpen = false, children, }) {
286
314
  const [open, setOpen] = React.useState(defaultOpen);
287
315
  const triggerRef = React.useRef(null);
288
- 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 }));
289
318
  }
290
319
  function MenubarSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
291
- const { open, setOpen, triggerRef } = useSub();
292
- 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) => {
293
322
  onMouseEnter?.(e);
294
323
  setOpen(true);
295
324
  }, onClick: (e) => {
@@ -307,7 +336,7 @@ function MenubarSubTrigger({ className, inset, children, onClick, onKeyDown, onM
307
336
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
308
337
  }
309
338
  function MenubarSubContent({ className, children, onKeyDown, ...props }) {
310
- const { open, setOpen, triggerRef } = useSub();
339
+ const { open, setOpen, triggerRef, subContentId } = useSub();
311
340
  const { layers } = useMenu();
312
341
  const [mounted, presenceRef] = usePresence(open);
313
342
  const { floatingRef, pos } = useFloating(triggerRef, mounted, {
@@ -317,6 +346,7 @@ function MenubarSubContent({ className, children, onKeyDown, ...props }) {
317
346
  alignOffset: -3,
318
347
  });
319
348
  const containerRef = React.useRef(null);
349
+ const hasPointerMoved = usePointerMoved();
320
350
  React.useEffect(() => {
321
351
  const node = containerRef.current;
322
352
  if (!node)
@@ -348,13 +378,17 @@ function MenubarSubContent({ className, children, onKeyDown, ...props }) {
348
378
  }, [open]);
349
379
  if (!mounted)
350
380
  return null;
351
- 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: {
352
382
  position: "fixed",
353
383
  left: pos?.x ?? 0,
354
384
  top: pos?.y ?? 0,
355
385
  visibility: pos ? "visible" : "hidden",
356
386
  ["--transform-origin"]: pos?.transformOrigin ?? "center",
357
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;
358
392
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
359
393
  if (item && item !== document.activeElement)
360
394
  item.focus();
@@ -5,8 +5,8 @@ declare function NavigationMenuItem({ className, ...props }: React.ComponentProp
5
5
  declare const navigationMenuTriggerStyle: (props?: import("../shadcn/lib/cva.js").CvaProps<{
6
6
  [x: string]: Record<string, string>;
7
7
  }> | undefined) => string;
8
- declare function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, ...props }: React.ComponentProps<"button">): React.JSX.Element;
9
- declare function NavigationMenuContent({ className, children, onMouseEnter, ...props }: React.ComponentProps<"div">): React.JSX.Element | null;
8
+ declare function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, onKeyDown, ...props }: React.ComponentProps<"button">): React.JSX.Element;
9
+ declare function NavigationMenuContent({ className, children, onMouseEnter, onKeyDown, ...props }: React.ComponentProps<"div">): React.JSX.Element | null;
10
10
  declare function NavigationMenuLink({ className, active, ...props }: React.ComponentProps<"a"> & {
11
11
  active?: boolean;
12
12
  }): React.JSX.Element;