Files
headlessui/packages/@headlessui-vue/src/components/listbox/listbox.ts
T
Robin Malfait a50be9255a Forward disabled state to hidden inputs in form-like components (#3004)
* make hidden inputs disabled if the wrapping component is disabled

* add tests to verify disabled hidden form elements

* update changelog
2024-02-21 14:16:31 +01:00

848 lines
25 KiB
TypeScript

import {
Fragment,
computed,
defineComponent,
h,
inject,
nextTick,
onMounted,
onUnmounted,
provide,
ref,
toRaw,
watch,
watchEffect,
type ComputedRef,
type InjectionKey,
type PropType,
type Ref,
type UnwrapNestedRefs,
} from 'vue'
import { useControllable } from '../../hooks/use-controllable'
import { useId } from '../../hooks/use-id'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useTextValue } from '../../hooks/use-text-value'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { State, useOpenClosed, useOpenClosedProvider } from '../../internal/open-closed'
import { Keys } from '../../keyboard'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { dom } from '../../utils/dom'
import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management'
import { objectToFormEntries } from '../../utils/form'
import { match } from '../../utils/match'
import { Features, compact, omit, render } from '../../utils/render'
function defaultComparator<T>(a: T, z: T): boolean {
return a === z
}
enum ListboxStates {
Open,
Closed,
}
enum ValueMode {
Single,
Multi,
}
enum ActivationTrigger {
Pointer,
Other,
}
function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type ListboxOptionData = {
textValue: string
disabled: boolean
value: unknown
domRef: Ref<HTMLElement | null>
}
type StateDefinition = {
// State
listboxState: Ref<ListboxStates>
value: ComputedRef<unknown>
orientation: Ref<'vertical' | 'horizontal'>
mode: ComputedRef<ValueMode>
compare: (a: unknown, z: unknown) => boolean
labelRef: Ref<HTMLLabelElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>
disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ComputedRef<ListboxOptionData> }[]>
searchQuery: Ref<string>
activeOptionIndex: Ref<number | null>
activationTrigger: Ref<ActivationTrigger>
// State mutators
closeListbox(): void
openListbox(): void
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
search(value: string): void
clearSearch(): void
registerOption(id: string, dataRef: ComputedRef<ListboxOptionData>): void
unregisterOption(id: string): void
select(value: unknown): void
}
let ListboxContext = Symbol('ListboxContext') as InjectionKey<StateDefinition>
function useListboxContext(component: string) {
let context = inject(ListboxContext, null)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Listbox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext)
throw err
}
return context
}
// ---
export let Listbox = defineComponent({
name: 'Listbox',
emits: { 'update:modelValue': (_value: any) => true },
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
by: { type: [String, Function], default: () => defaultComparator },
horizontal: { type: [Boolean], default: false },
modelValue: {
type: [Object, String, Number, Boolean] as PropType<
object | string | number | boolean | null
>,
default: undefined,
},
defaultValue: {
type: [Object, String, Number, Boolean] as PropType<
object | string | number | boolean | null
>,
default: undefined,
},
form: { type: String, optional: true },
name: { type: String, optional: true },
multiple: { type: [Boolean], default: false },
},
inheritAttrs: false,
setup(props, { slots, attrs, emit }) {
let listboxState = ref<StateDefinition['listboxState']['value']>(ListboxStates.Closed)
let labelRef = ref<StateDefinition['labelRef']['value']>(null)
let buttonRef = ref<StateDefinition['buttonRef']['value']>(null)
let optionsRef = ref<StateDefinition['optionsRef']['value']>(null)
let options = ref<StateDefinition['options']['value']>([])
let searchQuery = ref<StateDefinition['searchQuery']['value']>('')
let activeOptionIndex = ref<StateDefinition['activeOptionIndex']['value']>(null)
let activationTrigger = ref<StateDefinition['activationTrigger']['value']>(
ActivationTrigger.Other
)
function adjustOrderedState(
adjustment: (
options: UnwrapNestedRefs<StateDefinition['options']['value']>
) => UnwrapNestedRefs<StateDefinition['options']['value']> = (i) => i
) {
let currentActiveOption =
activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null
let sortedOptions = sortByDomNode(adjustment(options.value.slice()), (option) =>
dom(option.dataRef.domRef)
)
// If we inserted an option before the current active option then the active option index
// would be wrong. To fix this, we will re-lookup the correct index.
let adjustedActiveOptionIndex = currentActiveOption
? sortedOptions.indexOf(currentActiveOption)
: null
// Reset to `null` in case the currentActiveOption was removed.
if (adjustedActiveOptionIndex === -1) {
adjustedActiveOptionIndex = null
}
return {
options: sortedOptions,
activeOptionIndex: adjustedActiveOptionIndex,
}
}
let mode = computed(() => (props.multiple ? ValueMode.Multi : ValueMode.Single))
let [directValue, theirOnChange] = useControllable(
computed(() => props.modelValue),
(value: unknown) => emit('update:modelValue', value),
computed(() => props.defaultValue)
)
let value = computed(() =>
directValue.value === undefined
? match(mode.value, {
[ValueMode.Multi]: [],
[ValueMode.Single]: undefined,
})
: directValue.value
)
let api = {
listboxState,
value,
mode,
compare(a: any, z: any) {
if (typeof props.by === 'string') {
let property = props.by as unknown as any
return a?.[property] === z?.[property]
}
return props.by(a, z)
},
orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
labelRef,
buttonRef,
optionsRef,
disabled: computed(() => props.disabled),
options,
searchQuery,
activeOptionIndex,
activationTrigger,
closeListbox() {
if (props.disabled) return
if (listboxState.value === ListboxStates.Closed) return
listboxState.value = ListboxStates.Closed
activeOptionIndex.value = null
},
openListbox() {
if (props.disabled) return
if (listboxState.value === ListboxStates.Open) return
listboxState.value = ListboxStates.Open
},
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger) {
if (props.disabled) return
if (listboxState.value === ListboxStates.Closed) return
let adjustedState = adjustOrderedState()
let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific
? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> },
{
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (option) => option.id,
resolveDisabled: (option) => option.dataRef.disabled,
}
)
searchQuery.value = ''
activeOptionIndex.value = nextActiveOptionIndex
activationTrigger.value = trigger ?? ActivationTrigger.Other
options.value = adjustedState.options
},
search(value: string) {
if (props.disabled) return
if (listboxState.value === ListboxStates.Closed) return
let wasAlreadySearching = searchQuery.value !== ''
let offset = wasAlreadySearching ? 0 : 1
searchQuery.value += value.toLowerCase()
let reOrderedOptions =
activeOptionIndex.value !== null
? options.value
.slice(activeOptionIndex.value + offset)
.concat(options.value.slice(0, activeOptionIndex.value + offset))
: options.value
let matchingOption = reOrderedOptions.find(
(option) =>
option.dataRef.textValue.startsWith(searchQuery.value) && !option.dataRef.disabled
)
let matchIdx = matchingOption ? options.value.indexOf(matchingOption) : -1
if (matchIdx === -1 || matchIdx === activeOptionIndex.value) return
activeOptionIndex.value = matchIdx
activationTrigger.value = ActivationTrigger.Other
},
clearSearch() {
if (props.disabled) return
if (listboxState.value === ListboxStates.Closed) return
if (searchQuery.value === '') return
searchQuery.value = ''
},
registerOption(id: string, dataRef: ListboxOptionData) {
let adjustedState = adjustOrderedState((options) => {
return [...options, { id, dataRef }]
})
options.value = adjustedState.options
activeOptionIndex.value = adjustedState.activeOptionIndex
},
unregisterOption(id: string) {
let adjustedState = adjustOrderedState((options) => {
let idx = options.findIndex((a) => a.id === id)
if (idx !== -1) options.splice(idx, 1)
return options
})
options.value = adjustedState.options
activeOptionIndex.value = adjustedState.activeOptionIndex
activationTrigger.value = ActivationTrigger.Other
},
theirOnChange(value: unknown) {
if (props.disabled) return
theirOnChange(value)
},
select(value: unknown) {
if (props.disabled) return
theirOnChange(
match(mode.value, {
[ValueMode.Single]: () => value,
[ValueMode.Multi]: () => {
let copy = toRaw(api.value.value as unknown[]).slice()
let raw = toRaw(value)
let idx = copy.findIndex((value) => api.compare(raw, toRaw(value)))
if (idx === -1) {
copy.push(raw)
} else {
copy.splice(idx, 1)
}
return copy
},
})
)
},
}
// Handle outside click
useOutsideClick(
[buttonRef, optionsRef],
(event, target) => {
api.closeListbox()
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
dom(buttonRef)?.focus()
}
},
computed(() => listboxState.value === ListboxStates.Open)
)
// @ts-expect-error Types of property 'dataRef' are incompatible.
provide(ListboxContext, api)
useOpenClosedProvider(
computed(() =>
match(listboxState.value, {
[ListboxStates.Open]: State.Open,
[ListboxStates.Closed]: State.Closed,
})
)
)
let form = computed(() => dom(buttonRef)?.closest('form'))
onMounted(() => {
watch(
[form],
() => {
if (!form.value) return
if (props.defaultValue === undefined) return
function handle() {
api.theirOnChange(props.defaultValue)
}
form.value.addEventListener('reset', handle)
return () => {
form.value?.removeEventListener('reset', handle)
}
},
{ immediate: true }
)
})
return () => {
let { name, modelValue, disabled, form, ...theirProps } = props
let slot = { open: listboxState.value === ListboxStates.Open, disabled, value: value.value }
return h(Fragment, [
...(name != null && value.value != null
? objectToFormEntries({ [name]: value.value }).map(([name, value]) =>
h(
Hidden,
compact({
features: HiddenFeatures.Hidden,
key: name,
as: 'input',
type: 'hidden',
hidden: true,
readOnly: true,
form,
disabled,
name,
value,
})
)
)
: []),
render({
ourProps: {},
theirProps: {
...attrs,
...omit(theirProps, [
'defaultValue',
'onUpdate:modelValue',
'horizontal',
'multiple',
'by',
]),
},
slot,
slots,
attrs,
name: 'Listbox',
}),
])
}
},
})
// ---
export let ListboxLabel = defineComponent({
name: 'ListboxLabel',
props: {
as: { type: [Object, String], default: 'label' },
id: { type: String, default: () => `headlessui-listbox-label-${useId()}` },
},
setup(props, { attrs, slots }) {
let api = useListboxContext('ListboxLabel')
function handleClick() {
dom(api.buttonRef)?.focus({ preventScroll: true })
}
return () => {
let slot = {
open: api.listboxState.value === ListboxStates.Open,
disabled: api.disabled.value,
}
let { id, ...theirProps } = props
let ourProps = { id, ref: api.labelRef, onClick: handleClick }
return render({
ourProps,
theirProps,
slot,
attrs,
slots,
name: 'ListboxLabel',
})
}
},
})
// ---
export let ListboxButton = defineComponent({
name: 'ListboxButton',
props: {
as: { type: [Object, String], default: 'button' },
id: { type: String, default: () => `headlessui-listbox-button-${useId()}` },
},
setup(props, { attrs, slots, expose }) {
let api = useListboxContext('ListboxButton')
expose({ el: api.buttonRef, $el: api.buttonRef })
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
api.openListbox()
nextTick(() => {
dom(api.optionsRef)?.focus({ preventScroll: true })
if (!api.value.value) api.goToOption(Focus.First)
})
break
case Keys.ArrowUp:
event.preventDefault()
api.openListbox()
nextTick(() => {
dom(api.optionsRef)?.focus({ preventScroll: true })
if (!api.value.value) api.goToOption(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 (api.disabled.value) return
if (api.listboxState.value === ListboxStates.Open) {
api.closeListbox()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
} else {
event.preventDefault()
api.openListbox()
nextFrame(() => dom(api.optionsRef)?.focus({ preventScroll: true }))
}
}
let type = useResolveButtonType(
computed(() => ({ as: props.as, type: attrs.type })),
api.buttonRef
)
return () => {
let slot = {
open: api.listboxState.value === ListboxStates.Open,
disabled: api.disabled.value,
value: api.value.value,
}
let { id, ...theirProps } = props
let ourProps = {
ref: api.buttonRef,
id,
type: type.value,
'aria-haspopup': 'listbox',
'aria-controls': dom(api.optionsRef)?.id,
'aria-expanded': api.listboxState.value === ListboxStates.Open,
'aria-labelledby': api.labelRef.value ? [dom(api.labelRef)?.id, id].join(' ') : undefined,
disabled: api.disabled.value === true ? true : undefined,
onKeydown: handleKeyDown,
onKeyup: handleKeyUp,
onClick: handleClick,
}
return render({
ourProps,
theirProps,
slot,
attrs,
slots,
name: 'ListboxButton',
})
}
},
})
// ---
export let ListboxOptions = defineComponent({
name: 'ListboxOptions',
props: {
as: { type: [Object, String], default: 'ul' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
id: { type: String, default: () => `headlessui-listbox-options-${useId()}` },
},
setup(props, { attrs, slots, expose }) {
let api = useListboxContext('ListboxOptions')
let searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
expose({ el: api.optionsRef, $el: api.optionsRef })
function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce.value) clearTimeout(searchDebounce.value)
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#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.activeOptionIndex.value !== null) {
let activeOption = api.options.value[api.activeOptionIndex.value]
api.select(activeOption.dataRef.value)
}
if (api.mode.value === ValueMode.Single) {
api.closeListbox()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
}
break
case match(api.orientation.value, {
vertical: Keys.ArrowDown,
horizontal: Keys.ArrowRight,
}):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Next)
case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Previous)
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.First)
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Last)
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
api.closeListbox()
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
}
}
let usesOpenClosedState = useOpenClosed()
let visible = computed(() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState.value & State.Open) === State.Open
}
return api.listboxState.value === ListboxStates.Open
})
return () => {
let slot = { open: api.listboxState.value === ListboxStates.Open }
let { id, ...theirProps } = props
let ourProps = {
'aria-activedescendant':
api.activeOptionIndex.value === null
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-multiselectable': api.mode.value === ValueMode.Multi ? true : undefined,
'aria-labelledby': dom(api.buttonRef)?.id,
'aria-orientation': api.orientation.value,
id,
onKeydown: handleKeyDown,
role: 'listbox',
tabIndex: 0,
ref: api.optionsRef,
}
return render({
ourProps,
theirProps,
slot,
attrs,
slots,
features: Features.RenderStrategy | Features.Static,
visible: visible.value,
name: 'ListboxOptions',
})
}
},
})
export let ListboxOption = defineComponent({
name: 'ListboxOption',
props: {
as: { type: [Object, String], default: 'li' },
value: {
type: [Object, String, Number, Boolean] as PropType<
object | string | number | boolean | null
>,
},
disabled: { type: Boolean, default: false },
id: { type: String, default: () => `headlessui-listbox.option-${useId()}` },
},
setup(props, { slots, attrs, expose }) {
let api = useListboxContext('ListboxOption')
let internalOptionRef = ref<HTMLElement | null>(null)
expose({ el: internalOptionRef, $el: internalOptionRef })
let active = computed(() => {
return api.activeOptionIndex.value !== null
? api.options.value[api.activeOptionIndex.value].id === props.id
: false
})
let selected = computed(() =>
match(api.mode.value, {
[ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(props.value)),
[ValueMode.Multi]: () =>
(toRaw(api.value.value) as unknown[]).some((value) =>
api.compare(toRaw(value), toRaw(props.value))
),
})
)
let isFirstSelected = computed(() => {
return match(api.mode.value, {
[ValueMode.Multi]: () => {
let currentValues = toRaw(api.value.value) as unknown[]
return (
api.options.value.find((option) =>
currentValues.some((value) => api.compare(toRaw(value), toRaw(option.dataRef.value)))
)?.id === props.id
)
},
[ValueMode.Single]: () => selected.value,
})
})
let getTextValue = useTextValue(internalOptionRef)
let dataRef = computed<ListboxOptionData>(() => ({
disabled: props.disabled,
value: props.value,
get textValue() {
return getTextValue()
},
domRef: internalOptionRef,
}))
onMounted(() => api.registerOption(props.id, dataRef))
onUnmounted(() => api.unregisterOption(props.id))
onMounted(() => {
watch(
[api.listboxState, selected],
() => {
if (api.listboxState.value !== ListboxStates.Open) return
if (!selected.value) return
match(api.mode.value, {
[ValueMode.Multi]: () => {
if (isFirstSelected.value) api.goToOption(Focus.Specific, props.id)
},
[ValueMode.Single]: () => {
api.goToOption(Focus.Specific, props.id)
},
})
},
{ immediate: true }
)
})
watchEffect(() => {
if (api.listboxState.value !== ListboxStates.Open) return
if (!active.value) return
if (api.activationTrigger.value === ActivationTrigger.Pointer) return
nextTick(() => dom(internalOptionRef)?.scrollIntoView?.({ block: 'nearest' }))
})
function handleClick(event: MouseEvent) {
if (props.disabled) return event.preventDefault()
api.select(props.value)
if (api.mode.value === ValueMode.Single) {
api.closeListbox()
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
}
}
function handleFocus() {
if (props.disabled) return api.goToOption(Focus.Nothing)
api.goToOption(Focus.Specific, props.id)
}
let pointer = useTrackedPointer()
function handleEnter(evt: PointerEvent) {
pointer.update(evt)
}
function handleMove(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (active.value) return
api.goToOption(Focus.Specific, props.id, ActivationTrigger.Pointer)
}
function handleLeave(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (!active.value) return
api.goToOption(Focus.Nothing)
}
return () => {
let { disabled } = props
let slot = { active: active.value, selected: selected.value, disabled }
let { id, value: _value, disabled: _disabled, ...theirProps } = props
let ourProps = {
id,
ref: internalOptionRef,
role: 'option',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
// According to the WAI-ARIA best practices, we should use aria-checked for
// multi-select,but Voice-Over disagrees. So we use aria-checked instead for
// both single and multi-select.
'aria-selected': selected.value,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerenter: handleEnter,
onMouseenter: handleEnter,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
onMouseleave: handleLeave,
}
return render({
ourProps,
theirProps,
slot,
attrs,
slots,
name: 'ListboxOption',
})
}
},
})