'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 = MutableRefObject<{ disabled: boolean value: T domRef: MutableRefObject order: number | null }> interface StateDefinition { dataRef: MutableRefObject<_Data | null> virtual: { options: T[]; disabled: (value: unknown) => boolean } | null comboboxState: ComboboxState options: { id: string; dataRef: ComboboxOptionDataRef }[] activeOptionIndex: number | null activationTrigger: ActivationTrigger } enum ActionTypes { OpenCombobox, CloseCombobox, GoToOption, RegisterOption, UnregisterOption, SetActivationTrigger, UpdateVirtualOptions, } function adjustOrderedState( state: StateDefinition, adjustment: (options: StateDefinition['options']) => StateDefinition['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 = | { type: ActionTypes.CloseCombobox } | { type: ActionTypes.OpenCombobox } | { type: ActionTypes.GoToOption focus: Focus.Specific idx: number trigger?: ActivationTrigger } | { type: ActionTypes.GoToOption focus: Exclude trigger?: ActivationTrigger } | { type: ActionTypes.RegisterOption payload: { id: string; dataRef: ComboboxOptionDataRef } } | { type: ActionTypes.UnregisterOption; id: string } | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } | { type: ActionTypes.UpdateVirtualOptions; options: T[] } let reducers: { [P in ActionTypes]: ( state: StateDefinition, action: Extract, { type: P }> ) => StateDefinition } = { [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): () => 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 component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) throw err } return context } type _Actions = ReturnType let VirtualContext = createContext | 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 (
{ 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 ( {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', }, } )} ) })}
) } 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 buttonRef: MutableRefObject optionsRef: MutableRefObject } & Omit, '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 component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useData) throw err } return context } type _Data = ReturnType function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } // --- let DEFAULT_COMBOBOX_TAG = Fragment type ComboboxRenderPropArg = { 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>, 'value' | 'defaultValue' | 'multiple' | 'onChange' | 'by', { value?: TMultiple extends true ? EnsureArray : TValue defaultValue?: TMultiple extends true ? EnsureArray> : NoInfer onChange?( value: TMultiple extends true ? EnsureArray> : NoInfer | null ): void by?: ByComparator< TMultiple extends true ? EnsureArray>[number] : NoInfer > /** @deprecated The `` is now nullable default */ nullable?: boolean multiple?: TMultiple disabled?: boolean form?: string name?: string immediate?: boolean virtual?: { options: NoInfer[] disabled?: (value: NoInfer) => boolean } | null __demoMode?: boolean } > function ComboboxFn( props: ComboboxProps, ref: Ref ) { 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( 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) 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[number] : TValue let compare = useByComparator(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).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 }, [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 ( {name != null && ( )} {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_COMBOBOX_TAG, name: 'Combobox', })} ) } // --- 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): 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[0]['value'], >(props: ComboboxInputProps, ref: Ref) { 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) => { 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) => { // 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 = Props< TTag, ButtonRenderPropArg, ButtonPropsWeControl, { autoFocus?: boolean disabled?: boolean } > function ButtonFn( props: ComboboxButtonProps, ref: Ref ) { 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) => { 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) => { // 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 = Props< TTag, OptionsRenderPropArg, OptionsPropsWeControl, PropsForFeatures & { hold?: boolean anchor?: AnchorProps } > function OptionsFn( props: ComboboxOptionsProps, ref: Ref ) { 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: {theirProps.children}, }) } 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[0]['value'], >(props: ComboboxOptionProps, ref: Ref) { 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(null) let bag = useLatestValue['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) => { // 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 & RefProp ): JSX.Element } export interface _internal_ComponentComboboxButton extends HasDisplayName { ( props: ComboboxButtonProps & RefProp ): JSX.Element } export interface _internal_ComponentComboboxInput extends HasDisplayName { ( props: ComboboxInputProps & RefProp ): JSX.Element } export interface _internal_ComponentComboboxLabel extends _internal_ComponentLabel {} export interface _internal_ComponentComboboxOptions extends HasDisplayName { ( props: ComboboxOptionsProps & RefProp ): JSX.Element } export interface _internal_ComponentComboboxOption extends HasDisplayName { < TTag extends ElementType = typeof DEFAULT_OPTION_TAG, TType = Parameters[0]['value'], >( props: ComboboxOptionProps & RefProp ): 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 `