);
};
export interface ActionsMenuDropdownProps extends ActionsMenuProps {
trigger?: React.ReactNode;
/** Replace the entire default trigger button. When set, rendered directly as the DropdownMenuTrigger child. */
customTrigger?: React.ReactNode;
triggerAriaLabel?: string;
triggerClassName?: string;
contentClassName?: string;
align?: "start" | "center" | "end";
side?: "top" | "right" | "bottom" | "left";
sideOffset?: number;
/** Controlled open state. Pair with `onOpenChange`. Uncontrolled by default. */
open?: boolean;
/** Open-state change handler (also fires when an item closes the menu). */
onOpenChange?: (open: boolean) => void;
/** Forwarded to the dropdown content — e.g. `e.preventDefault()` to stop
* Radix returning focus (and its focus ring) to the trigger on close. */
onCloseAutoFocus?: (event: Event) => void;
}
export const ActionsMenuDropdown: React.FC = ({
groups,
onItemClick,
className,
trigger,
customTrigger,
triggerAriaLabel = "More actions",
triggerClassName,
contentClassName,
align = "end",
side = "bottom",
sideOffset = 6,
open: openProp,
onOpenChange,
onCloseAutoFocus,
}) => {
const [open = false, setOpen] = useControllableState({
prop: openProp,
defaultProp: false,
onChange: onOpenChange,
});
// Tell the surrounding surface an overlay is open, so it can stop moving
// the ground under it (the chat thread suspends its follow-the-bottom
// auto-scroll — see `OverlayOpenRegistryProvider`). Inert without a
// provider, so menus elsewhere are unaffected.
useReportOverlayOpen(open);
// Dismiss once the USER scrolls the trigger away.
//
// Two different situations, two different answers. Content moving on its own
// (a streaming reply) is handled above by suspending the auto-scroll — the
// menu must survive that, nobody asked for it to close. A deliberate scroll
// is the opposite: the reader left, and an anchored menu would ride along
// until it hovers over unrelated chrome. `hideWhenDetached` only kicks in
// once the trigger is FULLY clipped, so a half-scrolled trigger still drags
// a visible menu across the header.
//
// Scoped deliberately:
// • only scrolls of containers that actually contain the trigger — a
// scroll in a neighbouring column is none of our business;
// • only past `SCROLL_DISMISS_THRESHOLD_PX` of movement, so touch
// momentum and the ~1px settle after opening don't close the menu the
// user is reaching for.
const triggerRef = React.useRef(null);
React.useEffect(() => {
if (!open) return;
if (typeof document === "undefined") return;
const trigger = triggerRef.current;
if (!trigger) return;
const anchorTop = trigger.getBoundingClientRect().top;
const onScroll = (event: Event) => {
const target = event.target as Node | null;
// `document` fires for the page scroll and contains everything.
const scrolledTheTrigger =
target === document ||
(target instanceof Node && target.contains(trigger));
if (!scrolledTheTrigger) return;
const moved = Math.abs(trigger.getBoundingClientRect().top - anchorTop);
if (moved > SCROLL_DISMISS_THRESHOLD_PX) setOpen(false);
};
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
}, [open, setOpen]);
const handleItemClick = useCallback(
(item: ActionsMenuItem) => {
onItemClick?.(item);
if (
item.type !== "checkbox" &&
item.type !== "submenu" &&
item.closeOnSelect !== false
) {
setOpen(false);
}
},
[onItemClick, setOpen],
);
return (
{customTrigger ?? (
)
}
/>
)}
);
};
export default ActionsMenu;