@stevederico/skateboard-ui 4.10.0 → 4.12.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.
@@ -8,6 +8,7 @@ import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
9
  import { useTypeahead } from "./use-typeahead.js";
10
10
  import { usePointerMoved } from "./use-pointer-moved.js";
11
+ import { registerLayer } from "./layer-stack.js";
11
12
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
12
13
  const MenuContext = React.createContext(null);
13
14
  function useMenu() {
@@ -116,12 +117,36 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
116
117
  };
117
118
  }, [mounted, point]);
118
119
  React.useEffect(() => {
119
- const node = containerRef.current;
120
- if (!node)
120
+ if (!mounted)
121
121
  return;
122
- layers.current.add(node);
122
+ // The popup is portaled and attaches a frame late (Portal defers its child to
123
+ // its own effect), so containerRef.current is null when this effect first runs
124
+ // and the deps don't change once it attaches. Retry each frame until the node
125
+ // exists, then register it. Without this the dismiss handler's `layers` set
126
+ // stays empty, so a pointerdown INSIDE the menu fails the contains() check and
127
+ // is treated as an outside click — closing the menu before the item's onClick
128
+ // fires (real mouse only; a synthetic click with no pointerdown masks it).
129
+ let raf = 0;
130
+ let added = null;
131
+ let off = null;
132
+ const attach = () => {
133
+ const node = containerRef.current;
134
+ if (!node) {
135
+ raf = requestAnimationFrame(attach);
136
+ return;
137
+ }
138
+ added = node;
139
+ layers.current.add(node);
140
+ // Register in the shared cross-overlay stack so an outer popover's
141
+ // outside-press dismiss doesn't fire when a nested context menu is clicked.
142
+ off = registerLayer(node);
143
+ };
144
+ attach();
123
145
  return () => {
124
- layers.current.delete(node);
146
+ cancelAnimationFrame(raf);
147
+ if (added)
148
+ layers.current.delete(added);
149
+ off?.();
125
150
  };
126
151
  }, [mounted, layers]);
127
152
  // Focus the first item once the popup is actually visible — a
@@ -181,6 +206,8 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
181
206
  if (e.defaultPrevented)
182
207
  return;
183
208
  const c = containerRef.current;
209
+ if (!c)
210
+ return;
184
211
  if (e.key === "ArrowDown")
185
212
  (e.preventDefault(), moveFocus(c, "next"));
186
213
  else if (e.key === "ArrowUp")
@@ -213,7 +240,7 @@ function ContextMenuContent({ className, children, onKeyDown, ...props }) {
213
240
  match.focus();
214
241
  }
215
242
  }
216
- }, 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 transition-none 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 }) }));
243
+ }, 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 transition-none 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 }) }));
217
244
  }
218
245
  function ContextMenuGroup({ className, ...props }) {
219
246
  return _jsx("div", { role: "group", "data-slot": "context-menu-group", className: className, ...props });
@@ -317,12 +344,36 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
317
344
  const containerRef = React.useRef(null);
318
345
  const { onTypeaheadKeyDown } = useTypeahead();
319
346
  React.useEffect(() => {
320
- const node = containerRef.current;
321
- if (!node)
347
+ if (!mounted)
322
348
  return;
323
- layers.current.add(node);
349
+ // The popup is portaled and attaches a frame late (Portal defers its child to
350
+ // its own effect), so containerRef.current is null when this effect first runs
351
+ // and the deps don't change once it attaches. Retry each frame until the node
352
+ // exists, then register it. Without this the dismiss handler's `layers` set
353
+ // stays empty, so a pointerdown INSIDE the submenu fails the contains() check
354
+ // and is treated as an outside click — closing it before the item's onClick
355
+ // fires (real mouse only; a synthetic click with no pointerdown masks it).
356
+ let raf = 0;
357
+ let added = null;
358
+ let off = null;
359
+ const attach = () => {
360
+ const node = containerRef.current;
361
+ if (!node) {
362
+ raf = requestAnimationFrame(attach);
363
+ return;
364
+ }
365
+ added = node;
366
+ layers.current.add(node);
367
+ // Register in the shared cross-overlay stack so an outer popover's
368
+ // outside-press dismiss doesn't fire when this nested submenu is clicked.
369
+ off = registerLayer(node);
370
+ };
371
+ attach();
324
372
  return () => {
325
- layers.current.delete(node);
373
+ cancelAnimationFrame(raf);
374
+ if (added)
375
+ layers.current.delete(added);
376
+ off?.();
326
377
  };
327
378
  }, [mounted, layers]);
328
379
  const didFocus = React.useRef(false);
@@ -361,6 +412,8 @@ function ContextMenuSubContent({ className, children, onKeyDown, ...props }) {
361
412
  if (e.defaultPrevented)
362
413
  return;
363
414
  const c = containerRef.current;
415
+ if (!c)
416
+ return;
364
417
  if (e.key === "ArrowDown")
365
418
  (e.preventDefault(), moveFocus(c, "next"));
366
419
  else if (e.key === "ArrowUp")
package/dist/ui/dialog.js CHANGED
@@ -29,13 +29,15 @@ function Dialog({ open, defaultOpen = false, onOpenChange, children }) {
29
29
  // Track which labelling parts actually render so DialogContent can reference
30
30
  // them only when present, avoiding a dangling aria-labelledby/-describedby IDREF.
31
31
  const labelling = useDialogLabelling();
32
- return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, ...labelling }, children: children }));
32
+ // Stable id wiring the trigger's aria-controls to the content's id.
33
+ const contentId = React.useId();
34
+ return (_jsx(DialogContext.Provider, { value: { open: isOpen, setOpen, contentId, ...labelling }, children: children }));
33
35
  }
34
36
  function DialogTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
35
- const { open, setOpen } = useDialog();
37
+ const { open, setOpen, contentId } = useDialog();
36
38
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
37
39
  const Comp = useSlot ? Slot : "button";
38
- return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "dialog-trigger", "aria-haspopup": "dialog", "aria-expanded": open, "data-state": open ? "open" : "closed", onClick: (e) => {
40
+ return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "dialog-trigger", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", onClick: (e) => {
39
41
  onClick?.(e);
40
42
  if (!e.defaultPrevented)
41
43
  setOpen(true);
@@ -60,7 +62,7 @@ function DialogOverlay() {
60
62
  return null;
61
63
  }
62
64
  function DialogContent({ className, children, showCloseButton = true, ...props }) {
63
- const { open, setOpen, labelledBy, describedBy } = useDialog();
65
+ const { open, setOpen, contentId, labelledBy, describedBy } = useDialog();
64
66
  const ref = React.useRef(null);
65
67
  const pointerDownOnBackdrop = React.useRef(false);
66
68
  React.useEffect(() => {
@@ -87,7 +89,7 @@ function DialogContent({ className, children, showCloseButton = true, ...props }
87
89
  if (e.target === ref.current && pointerDownOnBackdrop.current) {
88
90
  setOpen(false);
89
91
  }
90
- }, className: "group/dialog m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsxs("div", { "data-slot": "dialog-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("relative grid w-full max-w-[calc(100%-2rem)] gap-6 rounded-xl bg-background p-6 text-sm text-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: [children, showCloseButton && (_jsx(DialogClose, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-4 right-4", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })) : null }));
92
+ }, className: "group/dialog m-0 grid max-h-none max-w-none place-items-center bg-transparent p-4 backdrop:bg-black/10 backdrop:duration-100 supports-backdrop-filter:backdrop:backdrop-blur-xs open:fixed open:inset-0 open:size-full", children: open ? (_jsxs("div", { id: contentId, "data-slot": "dialog-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, className: cn("relative grid w-full max-w-[calc(100%-2rem)] gap-6 rounded-xl bg-background p-6 text-sm text-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className), ...props, children: [children, showCloseButton && (_jsx(DialogClose, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "absolute top-4 right-4", size: "icon-sm", children: [_jsx(XIcon, {}), _jsx("span", { className: "sr-only", children: "Close" })] }) }))] })) : null }));
91
93
  }
92
94
  function DialogHeader({ className, ...props }) {
93
95
  return (_jsx("div", { "data-slot": "dialog-header", className: cn("flex flex-col gap-2", className), ...props }));
package/dist/ui/drawer.js CHANGED
@@ -54,9 +54,12 @@ function Drawer({ open, defaultOpen = false, onOpenChange, children }) {
54
54
  onChange: onOpenChange,
55
55
  });
56
56
  const { titleId, descriptionId, registerTitle, registerDescription, labelledBy, describedBy } = useDialogLabelling();
57
+ // Stable id wiring the trigger's aria-controls to the content's id.
58
+ const contentId = React.useId();
57
59
  return (_jsx(DrawerContext.Provider, { value: {
58
60
  open: isOpen,
59
61
  setOpen,
62
+ contentId,
60
63
  titleId,
61
64
  descriptionId,
62
65
  registerTitle,
@@ -66,10 +69,10 @@ function Drawer({ open, defaultOpen = false, onOpenChange, children }) {
66
69
  }, children: children }));
67
70
  }
68
71
  function DrawerTrigger({ asChild = false, render, nativeButton: _nativeButton, children, onClick, ...props }) {
69
- const { open, setOpen } = useDrawer();
72
+ const { open, setOpen, contentId } = useDrawer();
70
73
  const { useSlot, slotChild } = resolveRender(asChild, render, children);
71
74
  const Comp = useSlot ? Slot : "button";
72
- return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "drawer-trigger", "aria-haspopup": "dialog", "data-state": open ? "open" : "closed", onClick: (e) => {
75
+ return (_jsx(Comp, { type: useSlot ? undefined : "button", "data-slot": "drawer-trigger", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? contentId : undefined, "data-state": open ? "open" : "closed", onClick: (e) => {
73
76
  onClick?.(e);
74
77
  if (!e.defaultPrevented)
75
78
  setOpen(true);
@@ -94,7 +97,7 @@ function DrawerOverlay() {
94
97
  return null;
95
98
  }
96
99
  function DrawerContent({ className, children, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, ...props }) {
97
- const { open, setOpen, labelledBy, describedBy } = useDrawer();
100
+ const { open, setOpen, contentId, labelledBy, describedBy } = useDrawer();
98
101
  const ref = React.useRef(null);
99
102
  const pointerDownOnBackdrop = React.useRef(false);
100
103
  const [mounted, presenceRef] = usePresence(open);
@@ -213,7 +216,7 @@ function DrawerContent({ className, children, onPointerDown, onPointerMove, onPo
213
216
  };
214
217
  if (!mounted)
215
218
  return null;
216
- return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
219
+ return (_jsxs("dialog", { ...props, ref: mergeRefs(ref, presenceRef), id: contentId, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "data-slot": "drawer-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", onCancel: (e) => {
217
220
  e.preventDefault();
218
221
  setOpen(false);
219
222
  }, onClose: () => setOpen(false), onClick: (e) => {
@@ -54,8 +54,8 @@ declare function DropdownMenuSub({ defaultOpen, children, }: {
54
54
  defaultOpen?: boolean;
55
55
  children?: React.ReactNode;
56
56
  }): React.JSX.Element;
57
- declare function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }: React.ComponentProps<"div"> & {
57
+ declare function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, onPointerLeave, ...props }: React.ComponentProps<"div"> & {
58
58
  inset?: boolean;
59
59
  }): React.JSX.Element;
60
- declare function DropdownMenuSubContent({ className, children, onKeyDown, ...props }: React.ComponentProps<"div">): React.JSX.Element | null;
60
+ declare function DropdownMenuSubContent({ className, children, onKeyDown, onPointerLeave, ...props }: React.ComponentProps<"div">): React.JSX.Element | null;
61
61
  export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
@@ -9,6 +9,7 @@ import { usePresence } from "./use-presence.js";
9
9
  import { useControllableState } from "./use-controllable-state.js";
10
10
  import { useTypeahead } from "./use-typeahead.js";
11
11
  import { usePointerMoved } from "./use-pointer-moved.js";
12
+ import { registerLayer } from "./layer-stack.js";
12
13
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
13
14
  const MenuContext = React.createContext(null);
14
15
  function useMenu() {
@@ -95,6 +96,7 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
95
96
  // fires (real mouse only; a synthetic click with no pointerdown masks it).
96
97
  let raf = 0;
97
98
  let added = null;
99
+ let off = null;
98
100
  const attach = () => {
99
101
  const node = containerRef.current;
100
102
  if (!node) {
@@ -103,12 +105,16 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
103
105
  }
104
106
  added = node;
105
107
  layers.current.add(node);
108
+ // Also register in the shared cross-overlay stack so a dropdown nested in a
109
+ // popover doesn't trip the popover's outside-press dismiss.
110
+ off = registerLayer(node);
106
111
  };
107
112
  attach();
108
113
  return () => {
109
114
  cancelAnimationFrame(raf);
110
115
  if (added)
111
116
  layers.current.delete(added);
117
+ off?.();
112
118
  };
113
119
  }, [mounted, layers]);
114
120
  // Focus the first item once the popup is actually visible — a
@@ -172,6 +178,8 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
172
178
  if (e.defaultPrevented)
173
179
  return;
174
180
  const c = containerRef.current;
181
+ if (!c)
182
+ return;
175
183
  if (e.key === "ArrowDown")
176
184
  (e.preventDefault(), moveFocus(c, "next"));
177
185
  else if (e.key === "ArrowUp")
@@ -186,7 +194,10 @@ function DropdownMenuContent({ align = "start", alignOffset = 0, side = "bottom"
186
194
  triggerRef.current?.focus();
187
195
  }
188
196
  else if (e.key === "Tab") {
197
+ // Restore focus to the trigger so the browser's default Tab advances
198
+ // from there — content is portaled to end of body, orphaning focus.
189
199
  setOpen(false);
200
+ triggerRef.current?.focus();
190
201
  }
191
202
  else if (e.key === "Enter" || e.key === " ") {
192
203
  const active = document.activeElement;
@@ -278,13 +289,24 @@ function DropdownMenuSub({ defaultOpen = false, children, }) {
278
289
  const subContentId = React.useId();
279
290
  return (_jsx(SubContext.Provider, { value: { open, setOpen, triggerRef, subContentId }, children: children }));
280
291
  }
281
- function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, ...props }) {
292
+ function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown, onMouseEnter, onPointerLeave, ...props }) {
282
293
  const { open, setOpen, triggerRef, subContentId } = useSub();
283
294
  return (_jsxs("div", { ref: triggerRef, role: "menuitem", tabIndex: -1, "aria-haspopup": "menu", "aria-expanded": open, "aria-controls": open ? subContentId : undefined, "data-slot": "dropdown-menu-sub-trigger", "data-inset": inset ? "" : undefined, "data-popup-open": open ? "" : undefined, "data-open": open ? "" : undefined, className: cn(
284
295
  // focus-visible ring: keyboard-only focus target (focus:bg-accent is below WCAG 3:1).
285
296
  "flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), onMouseEnter: (e) => {
286
297
  onMouseEnter?.(e);
287
298
  setOpen(true);
299
+ }, onPointerLeave: (e) => {
300
+ onPointerLeave?.(e);
301
+ if (e.defaultPrevented)
302
+ return;
303
+ // Close when the pointer leaves the trigger toward anything that isn't the
304
+ // open submenu panel (keeps it open while moving into the sub-content).
305
+ const related = e.relatedTarget;
306
+ const subContent = document.getElementById(subContentId);
307
+ if (related instanceof Node && subContent?.contains(related))
308
+ return;
309
+ setOpen(false);
288
310
  }, onClick: (e) => {
289
311
  onClick?.(e);
290
312
  if (!e.defaultPrevented)
@@ -299,7 +321,7 @@ function DropdownMenuSubTrigger({ className, inset, children, onClick, onKeyDown
299
321
  }
300
322
  }, ...props, children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })] }));
301
323
  }
302
- function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
324
+ function DropdownMenuSubContent({ className, children, onKeyDown, onPointerLeave, ...props }) {
303
325
  const { open, setOpen, triggerRef, subContentId } = useSub();
304
326
  const { layers } = useMenu();
305
327
  const [mounted, presenceRef] = usePresence(open);
@@ -324,6 +346,7 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
324
346
  // fires (real mouse only; a synthetic click with no pointerdown masks it).
325
347
  let raf = 0;
326
348
  let added = null;
349
+ let off = null;
327
350
  const attach = () => {
328
351
  const node = containerRef.current;
329
352
  if (!node) {
@@ -332,12 +355,16 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
332
355
  }
333
356
  added = node;
334
357
  layers.current.add(node);
358
+ // Also register in the shared cross-overlay stack so a dropdown nested in a
359
+ // popover doesn't trip the popover's outside-press dismiss.
360
+ off = registerLayer(node);
335
361
  };
336
362
  attach();
337
363
  return () => {
338
364
  cancelAnimationFrame(raf);
339
365
  if (added)
340
366
  layers.current.delete(added);
367
+ off?.();
341
368
  };
342
369
  }, [mounted, layers]);
343
370
  const didFocus = React.useRef(false);
@@ -379,11 +406,23 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
379
406
  const item = e.target.closest('[role^="menuitem"]:not([data-disabled])');
380
407
  if (item && item !== document.activeElement)
381
408
  item.focus();
409
+ }, onPointerLeave: (e) => {
410
+ onPointerLeave?.(e);
411
+ if (e.defaultPrevented)
412
+ return;
413
+ // Close when the pointer leaves the panel toward anything that isn't the
414
+ // sub-trigger (keeps it open while moving back onto the trigger).
415
+ const related = e.relatedTarget;
416
+ if (related instanceof Node && triggerRef.current?.contains(related))
417
+ return;
418
+ setOpen(false);
382
419
  }, onKeyDown: (e) => {
383
420
  onKeyDown?.(e);
384
421
  if (e.defaultPrevented)
385
422
  return;
386
423
  const c = containerRef.current;
424
+ if (!c)
425
+ return;
387
426
  if (e.key === "ArrowDown")
388
427
  (e.preventDefault(), moveFocus(c, "next"));
389
428
  else if (e.key === "ArrowUp")
@@ -395,7 +434,7 @@ function DropdownMenuSubContent({ className, children, onKeyDown, ...props }) {
395
434
  }
396
435
  else if (e.key === "Enter" || e.key === " ") {
397
436
  const active = document.activeElement;
398
- if (active && c.contains(active)) {
437
+ if (active && c.contains(active) && active.getAttribute("role")?.startsWith("menuitem")) {
399
438
  e.preventDefault();
400
439
  active.click();
401
440
  }
@@ -1,10 +1,11 @@
1
+ import * as React from "react";
1
2
  import { type VariantProps } from "../shadcn/lib/cva.js";
2
3
  import { Label } from "./label.js";
3
- declare function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">): import("react").JSX.Element;
4
+ declare function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">): React.JSX.Element;
4
5
  declare function FieldLegend({ className, variant, ...props }: React.ComponentProps<"legend"> & {
5
6
  variant?: "legend" | "label";
6
- }): import("react").JSX.Element;
7
- declare function FieldGroup({ className, ...props }: React.ComponentProps<"div">): import("react").JSX.Element;
7
+ }): React.JSX.Element;
8
+ declare function FieldGroup({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
8
9
  declare const fieldVariants: (props?: import("../shadcn/lib/cva.js").CvaProps<{
9
10
  orientation: {
10
11
  vertical: string;
@@ -12,17 +13,17 @@ declare const fieldVariants: (props?: import("../shadcn/lib/cva.js").CvaProps<{
12
13
  responsive: string;
13
14
  };
14
15
  }> | undefined) => string;
15
- declare function Field({ className, orientation, ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>): import("react").JSX.Element;
16
- declare function FieldContent({ className, ...props }: React.ComponentProps<"div">): import("react").JSX.Element;
17
- declare function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>): import("react").JSX.Element;
18
- declare function FieldTitle({ className, ...props }: React.ComponentProps<"div">): import("react").JSX.Element;
19
- declare function FieldDescription({ className, ...props }: React.ComponentProps<"p">): import("react").JSX.Element;
16
+ declare function Field({ className, orientation, ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>): React.JSX.Element;
17
+ declare function FieldContent({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
18
+ declare function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>): React.JSX.Element;
19
+ declare function FieldTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
20
+ declare function FieldDescription({ className, ...props }: React.ComponentProps<"p">): React.JSX.Element;
20
21
  declare function FieldSeparator({ children, className, ...props }: React.ComponentProps<"div"> & {
21
22
  children?: React.ReactNode;
22
- }): import("react").JSX.Element;
23
+ }): React.JSX.Element;
23
24
  declare function FieldError({ className, children, errors, ...props }: React.ComponentProps<"div"> & {
24
25
  errors?: Array<{
25
26
  message?: string;
26
27
  } | undefined>;
27
- }): import("react").JSX.Element | null;
28
+ }): React.JSX.Element | null;
28
29
  export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle, };
package/dist/ui/field.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useMemo } from "react";
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
6
  import { Label } from "./label.js";
@@ -45,7 +45,7 @@ function FieldSeparator({ children, className, ...props }) {
45
45
  return (_jsxs("div", { "data-slot": "field-separator", "data-content": !!children, className: cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className), ...props, children: [_jsx(Separator, { className: "absolute inset-0 top-1/2" }), children && (_jsx("span", { className: "relative mx-auto block w-fit bg-background px-2 text-muted-foreground", "data-slot": "field-separator-content", children: children }))] }));
46
46
  }
47
47
  function FieldError({ className, children, errors, ...props }) {
48
- const content = useMemo(() => {
48
+ const content = React.useMemo(() => {
49
49
  if (children) {
50
50
  return children;
51
51
  }
@@ -114,6 +114,6 @@ function HoverCardContent({ className, style, ref, side = "bottom", sideOffset =
114
114
  }, onMouseLeave: (e) => {
115
115
  onMouseLeave?.(e);
116
116
  scheduleClose();
117
- }, className: cn("isolate z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden transition-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 }) }));
117
+ }, className: cn("isolate z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden transition-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 }) }));
118
118
  }
119
119
  export { HoverCard, HoverCardTrigger, HoverCardContent };
@@ -27,7 +27,9 @@ function InputGroupAddon({ className, align = "inline-start", ...props }) {
27
27
  if (e.target.closest("button")) {
28
28
  return;
29
29
  }
30
- e.currentTarget.parentElement?.querySelector("input")?.focus();
30
+ e.currentTarget.parentElement
31
+ ?.querySelector("input, textarea")
32
+ ?.focus();
31
33
  }, ...props }));
32
34
  }
33
35
  const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
@@ -0,0 +1,2 @@
1
+ export declare function registerLayer(node: HTMLElement): () => void;
2
+ export declare function isInLayer(target: Node | null): boolean;
@@ -0,0 +1,16 @@
1
+ const layers = new Set();
2
+ export function registerLayer(node) {
3
+ layers.add(node);
4
+ return () => {
5
+ layers.delete(node);
6
+ };
7
+ }
8
+ export function isInLayer(target) {
9
+ if (!target)
10
+ return false;
11
+ for (const node of layers) {
12
+ if (node.contains(target))
13
+ return true;
14
+ }
15
+ return false;
16
+ }
@@ -8,6 +8,7 @@ import { useFloating } from "./use-floating.js";
8
8
  import { usePresence } from "./use-presence.js";
9
9
  import { useTypeahead } from "./use-typeahead.js";
10
10
  import { usePointerMoved } from "./use-pointer-moved.js";
11
+ import { registerLayer } from "./layer-stack.js";
11
12
  import { ChevronRightIcon, CheckIcon } from "../icons/index.js";
12
13
  const MenubarRootContext = React.createContext(null);
13
14
  function useMenubarRoot() {
@@ -134,12 +135,34 @@ function MenubarContent({ align = "start", alignOffset = -4, side = "bottom", si
134
135
  const { onTypeaheadKeyDown } = useTypeahead();
135
136
  const hasPointerMoved = usePointerMoved();
136
137
  React.useEffect(() => {
137
- const node = containerRef.current;
138
- if (!node)
138
+ if (!mounted)
139
139
  return;
140
- layers.current.add(node);
140
+ // The popup is portaled and attaches a frame late (Portal defers its child to
141
+ // its own effect), so containerRef.current is null when this effect first runs
142
+ // and the deps don't change once it attaches. Retry each frame until the node
143
+ // exists, then register it. Without this the dismiss handler's `layers` set
144
+ // stays empty, so a pointerdown INSIDE the menu fails the contains() check and
145
+ // is treated as an outside click — closing the menu before the item's onClick
146
+ // fires (real mouse only; a synthetic click with no pointerdown masks it).
147
+ let raf = 0;
148
+ let added = null;
149
+ let off = null;
150
+ const attach = () => {
151
+ const node = containerRef.current;
152
+ if (!node) {
153
+ raf = requestAnimationFrame(attach);
154
+ return;
155
+ }
156
+ added = node;
157
+ layers.current.add(node);
158
+ off = registerLayer(node);
159
+ };
160
+ attach();
141
161
  return () => {
142
- layers.current.delete(node);
162
+ cancelAnimationFrame(raf);
163
+ if (added)
164
+ layers.current.delete(added);
165
+ off?.();
143
166
  };
144
167
  }, [mounted, layers]);
145
168
  // Focus the first item once the popup is actually visible — a
@@ -348,12 +371,32 @@ function MenubarSubContent({ className, children, onKeyDown, ...props }) {
348
371
  const containerRef = React.useRef(null);
349
372
  const hasPointerMoved = usePointerMoved();
350
373
  React.useEffect(() => {
351
- const node = containerRef.current;
352
- if (!node)
374
+ if (!mounted)
353
375
  return;
354
- layers.current.add(node);
376
+ // The sub-popup is portaled and attaches a frame late, so containerRef.current
377
+ // is null when this effect first runs and the deps don't change once it
378
+ // attaches. Retry each frame until the node exists, then register it — without
379
+ // this the dismiss handler's `layers` set stays empty and a real-mouse click
380
+ // inside the submenu reads as an outside click, closing it before onClick fires.
381
+ let raf = 0;
382
+ let added = null;
383
+ let off = null;
384
+ const attach = () => {
385
+ const node = containerRef.current;
386
+ if (!node) {
387
+ raf = requestAnimationFrame(attach);
388
+ return;
389
+ }
390
+ added = node;
391
+ layers.current.add(node);
392
+ off = registerLayer(node);
393
+ };
394
+ attach();
355
395
  return () => {
356
- layers.current.delete(node);
396
+ cancelAnimationFrame(raf);
397
+ if (added)
398
+ layers.current.delete(added);
399
+ off?.();
357
400
  };
358
401
  }, [mounted, layers]);
359
402
  const didFocus = React.useRef(false);
@@ -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 { useDismiss } from "./use-dismiss.js";
9
+ import { registerLayer } from "./layer-stack.js";
9
10
  import { usePresence } from "./use-presence.js";
10
11
  import { useControllableState } from "./use-controllable-state.js";
11
12
  import { useDialogLabelling } from "./use-labelling.js";
@@ -73,14 +74,35 @@ function PopoverContent({ className, style, ref, align = "center", alignOffset =
73
74
  // mounts it a tick after `open` flips, and it stays visibility:hidden until
74
75
  // useFloating positions it — focus() on a missing or hidden node is a silent
75
76
  // no-op. The effect cleanup cancels this when the popover closes.
77
+ let tries = 0;
76
78
  const focusIn = () => {
79
+ // Safety bound (~1.5s of frames) so a popover that can never take focus
80
+ // doesn't spin forever.
81
+ if (tries++ > 90)
82
+ return;
77
83
  const node = contentRef.current;
84
+ // Wait while the content is still mounting (presence renders it a tick
85
+ // after `open` flips) or still positioning (it stays visibility:hidden
86
+ // until useFloating measures it) — focus() on a missing/hidden node is a
87
+ // silent no-op.
78
88
  if (!node || getComputedStyle(node).visibility === "hidden") {
79
89
  raf = requestAnimationFrame(focusIn);
80
90
  return;
81
91
  }
92
+ // Focus is already where it belongs.
93
+ if (node.contains(document.activeElement))
94
+ return;
95
+ // Don't fight the user: if focus moved somewhere that isn't the trigger or
96
+ // <body>, they placed it deliberately — stop chasing.
97
+ const active = document.activeElement;
98
+ if (active && active !== trigger && active !== document.body)
99
+ return;
82
100
  const first = node.querySelector(FOCUSABLE_SELECTOR);
83
101
  (first ?? node).focus();
102
+ // Even once visible, the target can be a frame away from being focusable,
103
+ // so focus() may no-op — retry until focus genuinely lands inside.
104
+ if (!node.contains(document.activeElement))
105
+ raf = requestAnimationFrame(focusIn);
84
106
  };
85
107
  raf = requestAnimationFrame(focusIn);
86
108
  return () => {
@@ -92,6 +114,18 @@ function PopoverContent({ className, style, ref, align = "center", alignOffset =
92
114
  trigger.focus();
93
115
  };
94
116
  }, [open, triggerRef]);
117
+ // Register the floating content as an open dismissable layer while mounted, so
118
+ // an outer overlay's outside-press dismiss ignores clicks landing inside this
119
+ // popover (e.g. a popover nested in another popover). Keyed on `mounted` so it
120
+ // runs once contentRef attaches.
121
+ React.useEffect(() => {
122
+ if (!mounted)
123
+ return;
124
+ const node = contentRef.current;
125
+ if (!node)
126
+ return;
127
+ return registerLayer(node);
128
+ }, [mounted]);
95
129
  if (!mounted)
96
130
  return null;
97
131
  return (_jsx(Portal, { children: _jsx("div", { ref: mergeRefs(floatingRef, presenceRef, contentRef, ref), id: contentId, role: "dialog", "aria-labelledby": labelledBy, "aria-describedby": describedBy, tabIndex: -1, "data-slot": "popover-content", "data-state": open ? "open" : "closed", "data-open": open ? "" : undefined, "data-closed": open ? undefined : "", "data-side": pos?.side ?? side, onKeyDown: (e) => {
@@ -53,8 +53,19 @@ function RadioGroupItem({ className, value, onClick, disabled, ref, ...props })
53
53
  if (selected !== undefined)
54
54
  return;
55
55
  const el = btnRef.current;
56
- const first = el?.parentElement?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
56
+ // Scope to the radiogroup (not the immediate parent) so per-item wrappers
57
+ // like <label>/<div> don't change which radio counts as "first enabled" —
58
+ // this matches the arrow-key handler, which queries the group element.
59
+ const group = el?.closest('[role="radiogroup"]');
60
+ const first = (group ?? el)?.querySelector('[role="radio"]:not([disabled]):not([data-disabled])');
57
61
  setIsFirstEnabled(first === el);
62
+ // No dependency array on purpose: "first enabled" depends on the whole
63
+ // sibling set, so an item must recompute when ANY sibling's disabled state
64
+ // or order changes — a per-item dep list ([selected, value, disabled])
65
+ // misses those cross-sibling changes and can leave the group with zero tab
66
+ // stops. The body is cheap (two querySelectors) and setIsFirstEnabled bails
67
+ // out of re-render when the value is unchanged, so running every render is
68
+ // negligible.
58
69
  });
59
70
  const tabbable = checked || (selected === undefined && isFirstEnabled);
60
71
  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) => {