@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 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", { role: "combobox", "aria-expanded": "true", className: className, ...props, children: children }) }));
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 (_jsx("input", { role: "searchbox", autoComplete: "off", autoCorrect: "off", spellCheck: "false", value: value, onChange: handleChange, onKeyDown: handleKeyDown, placeholder: placeholder, className: className, ...props }));
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
- return (_jsx("div", { role: "listbox", className: className, ...props, children: children }));
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", { 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 }));
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 });
@@ -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
- setPos({ x, y });
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
- : "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] }));
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 }));
@@ -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, };
@@ -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
- return (_jsx(NavigationMenuItemContext.Provider, { value: { value, triggerRef }, children: _jsx("li", { "data-slot": "navigation-menu-item", className: cn("relative", className), ...props }) }));
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
- return (_jsxs("button", { ref: triggerRef, type: "button", "data-slot": "navigation-menu-trigger", "aria-expanded": isOpen, "data-state": isOpen ? "open" : "closed", "data-popup-open": isOpen ? "" : undefined, "data-open": isOpen ? "" : undefined, className: cn(navigationMenuTriggerStyle(), "group", className), onMouseEnter: (e) => {
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
- }, [floatingRef, presenceRef]);
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 }) }));
@@ -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;
@@ -35,7 +35,9 @@ function PopoverTrigger({ asChild = false, render, nativeButton: _nativeButton,
35
35
  setOpen(!open);
36
36
  }, ...props, children: slotChild }));
37
37
  }
38
- function PopoverContent({ className, style, ref, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, ...props }) {
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, style: {
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,
@@ -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 };
@@ -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
- // Roving tab index: the checked radio is tabbable; if none checked, the first
46
- // item is reachable (handled by Base UI semantics via tabIndex below).
47
- const tabbable = checked || selected === undefined;
48
- return (_jsx("button", { 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) => {
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
- return (_jsx("button", { type: "button", role: "tab", id: `${idBase}-tab-${idify(value)}`, "aria-selected": selected, "aria-controls": `${idBase}-panel-${idify(value)}`, tabIndex: selected ? 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
+ 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
@@ -74,7 +74,11 @@ export function computePosition(anchor, floating, opts, viewport) {
74
74
  : align === "end"
75
75
  ? "bottom"
76
76
  : "center";
77
- return { x, y, side, align, transformOrigin: `${originX} ${originY}` };
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
- if (!open)
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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stevederico/skateboard-ui",
3
3
  "private": false,
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {