@stevederico/skateboard-ui 4.0.0 → 4.1.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 +13 -0
- package/dist/components/core/Command.js +33 -7
- package/dist/ui/context-menu.js +29 -2
- package/dist/ui/drawer.js +13 -4
- package/dist/ui/dropdown-menu.js +24 -0
- package/dist/ui/menubar.js +13 -0
- 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 +60 -2
- package/dist/ui/radio-group.d.ts +1 -1
- package/dist/ui/radio-group.js +17 -5
- package/dist/ui/select.js +10 -0
- package/dist/ui/sheet.js +3 -3
- package/dist/ui/tabs.d.ts +1 -1
- package/dist/ui/tabs.js +16 -2
- package/dist/ui/use-floating.d.ts +4 -0
- package/dist/ui/use-floating.js +10 -2
- package/dist/ui/use-typeahead.d.ts +19 -0
- package/dist/ui/use-typeahead.js +64 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
4.1.0
|
|
4
|
+
|
|
5
|
+
Add menu/select typeahead
|
|
6
|
+
Fix popover focus management
|
|
7
|
+
Fix navigation-menu keyboard entry
|
|
8
|
+
Fix Command combobox ARIA
|
|
9
|
+
Fix tabs first tab-stop
|
|
10
|
+
Fix radio single tab-stop
|
|
11
|
+
Set menu available-height
|
|
12
|
+
Fix sheet/drawer exit animations
|
|
13
|
+
Reset floating position on close
|
|
14
|
+
Add a11y regression tests
|
|
15
|
+
|
|
3
16
|
4.0.0
|
|
4
17
|
|
|
5
18
|
Self-contained ui/ component tier (47 components, zero base-ui)
|
|
@@ -10,6 +10,9 @@ function useCommand() {
|
|
|
10
10
|
export function Command({ className, children, ...props }) {
|
|
11
11
|
const [filter, setFilter] = useState('');
|
|
12
12
|
const [selectedId, setSelectedId] = useState(null);
|
|
13
|
+
// Stable base id shared by the listbox container and every option, so the
|
|
14
|
+
// input's aria-controls / aria-activedescendant can reference them.
|
|
15
|
+
const listboxId = useId();
|
|
13
16
|
const itemsRef = useRef(new Map()); // id -> { value, onSelect, disabled }
|
|
14
17
|
const register = useCallback((id, meta) => {
|
|
15
18
|
itemsRef.current.set(id, meta);
|
|
@@ -55,12 +58,18 @@ export function Command({ className, children, ...props }) {
|
|
|
55
58
|
}, [selectedId]);
|
|
56
59
|
const isVisible = useCallback((id) => visibleIds().includes(id), [visibleIds]);
|
|
57
60
|
const visibleCount = visibleIds().length;
|
|
58
|
-
const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, register, unregister, isVisible, visibleCount, move, triggerSelected }), [filter, selectedId, register, unregister, isVisible, visibleCount, move, triggerSelected]);
|
|
59
|
-
return (_jsx(CommandContext.Provider, { value: value, children: _jsx("div", {
|
|
61
|
+
const value = useMemo(() => ({ filter, setFilter, selectedId, setSelectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected }), [filter, selectedId, listboxId, register, unregister, isVisible, visibleCount, move, triggerSelected]);
|
|
62
|
+
return (_jsx(CommandContext.Provider, { value: value, children: _jsx("div", { className: className, ...props, children: children }) }));
|
|
60
63
|
}
|
|
61
64
|
export function CommandInput({ className, placeholder, value: controlledValue, onValueChange, ...props }) {
|
|
62
|
-
const { filter, setFilter, move, triggerSelected } = useCommand();
|
|
65
|
+
const { filter, setFilter, move, triggerSelected, selectedId, listboxId, visibleCount } = useCommand();
|
|
63
66
|
const value = controlledValue ?? filter;
|
|
67
|
+
// The listbox is considered "shown"/expanded whenever it has visible options.
|
|
68
|
+
const isExpanded = visibleCount > 0;
|
|
69
|
+
// aria-activedescendant points at the DOM id of the active option (selectedId
|
|
70
|
+
// is rendered verbatim as each CommandItem's id). Undefined when nothing is
|
|
71
|
+
// active, so screen readers don't reference a missing element.
|
|
72
|
+
const activeDescendant = isExpanded && selectedId ? selectedId : undefined;
|
|
64
73
|
const handleChange = (e) => {
|
|
65
74
|
const v = e.target.value;
|
|
66
75
|
setFilter(v);
|
|
@@ -80,10 +89,16 @@ export function CommandInput({ className, placeholder, value: controlledValue, o
|
|
|
80
89
|
triggerSelected();
|
|
81
90
|
}
|
|
82
91
|
};
|
|
83
|
-
return (
|
|
92
|
+
return (
|
|
93
|
+
// The input is the combobox. Real DOM focus stays here during arrow
|
|
94
|
+
// navigation; only aria-activedescendant moves so screen readers announce
|
|
95
|
+
// the active option (WAI-ARIA combobox + activedescendant pattern).
|
|
96
|
+
_jsx("input", { role: "combobox", "aria-autocomplete": "list", "aria-expanded": isExpanded, "aria-controls": listboxId, "aria-activedescendant": activeDescendant, autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
|
|
84
97
|
}
|
|
85
98
|
export function CommandList({ className, children, ...props }) {
|
|
86
|
-
|
|
99
|
+
const { listboxId } = useCommand();
|
|
100
|
+
// id matches the input's aria-controls so the combobox owns this listbox.
|
|
101
|
+
return (_jsx("div", { role: "listbox", id: listboxId, className: className, ...props, children: children }));
|
|
87
102
|
}
|
|
88
103
|
export function CommandEmpty({ className, children, ...props }) {
|
|
89
104
|
const { visibleCount } = useCommand();
|
|
@@ -95,23 +110,34 @@ export function CommandGroup({ className, heading, children, ...props }) {
|
|
|
95
110
|
return (_jsxs("div", { role: "group", className: className, ...props, children: [heading && (_jsx("div", { "cmdk-group-heading": "", role: "presentation", children: heading })), children] }));
|
|
96
111
|
}
|
|
97
112
|
export function CommandItem({ className, value, onSelect, disabled = false, children, ...props }) {
|
|
113
|
+
// This id is both the value tracked in selectedId and the option's DOM id,
|
|
114
|
+
// which is what the input's aria-activedescendant references.
|
|
98
115
|
const id = useId();
|
|
99
116
|
const { register, unregister, selectedId, setSelectedId, isVisible } = useCommand();
|
|
117
|
+
const optionRef = useRef(null);
|
|
100
118
|
// Resolve a usable value string — fall back to children text if no value prop
|
|
101
119
|
const resolvedValue = value ?? (typeof children === 'string' ? children : id);
|
|
102
120
|
useEffect(() => {
|
|
103
121
|
register(id, { value: String(resolvedValue), onSelect, disabled });
|
|
104
122
|
return () => unregister(id);
|
|
105
123
|
}, [id, resolvedValue, onSelect, disabled, register, unregister]);
|
|
124
|
+
const isSelected = selectedId === id;
|
|
125
|
+
// Focus stays on the input under the activedescendant model, so scroll the
|
|
126
|
+
// active option into view manually when it becomes active.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (isSelected)
|
|
129
|
+
optionRef.current?.scrollIntoView({ block: 'nearest' });
|
|
130
|
+
}, [isSelected]);
|
|
106
131
|
if (!isVisible(id))
|
|
107
132
|
return null;
|
|
108
|
-
const isSelected = selectedId === id;
|
|
109
133
|
const handleClick = () => {
|
|
110
134
|
if (disabled)
|
|
111
135
|
return;
|
|
112
136
|
onSelect?.(String(resolvedValue));
|
|
113
137
|
};
|
|
114
|
-
return (_jsx("div", {
|
|
138
|
+
return (_jsx("div", {
|
|
139
|
+
// Stable DOM id targeted by aria-activedescendant when this option is active.
|
|
140
|
+
id: id, ref: optionRef, role: "option", "aria-selected": isSelected, "data-selected": isSelected ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, onMouseEnter: () => !disabled && setSelectedId(id), onClick: handleClick, className: className, ...props, children: children }));
|
|
115
141
|
}
|
|
116
142
|
export function CommandSeparator({ className, ...props }) {
|
|
117
143
|
return _jsx("div", { role: "separator", className: className, ...props });
|
package/dist/ui/context-menu.js
CHANGED
|
@@ -6,6 +6,7 @@ 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";
|
|
9
10
|
import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
|
|
10
11
|
const MenuContext = React.createContext(null);
|
|
11
12
|
function useMenu() {
|
|
@@ -63,8 +64,10 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
63
64
|
const { open, setOpen, point, contentId, layers } = useMenu();
|
|
64
65
|
const [mounted, presenceRef] = usePresence(open);
|
|
65
66
|
const containerRef = React.useRef(null);
|
|
67
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
66
68
|
// Position the menu at the cursor, clamped into the viewport once the
|
|
67
|
-
// popup has measurable dimensions.
|
|
69
|
+
// popup has measurable dimensions. availableHeight caps the menu so long
|
|
70
|
+
// lists scroll inside the viewport instead of overflowing it.
|
|
68
71
|
const [pos, setPos] = React.useState(null);
|
|
69
72
|
React.useLayoutEffect(() => {
|
|
70
73
|
if (!mounted) {
|
|
@@ -94,7 +97,9 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
94
97
|
y = Math.max(PAD, point.y - h);
|
|
95
98
|
x = Math.max(PAD, Math.min(x, vw - w - PAD));
|
|
96
99
|
y = Math.max(PAD, Math.min(y, vh - h - PAD));
|
|
97
|
-
|
|
100
|
+
// Space from the menu's top to the bottom of the viewport, minus padding.
|
|
101
|
+
const availableHeight = Math.max(0, vh - y - PAD);
|
|
102
|
+
setPos({ x, y, availableHeight });
|
|
98
103
|
};
|
|
99
104
|
measure();
|
|
100
105
|
window.addEventListener("scroll", measure, true);
|
|
@@ -159,6 +164,7 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
159
164
|
top: pos?.y ?? point.y,
|
|
160
165
|
visibility: pos ? "visible" : "hidden",
|
|
161
166
|
["--transform-origin"]: "top left",
|
|
167
|
+
["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
|
|
162
168
|
}, onPointerMove: (e) => {
|
|
163
169
|
const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
|
|
164
170
|
if (item && item !== document.activeElement)
|
|
@@ -190,6 +196,16 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
|
|
|
190
196
|
active.click();
|
|
191
197
|
}
|
|
192
198
|
}
|
|
199
|
+
else {
|
|
200
|
+
// Printable-character typeahead: jump to the matching item.
|
|
201
|
+
const items = itemsOf(c);
|
|
202
|
+
const idx = items.indexOf(document.activeElement);
|
|
203
|
+
const match = onTypeaheadKeyDown(e, items, idx);
|
|
204
|
+
if (match) {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
match.focus();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
193
209
|
}, className: cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 }) }));
|
|
194
210
|
}
|
|
195
211
|
function ContextMenuGroup({ className, ...props }) {
|
|
@@ -282,6 +298,7 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
|
282
298
|
alignOffset: -3,
|
|
283
299
|
});
|
|
284
300
|
const containerRef = React.useRef(null);
|
|
301
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
285
302
|
React.useEffect(() => {
|
|
286
303
|
const node = containerRef.current;
|
|
287
304
|
if (!node)
|
|
@@ -340,6 +357,16 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
|
340
357
|
active.click();
|
|
341
358
|
}
|
|
342
359
|
}
|
|
360
|
+
else {
|
|
361
|
+
// Printable-character typeahead: jump to the matching item.
|
|
362
|
+
const items = itemsOf(c);
|
|
363
|
+
const idx = items.indexOf(document.activeElement);
|
|
364
|
+
const match = onTypeaheadKeyDown(e, items, idx);
|
|
365
|
+
if (match) {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
match.focus();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
343
370
|
}, 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 }) }));
|
|
344
371
|
}
|
|
345
372
|
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
|
package/dist/ui/drawer.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
7
|
import { usePresence } from "./use-presence.js";
|
|
8
8
|
// Drag thresholds and animation timing ported from vaul (MIT — Emil Kowalski).
|
|
@@ -87,8 +87,13 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
|
|
|
87
87
|
const { open, setOpen, titleId, descriptionId } = useDrawer();
|
|
88
88
|
const ref = React.useRef(null);
|
|
89
89
|
const pointerDownOnBackdrop = React.useRef(false);
|
|
90
|
-
const [mounted] = usePresence(open);
|
|
90
|
+
const [mounted, presenceRef] = usePresence(open);
|
|
91
91
|
const dragState = React.useRef({ dragging: false, startY: 0, height: 0, startTime: 0 });
|
|
92
|
+
// A drag-dismiss already slides the panel out via an inline transform, so the
|
|
93
|
+
// CSS `animate-out` slide must be suppressed for that path — otherwise its
|
|
94
|
+
// keyframes would override the transform and snap the panel back into view.
|
|
95
|
+
// Programmatic closes (backdrop, Escape, controlled) keep the CSS animation.
|
|
96
|
+
const dismissingByDrag = React.useRef(false);
|
|
92
97
|
React.useEffect(() => {
|
|
93
98
|
const node = ref.current;
|
|
94
99
|
if (!node)
|
|
@@ -112,6 +117,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
|
|
|
112
117
|
if (open && ref.current) {
|
|
113
118
|
ref.current.style.transition = "";
|
|
114
119
|
ref.current.style.transform = "";
|
|
120
|
+
dismissingByDrag.current = false;
|
|
115
121
|
}
|
|
116
122
|
}, [open]);
|
|
117
123
|
const snapBack = React.useCallback(() => {
|
|
@@ -127,6 +133,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
|
|
|
127
133
|
setOpen(false);
|
|
128
134
|
return;
|
|
129
135
|
}
|
|
136
|
+
dismissingByDrag.current = true;
|
|
130
137
|
el.style.transition = TRANSITION;
|
|
131
138
|
el.style.transform = "translate3d(0, 100%, 0)";
|
|
132
139
|
window.setTimeout(() => setOpen(false), 500);
|
|
@@ -200,7 +207,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
|
|
|
200
207
|
};
|
|
201
208
|
if (!mounted)
|
|
202
209
|
return null;
|
|
203
|
-
return (_jsxs("dialog", { ...props, ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
210
|
+
return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
204
211
|
e.preventDefault();
|
|
205
212
|
setOpen(false);
|
|
206
213
|
}, onClose: () => setOpen(false), onClick: (e) => {
|
|
@@ -215,7 +222,9 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
|
|
|
215
222
|
// sizing so the drawer pins to the bottom edge at full width.
|
|
216
223
|
"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
224
|
? "animate-in fade-in-0 slide-in-from-bottom-52 duration-300"
|
|
218
|
-
:
|
|
225
|
+
: dismissingByDrag.current
|
|
226
|
+
? ""
|
|
227
|
+
: "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
228
|
}
|
|
220
229
|
function DrawerHeader({ className, ...props }) {
|
|
221
230
|
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 }));
|
package/dist/ui/dropdown-menu.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 { useControllableState } from "./use-controllable-state.js";
|
|
10
|
+
import { useTypeahead } from "./use-typeahead.js";
|
|
10
11
|
import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
|
|
11
12
|
const MenuContext = React.createContext(null);
|
|
12
13
|
function useMenu() {
|
|
@@ -76,6 +77,7 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
|
|
|
76
77
|
alignOffset,
|
|
77
78
|
});
|
|
78
79
|
const containerRef = React.useRef(null);
|
|
80
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
79
81
|
React.useEffect(() => {
|
|
80
82
|
const node = containerRef.current;
|
|
81
83
|
if (!node)
|
|
@@ -132,6 +134,7 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
|
|
|
132
134
|
top: pos?.y ?? 0,
|
|
133
135
|
visibility: pos ? "visible" : "hidden",
|
|
134
136
|
["--transform-origin"]: pos?.transformOrigin ?? "center",
|
|
137
|
+
["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
|
|
135
138
|
}, onPointerMove: (e) => {
|
|
136
139
|
const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
|
|
137
140
|
if (item && item !== document.activeElement)
|
|
@@ -164,6 +167,16 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
|
|
|
164
167
|
active.click();
|
|
165
168
|
}
|
|
166
169
|
}
|
|
170
|
+
else {
|
|
171
|
+
// WAI-ARIA typeahead: printable chars jump to the matching item.
|
|
172
|
+
const items = itemsOf(c);
|
|
173
|
+
const idx = items.indexOf(document.activeElement);
|
|
174
|
+
const match = onTypeaheadKeyDown(e, items, idx);
|
|
175
|
+
if (match) {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
match.focus();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
167
180
|
}, 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
181
|
}
|
|
169
182
|
function DropdownMenuGroup({ className, ...props }) {
|
|
@@ -256,6 +269,7 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
|
256
269
|
alignOffset: -3,
|
|
257
270
|
});
|
|
258
271
|
const containerRef = React.useRef(null);
|
|
272
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
259
273
|
React.useEffect(() => {
|
|
260
274
|
const node = containerRef.current;
|
|
261
275
|
if (!node)
|
|
@@ -318,6 +332,16 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
|
|
|
318
332
|
active.click();
|
|
319
333
|
}
|
|
320
334
|
}
|
|
335
|
+
else {
|
|
336
|
+
// WAI-ARIA typeahead: printable chars jump to the matching item.
|
|
337
|
+
const items = itemsOf(c);
|
|
338
|
+
const idx = items.indexOf(document.activeElement);
|
|
339
|
+
const match = onTypeaheadKeyDown(e, items, idx);
|
|
340
|
+
if (match) {
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
match.focus();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
321
345
|
}, 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
346
|
}
|
|
323
347
|
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,7 @@ 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";
|
|
9
10
|
import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
|
|
10
11
|
const MenubarRootContext = React.createContext(null);
|
|
11
12
|
function useMenubarRoot() {
|
|
@@ -125,6 +126,7 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
|
|
|
125
126
|
alignOffset,
|
|
126
127
|
});
|
|
127
128
|
const containerRef = React.useRef(null);
|
|
129
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
128
130
|
React.useEffect(() => {
|
|
129
131
|
const node = containerRef.current;
|
|
130
132
|
if (!node)
|
|
@@ -228,6 +230,17 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
|
|
|
228
230
|
active.click();
|
|
229
231
|
}
|
|
230
232
|
}
|
|
233
|
+
else {
|
|
234
|
+
// Printable-character typeahead: jump to the menu item whose label
|
|
235
|
+
// matches the typed buffer (WAI-ARIA menu pattern).
|
|
236
|
+
const items = itemsOf(c);
|
|
237
|
+
const idx = items.indexOf(document.activeElement);
|
|
238
|
+
const match = onTypeaheadKeyDown(e, items, idx);
|
|
239
|
+
if (match) {
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
match.focus();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
231
244
|
}, 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
245
|
}
|
|
233
246
|
function MenubarLabel({ className, inset, ...props }) {
|
|
@@ -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;
|
|
@@ -63,15 +63,56 @@ function NavigationMenuList({ className, ...props }) {
|
|
|
63
63
|
}
|
|
64
64
|
function NavigationMenuItem({ className, ...props }) {
|
|
65
65
|
const value = React.useId();
|
|
66
|
+
// One id + content ref per item so multiple triggers/panels don't collide
|
|
67
|
+
// when wiring aria-controls and focusing into the right portaled panel.
|
|
68
|
+
const contentId = React.useId();
|
|
66
69
|
const triggerRef = React.useRef(null);
|
|
67
|
-
|
|
70
|
+
const contentRef = React.useRef(null);
|
|
71
|
+
return (_jsx(NavigationMenuItemContext.Provider, { value: { value, triggerRef, contentId, contentRef }, children: _jsx("li", { "data-slot": "navigation-menu-item", className: cn("relative", className), ...props }) }));
|
|
68
72
|
}
|
|
73
|
+
// Tabbable descendants, in DOM order, used to move focus into the open content
|
|
74
|
+
// panel. Kept local (not imported) so this file stays self-contained.
|
|
75
|
+
const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
69
76
|
const navigationMenuTriggerStyle = cva("cursor-pointer group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted");
|
|
70
|
-
function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, ...props }) {
|
|
77
|
+
function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onClick, onKeyDown, ...props }) {
|
|
71
78
|
const { value, setValue, open } = useNavigationMenu();
|
|
72
|
-
const { value: itemValue, triggerRef } = useNavigationMenuItem();
|
|
79
|
+
const { value: itemValue, triggerRef, contentId, contentRef } = useNavigationMenuItem();
|
|
73
80
|
const isOpen = value === itemValue;
|
|
74
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Move keyboard focus into the open content panel so its links are
|
|
83
|
+
* reachable. The panel is portaled and opens on focus/hover, but without
|
|
84
|
+
* this a keyboard user can never enter it (WCAG 2.1.1 keyboard trap-out).
|
|
85
|
+
* ArrowDown (and Enter) open if needed, then focus the first focusable child.
|
|
86
|
+
*/
|
|
87
|
+
const handleKeyDown = (e) => {
|
|
88
|
+
onKeyDown?.(e);
|
|
89
|
+
if (e.defaultPrevented)
|
|
90
|
+
return;
|
|
91
|
+
if (e.key !== "ArrowDown" && e.key !== "Enter")
|
|
92
|
+
return;
|
|
93
|
+
// Enter still toggles via onClick; only intercept it to enter an open panel.
|
|
94
|
+
if (e.key === "Enter" && !isOpen)
|
|
95
|
+
return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
open(itemValue);
|
|
98
|
+
// The panel mounts this tick and is positioned a frame or two later (it
|
|
99
|
+
// starts visibility:hidden until useFloating measures it, and focus() on a
|
|
100
|
+
// hidden node is a no-op). Retry across a few frames until it is visible,
|
|
101
|
+
// bounded so a panel that never opens can't spin forever.
|
|
102
|
+
let frames = 0;
|
|
103
|
+
const focusFirst = () => {
|
|
104
|
+
const node = contentRef.current;
|
|
105
|
+
if (!node || getComputedStyle(node).visibility === "hidden") {
|
|
106
|
+
if (frames++ < 20)
|
|
107
|
+
requestAnimationFrame(focusFirst);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const first = node.querySelector(FOCUSABLE_SELECTOR);
|
|
111
|
+
(first ?? node).focus();
|
|
112
|
+
};
|
|
113
|
+
requestAnimationFrame(focusFirst);
|
|
114
|
+
};
|
|
115
|
+
return (_jsxs("button", { ref: triggerRef, type: "button", "data-slot": "navigation-menu-trigger", "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": isOpen ? contentId : undefined, "data-state": isOpen ? "open" : "closed", "data-popup-open": isOpen ? "" : undefined, "data-open": isOpen ? "" : undefined, className: cn(navigationMenuTriggerStyle(), "group", className), onMouseEnter: (e) => {
|
|
75
116
|
onMouseEnter?.(e);
|
|
76
117
|
if (!e.defaultPrevented)
|
|
77
118
|
open(itemValue);
|
|
@@ -83,11 +124,11 @@ function NavigationMenuTrigger({ className, children, onMouseEnter, onFocus, onC
|
|
|
83
124
|
onClick?.(e);
|
|
84
125
|
if (!e.defaultPrevented)
|
|
85
126
|
setValue(isOpen ? null : itemValue);
|
|
86
|
-
}, ...props, children: [children, " ", _jsx(ChevronDownIcon, { className: "relative top-px ml-1 size-3 transition duration-300 group-data-open:rotate-180", "aria-hidden": "true" })] }));
|
|
127
|
+
}, onKeyDown: handleKeyDown, ...props, children: [children, " ", _jsx(ChevronDownIcon, { className: "relative top-px ml-1 size-3 transition duration-300 group-data-open:rotate-180", "aria-hidden": "true" })] }));
|
|
87
128
|
}
|
|
88
|
-
function NavigationMenuContent({ className, children, onMouseEnter, ...props }) {
|
|
89
|
-
const { value, cancelClose, scheduleClose } = useNavigationMenu();
|
|
90
|
-
const { value: itemValue, triggerRef } = useNavigationMenuItem();
|
|
129
|
+
function NavigationMenuContent({ className, children, onMouseEnter, onKeyDown, ...props }) {
|
|
130
|
+
const { value, setValue, cancelClose, scheduleClose } = useNavigationMenu();
|
|
131
|
+
const { value: itemValue, triggerRef, contentId, contentRef } = useNavigationMenuItem();
|
|
91
132
|
const isOpen = value === itemValue;
|
|
92
133
|
const [mounted, presenceRef] = usePresence(isOpen);
|
|
93
134
|
const { floatingRef, pos } = useFloating(triggerRef, mounted, {
|
|
@@ -99,16 +140,44 @@ function NavigationMenuContent({ className, children, onMouseEnter, ...props })
|
|
|
99
140
|
const setRef = React.useCallback((node) => {
|
|
100
141
|
floatingRef.current = node;
|
|
101
142
|
presenceRef.current = node;
|
|
102
|
-
|
|
143
|
+
contentRef.current = node;
|
|
144
|
+
}, [floatingRef, presenceRef, contentRef]);
|
|
145
|
+
/**
|
|
146
|
+
* Restore focus to the trigger whenever the panel closes while focus is still
|
|
147
|
+
* inside it (root-level Escape, mouse-leave timeout, etc.). Without this the
|
|
148
|
+
* panel unmounts and keyboard focus is stranded on a detached node (a11y).
|
|
149
|
+
* If focus already moved elsewhere (e.g. an outside click), leave it alone.
|
|
150
|
+
*/
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
if (isOpen)
|
|
153
|
+
return;
|
|
154
|
+
const node = contentRef.current;
|
|
155
|
+
const trigger = triggerRef.current;
|
|
156
|
+
if (trigger && node && node.contains(document.activeElement))
|
|
157
|
+
trigger.focus();
|
|
158
|
+
}, [isOpen, contentRef, triggerRef]);
|
|
159
|
+
/**
|
|
160
|
+
* Escape closes the menu and returns focus to the trigger. The root already
|
|
161
|
+
* listens for Escape to clear the value, but only this panel knows to restore
|
|
162
|
+
* focus — otherwise keyboard focus would be stranded on a now-unmounted node.
|
|
163
|
+
*/
|
|
164
|
+
const handleKeyDown = (e) => {
|
|
165
|
+
onKeyDown?.(e);
|
|
166
|
+
if (e.defaultPrevented || e.key !== "Escape")
|
|
167
|
+
return;
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
setValue(null);
|
|
170
|
+
triggerRef.current?.focus();
|
|
171
|
+
};
|
|
103
172
|
if (!mounted)
|
|
104
173
|
return null;
|
|
105
|
-
return (_jsx(Portal, { children: _jsx("div", { ref: setRef, "data-slot": "navigation-menu-content", "data-state": isOpen ? "open" : "closed", "data-open": isOpen ? "" : undefined, "data-closed": isOpen ? undefined : "", "data-side": pos?.side ?? "bottom", style: {
|
|
174
|
+
return (_jsx(Portal, { children: _jsx("div", { ref: setRef, id: contentId, role: "menu", tabIndex: -1, "data-slot": "navigation-menu-content", "data-state": isOpen ? "open" : "closed", "data-open": isOpen ? "" : undefined, "data-closed": isOpen ? undefined : "", "data-side": pos?.side ?? "bottom", style: {
|
|
106
175
|
position: "fixed",
|
|
107
176
|
left: pos?.x ?? 0,
|
|
108
177
|
top: pos?.y ?? 0,
|
|
109
178
|
visibility: pos ? "visible" : "hidden",
|
|
110
179
|
["--transform-origin"]: pos?.transformOrigin ?? "center",
|
|
111
|
-
}, onMouseEnter: (e) => {
|
|
180
|
+
}, onKeyDown: handleKeyDown, onMouseEnter: (e) => {
|
|
112
181
|
onMouseEnter?.(e);
|
|
113
182
|
cancelClose();
|
|
114
183
|
}, onMouseLeave: scheduleClose, className: cn("z-50 w-auto origin-(--transform-origin) rounded-md bg-popover p-2 text-popover-foreground shadow ring-1 ring-foreground/10 duration-200 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none", className), ...props, children: children }) }));
|
package/dist/ui/popover.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface PopoverContentProps extends React.ComponentProps<"div"> {
|
|
|
18
18
|
sideOffset?: number;
|
|
19
19
|
alignOffset?: number;
|
|
20
20
|
}
|
|
21
|
-
declare function PopoverContent({ className, style, ref, align, alignOffset, side, sideOffset, ...props }: PopoverContentProps): React.JSX.Element | null;
|
|
21
|
+
declare function PopoverContent({ className, style, ref, align, alignOffset, side, sideOffset, onKeyDown, ...props }: PopoverContentProps): React.JSX.Element | null;
|
|
22
22
|
declare function PopoverHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
23
23
|
declare function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">): React.JSX.Element;
|
|
24
24
|
declare function PopoverDescription({ className, ...props }: React.ComponentProps<"p">): React.JSX.Element;
|
package/dist/ui/popover.js
CHANGED
|
@@ -35,7 +35,9 @@ function PopoverTrigger({ asChild = false, render, nativeButton: _nativeButton,
|
|
|
35
35
|
setOpen(!open);
|
|
36
36
|
}, ...props, children: slotChild }));
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Tabbable descendants, in DOM order, for focus-in and the Tab trap.
|
|
39
|
+
const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
40
|
+
function PopoverContent({ className, style, ref, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, onKeyDown, ...props }) {
|
|
39
41
|
const { open, setOpen, triggerRef, contentId } = usePopover();
|
|
40
42
|
const [mounted, presenceRef] = usePresence(open);
|
|
41
43
|
const { floatingRef, pos } = useFloating(triggerRef, mounted, {
|
|
@@ -44,10 +46,66 @@ function PopoverContent({ className, style, ref, align = "center", alignOffset =
|
|
|
44
46
|
sideOffset,
|
|
45
47
|
alignOffset,
|
|
46
48
|
});
|
|
49
|
+
const contentRef = React.useRef(null);
|
|
47
50
|
useDismiss(open, () => setOpen(false), [triggerRef, floatingRef]);
|
|
51
|
+
// Move focus into the popover when it opens and restore it to the trigger on
|
|
52
|
+
// close — a role="dialog" must manage focus (native <dialog> does this for
|
|
53
|
+
// free; this portaled div does not).
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (!open)
|
|
56
|
+
return;
|
|
57
|
+
const trigger = triggerRef.current;
|
|
58
|
+
let raf = 0;
|
|
59
|
+
// Retry across frames until the content is mounted AND visible: presence
|
|
60
|
+
// mounts it a tick after `open` flips, and it stays visibility:hidden until
|
|
61
|
+
// useFloating positions it — focus() on a missing or hidden node is a silent
|
|
62
|
+
// no-op. The effect cleanup cancels this when the popover closes.
|
|
63
|
+
const focusIn = () => {
|
|
64
|
+
const node = contentRef.current;
|
|
65
|
+
if (!node || getComputedStyle(node).visibility === "hidden") {
|
|
66
|
+
raf = requestAnimationFrame(focusIn);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const first = node.querySelector(FOCUSABLE_SELECTOR);
|
|
70
|
+
(first ?? node).focus();
|
|
71
|
+
};
|
|
72
|
+
raf = requestAnimationFrame(focusIn);
|
|
73
|
+
return () => {
|
|
74
|
+
cancelAnimationFrame(raf);
|
|
75
|
+
// Only reclaim focus if it is still inside the popover (an outside click
|
|
76
|
+
// already moved it elsewhere — don't yank it back).
|
|
77
|
+
const node = contentRef.current;
|
|
78
|
+
if (trigger && node && node.contains(document.activeElement))
|
|
79
|
+
trigger.focus();
|
|
80
|
+
};
|
|
81
|
+
}, [open, triggerRef]);
|
|
48
82
|
if (!mounted)
|
|
49
83
|
return null;
|
|
50
|
-
return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, ref), id: contentId, role: "dialog", "data-slot": "popover-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side,
|
|
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) => {
|
|
85
|
+
onKeyDown?.(e);
|
|
86
|
+
if (e.defaultPrevented || e.key !== "Tab")
|
|
87
|
+
return;
|
|
88
|
+
const node = contentRef.current;
|
|
89
|
+
if (!node)
|
|
90
|
+
return;
|
|
91
|
+
const focusables = Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
92
|
+
if (focusables.length === 0) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
node.focus();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const first = focusables[0];
|
|
98
|
+
const last = focusables[focusables.length - 1];
|
|
99
|
+
const activeEl = document.activeElement;
|
|
100
|
+
if (e.shiftKey && (activeEl === first || activeEl === node)) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
last.focus();
|
|
103
|
+
}
|
|
104
|
+
else if (!e.shiftKey && activeEl === last) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
first.focus();
|
|
107
|
+
}
|
|
108
|
+
}, style: {
|
|
51
109
|
...style,
|
|
52
110
|
position: "fixed",
|
|
53
111
|
left: pos?.x ?? 0,
|
package/dist/ui/radio-group.d.ts
CHANGED
|
@@ -13,5 +13,5 @@ declare function RadioGroup({ className, value, defaultValue, onValueChange, nam
|
|
|
13
13
|
export interface RadioGroupItemProps extends Omit<React.ComponentProps<"button">, "value"> {
|
|
14
14
|
value: string;
|
|
15
15
|
}
|
|
16
|
-
declare function RadioGroupItem({ className, value, onClick, disabled, ...props }: RadioGroupItemProps): React.JSX.Element;
|
|
16
|
+
declare function RadioGroupItem({ className, value, onClick, disabled, ref, ...props }: RadioGroupItemProps): React.JSX.Element;
|
|
17
17
|
export { RadioGroup, RadioGroupItem };
|
package/dist/ui/radio-group.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../shadcn/lib/utils.js";
|
|
5
|
+
import { mergeRefs } from "./slot.js";
|
|
5
6
|
import { useControllableState } from "./use-controllable-state.js";
|
|
6
7
|
const RadioGroupContext = React.createContext(null);
|
|
7
8
|
function useRadioGroup() {
|
|
@@ -39,13 +40,24 @@ function RadioGroup({ className, value, defaultValue, onValueChange, name, onKey
|
|
|
39
40
|
radios[next].click();
|
|
40
41
|
}, ...props }), name ? (_jsx("input", { type: "hidden", name: name, value: selected ?? "" })) : null] }));
|
|
41
42
|
}
|
|
42
|
-
function RadioGroupItem({ className, value, onClick, disabled, ...props }) {
|
|
43
|
+
function RadioGroupItem({ className, value, onClick, disabled, ref, ...props }) {
|
|
43
44
|
const { value: selected, setValue } = useRadioGroup();
|
|
44
45
|
const checked = selected === value;
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const btnRef = React.useRef(null);
|
|
47
|
+
// Roving tab index: the checked radio is the single tab stop. When nothing is
|
|
48
|
+
// checked, only the first enabled radio is tabbable — making every radio
|
|
49
|
+
// tabbable would put multiple stops in Tab order, breaking the radiogroup
|
|
50
|
+
// pattern.
|
|
51
|
+
const [isFirstEnabled, setIsFirstEnabled] = React.useState(false);
|
|
52
|
+
React.useLayoutEffect(() => {
|
|
53
|
+
if (selected !== undefined)
|
|
54
|
+
return;
|
|
55
|
+
const el = btnRef.current;
|
|
56
|
+
const first = el?.parentElement?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
|
|
57
|
+
setIsFirstEnabled(first === el);
|
|
58
|
+
});
|
|
59
|
+
const tabbable = checked || (selected === undefined && isFirstEnabled);
|
|
60
|
+
return (_jsx("button", { ref: mergeRefs(btnRef, ref), type: "button", role: "radio", "aria-checked": checked, disabled: disabled, tabIndex: tabbable ? 0 : -1, "data-slot": "radio-group-item", "data-state": checked ? "checked" : "unchecked", "data-checked": checked ? "" : undefined, className: cn("cursor-pointer group/radio-group-item peer relative flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", className), onClick: (e) => {
|
|
49
61
|
onClick?.(e);
|
|
50
62
|
if (!e.defaultPrevented)
|
|
51
63
|
setValue(value);
|
package/dist/ui/select.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 { useControllableState } from "./use-controllable-state.js";
|
|
10
|
+
import { useTypeahead } from "./use-typeahead.js";
|
|
10
11
|
import { ChevronDownIcon, CheckIcon } from "../icons/index.js";
|
|
11
12
|
const SelectContext = React.createContext(null);
|
|
12
13
|
function useSelect() {
|
|
@@ -88,6 +89,7 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
|
|
|
88
89
|
alignOffset,
|
|
89
90
|
});
|
|
90
91
|
const containerRef = React.useRef(null);
|
|
92
|
+
const { onTypeaheadKeyDown } = useTypeahead();
|
|
91
93
|
// Focus the selected (or first) option once the popup is actually visible.
|
|
92
94
|
const didFocus = React.useRef(false);
|
|
93
95
|
React.useEffect(() => {
|
|
@@ -141,6 +143,7 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
|
|
|
141
143
|
minWidth: triggerWidth,
|
|
142
144
|
visibility: pos ? "visible" : "hidden",
|
|
143
145
|
["--transform-origin"]: pos?.transformOrigin ?? "center",
|
|
146
|
+
["--available-height"]: pos ? `${pos.availableHeight}px` : undefined,
|
|
144
147
|
}, onPointerMove: (e) => {
|
|
145
148
|
const opt = e.target.closest('[role="option"]:not([data-disabled])');
|
|
146
149
|
if (opt && opt !== document.activeElement)
|
|
@@ -180,6 +183,13 @@ function SelectContent({ className, children, side = "bottom", sideOffset = 4, a
|
|
|
180
183
|
active.click();
|
|
181
184
|
}
|
|
182
185
|
}
|
|
186
|
+
else {
|
|
187
|
+
const match = onTypeaheadKeyDown(e, opts, idx);
|
|
188
|
+
if (match) {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
match.focus();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
183
193
|
}, className: cn("relative isolate z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: children }) }));
|
|
184
194
|
}
|
|
185
195
|
function SelectLabel({ className, ...props }) {
|
package/dist/ui/sheet.js
CHANGED
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../shadcn/lib/utils.js";
|
|
5
5
|
import { Button } from "./button.js";
|
|
6
|
-
import { Slot, resolveRender } from "./slot.js";
|
|
6
|
+
import { Slot, mergeRefs, resolveRender } from "./slot.js";
|
|
7
7
|
import { XIcon } from "../icons/index.js";
|
|
8
8
|
import { useControllableState } from "./use-controllable-state.js";
|
|
9
9
|
import { usePresence } from "./use-presence.js";
|
|
@@ -70,7 +70,7 @@ function SheetContent({ className, children, side = "right", showCloseButton = t
|
|
|
70
70
|
const { open, setOpen, titleId, descriptionId } = useSheet();
|
|
71
71
|
const ref = React.useRef(null);
|
|
72
72
|
const pointerDownOnBackdrop = React.useRef(false);
|
|
73
|
-
const [mounted] = usePresence(open);
|
|
73
|
+
const [mounted, presenceRef] = usePresence(open);
|
|
74
74
|
React.useEffect(() => {
|
|
75
75
|
const node = ref.current;
|
|
76
76
|
if (!node)
|
|
@@ -91,7 +91,7 @@ function SheetContent({ className, children, side = "right", showCloseButton = t
|
|
|
91
91
|
}, [mounted]);
|
|
92
92
|
if (!mounted)
|
|
93
93
|
return null;
|
|
94
|
-
return (_jsxs("dialog", { ...props, ref: ref, "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
94
|
+
return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": titleId, "aria-describedby": descriptionId, "data-slot": "sheet-content", "data-side": side, "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
|
|
95
95
|
e.preventDefault();
|
|
96
96
|
setOpen(false);
|
|
97
97
|
}, onClose: () => setOpen(false), onPointerDown: (e) => {
|
package/dist/ui/tabs.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ declare function TabsList({ className, variant, onKeyDown, ...props }: React.Com
|
|
|
21
21
|
export interface TabsTriggerProps extends Omit<React.ComponentProps<"button">, "value"> {
|
|
22
22
|
value: string;
|
|
23
23
|
}
|
|
24
|
-
declare function TabsTrigger({ className, value, onClick, ...props }: TabsTriggerProps): React.JSX.Element;
|
|
24
|
+
declare function TabsTrigger({ className, value, onClick, ref, ...props }: TabsTriggerProps): React.JSX.Element;
|
|
25
25
|
export interface TabsContentProps extends Omit<React.ComponentProps<"div">, "value"> {
|
|
26
26
|
value: string;
|
|
27
27
|
}
|
package/dist/ui/tabs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cva } from "../shadcn/lib/cva.js";
|
|
5
5
|
import { cn } from "../shadcn/lib/utils.js";
|
|
6
|
+
import { mergeRefs } from "./slot.js";
|
|
6
7
|
import { useControllableState } from "./use-controllable-state.js";
|
|
7
8
|
const TabsContext = React.createContext(null);
|
|
8
9
|
function useTabs() {
|
|
@@ -65,10 +66,23 @@ function TabsList({ className, variant = "default", onKeyDown, ...props }) {
|
|
|
65
66
|
}
|
|
66
67
|
}, ...props }));
|
|
67
68
|
}
|
|
68
|
-
function TabsTrigger({ className, value, onClick, ...props }) {
|
|
69
|
+
function TabsTrigger({ className, value, onClick, ref, ...props }) {
|
|
69
70
|
const { value: active, setValue, idBase } = useTabs();
|
|
70
71
|
const selected = active === value;
|
|
71
|
-
|
|
72
|
+
const btnRef = React.useRef(null);
|
|
73
|
+
// Roving tab index: the selected tab is tabbable. When nothing is selected the
|
|
74
|
+
// first enabled tab must still be reachable so the tablist stays in Tab order
|
|
75
|
+
// (WAI-ARIA: a tablist always has exactly one tab stop).
|
|
76
|
+
const [isFirstEnabled, setIsFirstEnabled] = React.useState(false);
|
|
77
|
+
React.useLayoutEffect(() => {
|
|
78
|
+
if (active !== undefined)
|
|
79
|
+
return;
|
|
80
|
+
const el = btnRef.current;
|
|
81
|
+
const first = el?.parentElement?.querySelector('[role="tab"]:not([disabled]):not([data-disabled])');
|
|
82
|
+
setIsFirstEnabled(first === el);
|
|
83
|
+
});
|
|
84
|
+
const tabbable = selected || (active === undefined && isFirstEnabled);
|
|
85
|
+
return (_jsx("button", { ref: mergeRefs(btnRef, ref), type: "button", role: "tab", id: `${idBase}-tab-${idify(value)}`, "aria-selected": selected, "aria-controls": `${idBase}-panel-${idify(value)}`, tabIndex: tabbable ? 0 : -1, "data-slot": "tabs-trigger", "data-active": selected ? "" : undefined, className: cn("cursor-pointer relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", className), onClick: (e) => {
|
|
72
86
|
onClick?.(e);
|
|
73
87
|
if (!e.defaultPrevented)
|
|
74
88
|
setValue(value);
|
|
@@ -13,6 +13,10 @@ export interface FloatingResult {
|
|
|
13
13
|
side: Side;
|
|
14
14
|
align: Align;
|
|
15
15
|
transformOrigin: string;
|
|
16
|
+
/** Usable height (px) between the anchor and the viewport edge on the chosen
|
|
17
|
+
* side, less offsets/padding. Exposed as `--available-height` so scrollable
|
|
18
|
+
* popups (menus, selects) can clamp with `max-h-(--available-height)`. */
|
|
19
|
+
availableHeight: number;
|
|
16
20
|
}
|
|
17
21
|
/**
|
|
18
22
|
* Pure positioning math: place a floating box relative to an anchor on the given
|
package/dist/ui/use-floating.js
CHANGED
|
@@ -74,7 +74,11 @@ export function computePosition(anchor, floating, opts, viewport) {
|
|
|
74
74
|
: align === "end"
|
|
75
75
|
? "bottom"
|
|
76
76
|
: "center";
|
|
77
|
-
|
|
77
|
+
// Space left for the popup on its final side, so it can clamp + scroll
|
|
78
|
+
// instead of overflowing the viewport. For left/right placements the popup
|
|
79
|
+
// spans the cross axis, so the usable height is the full viewport less padding.
|
|
80
|
+
const availableHeight = Math.max(0, (vertical ? space[side] - sideOffset : viewport.height) - PAD);
|
|
81
|
+
return { x, y, side, align, transformOrigin: `${originX} ${originY}`, availableHeight };
|
|
78
82
|
}
|
|
79
83
|
/**
|
|
80
84
|
* Position a floating element against an anchor, recomputing on scroll, resize,
|
|
@@ -95,8 +99,12 @@ export function useFloating(anchorRef, open, options = {}) {
|
|
|
95
99
|
setPos(computePosition(a.getBoundingClientRect(), { width: f.offsetWidth, height: f.offsetHeight }, { side, align, sideOffset, alignOffset }, { width: window.innerWidth, height: window.innerHeight }));
|
|
96
100
|
}, [anchorRef, side, align, sideOffset, alignOffset]);
|
|
97
101
|
React.useLayoutEffect(() => {
|
|
98
|
-
|
|
102
|
+
// Drop the last position on close so a reopen stays visibility:hidden until
|
|
103
|
+
// it is re-measured, rather than flashing one frame at stale coordinates.
|
|
104
|
+
if (!open) {
|
|
105
|
+
setPos(null);
|
|
99
106
|
return;
|
|
107
|
+
}
|
|
100
108
|
const ro = new ResizeObserver(update);
|
|
101
109
|
window.addEventListener("scroll", update, true);
|
|
102
110
|
window.addEventListener("resize", update);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* WAI-ARIA typeahead for listbox/menu widgets. Collects printable keystrokes
|
|
4
|
+
* into a buffer that resets after a short pause, then resolves the item whose
|
|
5
|
+
* label matches the buffer (prefix match). Repeating a single character cycles
|
|
6
|
+
* through the items starting with it. The caller owns the items and decides how
|
|
7
|
+
* to focus/activate the returned match.
|
|
8
|
+
*
|
|
9
|
+
* Usage inside a keydown handler, after handling Arrow/Home/End/Enter/Escape/Tab:
|
|
10
|
+
* const match = onTypeaheadKeyDown(e, items, currentIndex)
|
|
11
|
+
* if (match) { e.preventDefault(); match.focus() }
|
|
12
|
+
*
|
|
13
|
+
* @returns `onTypeaheadKeyDown(event, items, activeIndex)` → matched element or null.
|
|
14
|
+
* `items` must be the enabled item elements in DOM order; `activeIndex` is the
|
|
15
|
+
* index of the currently focused item within `items` (-1 if none).
|
|
16
|
+
*/
|
|
17
|
+
export declare function useTypeahead(): {
|
|
18
|
+
onTypeaheadKeyDown: (event: React.KeyboardEvent, items: HTMLElement[], activeIndex: number) => HTMLElement | null;
|
|
19
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
// How long a typeahead buffer survives between keystrokes before resetting, per
|
|
4
|
+
// the WAI-ARIA typeahead pattern (a short pause starts a fresh search).
|
|
5
|
+
const RESET_MS = 500;
|
|
6
|
+
/** The visible, trimmed, lower-cased label of an item, used for prefix matching. */
|
|
7
|
+
function labelOf(el) {
|
|
8
|
+
return (el.textContent ?? "").trim().toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* WAI-ARIA typeahead for listbox/menu widgets. Collects printable keystrokes
|
|
12
|
+
* into a buffer that resets after a short pause, then resolves the item whose
|
|
13
|
+
* label matches the buffer (prefix match). Repeating a single character cycles
|
|
14
|
+
* through the items starting with it. The caller owns the items and decides how
|
|
15
|
+
* to focus/activate the returned match.
|
|
16
|
+
*
|
|
17
|
+
* Usage inside a keydown handler, after handling Arrow/Home/End/Enter/Escape/Tab:
|
|
18
|
+
* const match = onTypeaheadKeyDown(e, items, currentIndex)
|
|
19
|
+
* if (match) { e.preventDefault(); match.focus() }
|
|
20
|
+
*
|
|
21
|
+
* @returns `onTypeaheadKeyDown(event, items, activeIndex)` → matched element or null.
|
|
22
|
+
* `items` must be the enabled item elements in DOM order; `activeIndex` is the
|
|
23
|
+
* index of the currently focused item within `items` (-1 if none).
|
|
24
|
+
*/
|
|
25
|
+
export function useTypeahead() {
|
|
26
|
+
const bufferRef = React.useRef("");
|
|
27
|
+
const timerRef = React.useRef(0);
|
|
28
|
+
React.useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
|
29
|
+
const onTypeaheadKeyDown = React.useCallback((event, items, activeIndex) => {
|
|
30
|
+
const key = event.key;
|
|
31
|
+
// Single printable char only. Space is reserved for activation, and any
|
|
32
|
+
// modifier combo (Ctrl/Meta/Alt) is a command, not text.
|
|
33
|
+
if (key.length !== 1 ||
|
|
34
|
+
key === " " ||
|
|
35
|
+
event.ctrlKey ||
|
|
36
|
+
event.metaKey ||
|
|
37
|
+
event.altKey)
|
|
38
|
+
return null;
|
|
39
|
+
window.clearTimeout(timerRef.current);
|
|
40
|
+
timerRef.current = window.setTimeout(() => {
|
|
41
|
+
bufferRef.current = "";
|
|
42
|
+
}, RESET_MS);
|
|
43
|
+
const prev = bufferRef.current;
|
|
44
|
+
bufferRef.current += key.toLowerCase();
|
|
45
|
+
const buffer = bufferRef.current;
|
|
46
|
+
// A repeated single character cycles through items starting with it rather
|
|
47
|
+
// than searching for the literal repetition (e.g. "aa" → next "a…" item).
|
|
48
|
+
const allSame = buffer.length > 1 && [...buffer].every((c) => c === buffer[0]);
|
|
49
|
+
const query = allSame ? buffer[0] : buffer;
|
|
50
|
+
const n = items.length;
|
|
51
|
+
if (n === 0)
|
|
52
|
+
return null;
|
|
53
|
+
// Extending an in-progress match keeps resolving from the current item;
|
|
54
|
+
// a fresh search or a cycle advances past it.
|
|
55
|
+
const start = allSame || prev === "" ? Math.max(0, activeIndex) + 1 : Math.max(0, activeIndex);
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const item = items[(start + i) % n];
|
|
58
|
+
if (labelOf(item).startsWith(query))
|
|
59
|
+
return item;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}, []);
|
|
63
|
+
return { onTypeaheadKeyDown };
|
|
64
|
+
}
|