83cda0aa75
* 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
1890 lines
58 KiB
TypeScript
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,
|
|
})
|