import { useState, useEffect, useRef, useCallback, cloneElement, Children, } from 'react'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; import type { Map as ImmutableMap } from 'immutable'; import Overlay from 'react-overlays/Overlay'; import type { OffsetValue, UsePopperOptions, } from 'react-overlays/esm/usePopper'; import { fetchRelationships } from 'mastodon/actions/accounts'; import { openDropdownMenu, closeDropdownMenu, } from 'mastodon/actions/dropdown_menu'; import { openModal, closeModal } from 'mastodon/actions/modal'; import { CircularProgress } from 'mastodon/components/circular_progress'; import { isUserTouching } from 'mastodon/is_mobile'; import type { MenuItem, ActionMenuItem, ExternalLinkMenuItem, } from 'mastodon/models/dropdown_menu'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import type { IconProp } from './icon'; import { IconButton } from './icon_button'; let id = 0; const isMenuItem = (item: unknown): item is MenuItem => { if (item === null) { return true; } return typeof item === 'object' && 'text' in item; }; const isActionItem = (item: unknown): item is ActionMenuItem => { if (!item || !isMenuItem(item)) { return false; } return 'action' in item; }; const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => { if (!item || !isMenuItem(item)) { return false; } return 'href' in item; }; type RenderItemFn = ( item: Item, index: number, handlers: { onClick: (e: React.MouseEvent) => void; onKeyUp: (e: React.KeyboardEvent) => void; }, ) => React.ReactNode; type ItemClickFn = (item: Item, index: number) => void; type RenderHeaderFn = (items: Item[]) => React.ReactNode; interface DropdownMenuProps { items?: Item[]; loading?: boolean; scrollable?: boolean; onClose: () => void; openedViaKeyboard: boolean; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onItemClick?: ItemClickFn; } export const DropdownMenu = ({ items, loading, scrollable, onClose, openedViaKeyboard, renderItem, renderHeader, onItemClick, }: DropdownMenuProps) => { const nodeRef = useRef(null); const focusedItemRef = useRef(null); useEffect(() => { const handleDocumentClick = (e: MouseEvent) => { if ( e.target instanceof Node && nodeRef.current && !nodeRef.current.contains(e.target) ) { onClose(); e.stopPropagation(); e.preventDefault(); } }; const handleKeyDown = (e: KeyboardEvent) => { if (!nodeRef.current) { return; } const items = Array.from(nodeRef.current.querySelectorAll('a, button')); const index = document.activeElement ? items.indexOf(document.activeElement) : -1; let element: Element | undefined; 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': onClose(); break; } if (element && element instanceof HTMLElement) { element.focus(); e.preventDefault(); e.stopPropagation(); } }; document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('keydown', handleKeyDown, { capture: true }); if (focusedItemRef.current && openedViaKeyboard) { focusedItemRef.current.focus({ preventScroll: true }); } return () => { document.removeEventListener('click', handleDocumentClick, { capture: true, }); document.removeEventListener('keydown', handleKeyDown, { capture: true }); }; }, [onClose, openedViaKeyboard]); const handleFocusedItemRef = useCallback( (c: HTMLAnchorElement | HTMLButtonElement | null) => { focusedItemRef.current = c as HTMLElement; }, [], ); const handleItemClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = items?.[i]; onClose(); if (!item) { return; } if (typeof onItemClick === 'function') { e.preventDefault(); onItemClick(item, i); } else if (isActionItem(item)) { e.preventDefault(); item.action(); } }, [onClose, onItemClick, items], ); const handleItemKeyUp = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { handleItemClick(e); } }, [handleItemClick], ); const nativeRenderItem = (option: Item, i: number) => { if (!isMenuItem(option)) { return null; } if (option === null) { return
  • ; } const { text, dangerous } = option; let element: React.ReactElement; if (isActionItem(option)) { element = ( ); } else if (isExternalLinkItem(option)) { element = ( {text} ); } else { element = ( {text} ); } return (
  • {element}
  • ); }; const renderItemMethod = renderItem ?? nativeRenderItem; return (
    {(loading || !items) && } {!loading && renderHeader && items && (
    {renderHeader(items)}
    )} {!loading && items && (
      {items.map((option, i) => renderItemMethod(option, i, { onClick: handleItemClick, onKeyUp: handleItemKeyUp, }), )}
    )}
    ); }; interface DropdownProps { children?: React.ReactElement; icon?: string; iconComponent?: IconProp; items?: Item[]; loading?: boolean; title?: string; disabled?: boolean; scrollable?: boolean; active?: boolean; scrollKey?: string; status?: ImmutableMap; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: () => void; onItemClick?: ItemClickFn; } const offset = [5, 5] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; export const Dropdown = ({ children, icon, iconComponent, items, loading, title = 'Menu', disabled, scrollable, active, status, renderItem, renderHeader, onOpen, onItemClick, scrollKey, }: DropdownProps) => { const dispatch = useAppDispatch(); const openDropdownId = useAppSelector((state) => state.dropdownMenu.openId); const openedViaKeyboard = useAppSelector( (state) => state.dropdownMenu.keyboard, ); const [currentId] = useState(id++); const open = currentId === openDropdownId; const activeElement = useRef(null); const targetRef = useRef(null); const handleClose = useCallback(() => { if (activeElement.current) { activeElement.current.focus({ preventScroll: true }); activeElement.current = null; } dispatch( closeModal({ modalType: 'ACTIONS', ignoreFocus: false, }), ); dispatch(closeDropdownMenu({ id: currentId })); }, [dispatch, currentId]); const handleItemClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = items?.[i]; handleClose(); if (!item) { return; } if (typeof onItemClick === 'function') { e.preventDefault(); onItemClick(item, i); } else if (isActionItem(item)) { e.preventDefault(); item.action(); } }, [handleClose, onItemClick, items], ); const handleClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const { type } = e; if (open) { handleClose(); } else { onOpen?.(); if (status) { dispatch(fetchRelationships([status.getIn(['account', 'id'])])); } if (isUserTouching()) { dispatch( openModal({ modalType: 'ACTIONS', modalProps: { status, actions: items, onClick: handleItemClick, }, }), ); } else { dispatch( openDropdownMenu({ id: currentId, keyboard: type !== 'click', scrollKey, }), ); } } }, [ dispatch, currentId, scrollKey, onOpen, handleItemClick, open, status, items, handleClose, ], ); const handleMouseDown = useCallback(() => { if (!open && document.activeElement instanceof HTMLElement) { activeElement.current = document.activeElement; } }, [open]); const handleButtonKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case ' ': case 'Enter': handleMouseDown(); break; } }, [handleMouseDown], ); const handleKeyPress = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case ' ': case 'Enter': handleClick(e); e.stopPropagation(); e.preventDefault(); break; } }, [handleClick], ); useEffect(() => { return () => { if (currentId === openDropdownId) { handleClose(); } }; }, [currentId, openDropdownId, handleClose]); let button: React.ReactElement; if (children) { button = cloneElement(Children.only(children), { onClick: handleClick, onMouseDown: handleMouseDown, onKeyDown: handleButtonKeyDown, onKeyPress: handleKeyPress, ref: targetRef, }); } else if (icon && iconComponent) { button = ( ); } else { return null; } return ( <> {button} {({ props, arrowProps, placement }) => (
    )} ); };