import React, { useCallback, useEffect, ReactElement, ReactNode, useState, useContext, } from 'react'; import { usePopper } from 'react-popper'; import { Placement } from '@popperjs/core'; import { ThemeContext } from 'styled-components'; import css from '../../utils/css'; import { DropdownWrapper, DropdownPopper, ContentWrapper, } from './StyledDropdown'; import { CommonProps } from '../common'; import { useResizeObserver } from '../../utils/hooks'; import assert from '../../utils/assert'; export interface DropdownProps extends CommonProps { /** * Content of the dropdown, usually a menu. */ content: ReactNode; /** * Whether the dropdown takes up the entire line. */ display?: 'inline-block' | 'block'; /** * Whether to keep dropdown content mounted when dropdown is closed. */ keepContentMounted?: boolean; /** * Closing callback. */ onClose: () => void; /** * Whether the dropdown is open. */ open: boolean; /** * Placement of dropdown content to the target. */ placement?: 'bottom-left' | 'bottom-right'; /** * Target element that dropdown menu is relative to. */ target: ReactElement; } const PLACEMENT_MAP: { 'bottom-left': 'bottom-start'; 'bottom-right': 'bottom-end'; } = { 'bottom-left': 'bottom-start', 'bottom-right': 'bottom-end', }; const DISPLAY_PROPERTIES = ['inline-block', 'block']; const FALLBACK_PLACEMENT_MAP: { 'bottom-left': Placement[]; 'bottom-right': Placement[]; } = { 'bottom-left': ['bottom-end', 'top-start', 'top-end'], 'bottom-right': ['bottom-start', 'top-end', 'top-start'], }; const checkIfDropdownContainTarget = ( dropdownWrapperElement: HTMLDivElement | null, target: Node ): boolean | undefined => { if (dropdownWrapperElement === null) { return undefined; } return dropdownWrapperElement.contains(target); }; const Dropdown = ({ open, content, target, keepContentMounted = false, placement = 'bottom-left', display = 'inline-block', onClose, id, className, style, sx = {}, 'data-test-id': dataTestId, }: DropdownProps): ReactElement => { assert( DISPLAY_PROPERTIES.includes(display), `[Dropdown] display:${display} isn't among the supported values (block, inline-block)` ); const [wrapperElement, setWrapperElement] = useState( null ); const [dropdownElement, setDropdownElement] = useState( null ); const [popperUpdateDone, setPopperUpdateDone] = useState(false); const [dropDownMinWidth, setDropdownMinWidth] = useState(); const theme = useContext(ThemeContext); const isDropdownOpen = open && popperUpdateDone; const { styles, attributes, update, forceUpdate } = usePopper( wrapperElement, dropdownElement, { placement: PLACEMENT_MAP[placement], strategy: 'fixed', modifiers: [ { name: 'offset', options: { offset: [0, theme.space.dropdown.margin], }, }, { name: 'flip', options: { fallbackPlacements: FALLBACK_PLACEMENT_MAP[placement], }, }, { name: 'computeStyles', options: { adaptive: false, gpuAcceleration: false, }, }, ], } ); const clickOutside = useCallback( e => { const clickInWrapper = checkIfDropdownContainTarget( wrapperElement, e.target ); if (clickInWrapper === false) { onClose(); } }, [onClose, wrapperElement] ); const wrapperResizeCallback = useCallback( ({ width }) => { setDropdownMinWidth(width); if (forceUpdate !== null && open === true) forceUpdate(); }, [setDropdownMinWidth, forceUpdate, open] ); const dropdownResizeCallback = useCallback(() => { if (forceUpdate !== null && open === true) forceUpdate(); }, [forceUpdate, open]); useEffect(() => { if (update !== null && open === true) { update().then(state => { if (state !== undefined) { setPopperUpdateDone(true); } }); } }, [open, update]); useEffect(() => { if (open === true) { document.addEventListener('click', clickOutside, true); } return (): void => { document.removeEventListener('click', clickOutside, true); }; }, [open, clickOutside]); useResizeObserver(wrapperResizeCallback, wrapperElement); useResizeObserver(dropdownResizeCallback, dropdownElement); return ( {target} {(open === true || keepContentMounted === true) && ( {content} )} ); }; export default Dropdown;