@stevederico/skateboard-ui 4.0.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/components/core/Command.js +33 -7
- package/dist/components/core/Context.js +6 -6
- package/dist/components/core/Utilities.js +18 -14
- package/dist/ui/alert-dialog.js +24 -16
- package/dist/ui/checkbox.d.ts +9 -6
- package/dist/ui/checkbox.js +21 -9
- package/dist/ui/context-menu.js +57 -10
- package/dist/ui/dialog.js +18 -17
- package/dist/ui/drawer.js +40 -19
- package/dist/ui/dropdown-menu.js +68 -14
- package/dist/ui/menubar.js +48 -14
- package/dist/ui/navigation-menu.d.ts +2 -2
- package/dist/ui/navigation-menu.js +80 -11
- package/dist/ui/popover.d.ts +1 -1
- package/dist/ui/popover.js +83 -6
- package/dist/ui/radio-group.d.ts +1 -1
- package/dist/ui/radio-group.js +17 -5
- package/dist/ui/select.d.ts +1 -1
- package/dist/ui/select.js +37 -6
- package/dist/ui/sheet.js +29 -18
- package/dist/ui/slider.d.ts +2 -1
- package/dist/ui/slider.js +11 -6
- package/dist/ui/tabs.d.ts +1 -1
- package/dist/ui/tabs.js +16 -2
- package/dist/ui/tooltip.js +27 -1
- package/dist/ui/use-floating.d.ts +4 -0
- package/dist/ui/use-floating.js +10 -2
- package/dist/ui/use-labelling.d.ts +21 -0
- package/dist/ui/use-labelling.js +43 -0
- package/dist/ui/use-pointer-moved.d.ts +12 -0
- package/dist/ui/use-pointer-moved.js +21 -0
- package/dist/ui/use-scroll-lock.d.ts +6 -0
- package/dist/ui/use-scroll-lock.js +29 -0
- package/dist/ui/use-typeahead.d.ts +19 -0
- package/dist/ui/use-typeahead.js +64 -0
- package/package.json +1 -1
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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":
|
|
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
|
-
:
|
|
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, };
|
package/dist/ui/dropdown-menu.js
CHANGED
|
@@ -7,6 +7,8 @@ import { Portal } from "./portal.js";
|
|
|
7
7
|
import { useFloating } from "./use-floating.js";
|
|
8
8
|
import { usePresence } from "./use-presence.js";
|
|
9
9
|
import { useControllableState } from "./use-controllable-state.js";
|
|
10
|
+
import { useTypeahead } from "./use-typeahead.js";
|
|
11
|
+
import { usePointerMoved } from "./use-pointer-moved.js";
|
|
10
12
|
import { 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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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),
|
|
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, };
|
package/dist/ui/menubar.js
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
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;
|