import classNames from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; import React from 'react'; import { spring } from 'react-motion'; // @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions import Overlay from 'react-overlays/lib/Overlay'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { Counter, IconButton } from 'soapbox/components/ui'; import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import Motion from 'soapbox/features/ui/util/optional-motion'; import type { Status } from 'soapbox/types/entities'; const listenerOptions = supportsPassiveEvents ? { passive: true } : false; let id = 0; export interface MenuItem { action?: React.EventHandler, middleClick?: React.EventHandler, text: string, href?: string, to?: string, newTab?: boolean, isLogout?: boolean, icon?: string, count?: number, destructive?: boolean, meta?: string, active?: boolean, } export type Menu = Array; interface IDropdownMenu extends RouteComponentProps { items: Menu, onClose: () => void, style?: React.CSSProperties, placement?: DropdownPlacement, arrowOffsetLeft?: string, arrowOffsetTop?: string, openedViaKeyboard: boolean, } interface IDropdownMenuState { mounted: boolean, } class DropdownMenu extends React.PureComponent { static defaultProps: Partial = { style: {}, placement: 'bottom', }; state = { mounted: false, }; node: HTMLDivElement | null = null; focusedItem: HTMLAnchorElement | null = null; handleDocumentClick = (e: Event) => { if (this.node && !this.node.contains(e.target as Node)) { this.props.onClose(); } }; componentDidMount() { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); if (this.focusedItem && this.props.openedViaKeyboard) { this.focusedItem.focus({ preventScroll: true }); } this.setState({ mounted: true }); } componentWillUnmount() { document.removeEventListener('click', this.handleDocumentClick); document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('touchend', this.handleDocumentClick); } setRef: React.RefCallback = c => { this.node = c; }; setFocusRef: React.RefCallback = c => { this.focusedItem = c; }; handleKeyDown = (e: KeyboardEvent) => { if (!this.node) return; const items = Array.from(this.node.getElementsByTagName('a')); 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': this.props.onClose(); break; } if (element) { element.focus(); e.preventDefault(); e.stopPropagation(); } }; handleItemKeyPress: React.EventHandler = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } }; handleClick: React.EventHandler = e => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = this.props.items[i]; if (!item) return; const { action, to } = item; this.props.onClose(); e.stopPropagation(); if (to) { e.preventDefault(); this.props.history.push(to); } else if (typeof action === 'function') { e.preventDefault(); action(e); } }; handleMiddleClick: React.EventHandler = e => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = this.props.items[i]; if (!item) return; const { middleClick } = item; this.props.onClose(); if (e.button === 1 && typeof middleClick === 'function') { e.preventDefault(); middleClick(e); } }; handleAuxClick: React.EventHandler = e => { if (e.button === 1) { this.handleMiddleClick(e); } }; renderItem(option: MenuItem | null, i: number): JSX.Element { if (option === null) { return
  • ; } const { text, href, to, newTab, isLogout, icon, count, destructive } = option; return (
  • {icon && } {text} {count ? ( ) : null}
  • ); } render() { const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; const { mounted } = this.state; return ( {({ opacity, scaleX, scaleY }) => ( // It should not be transformed when mounting because the resulting // size will be used to determine the coordinate of the menu by // react-overlays
      {items.map((option, i) => this.renderItem(option, i))}
    )} ); } } const RouterDropdownMenu = withRouter(DropdownMenu); export interface IDropdown extends RouteComponentProps { icon?: string, src?: string, items: Menu, size?: number, active?: boolean, pressed?: boolean, title?: string, disabled?: boolean, status?: Status, isUserTouching?: () => boolean, isModalOpen?: boolean, onOpen?: ( id: number, onItemClick: React.EventHandler, dropdownPlacement: DropdownPlacement, keyboard: boolean, ) => void, onClose?: (id: number) => void, dropdownPlacement?: string, openDropdownId?: number | null, openedViaKeyboard?: boolean, text?: string, onShiftClick?: React.EventHandler, children?: JSX.Element, dropdownMenuStyle?: React.CSSProperties, } interface IDropdownState { id: number, open: boolean, } export type DropdownPlacement = 'top' | 'bottom'; class Dropdown extends React.PureComponent { static defaultProps: Partial = { title: 'Menu', }; state = { id: id++, open: false, }; target: HTMLButtonElement | null = null; activeElement: Element | null = null; handleClick: React.EventHandler | React.KeyboardEvent> = e => { const { onOpen, onShiftClick, openDropdownId } = this.props; e.stopPropagation(); if (onShiftClick && e.shiftKey) { e.preventDefault(); onShiftClick(e); } else if (this.state.id === openDropdownId) { this.handleClose(); } else if (onOpen) { const { top } = e.currentTarget.getBoundingClientRect(); const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top'; onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click'); } }; handleClose = () => { if (this.activeElement && this.activeElement === this.target) { (this.activeElement as HTMLButtonElement).focus(); this.activeElement = null; } if (this.props.onClose) { this.props.onClose(this.state.id); } }; handleMouseDown: React.EventHandler = () => { if (!this.state.open) { this.activeElement = document.activeElement; } }; handleButtonKeyDown: React.EventHandler = (e) => { switch (e.key) { case ' ': case 'Enter': this.handleMouseDown(e); break; } }; handleKeyPress: React.EventHandler> = (e) => { switch (e.key) { case ' ': case 'Enter': this.handleClick(e); e.stopPropagation(); e.preventDefault(); break; } }; handleItemClick: React.EventHandler = e => { const i = Number(e.currentTarget.getAttribute('data-index')); const item = this.props.items[i]; if (!item) return; const { action, to } = item; this.handleClose(); e.preventDefault(); e.stopPropagation(); if (typeof action === 'function') { action(e); } else if (to) { this.props.history?.push(to); } }; setTargetRef: React.RefCallback = c => { this.target = c; }; findTarget = () => { return this.target; }; componentWillUnmount = () => { if (this.state.id === this.props.openDropdownId) { this.handleClose(); } }; render() { const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props; const open = this.state.id === openDropdownId; return ( <> {children ? ( React.cloneElement(children, { disabled, onClick: this.handleClick, onMouseDown: this.handleMouseDown, onKeyDown: this.handleButtonKeyDown, onKeyPress: this.handleKeyPress, ref: this.setTargetRef, }) ) : ( )} ); } } export default withRouter(Dropdown);