import classnames from 'classnames'; import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react'; import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useKeyPressEvent } from 'react-use'; import { Portal } from '../Portal'; import { Separator } from './Separator'; import { VStack } from './Stacks'; export type DropdownItem = | { label: string; disabled?: boolean; hidden?: boolean; leftSlot?: ReactNode; rightSlot?: ReactNode; onSelect?: () => void; } | '-----'; export interface DropdownProps { children: ReactElement>; items: DropdownItem[]; } export function Dropdown({ children, items }: DropdownProps) { const [open, setOpen] = useState(false); const ref = useRef(null); const child = useMemo( () => cloneElement(Children.only(children) as never, { ref, 'aria-has-popup': 'true', onClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setOpen((o) => !o); }, }), [children], ); const handleClose = useCallback(() => { setOpen(false); ref.current?.focus(); }, [ref.current]); useEffect(() => { ref.current?.setAttribute('aria-expanded', open.toString()); }, [open]); const triggerRect = useMemo(() => { if (!open) return null; return ref.current?.getBoundingClientRect(); }, [ref.current, open]); return ( <> {child} {open && triggerRect && ( )} ); } interface MenuProps { className?: string; items: DropdownProps['items']; triggerRect: DOMRect; onClose: () => void; } function Menu({ className, items, onClose, triggerRect }: MenuProps) { if (triggerRect === undefined) return null; const containerRef = useRef(null); const [menuStyles, setMenuStyles] = useState({}); // Calculate the max height so we can scroll const initMenu = useCallback((el: HTMLDivElement | null) => { if (el === null) return {}; const windowBox = document.documentElement.getBoundingClientRect(); const menuBox = el.getBoundingClientRect(); setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 }); }, []); useKeyPressEvent('ArrowUp', () => { setSelectedIndex((currIndex) => { let nextIndex = (currIndex ?? 0) - 1; const maxTries = items.length; for (let i = 0; i < maxTries; i++) { if (items[nextIndex] === '-----') { nextIndex--; } else if (nextIndex < 0) { nextIndex = items.length - 1; } else { break; } } return nextIndex; }); }); useKeyPressEvent('ArrowDown', () => { setSelectedIndex((currIndex) => { let nextIndex = (currIndex ?? -1) + 1; const maxTries = items.length; for (let i = 0; i < maxTries; i++) { if (items[nextIndex] === '-----') { nextIndex++; } else if (nextIndex >= items.length) { nextIndex = 0; } else { break; } } return nextIndex; }); }); const containerStyles: CSSProperties = useMemo(() => { const docWidth = document.documentElement.getBoundingClientRect().width; const spaceRemaining = docWidth - triggerRect.left; if (spaceRemaining < 200) { return { top: triggerRect?.bottom, right: 0, }; } return { top: triggerRect?.bottom, left: triggerRect?.left, }; }, [triggerRect]); const handleSelect = useCallback( (i: DropdownItem) => { onClose(); setSelectedIndex(null); if (i !== '-----') { i.onSelect?.(); } }, [onClose], ); const [selectedIndex, setSelectedIndex] = useState(null); return ( ); }