Files
headlessui/packages/@headlessui-react/src/components/combobox/combobox.tsx
T
Robin Malfait 83cda0aa75 Change default tags for ListboxOptions, ListboxOption, ComboboxOptions, ComboboxOption and TabGroup components (#3109)
* use `div` as default tag for `ListboxOptions` and `ListboxOption` components

* use `div` as default tag for `ComboboxOptions` and `ComboboxOption` components

* use `div` as default tag for `TabGroup`

* update changelog
2024-04-19 01:54:25 +02:00

1890 lines
58 KiB
TypeScript

'use client'
import { useFocusRing } from '@react-aria/focus'
import { useHover } from '@react-aria/interactions'
import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual'
import React, {
Fragment,
createContext,
createRef,
useCallback,
useContext,
useMemo,
useReducer,
useRef,
useState,
type CSSProperties,
type ElementType,
type MutableRefObject,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from 'react'
import { useActivePress } from '../../hooks/use-active-press'
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
import { useControllable } from '../../hooks/use-controllable'
import { useDisposables } from '../../hooks/use-disposables'
import { useElementSize } from '../../hooks/use-element-size'
import { useEvent } from '../../hooks/use-event'
import { useFrameDebounce } from '../../hooks/use-frame-debounce'
import { useId } from '../../hooks/use-id'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import { useWatch } from '../../hooks/use-watch'
import { useDisabled } from '../../internal/disabled'
import {
FloatingProvider,
useFloatingPanel,
useFloatingPanelProps,
useFloatingReference,
type AnchorProps,
} from '../../internal/floating'
import { FormFields } from '../../internal/form-fields'
import { useProvidedId } from '../../internal/id'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import type { EnsureArray, Props } from '../../types'
import { history } from '../../utils/active-element-history'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { disposables } from '../../utils/disposables'
import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match'
import { isMobile } from '../../utils/platform'
import {
RenderFeatures,
forwardRefWithAs,
mergeProps,
render,
type HasDisplayName,
type PropsForFeatures,
type RefProp,
} from '../../utils/render'
import { useDescribedBy } from '../description/description'
import { Keys } from '../keyboard'
import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label'
import { MouseButton } from '../mouse'
enum ComboboxState {
Open,
Closed,
}
enum ValueMode {
Single,
Multi,
}
enum ActivationTrigger {
Pointer,
Focus,
Other,
}
type ComboboxOptionDataRef<T> = MutableRefObject<{
disabled: boolean
value: T
domRef: MutableRefObject<HTMLElement | null>
order: number | null
}>
interface StateDefinition<T> {
dataRef: MutableRefObject<_Data | null>
virtual: { options: T[]; disabled: (value: unknown) => boolean } | null
comboboxState: ComboboxState
options: { id: string; dataRef: ComboboxOptionDataRef<T> }[]
activeOptionIndex: number | null
activationTrigger: ActivationTrigger
}
enum ActionTypes {
OpenCombobox,
CloseCombobox,
GoToOption,
RegisterOption,
UnregisterOption,
SetActivationTrigger,
UpdateVirtualOptions,
}
function adjustOrderedState<T>(
state: StateDefinition<T>,
adjustment: (options: StateDefinition<T>['options']) => StateDefinition<T>['options'] = (i) => i
) {
let currentActiveOption =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
let list = adjustment(state.options.slice())
let sortedOptions =
list.length > 0 && list[0].dataRef.current.order !== null
? // Prefer sorting based on the `order`
list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!)
: // Fallback to much slower DOM order
sortByDomNode(list, (option) => option.dataRef.current.domRef.current)
// 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,
}
}
type Actions<T> =
| { type: ActionTypes.CloseCombobox }
| { type: ActionTypes.OpenCombobox }
| {
type: ActionTypes.GoToOption
focus: Focus.Specific
idx: number
trigger?: ActivationTrigger
}
| {
type: ActionTypes.GoToOption
focus: Exclude<Focus, Focus.Specific>
trigger?: ActivationTrigger
}
| {
type: ActionTypes.RegisterOption
payload: { id: string; dataRef: ComboboxOptionDataRef<T> }
}
| { type: ActionTypes.UnregisterOption; id: string }
| { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger }
| { type: ActionTypes.UpdateVirtualOptions; options: T[] }
let reducers: {
[P in ActionTypes]: <T>(
state: StateDefinition<T>,
action: Extract<Actions<T>, { type: P }>
) => StateDefinition<T>
} = {
[ActionTypes.CloseCombobox](state) {
if (state.dataRef.current?.disabled) return state
if (state.comboboxState === ComboboxState.Closed) return state
return { ...state, activeOptionIndex: null, comboboxState: ComboboxState.Closed }
},
[ActionTypes.OpenCombobox](state) {
if (state.dataRef.current?.disabled) return state
if (state.comboboxState === ComboboxState.Open) return state
// Check if we have a selected value that we can make active
if (state.dataRef.current?.value) {
let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value)
if (idx !== -1) {
return {
...state,
activeOptionIndex: idx,
comboboxState: ComboboxState.Open,
}
}
}
return { ...state, comboboxState: ComboboxState.Open }
},
[ActionTypes.GoToOption](state, action) {
if (state.dataRef.current?.disabled) return state
if (
state.dataRef.current?.optionsRef.current &&
!state.dataRef.current?.optionsPropsRef.current.static &&
state.comboboxState === ComboboxState.Closed
) {
return state
}
if (state.virtual) {
let activeOptionIndex =
action.focus === Focus.Specific
? action.idx
: calculateActiveIndex(action, {
resolveItems: () => state.virtual!.options,
resolveActiveIndex: () =>
state.activeOptionIndex ??
state.virtual!.options.findIndex((option) => !state.virtual!.disabled(option)) ??
null,
resolveDisabled: state.virtual!.disabled,
resolveId() {
throw new Error('Function not implemented.')
},
})
let activationTrigger = action.trigger ?? ActivationTrigger.Other
if (
state.activeOptionIndex === activeOptionIndex &&
state.activationTrigger === activationTrigger
) {
return state
}
return {
...state,
activeOptionIndex,
activationTrigger,
}
}
let adjustedState = adjustOrderedState(state)
// It's possible that the activeOptionIndex is set to `null` internally, but
// this means that we will fallback to the first non-disabled option by default.
// We have to take this into account.
if (adjustedState.activeOptionIndex === null) {
let localActiveOptionIndex = adjustedState.options.findIndex(
(option) => !option.dataRef.current.disabled
)
if (localActiveOptionIndex !== -1) {
adjustedState.activeOptionIndex = localActiveOptionIndex
}
}
let activeOptionIndex =
action.focus === Focus.Specific
? action.idx
: calculateActiveIndex(action, {
resolveItems: () => adjustedState.options,
resolveActiveIndex: () => adjustedState.activeOptionIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
})
let activationTrigger = action.trigger ?? ActivationTrigger.Other
if (
state.activeOptionIndex === activeOptionIndex &&
state.activationTrigger === activationTrigger
) {
return state
}
return {
...state,
...adjustedState,
activeOptionIndex,
activationTrigger,
}
},
[ActionTypes.RegisterOption]: (state, action) => {
if (state.dataRef.current?.virtual) {
return {
...state,
options: [...state.options, action.payload],
}
}
let option = action.payload
let adjustedState = adjustOrderedState(state, (options) => {
options.push(option)
return options
})
// Check if we need to make the newly registered option active.
if (state.activeOptionIndex === null) {
if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) {
adjustedState.activeOptionIndex = adjustedState.options.indexOf(option)
}
}
let nextState = {
...state,
...adjustedState,
activationTrigger: ActivationTrigger.Other,
}
if (state.dataRef.current?.__demoMode && state.dataRef.current.value === undefined) {
nextState.activeOptionIndex = 0
}
return nextState
},
[ActionTypes.UnregisterOption]: (state, action) => {
if (state.dataRef.current?.virtual) {
return {
...state,
options: state.options.filter((option) => option.id !== action.id),
}
}
let adjustedState = adjustOrderedState(state, (options) => {
let idx = options.findIndex((option) => option.id === action.id)
if (idx !== -1) options.splice(idx, 1)
return options
})
return {
...state,
...adjustedState,
activationTrigger: ActivationTrigger.Other,
}
},
[ActionTypes.SetActivationTrigger]: (state, action) => {
if (state.activationTrigger === action.trigger) {
return state
}
return {
...state,
activationTrigger: action.trigger,
}
},
[ActionTypes.UpdateVirtualOptions]: (state, action) => {
if (state.virtual?.options === action.options) {
return state
}
let adjustedActiveOptionIndex = state.activeOptionIndex
if (state.activeOptionIndex !== null) {
let idx = action.options.indexOf(state.virtual!.options[state.activeOptionIndex])
if (idx !== -1) {
adjustedActiveOptionIndex = idx
} else {
adjustedActiveOptionIndex = null
}
}
return {
...state,
activeOptionIndex: adjustedActiveOptionIndex,
virtual: Object.assign({}, state.virtual, { options: action.options }),
}
},
}
let ComboboxActionsContext = createContext<{
openCombobox(): void
closeCombobox(): void
registerOption(id: string, dataRef: ComboboxOptionDataRef<unknown>): () => void
goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void
goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void
selectActiveOption(): void
setActivationTrigger(trigger: ActivationTrigger): void
onChange(value: unknown): void
} | null>(null)
ComboboxActionsContext.displayName = 'ComboboxActionsContext'
function useActions(component: string) {
let context = useContext(ComboboxActionsContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useActions)
throw err
}
return context
}
type _Actions = ReturnType<typeof useActions>
let VirtualContext = createContext<Virtualizer<any, any> | null>(null)
function VirtualProvider(props: {
children: (data: { option: unknown; open: boolean }) => React.ReactElement
}) {
let data = useData('VirtualProvider')
let [paddingStart, paddingEnd] = useMemo(() => {
let el = data.optionsRef.current
if (!el) return [0, 0]
let styles = window.getComputedStyle(el)
return [
parseFloat(styles.paddingBlockStart || styles.paddingTop),
parseFloat(styles.paddingBlockEnd || styles.paddingBottom),
]
}, [data.optionsRef.current])
let virtualizer = useVirtualizer({
scrollPaddingStart: paddingStart,
scrollPaddingEnd: paddingEnd,
count: data.virtual!.options.length,
estimateSize() {
return 40
},
getScrollElement() {
return (data.optionsRef.current ?? null) as HTMLElement | null
},
overscan: 12,
})
let [baseKey, setBaseKey] = useState(0)
useIsoMorphicEffect(() => {
setBaseKey((v) => v + 1)
}, [data.virtual?.options])
return (
<VirtualContext.Provider value={virtualizer}>
<div
style={{
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
ref={(el) => {
if (!el) {
return
}
// Scroll to the active index
{
// Ignore this when we are in a test environment
if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID !== undefined) {
return
}
// Do not scroll when the mouse/pointer is being used
if (data.activationTrigger === ActivationTrigger.Pointer) {
return
}
if (
data.activeOptionIndex !== null &&
data.virtual!.options.length > data.activeOptionIndex
) {
virtualizer.scrollToIndex(data.activeOptionIndex)
}
}
}}
>
{virtualizer.getVirtualItems().map((item) => {
return (
<Fragment key={item.key}>
{React.cloneElement(
props.children?.({
option: data.virtual!.options[item.index],
open: data.comboboxState === ComboboxState.Open,
}),
{
key: `${baseKey}-${item.key}`,
'data-index': item.index,
'aria-setsize': data.virtual!.options.length,
'aria-posinset': item.index + 1,
style: {
position: 'absolute',
top: 0,
left: 0,
transform: `translateY(${item.start}px)`,
overflowAnchor: 'none',
},
}
)}
</Fragment>
)
})}
</div>
</VirtualContext.Provider>
)
}
let ComboboxDataContext = createContext<
| ({
value: unknown
defaultValue: unknown
disabled: boolean
mode: ValueMode
activeOptionIndex: number | null
immediate: boolean
virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null
calculateIndex(value: unknown): number
compare(a: unknown, z: unknown): boolean
isSelected(value: unknown): boolean
isActive(value: unknown): boolean
__demoMode: boolean
optionsPropsRef: MutableRefObject<{
static: boolean
hold: boolean
}>
inputRef: MutableRefObject<HTMLInputElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>
} & Omit<StateDefinition<unknown>, 'dataRef'>)
| null
>(null)
ComboboxDataContext.displayName = 'ComboboxDataContext'
function useData(component: string) {
let context = useContext(ComboboxDataContext)
if (context === null) {
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useData)
throw err
}
return context
}
type _Data = ReturnType<typeof useData>
function stateReducer<T>(state: StateDefinition<T>, action: Actions<T>) {
return match(action.type, reducers, state, action)
}
// ---
let DEFAULT_COMBOBOX_TAG = Fragment
type ComboboxRenderPropArg<TValue, TActive = TValue> = {
open: boolean
disabled: boolean
activeIndex: number | null
activeOption: TActive | null
value: TValue
}
export type ComboboxProps<
TValue,
TMultiple extends boolean | undefined,
TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
> = Props<
TTag,
ComboboxRenderPropArg<NoInfer<TValue>>,
'value' | 'defaultValue' | 'multiple' | 'onChange' | 'by',
{
value?: TMultiple extends true ? EnsureArray<TValue> : TValue
defaultValue?: TMultiple extends true ? EnsureArray<NoInfer<TValue>> : NoInfer<TValue>
onChange?(
value: TMultiple extends true ? EnsureArray<NoInfer<TValue>> : NoInfer<TValue> | null
): void
by?: ByComparator<
TMultiple extends true ? EnsureArray<NoInfer<TValue>>[number] : NoInfer<TValue>
>
/** @deprecated The `<Combobox />` is now nullable default */
nullable?: boolean
multiple?: TMultiple
disabled?: boolean
form?: string
name?: string
immediate?: boolean
virtual?: {
options: NoInfer<TValue>[]
disabled?: (value: NoInfer<TValue>) => boolean
} | null
__demoMode?: boolean
}
>
function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG>(
props: ComboboxProps<TValue, boolean | undefined, TTag>,
ref: Ref<HTMLElement>
) {
let providedDisabled = useDisabled()
let {
value: controlledValue,
defaultValue,
onChange: controlledOnChange,
form,
name,
by,
disabled = providedDisabled || false,
__demoMode = false,
multiple = false,
immediate = false,
virtual = null,
// Deprecated, but let's pluck it from the props such that it doesn't end up
// on the `Fragment`
nullable: _nullable,
...theirProps
} = props
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
controlledValue,
controlledOnChange,
defaultValue
)
let [state, dispatch] = useReducer(stateReducer, {
dataRef: createRef(),
comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed,
options: [],
virtual: virtual
? { options: virtual.options, disabled: virtual.disabled ?? (() => false) }
: null,
activeOptionIndex: null,
activationTrigger: ActivationTrigger.Other,
} as StateDefinition<TValue>)
let defaultToFirstOption = useRef(false)
let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false })
let inputRef = useRef<_Data['inputRef']['current']>(null)
let buttonRef = useRef<_Data['buttonRef']['current']>(null)
let optionsRef = useRef<_Data['optionsRef']['current']>(null)
type TActualValue = true extends typeof multiple ? EnsureArray<TValue>[number] : TValue
let compare = useByComparator<TActualValue>(by)
let calculateIndex = useEvent((value: TValue) => {
if (virtual) {
if (by === null) {
return virtual.options.indexOf(value)
} else {
return virtual.options.findIndex((other) => compare(other, value))
}
} else {
return state.options.findIndex((other) => compare(other.dataRef.current.value, value))
}
})
let isSelected: (value: TValue) => boolean = useCallback(
(other) =>
match(data.mode, {
[ValueMode.Multi]: () =>
(value as EnsureArray<TValue>).some((option) => compare(option, other)),
[ValueMode.Single]: () => compare(value as TValue, other),
}),
[value]
)
let isActive = useEvent((other: TValue) => {
return state.activeOptionIndex === calculateIndex(other)
})
let data = useMemo<_Data>(
() => ({
...state,
immediate,
optionsPropsRef,
inputRef,
buttonRef,
optionsRef,
value,
defaultValue,
disabled,
mode: multiple ? ValueMode.Multi : ValueMode.Single,
virtual: state.virtual,
get activeOptionIndex() {
if (
defaultToFirstOption.current &&
state.activeOptionIndex === null &&
(virtual ? virtual.options.length > 0 : state.options.length > 0)
) {
if (virtual) {
let localActiveOptionIndex = virtual.options.findIndex(
(option) => !(virtual?.disabled?.(option) ?? false)
)
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
}
}
let localActiveOptionIndex = state.options.findIndex((option) => {
return !option.dataRef.current.disabled
})
if (localActiveOptionIndex !== -1) {
return localActiveOptionIndex
}
}
return state.activeOptionIndex
},
calculateIndex,
compare,
isSelected,
isActive,
__demoMode,
}),
[value, defaultValue, disabled, multiple, __demoMode, state, virtual]
)
useIsoMorphicEffect(() => {
if (!virtual) return
dispatch({ type: ActionTypes.UpdateVirtualOptions, options: virtual.options })
}, [virtual, virtual?.options])
useIsoMorphicEffect(() => {
state.dataRef.current = data
}, [data])
// Handle outside click
useOutsideClick(
[data.buttonRef, data.inputRef, data.optionsRef],
() => actions.closeCombobox(),
data.comboboxState === ComboboxState.Open
)
let slot = useMemo(() => {
return {
open: data.comboboxState === ComboboxState.Open,
disabled,
activeIndex: data.activeOptionIndex,
activeOption:
data.activeOptionIndex === null
? null
: data.virtual
? data.virtual.options[data.activeOptionIndex ?? 0]
: (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null,
value,
} satisfies ComboboxRenderPropArg<unknown>
}, [data, disabled, value])
let selectActiveOption = useEvent(() => {
if (data.activeOptionIndex === null) return
if (data.virtual) {
onChange(data.virtual.options[data.activeOptionIndex])
} else {
let { dataRef } = data.options[data.activeOptionIndex]
onChange(dataRef.current.value)
}
// It could happen that the `activeOptionIndex` stored in state is actually null, but we are
// getting the fallback active option back instead.
actions.goToOption(Focus.Specific, data.activeOptionIndex)
})
let openCombobox = useEvent(() => {
dispatch({ type: ActionTypes.OpenCombobox })
defaultToFirstOption.current = true
})
let closeCombobox = useEvent(() => {
dispatch({ type: ActionTypes.CloseCombobox })
defaultToFirstOption.current = false
})
let goToOption = useEvent((focus, idx, trigger) => {
defaultToFirstOption.current = false
if (focus === Focus.Specific) {
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, idx: idx!, trigger })
}
return dispatch({ type: ActionTypes.GoToOption, focus, trigger })
})
let registerOption = useEvent((id, dataRef) => {
dispatch({ type: ActionTypes.RegisterOption, payload: { id, dataRef } })
return () => {
// When we are unregistering the currently active option, then we also have to make sure to
// reset the `defaultToFirstOption` flag, so that visually something is selected and the next
// time you press a key on your keyboard it will go to the proper next or previous option in
// the list.
//
// Since this was the active option and it could have been anywhere in the list, resetting to
// the very first option seems like a fine default. We _could_ be smarter about this by going
// to the previous / next item in list if we know the direction of the keyboard navigation,
// but that might be too complex/confusing from an end users perspective.
if (data.isActive(dataRef.current.value)) {
defaultToFirstOption.current = true
}
dispatch({ type: ActionTypes.UnregisterOption, id })
}
})
let onChange = useEvent((value: unknown) => {
return match(data.mode, {
[ValueMode.Single]() {
return theirOnChange?.(value as TValue)
},
[ValueMode.Multi]() {
let copy = (data.value as TValue[]).slice()
let idx = copy.findIndex((item) => compare(item, value as TValue))
if (idx === -1) {
copy.push(value as TValue)
} else {
copy.splice(idx, 1)
}
return theirOnChange?.(copy as TValue[])
},
})
})
let setActivationTrigger = useEvent((trigger: ActivationTrigger) => {
dispatch({ type: ActionTypes.SetActivationTrigger, trigger })
})
let actions = useMemo<_Actions>(
() => ({
onChange,
registerOption,
goToOption,
closeCombobox,
openCombobox,
setActivationTrigger,
selectActiveOption,
}),
[]
)
let [labelledby, LabelProvider] = useLabels()
let ourProps = ref === null ? {} : { ref }
let reset = useCallback(() => {
return theirOnChange?.(defaultValue)
}, [theirOnChange /* Explicitly ignoring `defaultValue` */])
return (
<LabelProvider
value={labelledby}
props={{
htmlFor: data.inputRef.current?.id,
}}
slot={{
open: data.comboboxState === ComboboxState.Open,
disabled,
}}
>
<FloatingProvider>
<ComboboxActionsContext.Provider value={actions}>
<ComboboxDataContext.Provider value={data}>
<OpenClosedProvider
value={match(data.comboboxState, {
[ComboboxState.Open]: State.Open,
[ComboboxState.Closed]: State.Closed,
})}
>
{name != null && (
<FormFields
disabled={disabled}
data={value != null ? { [name]: value } : {}}
form={form}
onReset={reset}
/>
)}
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_COMBOBOX_TAG,
name: 'Combobox',
})}
</OpenClosedProvider>
</ComboboxDataContext.Provider>
</ComboboxActionsContext.Provider>
</FloatingProvider>
</LabelProvider>
)
}
// ---
let DEFAULT_INPUT_TAG = 'input' as const
type InputRenderPropArg = {
open: boolean
disabled: boolean
hover: boolean
focus: boolean
autofocus: boolean
}
type InputPropsWeControl =
| 'aria-activedescendant'
| 'aria-autocomplete'
| 'aria-controls'
| 'aria-expanded'
| 'aria-labelledby'
| 'disabled'
| 'role'
export type ComboboxInputProps<
TTag extends ElementType = typeof DEFAULT_INPUT_TAG,
TType = string,
> = Props<
TTag,
InputRenderPropArg,
InputPropsWeControl,
{
defaultValue?: TType
disabled?: boolean
displayValue?(item: TType): string
onChange?(event: React.ChangeEvent<HTMLInputElement>): void
autoFocus?: boolean
}
>
function InputFn<
TTag extends ElementType = typeof DEFAULT_INPUT_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof ComboboxRoot>[0]['value'],
>(props: ComboboxInputProps<TTag, TType>, ref: Ref<HTMLInputElement>) {
let data = useData('Combobox.Input')
let actions = useActions('Combobox.Input')
let internalId = useId()
let providedId = useProvidedId()
let {
id = providedId || `headlessui-combobox-input-${internalId}`,
onChange,
displayValue,
disabled = data.disabled || false,
autoFocus = false,
// @ts-ignore: We know this MAY NOT exist for a given tag but we only care when it _does_ exist.
type = 'text',
...theirProps
} = props
let inputRef = useSyncRefs(data.inputRef, ref, useFloatingReference())
let ownerDocument = useOwnerDocument(data.inputRef)
let isTyping = useRef(false)
let d = useDisposables()
let clear = useEvent(() => {
actions.onChange(null)
if (data.optionsRef.current) {
data.optionsRef.current.scrollTop = 0
}
actions.goToOption(Focus.Nothing)
})
// When a `displayValue` prop is given, we should use it to transform the current selected
// option(s) so that the format can be chosen by developers implementing this. This is useful if
// your data is an object and you just want to pick a certain property or want to create a dynamic
// value like `firstName + ' ' + lastName`.
//
// Note: This can also be used with multiple selected options, but this is a very simple transform
// which should always result in a string (since we are filling in the value of the text input),
// you don't have to use this at all, a more common UI is a "tag" based UI, which you can render
// yourself using the selected option(s).
let currentDisplayValue = useMemo(() => {
if (typeof displayValue === 'function' && data.value !== undefined) {
return displayValue(data.value as TType) ?? ''
} else if (typeof data.value === 'string') {
return data.value
} else {
return ''
}
}, [data.value, displayValue])
// Syncing the input value has some rules attached to it to guarantee a smooth and expected user
// experience:
//
// - When a user is not typing in the input field, it is safe to update the input value based on
// the selected option(s). See `currentDisplayValue` computation from above.
// - The value can be updated when:
// - The `value` is set from outside of the component
// - The `value` is set when the user uses their keyboard (confirm via enter or space)
// - The `value` is set when the user clicks on a value to select it
// - The value will be reset to the current selected option(s), when:
// - The user is _not_ typing (otherwise you will loose your current state / query)
// - The user cancels the current changes:
// - By pressing `escape`
// - By clicking `outside` of the Combobox
useWatch(
([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => {
// When the user is typing, we want to not touch the `input` at all. Especially when they are
// using an IME, we don't want to mess with the input at all.
if (isTyping.current) return
let input = data.inputRef.current
if (!input) return
if (oldState === ComboboxState.Open && state === ComboboxState.Closed) {
input.value = currentDisplayValue
} else if (currentDisplayValue !== oldCurrentDisplayValue) {
input.value = currentDisplayValue
}
// Once we synced the input value, we want to make sure the cursor is at the end of the input
// field. This makes it easier to continue typing and append to the query. We will bail out if
// the user is currently typing, because we don't want to mess with the cursor position while
// typing.
requestAnimationFrame(() => {
if (isTyping.current) return
if (!input) return
// Bail when the input is not the currently focused element. When it is not the focused
// element, and we call the `setSelectionRange`, then it will become the focused
// element which may be unwanted.
if (ownerDocument?.activeElement !== input) return
let { selectionStart, selectionEnd } = input
// A custom selection is used, no need to move the caret
if (Math.abs((selectionEnd ?? 0) - (selectionStart ?? 0)) !== 0) return
// A custom caret position is used, no need to move the caret
if (selectionStart !== 0) return
// Move the caret to the end
input.setSelectionRange(input.value.length, input.value.length)
})
},
[currentDisplayValue, data.comboboxState, ownerDocument]
)
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver
// a bit more happy and doesn't require some changes manually first before announcing items
// correctly. This is a bit of a hacks, but it is a workaround for a VoiceOver bug.
//
// TODO: VoiceOver is still relatively buggy if you start VoiceOver while the Combobox is already
// in an open state.
useWatch(
([newState], [oldState]) => {
if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) {
// When the user is typing, we want to not touch the `input` at all. Especially when they are
// using an IME, we don't want to mess with the input at all.
if (isTyping.current) return
let input = data.inputRef.current
if (!input) return
// Capture current state
let currentValue = input.value
let { selectionStart, selectionEnd, selectionDirection } = input
// Trick VoiceOver into announcing the value
input.value = ''
// Rollback to original state
input.value = currentValue
if (selectionDirection !== null) {
input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
} else {
input.setSelectionRange(selectionStart, selectionEnd)
}
}
},
[data.comboboxState]
)
let isComposing = useRef(false)
let handleCompositionStart = useEvent(() => {
isComposing.current = true
})
let handleCompositionEnd = useEvent(() => {
d.nextFrame(() => {
isComposing.current = false
})
})
let debounce = useFrameDebounce()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLInputElement>) => {
isTyping.current = true
debounce(() => {
isTyping.current = false
})
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
case Keys.Enter:
isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
// When the user is still in the middle of composing by using an IME, then we don't want to
// submit this value and close the Combobox yet. Instead, we will fallback to the default
// behavior which is to "end" the composition.
if (isComposing.current) return
event.preventDefault()
event.stopPropagation()
if (data.activeOptionIndex === null) {
actions.closeCombobox()
return
}
actions.selectActiveOption()
if (data.mode === ValueMode.Single) {
actions.closeCombobox()
}
break
case Keys.ArrowDown:
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return match(data.comboboxState, {
[ComboboxState.Open]: () => actions.goToOption(Focus.Next),
[ComboboxState.Closed]: () => actions.openCombobox(),
})
case Keys.ArrowUp:
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return match(data.comboboxState, {
[ComboboxState.Open]: () => actions.goToOption(Focus.Previous),
[ComboboxState.Closed]: () => {
actions.openCombobox()
d.nextFrame(() => {
if (!data.value) {
actions.goToOption(Focus.Last)
}
})
},
})
case Keys.Home:
if (event.shiftKey) {
break
}
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.First)
case Keys.PageUp:
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.First)
case Keys.End:
if (event.shiftKey) {
break
}
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.Last)
case Keys.PageDown:
isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.Last)
case Keys.Escape:
isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
event.preventDefault()
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
event.stopPropagation()
}
if (data.mode === ValueMode.Single) {
// We want to clear the value when the user presses escape if and only if the current
// value is not set (aka, they didn't select anything yet, or they cleared the input which
// caused the value to be set to `null`). If the current value is set, then we want to
// fallback to that value when we press escape (this part is handled in the watcher that
// syncs the value with the input field again).
if (data.value === null) {
clear()
}
}
return actions.closeCombobox()
case Keys.Tab:
isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
if (data.mode === ValueMode.Single && data.activationTrigger !== ActivationTrigger.Focus) {
actions.selectActiveOption()
}
actions.closeCombobox()
break
}
})
let handleChange = useEvent((event: React.ChangeEvent<HTMLInputElement>) => {
// Always call the onChange listener even if the user is still typing using an IME (Input Method
// Editor).
//
// The main issue is Android, where typing always uses the IME APIs. Just waiting until the
// compositionend event is fired to trigger an onChange is not enough, because then filtering
// options while typing won't work at all because we are still in "composing" mode.
onChange?.(event)
// When the value becomes empty in a single value mode then we want to clear
// the option entirely.
//
// This is can happen when you press backspace, but also when you select all the text and press
// ctrl/cmd+x.
if (data.mode === ValueMode.Single && event.target.value === '') {
clear()
}
// Open the combobox to show the results based on what the user has typed
actions.openCombobox()
})
let handleBlur = useEvent((event: ReactFocusEvent) => {
let relatedTarget =
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
isTyping.current = false
// Focus is moved into the list, we don't want to close yet.
if (data.optionsRef.current?.contains(relatedTarget)) return
// Focus is moved to the button, we don't want to close yet.
if (data.buttonRef.current?.contains(relatedTarget)) return
// Focus is moved, but the combobox is not open. This can mean two things:
//
// 1. The combobox was never opened, so we don't have to do anything.
// 2. The combobox was closed and focus was moved already. At that point we
// don't need to try and select the active option.
if (data.comboboxState !== ComboboxState.Open) return
event.preventDefault()
// We want to clear the value when the user presses escape or clicks outside
// the combobox if and only if the current value is not set (aka, they
// didn't select anything yet, or they cleared the input which caused the
// value to be set to `null`). If the current value is set, then we want to
// fallback to that value when we press escape (this part is handled in the
// watcher that syncs the value with the input field again).
if (data.mode === ValueMode.Single && data.value === null) {
clear()
}
return actions.closeCombobox()
})
let handleFocus = useEvent((event: ReactFocusEvent) => {
let relatedTarget =
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
if (data.buttonRef.current?.contains(relatedTarget)) return
if (data.optionsRef.current?.contains(relatedTarget)) return
if (data.disabled) return
if (!data.immediate) return
if (data.comboboxState === ComboboxState.Open) return
actions.openCombobox()
// We need to make sure that tabbing through a form doesn't result in incorrectly setting the
// value of the combobox. We will set the activation trigger to `Focus`, and we will ignore
// selecting the active option when the user tabs away.
d.nextFrame(() => {
actions.setActivationTrigger(ActivationTrigger.Focus)
})
})
let labelledBy = useLabelledBy()
let describedBy = useDescribedBy()
let { isFocused: focus, focusProps } = useFocusRing({ autoFocus })
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let slot = useMemo(() => {
return {
open: data.comboboxState === ComboboxState.Open,
disabled,
hover,
focus,
autofocus: autoFocus,
} satisfies InputRenderPropArg
}, [data, hover, focus, autoFocus, disabled])
let ourProps = mergeProps(
{
ref: inputRef,
id,
role: 'combobox',
type,
'aria-controls': data.optionsRef.current?.id,
'aria-expanded': data.comboboxState === ComboboxState.Open,
'aria-activedescendant':
data.activeOptionIndex === null
? undefined
: data.virtual
? data.options.find(
(option) =>
!data.virtual?.disabled(option.dataRef.current.value) &&
data.compare(
option.dataRef.current.value,
data.virtual!.options[data.activeOptionIndex!]
)
)?.id
: data.options[data.activeOptionIndex]?.id,
'aria-labelledby': labelledBy,
'aria-describedby': describedBy,
'aria-autocomplete': 'list',
defaultValue:
props.defaultValue ??
(data.defaultValue !== undefined ? displayValue?.(data.defaultValue as TType) : null) ??
data.defaultValue,
disabled: disabled || undefined,
autoFocus,
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
},
focusProps,
hoverProps
)
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_INPUT_TAG,
name: 'Combobox.Input',
})
}
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
type ButtonRenderPropArg = {
open: boolean
active: boolean
disabled: boolean
value: any
focus: boolean
hover: boolean
}
type ButtonPropsWeControl =
| 'aria-controls'
| 'aria-expanded'
| 'aria-haspopup'
| 'aria-labelledby'
| 'disabled'
| 'tabIndex'
export type ComboboxButtonProps<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG> = Props<
TTag,
ButtonRenderPropArg,
ButtonPropsWeControl,
{
autoFocus?: boolean
disabled?: boolean
}
>
function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: ComboboxButtonProps<TTag>,
ref: Ref<HTMLButtonElement>
) {
let data = useData('Combobox.Button')
let actions = useActions('Combobox.Button')
let buttonRef = useSyncRefs(data.buttonRef, ref)
let internalId = useId()
let {
id = `headlessui-combobox-button-${internalId}`,
disabled = data.disabled || false,
autoFocus = false,
...theirProps
} = props
let d = useDisposables()
let refocusInput = useRefocusableInput(data.inputRef)
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLUListElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
case Keys.Space:
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
if (data.comboboxState === ComboboxState.Closed) {
actions.openCombobox()
}
return d.nextFrame(() => refocusInput())
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
if (data.comboboxState === ComboboxState.Closed) {
actions.openCombobox()
d.nextFrame(() => {
if (!data.value) {
actions.goToOption(Focus.First)
}
})
}
return d.nextFrame(() => refocusInput())
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
if (data.comboboxState === ComboboxState.Closed) {
actions.openCombobox()
d.nextFrame(() => {
if (!data.value) {
actions.goToOption(Focus.Last)
}
})
}
return d.nextFrame(() => refocusInput())
case Keys.Escape:
if (data.comboboxState !== ComboboxState.Open) return
event.preventDefault()
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
event.stopPropagation()
}
actions.closeCombobox()
return d.nextFrame(() => refocusInput())
default:
return
}
})
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
// We use the `mousedown` event here since it fires before the focus event,
// allowing us to cancel the event before focus is moved from the
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
// preserving the cursor position and any text selection.
event.preventDefault()
if (isDisabledReactIssue7711(event.currentTarget)) return
// Since we're using the `mousedown` event instead of a `click` event here
// to preserve the focus of the `ComboboxInput`, we need to also check
// that the `left` mouse button was clicked.
if (event.button === MouseButton.Left) {
if (data.comboboxState === ComboboxState.Open) {
actions.closeCombobox()
} else {
actions.openCombobox()
}
}
// Ensure we focus the input
refocusInput()
})
let labelledBy = useLabelledBy([id])
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })
let slot = useMemo(() => {
return {
open: data.comboboxState === ComboboxState.Open,
active: active || data.comboboxState === ComboboxState.Open,
disabled,
value: data.value,
hover,
focus,
} satisfies ButtonRenderPropArg
}, [data, hover, focus, active, disabled])
let ourProps = mergeProps(
{
ref: buttonRef,
id,
type: useResolveButtonType(props, data.buttonRef),
tabIndex: -1,
'aria-haspopup': 'listbox',
'aria-controls': data.optionsRef.current?.id,
'aria-expanded': data.comboboxState === ComboboxState.Open,
'aria-labelledby': labelledBy,
disabled: disabled || undefined,
autoFocus,
onMouseDown: handleMouseDown,
onKeyDown: handleKeyDown,
},
focusProps,
hoverProps,
pressProps
)
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Combobox.Button',
})
}
// ---
let DEFAULT_OPTIONS_TAG = 'div' as const
type OptionsRenderPropArg = {
open: boolean
option: unknown
}
type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex'
let OptionsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG> = Props<
TTag,
OptionsRenderPropArg,
OptionsPropsWeControl,
PropsForFeatures<typeof OptionsRenderFeatures> & {
hold?: boolean
anchor?: AnchorProps
}
>
function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
props: ComboboxOptionsProps<TTag>,
ref: Ref<HTMLUListElement>
) {
let internalId = useId()
let {
id = `headlessui-combobox-options-${internalId}`,
hold = false,
anchor,
...theirProps
} = props
let data = useData('Combobox.Options')
let actions = useActions('Combobox.Options')
let [floatingRef, style] = useFloatingPanel(anchor)
let getFloatingPanelProps = useFloatingPanelProps()
let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null)
let usesOpenClosedState = useOpenClosed()
let visible = (() => {
if (usesOpenClosedState !== null) {
return (usesOpenClosedState & State.Open) === State.Open
}
return data.comboboxState === ComboboxState.Open
})()
// Ensure we close the combobox as soon as the input becomes hidden
useOnDisappear(data.inputRef, actions.closeCombobox, visible)
useIsoMorphicEffect(() => {
data.optionsPropsRef.current.static = props.static ?? false
}, [data.optionsPropsRef, props.static])
useIsoMorphicEffect(() => {
data.optionsPropsRef.current.hold = hold
}, [data.optionsPropsRef, hold])
useTreeWalker({
container: data.optionsRef.current,
enabled: data.comboboxState === ComboboxState.Open,
accept(node) {
if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
return NodeFilter.FILTER_ACCEPT
},
walk(node) {
node.setAttribute('role', 'none')
},
})
let labelledBy = useLabelledBy([data.buttonRef.current?.id])
let slot = useMemo(() => {
return {
open: data.comboboxState === ComboboxState.Open,
option: undefined,
} satisfies OptionsRenderPropArg
}, [data])
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
'aria-labelledby': labelledBy,
role: 'listbox',
'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
id,
ref: optionsRef,
style: {
...style,
'--input-width': useElementSize(data.inputRef, true).width,
'--button-width': useElementSize(data.buttonRef, true).width,
} as CSSProperties,
})
// Map the children in a scrollable container when virtualization is enabled
if (data.virtual && data.comboboxState === ComboboxState.Open) {
Object.assign(theirProps, {
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
})
}
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
name: 'Combobox.Options',
})
}
// ---
let DEFAULT_OPTION_TAG = 'div' as const
type OptionRenderPropArg = {
focus: boolean
/** @deprecated use `focus` instead */
active: boolean
selected: boolean
disabled: boolean
}
type OptionPropsWeControl = 'role' | 'tabIndex' | 'aria-disabled' | 'aria-selected'
export type ComboboxOptionProps<
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
TType = string,
> = Props<
TTag,
OptionRenderPropArg,
OptionPropsWeControl,
{
disabled?: boolean
value: TType
order?: number
}
>
function OptionFn<
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
// TODO: One day we will be able to infer this type from the generic in Combobox itself.
// But today is not that day..
TType = Parameters<typeof ComboboxRoot>[0]['value'],
>(props: ComboboxOptionProps<TTag, TType>, ref: Ref<HTMLLIElement>) {
let internalId = useId()
let {
id = `headlessui-combobox-option-${internalId}`,
disabled = false,
value,
order = null,
...theirProps
} = props
let data = useData('Combobox.Option')
let actions = useActions('Combobox.Option')
let refocusInput = useRefocusableInput(data.inputRef)
let active = data.virtual
? data.activeOptionIndex === data.calculateIndex(value)
: data.activeOptionIndex === null
? false
: data.options[data.activeOptionIndex]?.id === id
let selected = data.isSelected(value)
let internalOptionRef = useRef<HTMLLIElement | null>(null)
let bag = useLatestValue<ComboboxOptionDataRef<TType>['current']>({
disabled,
value,
domRef: internalOptionRef,
order,
})
let virtualizer = useContext(VirtualContext)
let optionRef = useSyncRefs(
ref,
internalOptionRef,
virtualizer ? virtualizer.measureElement : null
)
let select = useEvent(() => actions.onChange(value))
useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true)
useIsoMorphicEffect(() => {
if (!data.virtual) return
if (!data.__demoMode) return
let d = disposables()
d.requestAnimationFrame(() => {
enableScrollIntoView.current = true
})
return d.dispose
}, [data.virtual, data.__demoMode])
useIsoMorphicEffect(() => {
if (!enableScrollIntoView.current) return
if (data.comboboxState !== ComboboxState.Open) return
if (!active) return
if (data.activationTrigger === ActivationTrigger.Pointer) return
let d = disposables()
d.requestAnimationFrame(() => {
internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' })
})
return d.dispose
}, [
internalOptionRef,
active,
data.comboboxState,
data.activationTrigger,
/* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex,
])
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
// We use the `mousedown` event here since it fires before the focus event,
// allowing us to cancel the event before focus is moved from the
// `ComboboxInput` to the `ComboboxOption`. This keeps the input focused,
// preserving the cursor position and any text selection.
event.preventDefault()
// Since we're using the `mousedown` event instead of a `click` event here
// to preserve the focus of the `ComboboxInput`, we need to also check
// that the `left` mouse button was clicked.
if (event.button !== MouseButton.Left) {
return
}
if (disabled || data.virtual?.disabled(value)) return
select()
// We want to make sure that we don't accidentally trigger the virtual keyboard.
//
// This would happen if the input is focused, the options are open, you select an option (which
// would blur the input, and focus the option (button), then we re-focus the input).
//
// This would be annoying on mobile (or on devices with a virtual keyboard). Right now we are
// assuming that the virtual keyboard would open on mobile devices (iOS / Android). This
// assumption is not perfect, but will work in the majority of the cases.
//
// Ideally we can have a better check where we can explicitly check for the virtual keyboard.
// But right now this is still an experimental feature:
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard
if (!isMobile()) {
requestAnimationFrame(() => refocusInput())
}
if (data.mode === ValueMode.Single) {
requestAnimationFrame(() => actions.closeCombobox())
}
})
let handleFocus = useEvent(() => {
if (disabled || data.virtual?.disabled(value)) {
return actions.goToOption(Focus.Nothing)
}
let idx = data.calculateIndex(value)
actions.goToOption(Focus.Specific, idx)
})
let pointer = useTrackedPointer()
let handleEnter = useEvent((evt) => pointer.update(evt))
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled || data.virtual?.disabled(value)) return
if (active) return
let idx = data.calculateIndex(value)
actions.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer)
})
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled || data.virtual?.disabled(value)) return
if (!active) return
if (data.optionsPropsRef.current.hold) return
actions.goToOption(Focus.Nothing)
})
let slot = useMemo(
() => ({ active, focus: active, selected, disabled }) satisfies OptionRenderPropArg,
[active, selected, disabled]
)
let ourProps = {
id,
ref: optionRef,
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,
disabled: undefined, // Never forward the `disabled` prop
onMouseDown: handleMouseDown,
onFocus: handleFocus,
onPointerEnter: handleEnter,
onMouseEnter: handleEnter,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
onMouseLeave: handleLeave,
}
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_OPTION_TAG,
name: 'Combobox.Option',
})
}
// ---
export interface _internal_ComponentCombobox extends HasDisplayName {
<
TValue,
TMultiple extends boolean | undefined = false,
TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
>(
props: ComboboxProps<TValue, TMultiple, TTag> & RefProp<typeof ComboboxFn>
): JSX.Element
}
export interface _internal_ComponentComboboxButton extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: ComboboxButtonProps<TTag> & RefProp<typeof ButtonFn>
): JSX.Element
}
export interface _internal_ComponentComboboxInput extends HasDisplayName {
<TType, TTag extends ElementType = typeof DEFAULT_INPUT_TAG>(
props: ComboboxInputProps<TTag, TType> & RefProp<typeof InputFn>
): JSX.Element
}
export interface _internal_ComponentComboboxLabel extends _internal_ComponentLabel {}
export interface _internal_ComponentComboboxOptions extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
props: ComboboxOptionsProps<TTag> & RefProp<typeof OptionsFn>
): JSX.Element
}
export interface _internal_ComponentComboboxOption extends HasDisplayName {
<
TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
TType = Parameters<typeof ComboboxRoot>[0]['value'],
>(
props: ComboboxOptionProps<TTag, TType> & RefProp<typeof OptionFn>
): JSX.Element
}
let ComboboxRoot = forwardRefWithAs(ComboboxFn) as _internal_ComponentCombobox
export let ComboboxButton = forwardRefWithAs(ButtonFn) as _internal_ComponentComboboxButton
export let ComboboxInput = forwardRefWithAs(InputFn) as _internal_ComponentComboboxInput
/** @deprecated use `<Label>` instead of `<ComboboxLabel>` */
export let ComboboxLabel = Label as _internal_ComponentComboboxLabel
export let ComboboxOptions = forwardRefWithAs(OptionsFn) as _internal_ComponentComboboxOptions
export let ComboboxOption = forwardRefWithAs(OptionFn) as _internal_ComponentComboboxOption
export let Combobox = Object.assign(ComboboxRoot, {
Input: ComboboxInput,
Button: ComboboxButton,
/** @deprecated use `<Label>` instead of `<Combobox.Label>` */
Label: ComboboxLabel,
Options: ComboboxOptions,
Option: ComboboxOption,
})