import classNames from 'classnames'; import { atom } from 'jotai'; import * as m from 'motion/react-m'; import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject, SetStateAction, } from 'react'; import { Children, cloneElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { useKey, useWindowSize } from 'react-use'; import { useClickOutside } from '../../hooks/useClickOutside'; import type { HotkeyAction } from '../../hooks/useHotKey'; import { useHotKey } from '../../hooks/useHotKey'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import { generateId } from '../../lib/generateId'; import { getNodeText } from '../../lib/getNodeText'; import { jotaiStore } from '../../lib/jotai'; import { ErrorBoundary } from '../ErrorBoundary'; import { Overlay } from '../Overlay'; import { Button } from './Button'; import { Hotkey } from './Hotkey'; import { Icon, type IconProps } from './Icon'; import { LoadingIcon } from './LoadingIcon'; import { Separator } from './Separator'; import { HStack, VStack } from './Stacks'; export type DropdownItemSeparator = { type: 'separator'; label?: ReactNode; hidden?: boolean; }; export type DropdownItemContent = { type: 'content'; label?: ReactNode; hidden?: boolean; }; export type DropdownItemDefault = { type?: 'default'; label: ReactNode; hotKeyAction?: HotkeyAction; hotKeyLabelOnly?: boolean; color?: 'default' | 'primary' | 'danger' | 'info' | 'warning' | 'notice' | 'success'; disabled?: boolean; hidden?: boolean; leftSlot?: ReactNode; rightSlot?: ReactNode; waitForOnSelect?: boolean; keepOpenOnSelect?: boolean; onSelect?: () => void | Promise; submenu?: DropdownItem[]; /** If true, submenu opens on click instead of hover */ submenuOpenOnClick?: boolean; icon?: IconProps['icon']; }; export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent; export interface DropdownProps { children: ReactElement>; items: DropdownItem[]; fullWidth?: boolean; hotKeyAction?: HotkeyAction; onOpen?: () => void; } export interface DropdownRef { isOpen: boolean; open: (index?: number) => void; toggle: () => void; close?: () => void; next?: (incrBy?: number) => void; prev?: (incrBy?: number) => void; select?: () => void; } // Every dropdown gets a unique ID and we use this global atom to ensure // only one dropdown can be open at a time. // TODO: Also make ContextMenu use this const openAtom = atom(null); export const Dropdown = forwardRef(function Dropdown( { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps, ref, ) { const id = useRef(generateId()); const [isOpen, setIsOpen] = useState(false); useEffect(() => { return jotaiStore.sub(openAtom, () => { const globalOpenId = jotaiStore.get(openAtom); const newIsOpen = globalOpenId === id.current; if (newIsOpen !== isOpen) { setIsOpen(newIsOpen); } }); }, [isOpen]); // const [isOpen, _setIsOpen] = useState(false); const [defaultSelectedIndex, setDefaultSelectedIndex] = useState(null); const buttonRef = useRef(null); const menuRef = useRef>(null); const handleSetIsOpen = useCallback( (o: SetStateAction) => { jotaiStore.set(openAtom, (prevId) => { const prevIsOpen = prevId === id.current; const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o; // Persist background color of button until we close the dropdown if (newIsOpen) { onOpen?.(); if (buttonRef.current) { buttonRef.current.style.backgroundColor = window .getComputedStyle(buttonRef.current) .getPropertyValue('background-color'); } } return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state }); }, [onOpen], ); // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method // we have of detecting the dropdown closed, to do cleanup. useEffect(() => { if (!isOpen) { // Clear persisted BG if (buttonRef.current) buttonRef.current.style.backgroundColor = ''; // Set to different value when opened and closed to force it to update. This is to force // to reset its selected-index state, which it does when this prop changes setDefaultSelectedIndex(null); } }, [isOpen]); // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't, // the ref will not update when menuRef updates, causing stale callback state to be used. const menuRefCurrent = menuRef.current; useImperativeHandle( ref, () => ({ ...menuRefCurrent, isOpen: isOpen, toggle() { if (!isOpen) this.open(); else this.close(); }, open(index?: number) { handleSetIsOpen(true); setDefaultSelectedIndex(index ?? -1); }, close() { handleSetIsOpen(false); }, }), [isOpen, handleSetIsOpen, menuRefCurrent], ); useHotKey(hotKeyAction ?? null, () => { setDefaultSelectedIndex(0); handleSetIsOpen(true); }); const child = useMemo(() => { const existingChild = Children.only(children); const originalOnClick = existingChild.props?.onClick; const props: HTMLAttributes & { ref: RefObject } = { ...existingChild.props, ref: buttonRef, 'aria-haspopup': 'true', onClick: (e: MouseEvent) => { // Call original onClick first if it exists originalOnClick?.(e); // Only toggle dropdown if event wasn't prevented if (!e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); handleSetIsOpen((o) => !o); // Toggle dropdown } }, }; return cloneElement(existingChild, props); }, [children, handleSetIsOpen]); useEffect(() => { buttonRef.current?.setAttribute('aria-expanded', isOpen.toString()); }, [isOpen]); const windowSize = useWindowSize(); const triggerRect = useMemo(() => { if (!windowSize) return null; // No-op to TS happy with this dep if (!isOpen) return null; return buttonRef.current?.getBoundingClientRect(); }, [isOpen, windowSize]); return ( <> {child} handleSetIsOpen(false)} isOpen={isOpen} /> ); }); export interface ContextMenuProps { triggerPosition: { x: number; y: number } | null; className?: string; items: DropdownProps['items']; onClose: () => void; } export const ContextMenu = forwardRef(function ContextMenu( { triggerPosition, className, items, onClose }, ref, ) { const triggerShape = useMemo( () => ({ top: triggerPosition?.y ?? 0, bottom: triggerPosition?.y ?? 0, left: triggerPosition?.x ?? 0, right: triggerPosition?.x ?? 0, }), [triggerPosition], ); if (triggerPosition == null) return null; return ( ); }); interface MenuProps { className?: string; defaultSelectedIndex: number | null; triggerShape: Pick | null; onClose: () => void; onCloseAll?: () => void; showTriangle?: boolean; fullWidth?: boolean; isOpen: boolean; items: DropdownItem[]; triggerRef?: RefObject; isSubmenu?: boolean; } const Menu = forwardRef, MenuProps>( ( { className, isOpen, items, fullWidth, onClose, onCloseAll, triggerShape, defaultSelectedIndex, showTriangle, triggerRef, isSubmenu, }: MenuProps, ref, ) => { const [selectedIndex, setSelectedIndex] = useStateWithDeps( defaultSelectedIndex ?? -1, [defaultSelectedIndex], ); const [filter, setFilter] = useState(''); // Clear filter when menu opens useEffect(() => { if (isOpen) { setFilter(''); } }, [isOpen]); 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. const selectedIndexRef = useRef(selectedIndex); useEffect(() => { selectedIndexRef.current = selectedIndex; }, [selectedIndex]); const handleClose = useCallback(() => { onClose(); setActiveSubmenu(null); }, [onClose]); // Close the entire menu hierarchy (used when selecting an item) const handleCloseAll = useCallback(() => { if (onCloseAll) { onCloseAll(); } else { handleClose(); } }, [onCloseAll, handleClose]); // 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) { e.preventDefault(); setFilter((f) => f + e.key); setSelectedIndex(0); } else if (e.key === 'Backspace' && !isSpecial) { e.preventDefault(); setFilter((f) => f.slice(0, -1)); } }; useKey( 'Escape', () => { if (!isOpen) return; if (activeSubmenu) setActiveSubmenu(null); else if (filter !== '') setFilter(''); else handleClose(); }, {}, [isOpen, filter, setFilter, handleClose, activeSubmenu], ); const handlePrev = useCallback( (incrBy = 1) => { setSelectedIndex((currIndex) => { let nextIndex = (currIndex ?? 0) - incrBy; const maxTries = items.length; for (let i = 0; i < maxTries; i++) { if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') { nextIndex--; } else if (nextIndex < 0) { nextIndex = items.length - 1; } else { break; } } return nextIndex; }); }, [items, setSelectedIndex], ); const handleNext = useCallback( (incrBy = 1) => { setSelectedIndex((currIndex) => { let nextIndex = (currIndex ?? -1) + incrBy; const maxTries = items.length; for (let i = 0; i < maxTries; i++) { if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') { nextIndex++; } else if (nextIndex >= items.length) { nextIndex = 0; } else { break; } } return nextIndex; }); }, [items, setSelectedIndex], ); // Ensure selection is on a valid item (not hidden/separator/content) useEffect(() => { const item = items[selectedIndex ?? -1]; if (item?.hidden || item?.type === 'separator' || item?.type === 'content') { handleNext(); } }, [selectedIndex, items, handleNext]); useKey( 'ArrowUp', (e) => { if (!isOpen || activeSubmenu) return; e.preventDefault(); handlePrev(); }, {}, [isOpen, activeSubmenu], ); useKey( 'ArrowDown', (e) => { if (!isOpen || activeSubmenu) return; e.preventDefault(); handleNext(); }, {}, [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( async (item: DropdownItem, parentEl?: HTMLButtonElement) => { // Handle click-to-open submenu if ('submenu' in item && item.submenu && item.submenuOpenOnClick && parentEl) { setActiveSubmenu({ item, parent: parentEl }); return; } if (!('onSelect' in item) || !item.onSelect) return; setSelectedIndex(null); const promise = item.onSelect(); if (item.waitForOnSelect) { try { await promise; } catch { // Nothing } } if (!item.keepOpenOnSelect) handleCloseAll(); }, [handleCloseAll, setSelectedIndex], ); useImperativeHandle(ref, () => { return { close: handleClose, prev: handlePrev, next: handleNext, select: async () => { const item = items[selectedIndexRef.current ?? -1] ?? null; if (!item) return; await handleSelect(item); }, }; }, [handleClose, handleNext, handlePrev, handleSelect, items]); const styles = useMemo<{ container: CSSProperties; menu: CSSProperties; triangle: CSSProperties; upsideDown: boolean; }>(() => { 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 spaceBelow = docRect.height - parentRect.top; const spaceAbove = parentRect.bottom; const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right // Estimate submenu height (items * ~28px + padding), flip if not enough space below const estimatedHeight = items.length * 28 + 20; const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow; return { upsideDown: openUpward, container: { top: openUpward ? undefined : parentRect.top, bottom: openUpward ? docRect.height - parentRect.bottom : undefined, left: openLeft ? undefined : parentRect.right, right: openLeft ? docRect.width - parentRect.left : undefined, }, menu: { maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`, }, triangle: {}, // No triangle for submenus }; } const menuMarginY = 5; const docRect = document.documentElement.getBoundingClientRect(); const width = triggerShape.right - triggerShape.left; const heightAbove = triggerShape.top; const heightBelow = docRect.height - triggerShape.bottom; const horizontalSpaceRemaining = docRect.width - triggerShape.left; const top = triggerShape.bottom; const onRight = horizontalSpaceRemaining < 300; const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200; const triggerWidth = triggerShape.right - triggerShape.left; return { upsideDown, container: { top: !upsideDown ? top + menuMarginY : undefined, bottom: upsideDown ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY : undefined, right: onRight ? docRect.width - triggerShape.right : undefined, left: !onRight ? triggerShape.left : undefined, minWidth: fullWidth ? triggerWidth : undefined, maxWidth: '40rem', }, triangle: { width: '0.4rem', height: '0.4rem', ...(onRight ? { right: width / 2, marginRight: '-0.2rem' } : { left: width / 2, marginLeft: '-0.2rem' }), ...(upsideDown ? { bottom: '-0.2rem', rotate: '225deg' } : { top: '-0.2rem', rotate: '45deg' }), }, menu: { maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`, }, }; }, [fullWidth, items.length, triggerShape, isSubmenu]); const filteredItems = useMemo( () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())), [items, filter], ); const handleFocus = useCallback( (i: DropdownItem) => { const index = filteredItems.indexOf(i) ?? null; setSelectedIndex(index); }, [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 && !item.submenuOpenOnClick) { 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)} onCloseAll={handleCloseAll} triggerShape={submenuTriggerShape} />
)}
); // Hotkeys must be rendered even when menu is closed (so they work globally) const hotKeyElements = items.map( (item, i) => item.type !== 'separator' && item.type !== 'content' && !item.hotKeyLabelOnly && item.hotKeyAction && ( ), ); if (!isOpen) { return <>{hotKeyElements}; } if (isSubmenu) { return menuContent; } return ( <> {hotKeyElements} {menuContent} ); }, ); interface MenuItemProps { className?: string; item: DropdownItemDefault; onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise; onFocus: (item: DropdownItemDefault) => void; onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void; focused: boolean; isParentOfActiveSubmenu?: boolean; } 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); await onSelect?.(item, buttonRef.current ?? undefined); if (item.waitForOnSelect) setIsLoading(false); }, [item, onSelect]); const handleFocus = useCallback( (e: ReactFocusEvent) => { e.stopPropagation(); // Don't trigger focus on any parents return onFocus?.(item); }, [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); } }, [focused], ); const handleMouseEnter = (e: MouseEvent) => { onHover(item, e.currentTarget); e.currentTarget.focus(); }; const rightSlot = item.submenu ? ( ) : ( (item.rightSlot ?? ) ); return ( ); } interface MenuItemHotKeyProps { action: HotkeyAction | undefined; onSelect: MenuItemProps['onSelect']; item: MenuItemProps['item']; } 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); }