Files
headlessui/packages/@headlessui-vue/src/components/menu/menu.ts
T
Robin Malfait 186a4cfcef Improve typeahead search logic (#1051)
* improve typeahead search logic

This ensures that if you have 4 items:
- Alice
- Bob
- Charlie
- Bob

And you search for `b`, then you jump to the first `Bob`, but if yuo
search again for `b` then we used to go to the very first `Bob` because
we always searched from the top. Now we will search from the active item
and onwards. Which means that we will now jump to the second `Bob`.

* update changelog
2022-01-19 13:49:57 +01:00

520 lines
16 KiB
TypeScript

import {
defineComponent,
ref,
provide,
inject,
onMounted,
onUnmounted,
computed,
nextTick,
InjectionKey,
Ref,
watchEffect,
} from 'vue'
import { Features, render } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { useWindowEvent } from '../../hooks/use-window-event'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { match } from '../../utils/match'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
enum MenuStates {
Open,
Closed,
}
function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type MenuItemDataRef = Ref<{ textValue: string; disabled: boolean }>
type StateDefinition = {
// State
menuState: Ref<MenuStates>
buttonRef: Ref<HTMLButtonElement | null>
itemsRef: Ref<HTMLDivElement | null>
items: Ref<{ id: string; dataRef: MenuItemDataRef }[]>
searchQuery: Ref<string>
activeItemIndex: Ref<number | null>
// State mutators
closeMenu(): void
openMenu(): void
goToItem(focus: Focus, id?: string): void
search(value: string): void
clearSearch(): void
registerItem(id: string, dataRef: MenuItemDataRef): void
unregisterItem(id: string): void
}
let MenuContext = Symbol('MenuContext') as InjectionKey<StateDefinition>
function useMenuContext(component: string) {
let context = inject(MenuContext, null)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Menu /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext)
throw err
}
return context
}
export let Menu = defineComponent({
name: 'Menu',
props: { as: { type: [Object, String], default: 'template' } },
setup(props, { slots, attrs }) {
let menuState = ref<StateDefinition['menuState']['value']>(MenuStates.Closed)
let buttonRef = ref<StateDefinition['buttonRef']['value']>(null)
let itemsRef = ref<StateDefinition['itemsRef']['value']>(null)
let items = ref<StateDefinition['items']['value']>([])
let searchQuery = ref<StateDefinition['searchQuery']['value']>('')
let activeItemIndex = ref<StateDefinition['activeItemIndex']['value']>(null)
let api = {
menuState,
buttonRef,
itemsRef,
items,
searchQuery,
activeItemIndex,
closeMenu: () => {
menuState.value = MenuStates.Closed
activeItemIndex.value = null
},
openMenu: () => (menuState.value = MenuStates.Open),
goToItem(focus: Focus, id?: string) {
let nextActiveItemIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => items.value,
resolveActiveIndex: () => activeItemIndex.value,
resolveId: item => item.id,
resolveDisabled: item => item.dataRef.disabled,
}
)
if (searchQuery.value === '' && activeItemIndex.value === nextActiveItemIndex) return
searchQuery.value = ''
activeItemIndex.value = nextActiveItemIndex
},
search(value: string) {
searchQuery.value += value.toLowerCase()
let reOrderedItems =
activeItemIndex.value !== null
? items.value
.slice(activeItemIndex.value + 1)
.concat(items.value.slice(0, activeItemIndex.value + 1))
: items.value
let matchingItem = reOrderedItems.find(
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
)
let matchIdx = matchingItem ? items.value.indexOf(matchingItem) : -1
if (matchIdx === -1 || matchIdx === activeItemIndex.value) return
activeItemIndex.value = matchIdx
},
clearSearch() {
searchQuery.value = ''
},
registerItem(id: string, dataRef: MenuItemDataRef) {
let orderMap = Array.from(
itemsRef.value?.querySelectorAll('[id^="headlessui-menu-item-"]') ?? []
).reduce(
(lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
{}
) as Record<string, number>
// @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }'
items.value = [...items.value, { id, dataRef }].sort(
(a, z) => orderMap[a.id] - orderMap[z.id]
)
},
unregisterItem(id: string) {
let nextItems = items.value.slice()
let currentActiveItem =
activeItemIndex.value !== null ? nextItems[activeItemIndex.value] : null
let idx = nextItems.findIndex(a => a.id === id)
if (idx !== -1) nextItems.splice(idx, 1)
items.value = nextItems
activeItemIndex.value = (() => {
if (idx === activeItemIndex.value) 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)
})()
},
}
useWindowEvent('mousedown', event => {
let target = event.target as HTMLElement
let active = document.activeElement
if (menuState.value !== MenuStates.Open) return
if (dom(buttonRef)?.contains(target)) return
if (!dom(itemsRef)?.contains(target)) api.closeMenu()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) dom(buttonRef)?.focus({ preventScroll: true })
})
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(MenuContext, api)
useOpenClosedProvider(
computed(() =>
match(menuState.value, {
[MenuStates.Open]: State.Open,
[MenuStates.Closed]: State.Closed,
})
)
)
return () => {
let slot = { open: menuState.value === MenuStates.Open }
return render({ props, slot, slots, attrs, name: 'Menu' })
}
},
})
export let MenuButton = defineComponent({
name: 'MenuButton',
props: {
disabled: { type: Boolean, default: false },
as: { type: [Object, String], default: 'button' },
},
render() {
let api = useMenuContext('MenuButton')
let slot = { open: api.menuState.value === MenuStates.Open }
let propsWeControl = {
ref: 'el',
id: this.id,
type: this.type,
'aria-haspopup': true,
'aria-controls': dom(api.itemsRef)?.id,
'aria-expanded': this.$props.disabled ? undefined : api.menuState.value === MenuStates.Open,
onKeydown: this.handleKeyDown,
onKeyup: this.handleKeyUp,
onClick: this.handleClick,
}
return render({
props: { ...this.$props, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
name: 'MenuButton',
})
},
setup(props, { attrs }) {
let api = useMenuContext('MenuButton')
let id = `headlessui-menu-button-${useId()}`
function handleKeyDown(event: 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()
event.stopPropagation()
api.openMenu()
nextTick(() => {
dom(api.itemsRef)?.focus({ preventScroll: true })
api.goToItem(Focus.First)
})
break
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
api.openMenu()
nextTick(() => {
dom(api.itemsRef)?.focus({ preventScroll: true })
api.goToItem(Focus.Last)
})
break
}
}
function handleKeyUp(event: KeyboardEvent) {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
}
function handleClick(event: MouseEvent) {
if (props.disabled) return
if (api.menuState.value === MenuStates.Open) {
api.closeMenu()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
} else {
event.preventDefault()
event.stopPropagation()
api.openMenu()
nextFrame(() => dom(api.itemsRef)?.focus({ preventScroll: true }))
}
}
return {
id,
el: api.buttonRef,
type: useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
api.buttonRef
),
handleKeyDown,
handleKeyUp,
handleClick,
}
},
})
export let MenuItems = defineComponent({
name: 'MenuItems',
props: {
as: { type: [Object, String], default: 'div' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
},
render() {
let api = useMenuContext('MenuItems')
let slot = { open: api.menuState.value === MenuStates.Open }
let propsWeControl = {
'aria-activedescendant':
api.activeItemIndex.value === null
? undefined
: api.items.value[api.activeItemIndex.value]?.id,
'aria-labelledby': dom(api.buttonRef)?.id,
id: this.id,
onKeydown: this.handleKeyDown,
onKeyup: this.handleKeyUp,
role: 'menu',
tabIndex: 0,
ref: 'el',
}
let passThroughProps = this.$props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: this.visible,
name: 'MenuItems',
})
},
setup() {
let api = useMenuContext('MenuItems')
let id = `headlessui-menu-items-${useId()}`
let searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
useTreeWalker({
container: computed(() => dom(api.itemsRef)),
enabled: computed(() => api.menuState.value === MenuStates.Open),
accept(node) {
if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
return NodeFilter.FILTER_ACCEPT
},
walk(node) {
node.setAttribute('role', 'none')
},
})
function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce.value) clearTimeout(searchDebounce.value)
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 (api.searchQuery.value !== '') {
event.preventDefault()
event.stopPropagation()
return api.search(event.key)
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
if (api.activeItemIndex.value !== null) {
let { id } = api.items.value[api.activeItemIndex.value]
document.getElementById(id)?.click()
}
api.closeMenu()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
break
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
return api.goToItem(Focus.Next)
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
return api.goToItem(Focus.Previous)
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return api.goToItem(Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return api.goToItem(Focus.Last)
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
api.closeMenu()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
break
default:
if (event.key.length === 1) {
api.search(event.key)
searchDebounce.value = setTimeout(() => api.clearSearch(), 350)
}
break
}
}
function handleKeyUp(event: KeyboardEvent) {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
}
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return usesOpenClosedState.value === State.Open
}
return api.menuState.value === MenuStates.Open
})
return { id, el: api.itemsRef, handleKeyDown, handleKeyUp, visible }
},
})
export let MenuItem = defineComponent({
name: 'MenuItem',
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: Boolean, default: false },
},
setup(props, { slots, attrs }) {
let api = useMenuContext('MenuItem')
let id = `headlessui-menu-item-${useId()}`
let active = computed(() => {
return api.activeItemIndex.value !== null
? api.items.value[api.activeItemIndex.value].id === id
: false
})
let dataRef = ref<MenuItemDataRef['value']>({ disabled: props.disabled, textValue: '' })
onMounted(() => {
let textValue = document
.getElementById(id)
?.textContent?.toLowerCase()
.trim()
if (textValue !== undefined) dataRef.value.textValue = textValue
})
onMounted(() => api.registerItem(id, dataRef))
onUnmounted(() => api.unregisterItem(id))
watchEffect(() => {
if (api.menuState.value !== MenuStates.Open) return
if (!active.value) return
nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
})
function handleClick(event: MouseEvent) {
if (props.disabled) return event.preventDefault()
api.closeMenu()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
}
function handleFocus() {
if (props.disabled) return api.goToItem(Focus.Nothing)
api.goToItem(Focus.Specific, id)
}
function handleMove() {
if (props.disabled) return
if (active.value) return
api.goToItem(Focus.Specific, id)
}
function handleLeave() {
if (props.disabled) return
if (!active.value) return
api.goToItem(Focus.Nothing)
}
return () => {
let { disabled } = props
let slot = { active: active.value, disabled }
let propsWeControl = {
id,
role: 'menuitem',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
onClick: handleClick,
onFocus: handleFocus,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
onMouseleave: handleLeave,
}
return render({
props: { ...props, ...propsWeControl },
slot,
attrs,
slots,
name: 'MenuItem',
})
}
},
})