import { useState, useEffect, useRef, useCallback, cloneElement, Children, useId, } 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, Placement, } from 'react-overlays/esm/usePopper'; import { fetchRelationships } from 'flavours/glitch/actions/accounts'; import { openDropdownMenu, closeDropdownMenu, } from 'flavours/glitch/actions/dropdown_menu'; import { openModal, closeModal } from 'flavours/glitch/actions/modal'; import { fetchStatus } from 'flavours/glitch/actions/statuses'; import { CircularProgress } from 'flavours/glitch/components/circular_progress'; import { isUserTouching } from 'flavours/glitch/is_mobile'; import { isMenuItem, isActionItem, isExternalLinkItem, } from 'flavours/glitch/models/dropdown_menu'; import type { MenuItem } from 'flavours/glitch/models/dropdown_menu'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { Icon } from './icon'; import type { IconProp } from './icon'; import { IconButton } from './icon_button'; let id = 0; export type RenderItemFn = ( item: Item, index: number, onClick: React.MouseEventHandler, ) => 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 DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ item, }) => { if (item === null) { return null; } const { text, description, icon } = item; return ( <> {icon && } {text} {Boolean(description) && ( {description} )} ); }; export const DropdownMenu = ({ items, loading, scrollable, onClose, openedViaKeyboard, renderItem, renderHeader, onItemClick, }: DropdownMenuProps) => { const nodeRef = 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 (openedViaKeyboard) { const firstMenuItem = nodeRef.current?.querySelector< HTMLAnchorElement | HTMLButtonElement >('li:first-child > :is(a, button)'); firstMenuItem?.focus({ preventScroll: true }); } return () => { document.removeEventListener('click', handleDocumentClick, { capture: true, }); document.removeEventListener('keydown', handleKeyDown, { capture: true }); }; }, [onClose, openedViaKeyboard]); const handleItemClick = useCallback( (e: React.MouseEvent | React.KeyboardEvent) => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = items?.[i]; const isItemDisabled = Boolean( item && typeof item === 'object' && 'disabled' in item && item.disabled, ); if (!item || isItemDisabled) { return; } onClose(); if (typeof onItemClick === 'function') { e.preventDefault(); onItemClick(item, i); } else if (isActionItem(item)) { e.preventDefault(); item.action(e); } }, [onClose, onItemClick, items], ); const nativeRenderItem = (option: Item, i: number) => { if (!isMenuItem(option)) { return null; } if (option === null) { return
  • ; } const { text, highlighted, disabled, dangerous } = option; let element: React.ReactElement; if (isActionItem(option)) { element = ( ); } else if (isExternalLinkItem(option)) { element = ( ); } else { element = ( ); } return (
  • {element}
  • ); }; const renderItemMethod = renderItem ?? nativeRenderItem; return (
    {(loading || !items) && } {!loading && renderHeader && items && (
    {renderHeader(items)}
    )} {!loading && items && (
      {items.map((option, i) => renderItemMethod(option, i, handleItemClick), )}
    )}
    ); }; interface DropdownProps { children?: React.ReactElement; icon?: string; iconComponent?: IconProp; items?: Item[]; loading?: boolean; title?: string; disabled?: boolean; scrollable?: boolean; placement?: Placement; offset?: OffsetValue; /** * Prevent the `ScrollableList` with this scrollKey * from being scrolled while the dropdown is open */ scrollKey?: string; status?: ImmutableMap; needsStatusRefresh?: boolean; forceDropdown?: boolean; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: // Must use a union type for the full function as a union with void is not allowed. | ((event: React.MouseEvent | React.KeyboardEvent) => void) | ((event: React.MouseEvent | React.KeyboardEvent) => boolean); onItemClick?: ItemClickFn; } const popperConfig = { strategy: 'fixed' } as UsePopperOptions; export const Dropdown = ({ children, icon, iconComponent, items, loading, title = 'Menu', disabled, scrollable, placement = 'bottom', offset = [5, 5], status, needsStatusRefresh, forceDropdown = false, 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 buttonRef = useRef(null); const menuId = useId(); const prefetchAccountId = status ? status.getIn(['account', 'id']) : undefined; const statusId = status?.get('id') as string | undefined; const handleClose = useCallback(() => { if (buttonRef.current) { buttonRef.current.focus({ preventScroll: true }); } dispatch( closeModal({ modalType: 'ACTIONS', ignoreFocus: false, }), ); dispatch(closeDropdownMenu({ id: currentId })); }, [dispatch, currentId]); const handleItemClick = useCallback( (e: React.MouseEvent) => { 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(e); } }, [handleClose, onItemClick, items], ); const isKeypressRef = useRef(false); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { isKeypressRef.current = true; } }, []); const unsetIsKeypress = useCallback(() => { isKeypressRef.current = false; }, []); const toggleDropdown = useCallback( (e: React.MouseEvent) => { if (open) { handleClose(); } else { const allow = onOpen?.(e); if (allow === false) { return; } if (prefetchAccountId) { dispatch(fetchRelationships([prefetchAccountId])); } if (needsStatusRefresh && statusId) { dispatch( fetchStatus(statusId, { forceFetch: true, alsoFetchContext: false, }), ); } if (isUserTouching() && !forceDropdown) { dispatch( openModal({ modalType: 'ACTIONS', modalProps: { actions: items, onClick: handleItemClick, }, }), ); } else { dispatch( openDropdownMenu({ id: currentId, keyboard: isKeypressRef.current, scrollKey, }), ); isKeypressRef.current = false; } } }, [ dispatch, currentId, prefetchAccountId, scrollKey, onOpen, handleItemClick, open, items, forceDropdown, handleClose, statusId, needsStatusRefresh, ], ); useEffect(() => { return () => { if (currentId === openDropdownId) { handleClose(); } }; }, [currentId, openDropdownId, handleClose]); let button: React.ReactElement; const buttonProps = { disabled, onClick: toggleDropdown, onKeyDown: handleKeyDown, onKeyUp: unsetIsKeypress, onBlur: unsetIsKeypress, 'aria-expanded': open, 'aria-controls': menuId, ref: buttonRef, }; if (children) { button = cloneElement(Children.only(children), buttonProps); } else if (icon && iconComponent) { button = ( ); } else { return null; } return ( <> {button} {({ props, arrowProps, placement }) => (
    )} ); };