import { createContext, forwardRef, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import FocusLock from 'react-focus-lock' import { SpringValue, SpringConfig } from 'react-spring' import { useKey } from 'react-use' import { useTaply, TapState } from './taply' import { StyleProps, StyleMap, useStyles } from './styles' import { OpenAnimation, AnimationFunction, animationFunctions } from './animation' import { Layer } from './layers' import { Popup } from './popup' import { defaultPlacement, oppositeSides, PopupPlacement, PopupSide } from './PopupController' import { DescendantsManager, useDescendant, useDescendants } from './utils/descendants' import mergeRefs from './utils/mergeRefs' import configs from './utils/springConfigs' import useAnimatedValue from './utils/useAnimatedValue' import useControlledState from './utils/useControlledState' import useViewport from './utils/useViewport' import scrollIntoView from './utils/scrollIntoView' import useMeasureLazy from './utils/useMeasureLazy' export interface MenuProps extends StyleProps<[MenuProps]> { /** Content of the dropdown menu */ menu: (props: MenuRenderProps) => React.ReactNode /** Trigger element that menu will be attached to */ children: (ref: any, props: MenuRenderProps) => React.ReactNode /** Placement of the menu relative to the target */ placement?: Partial /** Function that is called when `` is selected */ onSelect?: (value?: string) => void /** Whether the menu should close when an item is selected */ closeOnSelect?: boolean /** Component for hide and show animation */ animation?: AnimationFunction /** Maximum height of the list, in px. */ maxHeight?: number /** Select first item on open */ autoSelectFirstItem?: boolean /** If `true`, menu width will match the width of the button element. */ matchWidth?: boolean /** Config for `react-spring` animation */ springConfig?: SpringConfig } export interface MenuListProps extends StyleProps<[MenuListProps]> { children: React.ReactNode focusLock: boolean onFocus?: () => void onBlur?: () => void onClose: () => void onSelect?: (value?: string) => void autoSelectFirstItem: boolean closeOnSelect?: boolean } export interface MenuItemProps extends StyleProps<[MenuItemProps, { isSelected: boolean }]> { /** Value of the item that will be passed to the `onSelect()` handler of * the Menu */ value?: string /** * Handler that is called when the item is selected by clicking on it or * pressing the `Enter` key */ onSelect?: () => void isDisabled?: boolean onHover?: () => void onBlur?: () => void children: React.ReactNode | ((isSelected: boolean) => React.ReactNode) } interface MenuDescendantProps { isDisabled?: boolean onSelect?: () => void value?: string } interface MenuContextProps { descendants: DescendantsManager selectedIndex: number setSelectedIndex: (index: number) => void onSelect: (index: number) => void } const MenuContext = createContext(undefined) const MenuItem = forwardRef((props, ref) => { const { isDisabled, onSelect, value, children, onHover, onBlur } = props const menuContext = useContext(MenuContext) if (!menuContext) { throw new Error('MenuItem can be used only inside Menu or MenuList') } const { descendants, selectedIndex, setSelectedIndex, onSelect: menuOnSelect } = menuContext const { ref: descendantRef, index } = useDescendant(descendants, { isDisabled, onSelect, value }) const isSelected = index !== -1 && index === selectedIndex useEffect(() => { if (isSelected) { if (onHover) onHover() } else { if (onBlur) onBlur() } }, [isSelected]) const onChangeTapState = useCallback( (tapState: TapState) => { if (tapState.isHovered) setSelectedIndex(index) }, [index] ) const styles = useStyles(undefined, [props, { isSelected }]) const { tapState, render } = useTaply({ onChangeTapState, onClick: () => menuOnSelect(index), isDisabled, isFocusable: false }) return render((attrs, taplyRef) => (
{typeof children === 'function' ? children(isSelected) : children}
)) }) const getNextIndex = (index: number, length: number) => index < length - 1 ? index + 1 : 0 const getPrevIndex = (index: number, length: number) => index > 0 ? index - 1 : length - 1 const MenuList = forwardRef((props: MenuListProps, ref) => { const { children, onSelect, autoSelectFirstItem, onClose, closeOnSelect, focusLock } = props const descendants = useDescendants() const [selectedIndex, setSelectedIndex] = useState(-1) useEffect(() => { if (autoSelectFirstItem) { setTimeout(() => setSelectedIndex( descendants.items.findIndex( (item) => !item.props.isDisabled ) ) ) // TODO clear timeout just in case } }, []) useKey('Escape', onClose) const select = useCallback( (index: number) => { const { onSelect: itemOnSelect, value } = descendants.items[index].props if (itemOnSelect) itemOnSelect() if (onSelect && value !== undefined) onSelect(value) if (closeOnSelect) onClose() }, [onSelect, descendants] ) const context = { descendants, selectedIndex, setSelectedIndex, onSelect: select } const styles = useStyles(undefined, [props]) const containerRef = useRef() const onKeyDown = useCallback( (event: React.KeyboardEvent) => { const selectableDescendants = descendants.items.filter( (item) => !item.props.isDisabled ) const mapIndexFromSelectable = (index: number) => descendants.items.findIndex( (item) => item === selectableDescendants[index] ) const selectableIndex = selectableDescendants.findIndex( (item) => item === descendants.items[selectedIndex] ) const selectItem = (index: number) => { setSelectedIndex(index) const item = descendants.items[index]?.element scrollIntoView(item) } const handlers: { [key: string]: (e: React.KeyboardEvent) => void } = { ArrowDown: () => { const nextIndex = mapIndexFromSelectable( getNextIndex( selectableIndex, selectableDescendants.length ) ) selectItem(nextIndex) }, ArrowUp: () => { const prevIndex = mapIndexFromSelectable( getPrevIndex( selectableIndex, selectableDescendants.length ) ) selectItem(prevIndex) }, Home: () => selectItem(mapIndexFromSelectable(0)), End: () => selectItem( mapIndexFromSelectable(selectableDescendants.length - 1) ), Enter: () => select(selectedIndex) } const handler = handlers[event.key] if (handler) { event.preventDefault() handler(event) } }, [selectedIndex] ) return (
{children}
) }) const menuStyles = (props: MenuProps, isOpen: boolean): StyleMap => ({ overlay: { background: 'transparent', position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', userSelect: 'none', pointerEvents: isOpen ? 'all' : 'none' }, list: { background: 'white', overflowY: 'auto' } }) interface MenuPopupProps { renderProps: MenuRenderProps menuProps: MenuProps & typeof menuDefaultProps styles: { [key: string]: React.CSSProperties } } const MenuPopup = forwardRef( (props: MenuPopupProps, ref) => { const { renderProps, menuProps, styles } = props const { isOpen, isActive, open, close, openValue, side, triggerWidth } = renderProps const { maxHeight, placement, onSelect, closeOnSelect, matchWidth, menu, autoSelectFirstItem, animation } = menuProps const viewport = useViewport() const [contrainedMaxHeight, setConstrainedMaxHeight] = useState(0) useLayoutEffect(() => { if (!isActive) return let availableHeight = viewport.height - 2 * (placement?.padding || 0) if (maxHeight !== undefined) { availableHeight = Math.min(availableHeight, maxHeight) } setConstrainedMaxHeight(availableHeight) }, [isActive, maxHeight, placement, viewport.height]) return ( {menu(renderProps)} ) } ) export type MenuRenderProps = { isOpen: boolean isActive: boolean open: () => void close: () => void side: PopupSide triggerWidth: number openValue: SpringValue } const menuDefaultPlacement: Partial = { ...defaultPlacement, constrain: true, padding: 16 } const menuDefaultProps = { closeOnSelect: true, placement: menuDefaultPlacement, animation: animationFunctions.compose([ animationFunctions.slide, animationFunctions.fade ]), springConfig: configs.stiff as SpringConfig, autoSelectFirstItem: true } const Menu = (inProps: MenuProps) => { const props = { ...menuDefaultProps, ...inProps } const { children, placement, springConfig, matchWidth } = props const [isOpen, setIsOpen] = useControlledState(props, 'isOpen', false) const [openValue, isRest] = useAnimatedValue(isOpen ? 1 : 0, { config: springConfig }) const [side, setSide] = useState('top') const isActive = isOpen || !isRest const styles = useStyles(menuStyles, [props, isOpen]) const triggerRef = useRef(null) const open = useCallback(() => setIsOpen(true), []) const close = useCallback(() => { setIsOpen(false) setTimeout(() => triggerRef.current?.focus?.()) }, []) const [measureRef, { width }] = useMeasureLazy({ isEnabled: isActive && matchWidth }) const renderProps: MenuRenderProps = { isOpen, isActive, open, close, openValue, side, triggerWidth: width } const popup = (ref: React.Ref) => ( ) return ( <> {isActive && (
e.preventDefault()} style={styles.overlay} /> )} {(ref) => children( mergeRefs(ref, triggerRef, measureRef), renderProps ) } ) } export { Menu, MenuList, MenuItem }