import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui/react'; import clsx from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu'; import { useAppDispatch } from 'soapbox/hooks'; import { userTouching } from 'soapbox/is-mobile'; import { IconButton, Portal } from '../ui'; import DropdownMenuItem, { MenuItem } from './dropdown-menu-item'; import type { Status } from 'soapbox/types/entities'; type Menu = Array; interface IDropdownMenu { children?: React.ReactElement; disabled?: boolean; items: Menu; onClose?: () => void; onOpen?: () => void; onShiftClick?: React.EventHandler; placement?: Placement; src?: string; status?: Status; title?: string; } const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const DropdownMenu = (props: IDropdownMenu) => { const { children, disabled, items, onClose, onOpen, onShiftClick, placement: initialPlacement = 'top', src = require('@tabler/icons/outline/dots.svg'), title = 'Menu', } = props; const dispatch = useAppDispatch(); const [isOpen, setIsOpen] = useState(false); const [isDisplayed, setIsDisplayed] = useState(false); const touching = userTouching.matches; const arrowRef = useRef(null); const { x, y, strategy, refs, middlewareData, placement } = useFloating({ placement: initialPlacement, middleware: [ offset(12), flip(), shift({ padding: 8, }), arrow({ element: arrowRef, }), ], }); const handleClick: React.EventHandler< React.MouseEvent | React.KeyboardEvent > = (event) => { event.stopPropagation(); if (onShiftClick && event.shiftKey) { event.preventDefault(); onShiftClick(event); return; } if (isOpen) { handleClose(); } else { handleOpen(); } }; const handleOpen = () => { dispatch(openDropdownMenu()); setIsOpen(true); if (onOpen) { onOpen(); } }; const handleClose = () => { (refs.reference.current as HTMLButtonElement)?.focus(); closeDropdownMenu(); setIsOpen(false); if (onClose) { onClose(); } }; const closeDropdownMenu = () => { dispatch((dispatch, getState) => { const isOpenRedux = getState().dropdown_menu.isOpen; if (isOpenRedux) { dispatch(closeDropdownMenuRedux()); } }); }; const handleKeyPress: React.EventHandler> = (event) => { switch (event.key) { case ' ': case 'Enter': event.stopPropagation(); event.preventDefault(); handleClick(event); break; } }; const handleDocumentClick = (event: Event) => { if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) { handleClose(); } }; const handleKeyDown = (e: KeyboardEvent) => { if (!refs.floating.current) return; const items = Array.from(refs.floating.current.querySelectorAll('a, button')); const index = items.indexOf(document.activeElement as any); let element = null; switch (e.key) { case 'ArrowDown': element = items[index + 1] || items[0]; break; case 'ArrowUp': element = items[index - 1] || items[items.length - 1]; break; case 'Tab': if (e.shiftKey) { element = items[index - 1] || items[items.length - 1]; } else { element = items[index + 1] || items[0]; } break; case 'Home': element = items[0]; break; case 'End': element = items[items.length - 1]; break; case 'Escape': handleClose(); break; } if (element) { (element as HTMLAnchorElement).focus(); e.preventDefault(); e.stopPropagation(); } }; const arrowProps: React.CSSProperties = useMemo(() => { if (middlewareData.arrow) { const { x, y } = middlewareData.arrow; const staticPlacement = { top: 'bottom', right: 'left', bottom: 'top', left: 'right', }[placement.split('-')[0]]; return { left: x !== null ? `${x}px` : '', top: y !== null ? `${y}px` : '', // Ensure the static side gets unset when // flipping to other placements' axes. right: '', bottom: '', [staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`, transform: 'rotate(45deg)', }; } return {}; }, [middlewareData.arrow, placement]); useEffect(() => () => { closeDropdownMenu(); }, []); useEffect(() => { if (isOpen) { document.addEventListener('click', handleDocumentClick, false); document.addEventListener('keydown', handleKeyDown, false); document.addEventListener('touchend', handleDocumentClick, listenerOptions); return () => { document.removeEventListener('click', handleDocumentClick); document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('touchend', handleDocumentClick); }; } }, [isOpen, refs.floating.current]); useEffect(() => { setTimeout(() => setIsDisplayed(isOpen), isOpen ? 0 : 150); }, [isOpen]); if (items.length === 0) { return null; } const autoFocus = !items.some((item) => item?.active); const getClassName = () => { const className = clsx('z-[1001] bg-white py-1 shadow-lg ease-in-out focus:outline-none black:bg-black no-reduce-motion:transition-all dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', touching ? clsx({ 'overflow-hidden fixed left-0 right-0 mx-auto w-[calc(100vw-2rem)] max-w-lg rounded-t-xl duration-200': true, 'bottom-0 opacity-100': isDisplayed && isOpen, '-bottom-32 opacity-0': !(isDisplayed && isOpen), }) : clsx({ 'rounded-md w-56 duration-100': true, 'scale-0': !(isDisplayed && isOpen), 'scale-100': isDisplayed && isOpen, 'origin-bottom': placement === 'top', 'origin-left': placement === 'right', 'origin-top': placement === 'bottom', 'origin-right': placement === 'left', 'origin-bottom-left': placement === 'top-start', 'origin-bottom-right': placement === 'top-end', 'origin-top-left': placement === 'bottom-start', 'origin-top-right': placement === 'bottom-end', })); return className; }; return ( <> {children ? ( React.cloneElement(children, { disabled, onClick: handleClick, onKeyPress: handleKeyPress, ref: refs.setReference, }) ) : ( )} {isOpen || isDisplayed ? ( {touching && (
)}
    {items.map((item, idx) => ( ))} {touching && (
  • )}
{/* Arrow */} {!touching && (
)}
) : null} ); }; export { type Menu, DropdownMenu as default };