Files
headlessui/packages/@headlessui-react/src/components/menu/menu.tsx
T
Robin Malfait e9fd05e06d ensure that anchor links are still clickable
This is an issue in the Vue version (it just works in the React version)
but I added tests for them anyway.

While this solution "works" I am not 100% happy with it. Let me explain
what's happening here and why I am not that happy about it:

- For starters, the Vue `nextTick` is apparently too fast. So what we do
  is when we get the pointer up event, we will close the menu and
  re-focus the button. We ran this code in a `nextTick` so that we can
  ensure that we close the menu *after* all the click events are
  finished. However because this is too fast, the menu is already closed
  and the anchor link is already unmounted and thus not clickable
  anymore. So instead we use a double requestAnimationFrame (to mimick a
  `nextFrame` as seen in the `disposables` from the React code). This
  works, but a bit messy, oh well.
- The next reason why I am not that happy is because I can't reproduce
  it in JSDOM (Jest tests). When you *click* a link in JSDOM it doesn't
  update the `window.location.hash` or `window.location.href`. To mimick
  that behaviour I put a `@click` event on the anchor to verify that we
  actually clicked it. However this already works, even before the
  "fix". So I left a TODO in there so that we can hopefully fix the
  test, so that we _can_ reproduce this behaviour.

Fixes: #14
2020-09-28 12:30:52 +02:00

613 lines
17 KiB
TypeScript

// 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 { Transition, TransitionClasses } from '../transitions/transition'
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'
enum MenuStates {
Open,
Closed,
}
// TODO: This must already exist somewhere, right? 🤔
// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values
enum Key {
Space = ' ',
Enter = 'Enter',
Escape = 'Escape',
Backspace = 'Backspace',
ArrowUp = 'ArrowUp',
ArrowDown = 'ArrowDown',
Home = 'Home',
End = 'End',
PageUp = 'PageUp',
PageDown = 'PageDown',
Tab = 'Tab',
}
type MenuItemDataRef = React.MutableRefObject<{ textValue?: string; disabled: boolean }>
type StateDefinition = {
menuState: MenuStates
buttonRef: React.MutableRefObject<HTMLButtonElement | null>
itemsRef: React.MutableRefObject<HTMLDivElement | null>
items: { id: string; dataRef: MenuItemDataRef }[]
searchQuery: string
activeItemIndex: number | null
}
enum ActionTypes {
ToggleMenu,
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.ToggleMenu }
| { 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<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.ToggleMenu]: state => ({
...state,
menuState: match(state.menuState, {
[MenuStates.Open]: MenuStates.Closed,
[MenuStates.Closed]: MenuStates.Open,
}),
}),
[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<Actions>] | 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<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
props: Props<TTag, MenuRenderPropArg>
) {
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: PointerEvent) {
if (event.defaultPrevented) return
if (menuState !== MenuStates.Open) return
if (!itemsRef.current?.contains(event.target as HTMLElement)) {
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => buttonRef.current?.focus())
}
}
window.addEventListener('pointerdown', handler)
return () => window.removeEventListener('pointerdown', handler)
}, [menuState, itemsRef, buttonRef, d, dispatch])
const propsBag = React.useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState])
return (
<MenuContext.Provider value={reducerBag}>
{render(props, propsBag, DEFAULT_MENU_TAG)}
</MenuContext.Provider>
)
}
// ---
type ButtonPropsWeControl =
| 'ref'
| 'id'
| 'type'
| 'aria-haspopup'
| 'aria-controls'
| 'aria-expanded'
| 'onKeyDown'
| 'onFocus'
| 'onBlur'
| 'onPointerUp'
| 'onPointerDown'
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<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: React.Ref<HTMLButtonElement>
) {
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<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
case Key.Space:
case Key.Enter:
case Key.ArrowDown:
event.preventDefault()
dispatch({ type: ActionTypes.OpenMenu })
d.nextFrame(() => {
state.itemsRef.current?.focus()
dispatch({ type: ActionTypes.GoToItem, focus: Focus.FirstItem })
})
break
case Key.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 handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
// We have a `pointerdown` event listener in the menu for the 'outside click', so we just want
// to prevent going there if we happen to click this button.
event.preventDefault()
}, [])
const handlePointerUp = React.useCallback(() => {
dispatch({ type: ActionTypes.ToggleMenu })
d.nextFrame(() => state.itemsRef.current?.focus())
}, [dispatch, d, state])
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,
onPointerDown: handlePointerDown,
}
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<TTag, ItemsRenderPropArg, ItemsPropsWeControl> &
TransitionClasses & { static?: boolean },
ref: React.Ref<HTMLDivElement>
) {
const {
enter,
enterFrom,
enterTo,
leave,
leaveFrom,
leaveTo,
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 d = useDisposables()
const searchDisposables = useDisposables()
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
searchDisposables.dispose()
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Key.Enter:
dispatch({ type: ActionTypes.CloseMenu })
if (state.activeItemIndex !== null) {
const { id } = state.items[state.activeItemIndex]
document.getElementById(id)?.click()
d.nextFrame(() => state.buttonRef.current?.focus())
}
break
case Key.ArrowDown:
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.NextItem })
case Key.ArrowUp:
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.PreviousItem })
case Key.Home:
case Key.PageUp:
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.FirstItem })
case Key.End:
case Key.PageDown:
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.LastItem })
case Key.Escape:
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => state.buttonRef.current?.focus())
break
case Key.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
}
},
[d, 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) {
return render(
{ ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } },
propsBag,
DEFAULT_ITEMS_TAG
)
}
return (
<Transition
show={state.menuState === MenuStates.Open}
{...{ enter, enterFrom, enterTo, leave, leaveFrom, leaveTo }}
>
{ref =>
render(
{
...passthroughProps,
...propsWeControl,
...{
ref(elementRef: HTMLDivElement) {
ref.current = elementRef
itemsRef(elementRef)
},
},
},
propsBag,
DEFAULT_ITEMS_TAG
)
}
</Transition>
)
})
// ---
type MenuItemPropsWeControl =
| 'id'
| 'role'
| 'tabIndex'
| 'aria-disabled'
| 'onPointerEnter'
| 'onPointerLeave'
| 'onPointerUp'
| 'onFocus'
const DEFAULT_ITEM_TAG = React.Fragment
type ItemRenderPropArg = { active: boolean; disabled: boolean }
function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
props: Props<TTag, ItemRenderPropArg, MenuItemPropsWeControl | 'className'> & {
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<MenuItemDataRef['current']>({ 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 handlePointerEnter = React.useCallback(() => {
if (disabled) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.SpecificItem, id })
}, [disabled, id, dispatch])
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 handlePointerLeave = React.useCallback(() => {
if (disabled) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
}, [disabled, dispatch])
const handleMouseMove = React.useCallback(() => {
if (disabled) return
if (active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.SpecificItem, id })
}, [disabled, active, id, dispatch])
const handlePointerUp = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => state.buttonRef.current?.focus())
},
[dispatch, disabled, d, state.buttonRef]
)
const handleClick = React.useCallback(
(event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
if (onClick) return onClick(event)
},
[disabled, onClick]
)
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,
onMouseMove: handleMouseMove,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onPointerUp: handlePointerUp,
}
return render<TTag, ItemRenderPropArg>(
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_ITEM_TAG
)
}
function resolvePropValue<TProperty, TBag>(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