From dc793181bbcca5c61e623aba4d640c123defbeed Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 14 Aug 2026 22:40:07 -0700 Subject: [PATCH] Add submenuTrigger option for dropdown items (#548) --- apps/yaak-client/components/core/Dropdown.tsx | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index a1836206..47ed8114 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -36,6 +36,7 @@ import { fireAndForget } from "../../lib/fireAndForget"; import { ErrorBoundary } from "../ErrorBoundary"; import { Button } from "./Button"; import { Hotkey } from "./Hotkey"; +import { IconButton } from "./IconButton"; import { Separator } from "./Separator"; export type DropdownItemSeparator = { @@ -66,6 +67,12 @@ export type DropdownItemDefault = { submenu?: DropdownItem[]; /** If true, submenu opens on click instead of hover */ submenuOpenOnClick?: boolean; + /** + * How the submenu opens. "row" (default) opens it from the row itself (hover, or click + * with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders + * a dedicated button on the right that opens the submenu. + */ + submenuTrigger?: "row" | "button"; icon?: IconProps["icon"]; }; @@ -502,9 +509,15 @@ const Menu = forwardRef { @@ -629,7 +642,7 @@ const Menu = forwardRef { const submenuEl = submenuRef.current; if (!submenuEl || !activeSubmenu) { @@ -797,6 +812,7 @@ const Menu = forwardRef setActiveSubmenu({ item, parent: el })} // oxlint-disable-next-line no-array-index-key -- It's fine key={i} item={item} @@ -868,6 +884,7 @@ interface MenuItemProps { onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise; onFocus: (item: DropdownItemDefault) => void; onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; + onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void; focused: boolean; isParentOfActiveSubmenu?: boolean; } @@ -879,6 +896,7 @@ function MenuItem({ onHover, item, onSelect, + onOpenSubmenu, isParentOfActiveSubmenu, ...props }: MenuItemProps) { @@ -914,19 +932,22 @@ function MenuItem({ e.currentTarget.focus(); }; - const rightSlot = item.submenu ? ( - - ) : ( - (item.rightSlot ?? ) - ); + const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button"; - return ( + const rightSlot = + item.submenu && !hasButtonSubmenu ? ( + + ) : ( + (item.rightSlot ?? ) + ); + + const button = ( ); + + if (!hasButtonSubmenu) { + return button; + } + + // The submenu trigger overlays the row as a sibling (not a child) because the row is + // itself a button and buttons cannot nest. Hover handling lives on this wrapper so the + // row keeps its focus highlight while the mouse is over the trigger. + return ( +
{ + const el = buttonRef.current; + if (el == null) return; + onHover(item, el); + el.focus(); + }} + onMouseLeave={() => buttonRef.current?.blur()} + > + {button} +
+ { + // Prevent the trigger from stealing focus, which would unhighlight the row + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + onOpenSubmenu(item, e.currentTarget); + }} + /> +
+
+ ); } interface MenuItemHotKeyProps {