diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 8dbdff26..69dc6825 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -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; + submenu?: DropdownItem[]; + icon?: IconProps['icon']; }; export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent; @@ -275,10 +277,11 @@ interface MenuProps { isOpen: boolean; items: DropdownItem[]; triggerRef?: RefObject; + isSubmenu?: boolean; } const Menu = forwardRef, MenuProps>( - function Menu( + ( { className, isOpen, @@ -289,14 +292,24 @@ const Menu = forwardRef { const [selectedIndex, setSelectedIndex] = useStateWithDeps( defaultSelectedIndex ?? -1, [defaultSelectedIndex], ); const [filter, setFilter] = useState(''); + const [activeSubmenu, setActiveSubmenu] = useState<{ + item: DropdownItemDefault; + parent: HTMLButtonElement; + viaKeyboard?: boolean; + } | null>(null); + + const mousePosition = useRef({ x: 0, y: 0 }); + const submenuTimeoutRef = useRef(null); + const submenuRef = useRef(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 { onClose(); setFilter(''); + setActiveSubmenu(null); }, [onClose]); - // Close menu on space bar + // Handle type-ahead filtering (only for the deepest open menu) const handleMenuKeyDown = (e: ReactKeyboardEvent) => { + // 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 { 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 { - 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(() => { 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 items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())), @@ -488,9 +543,237 @@ const Menu = forwardRef { + 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(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 = ( + { + // 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 && ( + + )} + + {filter && ( + + +
{filter}
+
+ )} + {filteredItems.length === 0 && ( + No matches + )} + {filteredItems.map((item, i) => { + if (item.hidden) { + return null; + } + if (item.type === 'separator') { + return ( + + {item.label} + + ); + } + 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 +
+ {item.label} +
+ ); + } + const isParentOfActiveSubmenu = activeSubmenu?.item === item; + return ( + + ); + })} +
+ {activeSubmenu && ( + // biome-ignore lint/a11y/noStaticElementInteractions: Container div that cancels hover timeout +
{ + if (submenuTimeoutRef.current) { + clearTimeout(submenuTimeoutRef.current); + } + }} + > + setActiveSubmenu(null)} + triggerShape={submenuTriggerShape} + /> +
+ )} +
+ ); + + if (!isOpen) { + return null; + } + + if (isSubmenu) { + return menuContent; + } + return ( <> {items.map( @@ -507,95 +790,9 @@ const Menu = forwardRef ), )} - {isOpen && ( - - { - // 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 && ( - - )} - - {filter && ( - - -
{filter}
-
- )} - {filteredItems.length === 0 && ( - No matches - )} - {filteredItems.map((item, i) => { - if (item.hidden) { - return null; - } - if (item.type === 'separator') { - return ( - - {item.label} - - ); - } - 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 -
- {item.label} -
- ); - } - return ( - - ); - })} -
-
-
- )} + + {menuContent} + ); }, @@ -606,10 +803,21 @@ interface MenuItemProps { item: DropdownItemDefault; onSelect: (item: DropdownItemDefault) => Promise; 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(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 ?? ; + const handleMouseEnter = (e: MouseEvent) => { + onHover(item, e.currentTarget); + e.currentTarget.focus(); + }; + + const rightSlot = item.submenu ? ( + + ) : ( + (item.rightSlot ?? ) + ); return (