Add submenuTrigger option for dropdown items (#548)

This commit is contained in:
Gregory Schier
2026-08-14 22:40:07 -07:00
committed by GitHub
parent 7dfa7e07e3
commit dc793181bb
+81 -13
View File
@@ -36,6 +36,7 @@ import { fireAndForget } from "../../lib/fireAndForget";
import { ErrorBoundary } from "../ErrorBoundary"; import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button"; import { Button } from "./Button";
import { Hotkey } from "./Hotkey"; import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import { Separator } from "./Separator"; import { Separator } from "./Separator";
export type DropdownItemSeparator = { export type DropdownItemSeparator = {
@@ -66,6 +67,12 @@ export type DropdownItemDefault = {
submenu?: DropdownItem[]; submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */ /** If true, submenu opens on click instead of hover */
submenuOpenOnClick?: boolean; 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"]; icon?: IconProps["icon"];
}; };
@@ -502,9 +509,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
} }
} }
if (!item.keepOpenOnSelect) handleCloseAll(); if (!item.keepOpenOnSelect) {
handleCloseAll();
} else if (isSubmenu) {
// Keep the parent menu open, but close this submenu — its items may no
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
handleClose();
}
}, },
[handleCloseAll, setSelectedIndex], [handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
); );
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
@@ -629,7 +642,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
const item = filteredItems[selectedIndex ?? -1]; const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === "separator" || item.type === "content") return; if (!item || item.type === "separator" || item.type === "content") return;
e.preventDefault(); e.preventDefault();
if (item.submenu) { if (item.submenu && item.submenuTrigger !== "button") {
const parent = document.activeElement as HTMLButtonElement; const parent = document.activeElement as HTMLButtonElement;
if (parent) { if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true }); setActiveSubmenu({ item, parent, viaKeyboard: true });
@@ -648,9 +661,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
clearTimeout(submenuTimeoutRef.current); clearTimeout(submenuTimeoutRef.current);
} }
if (item.submenu && !item.submenuOpenOnClick) { if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
setActiveSubmenu({ item, parent }); setActiveSubmenu({ item, parent });
} else if (activeSubmenu) { } else if (activeSubmenu && activeSubmenu.item !== item) {
// Hovering the row that owns the open submenu must not dismiss it — the
// pointer travels across the row on its way to a button-triggered submenu
submenuTimeoutRef.current = window.setTimeout(() => { submenuTimeoutRef.current = window.setTimeout(() => {
const submenuEl = submenuRef.current; const submenuEl = submenuRef.current;
if (!submenuEl || !activeSubmenu) { if (!submenuEl || !activeSubmenu) {
@@ -797,6 +812,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
onFocus={handleFocus} onFocus={handleFocus}
onSelect={handleSelect} onSelect={handleSelect}
onHover={handleItemHover} onHover={handleItemHover}
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
// oxlint-disable-next-line no-array-index-key -- It's fine // oxlint-disable-next-line no-array-index-key -- It's fine
key={i} key={i}
item={item} item={item}
@@ -868,6 +884,7 @@ interface MenuItemProps {
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>; onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void; onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean; focused: boolean;
isParentOfActiveSubmenu?: boolean; isParentOfActiveSubmenu?: boolean;
} }
@@ -879,6 +896,7 @@ function MenuItem({
onHover, onHover,
item, item,
onSelect, onSelect,
onOpenSubmenu,
isParentOfActiveSubmenu, isParentOfActiveSubmenu,
...props ...props
}: MenuItemProps) { }: MenuItemProps) {
@@ -914,19 +932,22 @@ function MenuItem({
e.currentTarget.focus(); e.currentTarget.focus();
}; };
const rightSlot = item.submenu ? ( const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
return ( const rightSlot =
item.submenu && !hasButtonSubmenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const button = (
<Button <Button
ref={initRef} ref={initRef}
size="sm" size="sm"
tabIndex={-1} tabIndex={-1}
onMouseEnter={handleMouseEnter} onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
onMouseLeave={(e) => e.currentTarget.blur()} onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
disabled={item.disabled} disabled={item.disabled}
onFocus={handleFocus} onFocus={handleFocus}
onClick={handleClick} onClick={handleClick}
@@ -947,6 +968,7 @@ function MenuItem({
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap", "min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1", "focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm", isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
hasButtonSubmenu && "pr-8",
item.color === "danger" && "text-danger!", item.color === "danger" && "text-danger!",
item.color === "primary" && "text-primary!", item.color === "primary" && "text-primary!",
item.color === "success" && "text-success!", item.color === "success" && "text-success!",
@@ -959,6 +981,52 @@ function MenuItem({
<div className={classNames("truncate min-w-20")}>{item.label}</div> <div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button> </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 (
<div
className="relative grid group/menuitem"
onMouseEnter={() => {
const el = buttonRef.current;
if (el == null) return;
onHover(item, el);
el.focus();
}}
onMouseLeave={() => buttonRef.current?.blur()}
>
{button}
<div
className={classNames(
"absolute right-1.5 inset-y-0 flex items-center",
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
)}
>
<IconButton
color="custom"
size="2xs"
tabIndex={-1}
icon="ellipsis_vertical"
iconColor="secondary"
title="More actions"
className="h-full! w-7!"
onMouseDown={(e) => {
// Prevent the trigger from stealing focus, which would unhighlight the row
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
onOpenSubmenu(item, e.currentTarget);
}}
/>
</div>
</div>
);
} }
interface MenuItemHotKeyProps { interface MenuItemHotKeyProps {