479853d5ed
* track `isTyping` in state While you are typing, we should not sync the value with the `<input>` because otherwise it would override your changes. The moment you close the Combobox (by selecting an option, clicking outside, pressing escape or tabbing away) we can mark the component as not typing anymore. Once you are not typing anymore, then we can re-sync the input with the given value. * remove unused `useFrameDebounce` hook * require `isTyping` boolean * update changelog
2024 lines
63 KiB
TypeScript
2024 lines
63 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 { useDefaultValue } from '../../hooks/use-default-value'
|
|
import { useDisposables } from '../../hooks/use-disposables'
|
|
import { useElementSize } from '../../hooks/use-element-size'
|
|
import { useEvent } from '../../hooks/use-event'
|
|
import { useId } from '../../hooks/use-id'
|
|
import { useInertOthers } from '../../hooks/use-inert-others'
|
|
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 { useScrollLock } from '../../hooks/use-scroll-lock'
|
|
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,
|
|
useResolvedAnchor,
|
|
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'
|
|
import { Portal } from '../portal/portal'
|
|
|
|
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
|
|
|
|
isTyping: boolean
|
|
|
|
__demoMode: boolean
|
|
}
|
|
|
|
enum ActionTypes {
|
|
OpenCombobox,
|
|
CloseCombobox,
|
|
|
|
GoToOption,
|
|
SetTyping,
|
|
|
|
RegisterOption,
|
|
UnregisterOption,
|
|
|
|
SetActivationTrigger,
|
|
|
|
UpdateVirtualConfiguration,
|
|
}
|
|
|
|
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.SetTyping; isTyping: boolean }
|
|
| {
|
|
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.UpdateVirtualConfiguration
|
|
options: T[]
|
|
disabled: ((value: any) => boolean) | null
|
|
}
|
|
|
|
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,
|
|
|
|
isTyping: false,
|
|
|
|
// Clear the last known activation trigger
|
|
// This is because if a user interacts with the combobox using a mouse
|
|
// resulting in it closing we might incorrectly handle the next interaction
|
|
// for example, not scrolling to the active option in a virtual list
|
|
activationTrigger: ActivationTrigger.Other,
|
|
|
|
__demoMode: false,
|
|
}
|
|
},
|
|
[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,
|
|
__demoMode: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ...state, comboboxState: ComboboxState.Open, __demoMode: false }
|
|
},
|
|
[ActionTypes.SetTyping](state, action) {
|
|
if (state.isTyping === action.isTyping) return state
|
|
return { ...state, isTyping: action.isTyping }
|
|
},
|
|
[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 { options, disabled } = state.virtual
|
|
let activeOptionIndex =
|
|
action.focus === Focus.Specific
|
|
? action.idx
|
|
: calculateActiveIndex(action, {
|
|
resolveItems: () => options,
|
|
resolveActiveIndex: () =>
|
|
state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null,
|
|
resolveDisabled: 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,
|
|
isTyping: false,
|
|
__demoMode: false,
|
|
}
|
|
}
|
|
|
|
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,
|
|
isTyping: false,
|
|
activeOptionIndex,
|
|
activationTrigger,
|
|
__demoMode: false,
|
|
}
|
|
},
|
|
[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.UpdateVirtualConfiguration]: (state, action) => {
|
|
if (state.virtual === null) {
|
|
return {
|
|
...state,
|
|
virtual: { options: action.options, disabled: action.disabled ?? (() => false) },
|
|
}
|
|
}
|
|
|
|
if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) {
|
|
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: { options: action.options, disabled: action.disabled ?? (() => false) },
|
|
}
|
|
},
|
|
}
|
|
|
|
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
|
|
setIsTyping(isTyping: boolean): 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 { options } = data.virtual!
|
|
|
|
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: 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)
|
|
}, [options])
|
|
|
|
let items = virtualizer.getVirtualItems()
|
|
|
|
if (items.length === 0) {
|
|
return null
|
|
}
|
|
|
|
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 && options.length > data.activeOptionIndex) {
|
|
virtualizer.scrollToIndex(data.activeOptionIndex)
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{items.map((item) => {
|
|
return (
|
|
<Fragment key={item.key}>
|
|
{React.cloneElement(
|
|
props.children?.({
|
|
option: options[item.index],
|
|
open: data.comboboxState === ComboboxState.Open,
|
|
}),
|
|
{
|
|
key: `${baseKey}-${item.key}`,
|
|
'data-index': item.index,
|
|
'aria-setsize': 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<HTMLElement | 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
|
|
|
|
onClose?(): void
|
|
|
|
__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: _defaultValue,
|
|
onChange: controlledOnChange,
|
|
form,
|
|
name,
|
|
by,
|
|
disabled = providedDisabled || false,
|
|
onClose,
|
|
__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 defaultValue = useDefaultValue(_defaultValue)
|
|
let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>(
|
|
controlledValue,
|
|
controlledOnChange,
|
|
defaultValue
|
|
)
|
|
|
|
let [state, dispatch] = useReducer(stateReducer, {
|
|
dataRef: createRef(),
|
|
comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed,
|
|
isTyping: false,
|
|
options: [],
|
|
virtual: virtual
|
|
? { options: virtual.options, disabled: virtual.disabled ?? (() => false) }
|
|
: null,
|
|
activeOptionIndex: null,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
__demoMode,
|
|
} 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: virtual ? state.virtual : null,
|
|
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,
|
|
}),
|
|
[value, defaultValue, disabled, multiple, __demoMode, state, virtual]
|
|
)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (!virtual) return
|
|
dispatch({
|
|
type: ActionTypes.UpdateVirtualConfiguration,
|
|
options: virtual.options,
|
|
disabled: virtual.disabled ?? null,
|
|
})
|
|
}, [virtual, virtual?.options, virtual?.disabled])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
state.dataRef.current = data
|
|
}, [data])
|
|
|
|
// Handle outside click
|
|
let outsideClickEnabled = data.comboboxState === ComboboxState.Open
|
|
useOutsideClick(outsideClickEnabled, [data.buttonRef, data.inputRef, data.optionsRef], () =>
|
|
actions.closeCombobox()
|
|
)
|
|
|
|
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
|
|
|
|
actions.setIsTyping(false)
|
|
|
|
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
|
|
onClose?.()
|
|
})
|
|
|
|
let setIsTyping = useEvent((isTyping: boolean) => {
|
|
dispatch({ type: ActionTypes.SetTyping, isTyping })
|
|
})
|
|
|
|
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,
|
|
setIsTyping,
|
|
closeCombobox,
|
|
openCombobox,
|
|
setActivationTrigger,
|
|
selectActiveOption,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
let [labelledby, LabelProvider] = useLabels()
|
|
|
|
let ourProps = ref === null ? {} : { ref }
|
|
|
|
let reset = useCallback(() => {
|
|
if (defaultValue === undefined) return
|
|
return theirOnChange?.(defaultValue)
|
|
}, [theirOnChange, 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 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 (data.isTyping) 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 (data.isTyping) 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, data.isTyping]
|
|
)
|
|
|
|
// 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 (data.isTyping) 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 handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
actions.setIsTyping(true)
|
|
|
|
switch (event.key) {
|
|
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
|
|
|
|
case Keys.Enter:
|
|
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:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
return match(data.comboboxState, {
|
|
[ComboboxState.Open]: () => actions.goToOption(Focus.Next),
|
|
[ComboboxState.Closed]: () => actions.openCombobox(),
|
|
})
|
|
|
|
case Keys.ArrowUp:
|
|
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
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.First)
|
|
|
|
case Keys.PageUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.First)
|
|
|
|
case Keys.End:
|
|
if (event.shiftKey) {
|
|
break
|
|
}
|
|
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.Last)
|
|
|
|
case Keys.PageDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.Last)
|
|
|
|
case Keys.Escape:
|
|
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:
|
|
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)
|
|
|
|
// 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) =>
|
|
!option.dataRef.current.disabled &&
|
|
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<HTMLElement>) => {
|
|
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
|
|
portal?: boolean
|
|
modal?: boolean
|
|
}
|
|
>
|
|
|
|
function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
|
|
props: ComboboxOptionsProps<TTag>,
|
|
ref: Ref<HTMLElement>
|
|
) {
|
|
let internalId = useId()
|
|
let {
|
|
id = `headlessui-combobox-options-${internalId}`,
|
|
hold = false,
|
|
anchor: rawAnchor,
|
|
portal = false,
|
|
modal = true,
|
|
...theirProps
|
|
} = props
|
|
let data = useData('Combobox.Options')
|
|
let actions = useActions('Combobox.Options')
|
|
let anchor = useResolvedAnchor(rawAnchor)
|
|
|
|
// Always enable `portal` functionality, when `anchor` is enabled
|
|
if (anchor) {
|
|
portal = true
|
|
}
|
|
|
|
let [floatingRef, style] = useFloatingPanel(anchor)
|
|
let getFloatingPanelProps = useFloatingPanelProps()
|
|
let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null)
|
|
let ownerDocument = useOwnerDocument(data.optionsRef)
|
|
|
|
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(visible, data.inputRef, actions.closeCombobox)
|
|
|
|
// Enable scroll locking when the combobox is visible, and `modal` is enabled
|
|
let scrollLockEnabled = data.__demoMode
|
|
? false
|
|
: modal && data.comboboxState === ComboboxState.Open
|
|
useScrollLock(scrollLockEnabled, ownerDocument)
|
|
|
|
// Mark other elements as inert when the combobox is visible, and `modal` is enabled
|
|
let inertOthersEnabled = data.__demoMode
|
|
? false
|
|
: modal && data.comboboxState === ComboboxState.Open
|
|
useInertOthers(inertOthersEnabled, {
|
|
allowed: useEvent(() => [
|
|
data.inputRef.current,
|
|
data.buttonRef.current,
|
|
data.optionsRef.current,
|
|
]),
|
|
})
|
|
|
|
useIsoMorphicEffect(() => {
|
|
data.optionsPropsRef.current.static = props.static ?? false
|
|
}, [data.optionsPropsRef, props.static])
|
|
useIsoMorphicEffect(() => {
|
|
data.optionsPropsRef.current.hold = hold
|
|
}, [data.optionsPropsRef, hold])
|
|
|
|
useTreeWalker(data.comboboxState === ComboboxState.Open, {
|
|
container: data.optionsRef.current,
|
|
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])
|
|
|
|
// When the user scrolls **using the mouse** (so scroll event isn't appropriate)
|
|
// we want to make sure that the current activation trigger is set to pointer.
|
|
let handleWheel = useEvent(() => {
|
|
actions.setActivationTrigger(ActivationTrigger.Pointer)
|
|
})
|
|
|
|
let handleMouseDown = useEvent((event: ReactMouseEvent) => {
|
|
// When clicking inside of the scrollbar, a `click` event will be triggered
|
|
// on the focusable element _below_ the scrollbar. If you use a `<Combobox>`
|
|
// inside of a `<Dialog>`, clicking the scrollbar of the `<ComboboxOptions>`
|
|
// will move focus to the `<Dialog>` which blurs the `<ComboboxInput>` and
|
|
// closes the `<Combobox>`.
|
|
//
|
|
// Preventing the default behavior in the `mousedown` event (which happens
|
|
// before `click`) will prevent this issue because the `click` never fires.
|
|
event.preventDefault()
|
|
|
|
// When the user clicks in the `<Options/>`, we want to make sure that we
|
|
// set the activation trigger to `pointer` to prevent auto scrolling to the
|
|
// active option while the user is scrolling.
|
|
actions.setActivationTrigger(ActivationTrigger.Pointer)
|
|
})
|
|
|
|
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
|
|
'aria-labelledby': labelledBy,
|
|
role: 'listbox',
|
|
'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
|
|
id,
|
|
ref: optionsRef,
|
|
style: {
|
|
...theirProps.style,
|
|
...style,
|
|
'--input-width': useElementSize(data.inputRef, true).width,
|
|
'--button-width': useElementSize(data.buttonRef, true).width,
|
|
} as CSSProperties,
|
|
onWheel: data.activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel,
|
|
onMouseDown: handleMouseDown,
|
|
})
|
|
|
|
// Map the children in a scrollable container when virtualization is enabled
|
|
if (data.virtual && visible) {
|
|
Object.assign(theirProps, {
|
|
// @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
|
|
children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
|
|
})
|
|
}
|
|
|
|
// Frozen state, the selected value will only update visually when the user re-opens the <Combobox />
|
|
let [frozenValue, setFrozenValue] = useState(data.value)
|
|
if (
|
|
data.value !== frozenValue &&
|
|
data.comboboxState === ComboboxState.Open &&
|
|
data.mode !== ValueMode.Multi
|
|
) {
|
|
setFrozenValue(data.value)
|
|
}
|
|
|
|
let isSelected = useEvent((compareValue: unknown) => {
|
|
return data.compare(frozenValue, compareValue)
|
|
})
|
|
|
|
return (
|
|
<Portal enabled={portal ? props.static || visible : false}>
|
|
<ComboboxDataContext.Provider
|
|
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
|
|
>
|
|
{render({
|
|
ourProps,
|
|
theirProps,
|
|
slot,
|
|
defaultTag: DEFAULT_OPTIONS_TAG,
|
|
features: OptionsRenderFeatures,
|
|
visible,
|
|
name: 'Combobox.Options',
|
|
})}
|
|
</ComboboxDataContext.Provider>
|
|
</Portal>
|
|
)
|
|
}
|
|
|
|
// ---
|
|
|
|
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<HTMLElement>) {
|
|
let data = useData('Combobox.Option')
|
|
let actions = useActions('Combobox.Option')
|
|
|
|
let internalId = useId()
|
|
let {
|
|
id = `headlessui-combobox-option-${internalId}`,
|
|
value,
|
|
disabled = data.virtual?.disabled?.(value) ?? false,
|
|
order = null,
|
|
...theirProps
|
|
} = props
|
|
|
|
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<HTMLElement | 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.setIsTyping(false)
|
|
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
|
|
return disposables().requestAnimationFrame(() => {
|
|
enableScrollIntoView.current = true
|
|
})
|
|
}, [data.virtual, data.__demoMode])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (!enableScrollIntoView.current) return
|
|
if (data.comboboxState !== ComboboxState.Open) return
|
|
if (!active) return
|
|
if (data.activationTrigger === ActivationTrigger.Pointer) return
|
|
return disposables().requestAnimationFrame(() => {
|
|
internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' })
|
|
})
|
|
}, [
|
|
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) 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) {
|
|
actions.closeCombobox()
|
|
}
|
|
})
|
|
|
|
let handleFocus = useEvent(() => {
|
|
if (disabled) {
|
|
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) 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) return
|
|
if (!active) return
|
|
if (data.optionsPropsRef.current.hold) return
|
|
actions.goToOption(Focus.Nothing)
|
|
})
|
|
|
|
let slot = useMemo(() => {
|
|
return {
|
|
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, {
|
|
/** @deprecated use `<ComboboxInput>` instead of `<Combobox.Input>` */
|
|
Input: ComboboxInput,
|
|
/** @deprecated use `<ComboboxButton>` instead of `<Combobox.Button>` */
|
|
Button: ComboboxButton,
|
|
/** @deprecated use `<Label>` instead of `<Combobox.Label>` */
|
|
Label: ComboboxLabel,
|
|
/** @deprecated use `<ComboboxOptions>` instead of `<Combobox.Options>` */
|
|
Options: ComboboxOptions,
|
|
/** @deprecated use `<ComboboxOption>` instead of `<Combobox.Option>` */
|
|
Option: ComboboxOption,
|
|
})
|