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