e9fd05e06d
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
613 lines
17 KiB
TypeScript
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
|