'use client'; import styles from '@components/DropdownMenuTrigger.module.css'; import * as Position from '@common/position'; import * as React from 'react'; import * as Utilities from '@common/utilities'; import DropdownMenu from '@components/DropdownMenu'; import OutsideElementEvent from '@components/detectors/OutsideElementEvent'; import { createPortal } from 'react-dom'; import { useHotkeys } from '@modules/hotkeys'; interface DropdownMenuTriggerProps { children: React.ReactElement>; items: any; hotkey?: string; } function DropdownMenuTrigger({ children, items, hotkey }: DropdownMenuTriggerProps) { const [open, setOpen] = React.useState(false); const [focusChildren, setFocusChildren] = React.useState(false); const [willClose, setWillClose] = React.useState(false); const [placement, setPlacement] = React.useState('bottom'); const [position, setPosition] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); const triggerRef = React.useRef(null); const elementRef = React.useRef(null); const onClick = React.useCallback((event?: React.MouseEvent) => { if (event) event.preventDefault(); setOpen(true); }, []); const onOutsideEvent = React.useCallback(() => setOpen(false), []); const onClose = React.useCallback(() => setWillClose(true), []); if (hotkey) { useHotkeys(hotkey, () => { setOpen(!open); }); } React.useEffect(() => { if (focusChildren) { const element = elementRef.current; if (element) { const firstFocusable = Utilities.findFocusableDescendant(element); if (firstFocusable) { firstFocusable.focus(); } else { element.focus(); } } setFocusChildren(false); } }, [focusChildren]); React.useEffect(() => { if (willClose) { setOpen(false); setWillClose(false); } }, [willClose]); //NOTE(jimmylee): WAI-ARIA requires focus return to the trigger on close so keyboard users are not stranded. const prevOpen = React.useRef(false); React.useEffect(() => { if (prevOpen.current && !open) { triggerRef.current?.focus(); } prevOpen.current = open; }, [open]); React.useEffect(() => { if (!open || !triggerRef.current || !elementRef.current) return; const updatePosition = () => { const { placement, position } = Position.calculate(triggerRef.current!, elementRef.current!); setPlacement(placement); setPosition(position); }; updatePosition(); setFocusChildren(true); const handleResizeOrScroll = () => updatePosition(); const observer = new MutationObserver(() => updatePosition()); observer.observe(document.body, { attributes: true, childList: true, subtree: true }); window.addEventListener('resize', handleResizeOrScroll); window.addEventListener('scroll', handleResizeOrScroll, true); return () => { window.removeEventListener('resize', handleResizeOrScroll); window.removeEventListener('scroll', handleResizeOrScroll, true); observer.disconnect(); }; }, [open]); const element = open ? createPortal( , document.body ) : null; const mergeRefs = React.useCallback( (node: HTMLElement | null) => { triggerRef.current = node; if (typeof (children as any).ref === 'function') { (children as any).ref(node); } else if ((children as any).ref) { (children as any).ref.current = node; } }, [children] ); return (
{React.cloneElement(children, { tabIndex: 0, onClick, ref: mergeRefs, } as any)} {element}
); } export default DropdownMenuTrigger;