import classnames from 'classnames'; import FocusTrap from 'focus-trap-react'; import { motion } from 'framer-motion'; 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 DropdownItemSeparator = { type: 'separator'; label?: string; }; export type DropdownItem = | { type?: 'default'; label: string; disabled?: boolean; hidden?: boolean; leftSlot?: ReactNode; rightSlot?: ReactNode; onSelect?: () => void; } | DropdownItemSeparator; 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(() => { const existingChild = Children.only(children); // eslint-disable-next-line @typescript-eslint/no-explicit-any const props: any = { ...existingChild.props, ref, 'aria-haspopup': 'true', onClick: existingChild.props?.onClick ?? ((e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setOpen((o) => !o); }), }; return cloneElement(existingChild, props); }, [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('Escape', () => { onClose(); }); useKeyPressEvent('ArrowUp', () => { setSelectedIndex((currIndex) => { let nextIndex = (currIndex ?? 0) - 1; const maxTries = items.length; for (let i = 0; i < maxTries; i++) { if (items[nextIndex]?.type === 'separator') { 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]?.type === 'separator') { nextIndex++; } else if (nextIndex >= items.length) { nextIndex = 0; } else { break; } } return nextIndex; }); }); const { containerStyles, triangleStyles } = useMemo<{ containerStyles: CSSProperties; triangleStyles: CSSProperties; }>(() => { const docWidth = document.documentElement.getBoundingClientRect().width; const spaceRemaining = docWidth - triggerRect.left; const top = triggerRect?.bottom + 5; const onRight = spaceRemaining < 200; const containerStyles = onRight ? { top, right: docWidth - triggerRect?.right } : { top, left: triggerRect?.left }; const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' }; const triangleStyles = onRight ? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size } : { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size }; return { containerStyles, triangleStyles }; }, [triggerRect]); const handleSelect = useCallback( (i: DropdownItem) => { onClose(); setSelectedIndex(null); if (i.type !== 'separator') { i.onSelect?.(); } }, [onClose], ); const handleFocus = useCallback( (i: DropdownItem) => { const index = items.findIndex((item) => item === i) ?? null; setSelectedIndex(index); }, [items], ); const [selectedIndex, setSelectedIndex] = useState(null); return (
{containerStyles && ( {items.map((item, i) => { if (item.type === 'separator') { return ; } if (item.hidden) { return null; } return ( ); })} )}
); } interface MenuItemProps { className?: string; item: DropdownItem; onSelect: (item: DropdownItem) => void; onFocus: (item: DropdownItem) => void; focused: boolean; } function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) { const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]); const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]); const initRef = useCallback( (el: HTMLButtonElement | null) => { if (el === null) return; if (focused) { setTimeout(() => el.focus(), 0); } }, [focused], ); if (item.type === 'separator') return ; return ( ); }