import matches from 'dom-helpers/matches' ; import qsa from 'dom-helpers/querySelectorAll' ; import addEventListener from 'dom-helpers/addEventListener' ; import React , { useCallback , useRef , useEffect , useMemo } from 'react' ; import PropTypes from 'prop-types' ; import { useUncontrolledProp } from 'uncontrollable' ; import usePrevious from '@restart/hooks/usePrevious' ; import useForceUpdate from '@restart/hooks/useForceUpdate' ; import useGlobalListener from '@restart/hooks/useGlobalListener' ; import useEventCallback from '@restart/hooks/useEventCallback' ; import DropdownContext , { DropDirection } from './DropdownContext' ; import DropdownMenu from './DropdownMenu' ; import DropdownToggle from './DropdownToggle' ; const propTypes = { type { Function children : PropTypes . node , /**
     * Determines the direction and location of the Menu in relation to it's Toggle.
     */ drop : PropTypes . oneOf ( [ 'up' , 'left' , 'right' , 'down' ] ) , /**
     * Controls the focus behavior for when the Dropdown is opened. Set to
     * `true` to always focus the first menu item, `keyboard` to focus only when
     * navigating via the keyboard, or `false` to disable completely
     *
     * The Default behavior is `false` **unless** the Menu has a `role="menu"`
     * where it will default to `keyboard` to match the recommended [ARIA Authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton).
     */ focusFirstItemOnShow : PropTypes . oneOf ( [ false , true , 'keyboard' ] ) , /**
     * A css slector string that will return __focusable__ menu items.
     * Selectors should be relative to the menu component:
     * e.g. ` > li:not('.disabled')`
     */ itemSelector : PropTypes . string , /**
     * Align the menu to the 'end' side of the placement side of the Dropdown toggle. The default placement is `top-start` or `bottom-start`.
     */ alignEnd : PropTypes . bool , controllable show : PropTypes . bool , /**
     * Sets the initial show position of the Dropdown.
     */ defaultShow : PropTypes . bool , controllable onToggle : PropTypes . func , } ; export interface DropdownInjectedProps { onKeyDown : React . KeyboardEventHandler ; } export interface DropdownProps { drop ? : DropDirection ; alignEnd ? : boolean ; defaultShow ? : boolean ; show ? : boolean ; onToggle : ( nextShow : boolean , event ? : React . SyntheticEvent | Event ) => void ; itemSelector ? : string ; focusFirstItemOnShow ? : false | true | 'keyboard' ; children : React . ReactNode ; } function useRefWithUpdate ( ) { const forceUpdate = useForceUpdate ( ) ; const ref = useRef < HTMLElement | null > ( null ) ; const attachRef = useCallback ( ( element ) => { ref . current = element ; forceUpdate ( ) ; } , [ forceUpdate ] ) ; return [ ref , attachRef ] as const ; } displayName public function Dropdown ( { drop , alignEnd , defaultShow , show : rawShow , onToggle : rawOnToggle , itemSelector = '* > *' , focusFirstItemOnShow , children , } ) { const [ show , onToggle ] = useUncontrolledProp ( rawShow , defaultShow ! , rawOnToggle ) ; const [ menuRef , setMenu ] = useRefWithUpdate ( ) ; const menuElement = menuRef . current ; const [ toggleRef , setToggle ] = useRefWithUpdate ( ) ; const toggleElement = toggleRef . current ; const lastShow = usePrevious ( show ) ; const lastSourceEvent = useRef < string | null > ( null ) ; const focusInDropdown = useRef ( false ) ; const toggle = useCallback ( ( nextShow , event ? ) => { onToggle ( nextShow , event ) ; } , [ onToggle ] ) ; const context = useMemo ( ( ) => ( { toggle , drop , show , alignEnd , menuElement , toggleElement , setMenu , setToggle , } ) , [ toggle , drop , show , alignEnd , menuElement , toggleElement , setMenu , setToggle , ] ) ; if ( menuElement && lastShow && ! show ) { focusInDropdown . current = menuElement . contains ( document . activeElement ) ; } const focusToggle = useEventCallback ( ( ) => { if ( toggleElement && toggleElement . focus ) { toggleElement . focus ( ) ; } } ) ; const maybeFocusFirst = useEventCallback ( ( ) => { const type = lastSourceEvent . current ; let focusType = focusFirstItemOnShow ; if ( focusType == null ) { focusType = menuRef . current && matches ( menuRef . current , '[role=menu]' ) ? 'keyboard' : false ; } if ( focusType === false || ( focusType === 'keyboard' && ! /^key.+$/ . test ( type ! ) ) ) { return ; } const first = qsa ( menuRef . current ! , itemSelector ) [ 0 ] ; if ( first && first . focus ) first . focus ( ) ; } ) ; useEffect ( ( ) => { if ( show ) maybeFocusFirst ( ) ; else if ( focusInDropdown . current ) { focusInDropdown . current = false ; focusToggle ( ) ; } } , [ show , focusInDropdown , focusToggle , maybeFocusFirst ] ) ; useEffect ( ( ) => { lastSourceEvent . current = null ; } ) ; const getNextFocusedChild = ( current , offset ) => { if ( ! menuRef . current ) return null ; const items = qsa ( menuRef . current , itemSelector ) ; let index = items . indexOf ( current ) + offset ; index = Math . max ( 0 , Math . min ( index , items . length ) ) ; return items [ index ] ; } ; useGlobalListener ( 'keydown' , ( event ) => { const { key } = event ; const target = event . target as HTMLElement ; const fromMenu = menuRef . current ?. contains ( target ) ; const fromToggle = toggleRef . current ?. contains ( target ) ; const isInput = /input|textarea/i . test ( target . tagName ) ; if ( isInput && ( key === ' ' || ( key !== 'Escape' && fromMenu ) ) ) { return ; } if ( ! fromMenu && ! fromToggle ) { return ; } if ( ! menuRef . current && key === 'Tab' ) { return ; } lastSourceEvent . current = event . type ; switch ( key ) { case 'ArrowUp' : { const next = getNextFocusedChild ( target , - 1 ) ; if ( next && next . focus ) next . focus ( ) ; event . preventDefault ( ) ; return ; } case 'ArrowDown' : event . preventDefault ( ) ; if ( ! show ) { onToggle ( true , event ) ; } else { const next = getNextFocusedChild ( target , 1 ) ; if ( next && next . focus ) next . focus ( ) ; } return ; case 'Tab' : addEventListener ( document as any , 'keyup' , ( e ) => { if ( ( e . key === 'Tab' && ! e . target ) || ! menuRef . current ?. contains ( e . target as HTMLElement ) ) { onToggle ( false , event ) ; } } , { once : true } ) ; break ; case 'Escape' : event . preventDefault ( ) ; event . stopPropagation ( ) ; onToggle ( false , event ) ; break ; default : } } ) ; return ( < DropdownContext . Provider value = { context } > { children } < /DropdownContext.Provider>); } Dropdown . displayName = 'ReactOverlaysDropdown' ; Dropdown . propTypes = propTypes ; Dropdown . Menu = DropdownMenu ; Dropdown . Toggle = DropdownToggle ; export default Dropdown ;