// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#menubutton import * as React from 'react' import { Props } from '../../types' import { match } from '../../utils/match' import { forwardRefWithAs, render } from '../../utils/render' import { disposables } from '../../utils/disposables' import { useDisposables } from '../../hooks/use-disposables' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useId } from '../../hooks/use-id' import { Keys } from '../keyboard' enum MenuStates { Open, Closed, } type MenuItemDataRef = React.MutableRefObject<{ textValue?: string; disabled: boolean }> type StateDefinition = { menuState: MenuStates buttonRef: React.MutableRefObject itemsRef: React.MutableRefObject items: { id: string; dataRef: MenuItemDataRef }[] searchQuery: string activeItemIndex: number | null } enum ActionTypes { OpenMenu, CloseMenu, GoToItem, Search, ClearSearch, RegisterItem, UnregisterItem, } enum Focus { FirstItem, PreviousItem, NextItem, LastItem, SpecificItem, Nothing, } function calculateActiveItemIndex( state: StateDefinition, focus: Focus, id?: string ): StateDefinition['activeItemIndex'] { if (state.items.length <= 0) return null const items = state.items const activeItemIndex = state.activeItemIndex ?? -1 const nextActiveIndex = match(focus, { [Focus.FirstItem]: () => items.findIndex(item => !item.dataRef.current.disabled), [Focus.PreviousItem]: () => { const idx = items .slice() .reverse() .findIndex((item, idx, all) => { if (activeItemIndex !== -1 && all.length - idx - 1 >= activeItemIndex) return false return !item.dataRef.current.disabled }) if (idx === -1) return idx return items.length - 1 - idx }, [Focus.NextItem]: () => { return items.findIndex((item, idx) => { if (idx <= activeItemIndex) return false return !item.dataRef.current.disabled }) }, [Focus.LastItem]: () => { const idx = items .slice() .reverse() .findIndex(item => !item.dataRef.current.disabled) if (idx === -1) return idx return items.length - 1 - idx }, [Focus.SpecificItem]: () => items.findIndex(item => item.id === id), [Focus.Nothing]: () => null, }) if (nextActiveIndex === -1) return state.activeItemIndex return nextActiveIndex } type Actions = | { type: ActionTypes.CloseMenu } | { type: ActionTypes.OpenMenu } | { type: ActionTypes.GoToItem; focus: Focus; id?: string } | { type: ActionTypes.Search; value: string } | { type: ActionTypes.ClearSearch } | { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef } | { type: ActionTypes.UnregisterItem; id: string } const reducers: { [P in ActionTypes]: ( state: StateDefinition, action: Extract ) => StateDefinition } = { [ActionTypes.CloseMenu]: state => ({ ...state, menuState: MenuStates.Closed }), [ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }), [ActionTypes.GoToItem]: (state, action) => { const activeItemIndex = calculateActiveItemIndex(state, action.focus, action.id) if (state.searchQuery === '' && state.activeItemIndex === activeItemIndex) { return state } return { ...state, searchQuery: '', activeItemIndex, } }, [ActionTypes.Search]: (state, action) => { const searchQuery = state.searchQuery + action.value const match = state.items.findIndex( item => item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled ) if (match === -1 || match === state.activeItemIndex) { return { ...state, searchQuery } } return { ...state, searchQuery, activeItemIndex: match, } }, [ActionTypes.ClearSearch]: state => ({ ...state, searchQuery: '' }), [ActionTypes.RegisterItem]: (state, action) => ({ ...state, items: [...state.items, { id: action.id, dataRef: action.dataRef }], }), [ActionTypes.UnregisterItem]: (state, action) => { const nextItems = state.items.slice() const currentActiveItem = state.activeItemIndex !== null ? nextItems[state.activeItemIndex] : null const idx = nextItems.findIndex(a => a.id === action.id) if (idx !== -1) nextItems.splice(idx, 1) return { ...state, items: nextItems, activeItemIndex: (() => { if (idx === state.activeItemIndex) return null if (currentActiveItem === null) return null // If we removed the item before the actual active index, then it would be out of sync. To // fix this, we will find the correct (new) index position. return nextItems.indexOf(currentActiveItem) })(), } }, } const MenuContext = React.createContext<[StateDefinition, React.Dispatch] | null>(null) function useMenuContext(component: string) { const context = React.useContext(MenuContext) if (context === null) { const err = new Error(`<${component} /> is missing a parent <${Menu.name} /> component.`) if (Error.captureStackTrace) { Error.captureStackTrace(err, useMenuContext) } throw err } return context } function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } // --- const DEFAULT_MENU_TAG = React.Fragment type MenuRenderPropArg = { open: boolean } export function Menu( props: Props ) { const d = useDisposables() const reducerBag = React.useReducer(stateReducer, { menuState: MenuStates.Closed, buttonRef: React.createRef(), itemsRef: React.createRef(), items: [], searchQuery: '', activeItemIndex: null, } as StateDefinition) const [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag React.useEffect(() => { function handler(event: MouseEvent) { if (menuState !== MenuStates.Open) return if (buttonRef.current?.contains(event.target as HTMLElement)) return if (!itemsRef.current?.contains(event.target as HTMLElement)) { dispatch({ type: ActionTypes.CloseMenu }) } if (!event.defaultPrevented) d.nextFrame(() => buttonRef.current?.focus()) } window.addEventListener('click', handler) return () => window.removeEventListener('click', handler) }, [menuState, itemsRef, buttonRef, d, dispatch]) const propsBag = React.useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState]) return ( {render(props, propsBag, DEFAULT_MENU_TAG)} ) } // --- type ButtonPropsWeControl = | 'ref' | 'id' | 'type' | 'aria-haspopup' | 'aria-controls' | 'aria-expanded' | 'onKeyDown' | 'onFocus' | 'onBlur' | 'onPointerUp' const DEFAULT_BUTTON_TAG = 'button' type ButtonRenderPropArg = { open: boolean; focused: boolean } const Button = forwardRefWithAs(function Button< TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG >( props: Props, ref: React.Ref ) { const [state, dispatch] = useMenuContext([Menu.name, Button.name].join('.')) const buttonRef = useSyncRefs(state.buttonRef, ref) const [focused, setFocused] = React.useState(false) const id = `headlessui-menu-button-${useId()}` const d = useDisposables() const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 case Keys.Space: case Keys.Enter: case Keys.ArrowDown: event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) d.nextFrame(() => { state.itemsRef.current?.focus() dispatch({ type: ActionTypes.GoToItem, focus: Focus.FirstItem }) }) break case Keys.ArrowUp: event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) d.nextFrame(() => { state.itemsRef.current?.focus() dispatch({ type: ActionTypes.GoToItem, focus: Focus.LastItem }) }) break } }, [dispatch, state, d] ) const handlePointerUp = React.useCallback( (event: MouseEvent) => { if (props.disabled) return if (state.menuState === MenuStates.Open) { dispatch({ type: ActionTypes.CloseMenu }) d.nextFrame(() => state.buttonRef.current?.focus()) } else { event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) d.nextFrame(() => state.itemsRef.current?.focus()) } }, [dispatch, d, state, props.disabled] ) const handleFocus = React.useCallback(() => { if (state.menuState === MenuStates.Open) state.itemsRef.current?.focus() setFocused(true) }, [state, setFocused]) const handleBlur = React.useCallback(() => setFocused(false), [setFocused]) const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open, focused }), [ state, focused, ]) const passthroughProps = props const propsWeControl = { ref: buttonRef, id, type: 'button', 'aria-haspopup': true, 'aria-controls': state.itemsRef.current?.id, 'aria-expanded': state.menuState === MenuStates.Open ? true : undefined, onKeyDown: handleKeyDown, onFocus: handleFocus, onBlur: handleBlur, onPointerUp: handlePointerUp, } return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG) }) // --- type ItemsPropsWeControl = | 'aria-activedescendant' | 'aria-labelledby' | 'id' | 'onKeyDown' | 'ref' | 'role' | 'tabIndex' const DEFAULT_ITEMS_TAG = 'div' type ItemsRenderPropArg = { open: boolean } const Items = forwardRefWithAs(function Items< TTag extends React.ElementType = typeof DEFAULT_ITEMS_TAG >( props: Props & { static?: boolean }, ref: React.Ref ) { const { static: isStatic = false, ...passthroughProps } = props const [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.')) const itemsRef = useSyncRefs(state.itemsRef, ref) const id = `headlessui-menu-items-${useId()}` const searchDisposables = useDisposables() const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { searchDisposables.dispose() switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 // @ts-expect-error Fallthrough is expected here case Keys.Space: if (state.searchQuery !== '') { event.preventDefault() return dispatch({ type: ActionTypes.Search, value: event.key }) } // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) if (state.activeItemIndex !== null) { const { id } = state.items[state.activeItemIndex] document.getElementById(id)?.click() } disposables().nextFrame(() => state.buttonRef.current?.focus()) break case Keys.ArrowDown: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.NextItem }) case Keys.ArrowUp: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.PreviousItem }) case Keys.Home: case Keys.PageUp: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.FirstItem }) case Keys.End: case Keys.PageDown: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.LastItem }) case Keys.Escape: event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) disposables().nextFrame(() => state.buttonRef.current?.focus()) break case Keys.Tab: return event.preventDefault() default: if (event.key.length === 1) { dispatch({ type: ActionTypes.Search, value: event.key }) searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350) } break } }, [dispatch, searchDisposables, state] ) const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state]) const propsWeControl = { 'aria-activedescendant': state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id, 'aria-labelledby': state.buttonRef.current?.id, id, onKeyDown: handleKeyDown, role: 'menu', tabIndex: 0, } if (!isStatic && state.menuState === MenuStates.Closed) return null return render( { ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } }, propsBag, DEFAULT_ITEMS_TAG ) }) // --- type MenuItemPropsWeControl = | 'id' | 'role' | 'tabIndex' | 'aria-disabled' | 'onPointerLeave' | 'onFocus' const DEFAULT_ITEM_TAG = React.Fragment type ItemRenderPropArg = { active: boolean; disabled: boolean } function Item( props: Props & { disabled?: boolean onClick?: (event: { preventDefault: Function }) => void // Special treatment, can either be a string or a function that resolves to a string className?: ((bag: ItemRenderPropArg) => string) | string } ) { const { disabled = false, className, onClick, ...passthroughProps } = props const [state, dispatch] = useMenuContext([Menu.name, Item.name].join('.')) const d = useDisposables() const id = `headlessui-menu-item-${useId()}` const active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false const bag = React.useRef({ disabled }) useIsoMorphicEffect(() => { bag.current.disabled = disabled }, [bag, disabled]) useIsoMorphicEffect(() => { bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() }, [bag, id]) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag }) return () => dispatch({ type: ActionTypes.UnregisterItem, id }) }, [bag, id]) const handleClick = React.useCallback( (event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) d.nextFrame(() => state.buttonRef.current?.focus()) if (onClick) return onClick(event) }, [d, dispatch, state.buttonRef, disabled, onClick] ) const handleFocus = React.useCallback(() => { if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToItem, focus: Focus.SpecificItem, id }) }, [disabled, id, dispatch]) const handlePointerMove = React.useCallback(() => { if (disabled) return if (active) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.SpecificItem, id }) }, [disabled, active, id, dispatch]) const handlePointerLeave = React.useCallback(() => { if (disabled) return if (!active) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) }, [disabled, active, dispatch]) const propsBag = React.useMemo(() => ({ active, disabled }), [active, disabled]) const propsWeControl = { id, role: 'menuitem', tabIndex: -1, className: resolvePropValue(className, propsBag), 'aria-disabled': disabled === true ? true : undefined, onClick: handleClick, onFocus: handleFocus, onPointerMove: handlePointerMove, onPointerLeave: handlePointerLeave, } return render( { ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_ITEM_TAG ) } function resolvePropValue(property: TProperty, bag: TBag) { if (property === undefined) return undefined if (typeof property === 'function') return property(bag) return property } // --- Menu.Button = Button Menu.Items = Items Menu.Item = Item