Add support for nested sub menus (#352)

This commit is contained in:
Gregory Schier
2026-01-07 15:10:44 -08:00
committed by GitHub
parent ebcdee9be0
commit 3bcc0b8356

View File

@@ -35,7 +35,7 @@ import { ErrorBoundary } from '../ErrorBoundary';
import { Overlay } from '../Overlay';
import { Button } from './Button';
import { Hotkey } from './Hotkey';
import { Icon } from './Icon';
import { Icon, type IconProps } from './Icon';
import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
@@ -65,6 +65,8 @@ export type DropdownItemDefault = {
waitForOnSelect?: boolean;
keepOpenOnSelect?: boolean;
onSelect?: () => void | Promise<void>;
submenu?: DropdownItem[];
icon?: IconProps['icon'];
};
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
@@ -275,10 +277,11 @@ interface MenuProps {
isOpen: boolean;
items: DropdownItem[];
triggerRef?: RefObject<HTMLButtonElement | null>;
isSubmenu?: boolean;
}
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'>, MenuProps>(
function Menu(
(
{
className,
isOpen,
@@ -289,14 +292,24 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
defaultSelectedIndex,
showTriangle,
triggerRef,
isSubmenu,
}: MenuProps,
ref,
) {
) => {
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
defaultSelectedIndex ?? -1,
[defaultSelectedIndex],
);
const [filter, setFilter] = useState<string>('');
const [activeSubmenu, setActiveSubmenu] = useState<{
item: DropdownItemDefault;
parent: HTMLButtonElement;
viaKeyboard?: boolean;
} | null>(null);
const mousePosition = useRef({ x: 0, y: 0 });
const submenuTimeoutRef = useRef<number | null>(null);
const submenuRef = useRef<HTMLDivElement>(null);
// HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
// have access to the latest value.
@@ -308,10 +321,14 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
const handleClose = useCallback(() => {
onClose();
setFilter('');
setActiveSubmenu(null);
}, [onClose]);
// Close menu on space bar
// Handle type-ahead filtering (only for the deepest open menu)
const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
// Skip if this menu has a submenu open - let the submenu handle typing
if (activeSubmenu) return;
const isCharacter = e.key.length === 1;
const isSpecial = e.ctrlKey || e.metaKey || e.altKey;
if (isCharacter && !isSpecial) {
@@ -328,11 +345,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
'Escape',
() => {
if (!isOpen) return;
if (filter !== '') setFilter('');
if (activeSubmenu) setActiveSubmenu(null);
else if (filter !== '') setFilter('');
else handleClose();
},
{},
[isOpen, filter, setFilter, handleClose],
[isOpen, filter, setFilter, handleClose, activeSubmenu],
);
const handlePrev = useCallback(
@@ -378,23 +396,40 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
useKey(
'ArrowUp',
(e) => {
if (!isOpen) return;
if (!isOpen || activeSubmenu) return;
e.preventDefault();
handlePrev();
},
{},
[isOpen],
[isOpen, activeSubmenu],
);
useKey(
'ArrowDown',
(e) => {
if (!isOpen) return;
if (!isOpen || activeSubmenu) return;
e.preventDefault();
handleNext();
},
{},
[isOpen],
[isOpen, activeSubmenu],
);
useKey(
'ArrowLeft',
(e) => {
if (!isOpen) return;
// Only handle if this menu doesn't have an open submenu
// (let the deepest submenu handle the key first)
if (activeSubmenu) return;
// If this is a submenu, ArrowLeft closes it and returns to parent
if (isSubmenu) {
e.preventDefault();
onClose();
}
},
{},
[isOpen, isSubmenu, activeSubmenu, onClose],
);
const handleSelect = useCallback(
@@ -437,6 +472,26 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
}>(() => {
if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };
if (isSubmenu) {
const parentRect = triggerShape;
const docRect = document.documentElement.getBoundingClientRect();
const spaceRight = docRect.width - parentRect.right;
const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right
return {
upsideDown: false,
container: {
top: parentRect.top,
left: openLeft ? undefined : parentRect.right,
right: openLeft ? docRect.width - parentRect.left : undefined,
},
menu: {
maxHeight: `${docRect.height - parentRect.top - 20}px`,
},
triangle: {}, // No triangle for submenus
};
}
const menuMarginY = 5;
const docRect = document.documentElement.getBoundingClientRect();
const width = triggerShape.right - triggerShape.left;
@@ -473,7 +528,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,
},
};
}, [fullWidth, items.length, triggerShape]);
}, [fullWidth, items.length, triggerShape, isSubmenu]);
const filteredItems = useMemo(
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
@@ -488,9 +543,237 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
[filteredItems, setSelectedIndex],
);
useKey(
'ArrowRight',
(e) => {
if (!isOpen || activeSubmenu) return;
const item = filteredItems[selectedIndex ?? -1];
if (item?.type !== 'separator' && item?.type !== 'content' && item?.submenu) {
e.preventDefault();
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true });
}
}
},
{},
[isOpen, activeSubmenu, filteredItems, selectedIndex],
);
useKey(
'Enter',
(e) => {
if (!isOpen || activeSubmenu) return;
const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === 'separator' || item.type === 'content') return;
e.preventDefault();
if (item.submenu) {
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true });
}
} else if (item.onSelect) {
handleSelect(item);
}
},
{},
[isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],
);
const handleItemHover = useCallback(
(item: DropdownItemDefault, parent: HTMLButtonElement) => {
if (submenuTimeoutRef.current) {
clearTimeout(submenuTimeoutRef.current);
}
if (item.submenu) {
setActiveSubmenu({ item, parent });
} else if (activeSubmenu) {
submenuTimeoutRef.current = window.setTimeout(() => {
const submenuEl = submenuRef.current;
if (!submenuEl || !activeSubmenu) {
setActiveSubmenu(null);
return;
}
const { parent } = activeSubmenu;
const parentRect = parent.getBoundingClientRect();
const submenuRect = submenuEl.getBoundingClientRect();
const mouse = mousePosition.current;
if (
mouse.x >= submenuRect.left &&
mouse.x <= submenuRect.right &&
mouse.y >= submenuRect.top &&
mouse.y <= submenuRect.bottom
) {
return;
}
const tolerance = 5;
const p1 = { x: parentRect.right, y: parentRect.top - tolerance };
const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };
const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };
const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };
const inTriangle =
isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);
if (!inTriangle) {
setActiveSubmenu(null);
}
}, 100);
}
},
[activeSubmenu],
);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, handleClose, triggerRef);
// Keep focus on menu container when filtering leaves no items
useEffect(() => {
if (filteredItems.length === 0 && filter && menuRef.current) {
menuRef.current.focus();
}
}, [filteredItems.length, filter]);
const submenuTriggerShape = useMemo(() => {
if (!activeSubmenu) return null;
const rect = activeSubmenu.parent.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
}, [activeSubmenu]);
const handleMouseMove = (event: React.MouseEvent) => {
mousePosition.current = { x: event.clientX, y: event.clientY };
};
const menuContent = (
<m.div
ref={menuRef}
tabIndex={0}
onKeyDown={handleMenuKeyDown}
onMouseMove={handleMouseMove}
onContextMenu={(e) => {
// Prevent showing any ancestor context menus
e.stopPropagation();
e.preventDefault();
}}
initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
style={styles.container}
className={classNames(
className,
'x-theme-menu',
'outline-none my-1 pointer-events-auto z-40',
'fixed',
)}
>
{showTriangle && !isSubmenu && (
<span
aria-hidden
style={styles.triangle}
className="bg-surface absolute border-border-subtle border-t border-l"
/>
)}
<VStack
style={styles.menu}
className={classNames(
className,
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
'border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5',
)}
>
{filter && (
<HStack
space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
>
<Icon icon="search" size="xs" />
<div className="text">{filter}</div>
</HStack>
)}
{filteredItems.length === 0 && (
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
)}
{filteredItems.map((item, i) => {
if (item.hidden) {
return null;
}
if (item.type === 'separator') {
return (
<Separator
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else available
key={i}
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
>
{item.label}
</Separator>
);
}
if (item.type === 'content') {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Needs to be clickable but want to support nested buttons
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
{item.label}
</div>
);
}
const isParentOfActiveSubmenu = activeSubmenu?.item === item;
return (
<MenuItem
focused={i === selectedIndex}
isParentOfActiveSubmenu={isParentOfActiveSubmenu}
onFocus={handleFocus}
onSelect={handleSelect}
onHover={handleItemHover}
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
key={i}
item={item}
/>
);
})}
</VStack>
{activeSubmenu && (
// biome-ignore lint/a11y/noStaticElementInteractions: Container div that cancels hover timeout
<div
ref={submenuRef}
onMouseEnter={() => {
if (submenuTimeoutRef.current) {
clearTimeout(submenuTimeoutRef.current);
}
}}
>
<Menu
isSubmenu
isOpen
items={activeSubmenu.item.submenu ?? []}
defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}
onClose={() => setActiveSubmenu(null)}
triggerShape={submenuTriggerShape}
/>
</div>
)}
</m.div>
);
if (!isOpen) {
return null;
}
if (isSubmenu) {
return menuContent;
}
return (
<>
{items.map(
@@ -507,95 +790,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
/>
),
)}
{isOpen && (
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
<m.div
ref={menuRef}
tabIndex={0}
onKeyDown={handleMenuKeyDown}
onContextMenu={(e) => {
// Prevent showing any ancestor context menus
e.stopPropagation();
e.preventDefault();
}}
initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu"
aria-orientation="vertical"
dir="ltr"
style={styles.container}
className={classNames(
className,
'x-theme-menu',
'outline-none my-1 pointer-events-auto fixed z-40',
)}
>
{showTriangle && (
<span
aria-hidden
style={styles.triangle}
className="bg-surface absolute border-border-subtle border-t border-l"
/>
)}
<VStack
style={styles.menu}
className={classNames(
className,
'h-auto bg-surface rounded-md shadow-lg py-1.5 border',
'border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5',
)}
>
{filter && (
<HStack
space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
>
<Icon icon="search" size="xs" />
<div className="text">{filter}</div>
</HStack>
)}
{filteredItems.length === 0 && (
<span className="text-text-subtlest text-center px-2 py-1">No matches</span>
)}
{filteredItems.map((item, i) => {
if (item.hidden) {
return null;
}
if (item.type === 'separator') {
return (
<Separator
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else available
key={i}
className={classNames('my-1.5', item.label ? 'ml-2' : null)}
>
{item.label}
</Separator>
);
}
if (item.type === 'content') {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Needs to be clickable but want to support nested buttons
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine
<div key={i} className={classNames('my-1 mx-2 max-w-xs')} onClick={onClose}>
{item.label}
</div>
);
}
return (
<MenuItem
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
key={i}
item={item}
/>
);
})}
</VStack>
</m.div>
</Overlay>
)}
<Overlay noBackdrop open={isOpen} portalName="dropdown-menu">
{menuContent}
</Overlay>
</>
);
},
@@ -606,10 +803,21 @@ interface MenuItemProps {
item: DropdownItemDefault;
onSelect: (item: DropdownItemDefault) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean;
isParentOfActiveSubmenu?: boolean;
}
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
function MenuItem({
className,
focused,
onFocus,
onHover,
item,
onSelect,
isParentOfActiveSubmenu,
...props
}: MenuItemProps) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = useCallback(async () => {
if (item.waitForOnSelect) setIsLoading(true);
@@ -625,8 +833,10 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
[item, onFocus],
);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
buttonRef.current = el;
if (el === null) return;
if (focused) {
setTimeout(() => el.focus(), 0);
@@ -635,23 +845,32 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
[focused],
);
const rightSlot = item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />;
const handleMouseEnter = (e: MouseEvent<HTMLButtonElement>) => {
onHover(item, e.currentTarget);
e.currentTarget.focus();
};
const rightSlot = item.submenu ? (
<Icon icon="chevron_right" />
) : (
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />)
);
return (
<Button
ref={initRef}
size="sm"
tabIndex={-1}
onMouseEnter={(e) => e.currentTarget.focus()}
onMouseEnter={handleMouseEnter}
onMouseLeave={(e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
justify="start"
leftSlot={
(isLoading || item.leftSlot) && (
(isLoading || item.leftSlot || item.icon) && (
<div className={classNames('pr-2 flex justify-start [&_svg]:opacity-70')}>
{isLoading ? <LoadingIcon /> : item.leftSlot}
{isLoading ? <LoadingIcon /> : item.icon ? <Icon icon={item.icon} /> : item.leftSlot}
</div>
)
}
@@ -663,6 +882,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1',
isParentOfActiveSubmenu && 'bg-surface-highlight text rounded',
item.color === 'danger' && '!text-danger',
item.color === 'primary' && '!text-primary',
item.color === 'success' && '!text-success',
@@ -687,3 +907,27 @@ function MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {
useHotKey(action ?? null, () => onSelect(item));
return null;
}
function sign(
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
) {
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
}
function isPointInTriangle(
pt: { x: number; y: number },
v1: { x: number; y: number },
v2: { x: number; y: number },
v3: { x: number; y: number },
) {
const d1 = sign(pt, v1, v2);
const d2 = sign(pt, v2, v3);
const d3 = sign(pt, v3, v1);
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(has_neg && has_pos);
}