40fee45afe
* First attempt at a multi-listbox * implement `multiple` mode on Listbox * add multiple Listbox example to playground * implement `multiple` mode on Combobox * make sure groupContext is not undefined or null On vercel, getting a strange issue like `TypeError: undefined is not an object (evaluating 'r.resolveTarget')` which doesn't happen locally or once published. Would expect it to be `null` since we default to `null`. Hopefully this fixes things. * bump all the dependencies * make sure that `@types/react` use set to the correct version `@types/react-dom` hardcoded the `@types/react` to version `16.14.21` instead of using the latest `16.14.24` resulting in type mismatches. *cries in inconsistency* * update changelog * add multiple Combobox example to playground * refactor Combobox, use actions * use combobox data This is a first step in refactoring everything where we use dedicated actions and data instead of accessing the reducer state directly. It also allows us to get rid of mutations in render where we updated some values in render directly which is not ideal. Co-authored-by: pvanliefland <pierre.vanliefland@gmail.com>
1101 lines
33 KiB
TypeScript
1101 lines
33 KiB
TypeScript
import React, {
|
|
Fragment,
|
|
createContext,
|
|
createRef,
|
|
useCallback,
|
|
useContext,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
|
|
// Types
|
|
Dispatch,
|
|
ElementType,
|
|
KeyboardEvent as ReactKeyboardEvent,
|
|
MouseEvent as ReactMouseEvent,
|
|
MutableRefObject,
|
|
Ref,
|
|
ContextType,
|
|
} from 'react'
|
|
|
|
import { useDisposables } from '../../hooks/use-disposables'
|
|
import { useId } from '../../hooks/use-id'
|
|
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
|
|
import { useComputed } from '../../hooks/use-computed'
|
|
import { useSyncRefs } from '../../hooks/use-sync-refs'
|
|
import { Props } from '../../types'
|
|
import { Features, forwardRefWithAs, PropsForFeatures, render, compact } from '../../utils/render'
|
|
import { match } from '../../utils/match'
|
|
import { disposables } from '../../utils/disposables'
|
|
import { Keys } from '../keyboard'
|
|
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
|
|
import { isDisabledReactIssue7711 } from '../../utils/bugs'
|
|
import { useOutsideClick } from '../../hooks/use-outside-click'
|
|
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
|
|
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
|
|
import { useLatestValue } from '../../hooks/use-latest-value'
|
|
import { useTreeWalker } from '../../hooks/use-tree-walker'
|
|
import { sortByDomNode } from '../../utils/focus-management'
|
|
import { VisuallyHidden } from '../../internal/visually-hidden'
|
|
import { objectToFormEntries } from '../../utils/form'
|
|
|
|
enum ComboboxStates {
|
|
Open,
|
|
Closed,
|
|
}
|
|
|
|
enum ValueMode {
|
|
Single,
|
|
Multi,
|
|
}
|
|
|
|
enum ActivationTrigger {
|
|
Pointer,
|
|
Other,
|
|
}
|
|
|
|
type ComboboxOptionDataRef = MutableRefObject<{
|
|
textValue?: string
|
|
disabled: boolean
|
|
value: unknown
|
|
domRef: MutableRefObject<HTMLElement | null>
|
|
}>
|
|
|
|
interface StateDefinition {
|
|
comboboxState: ComboboxStates
|
|
|
|
comboboxPropsRef: MutableRefObject<{
|
|
value: unknown
|
|
onChange(value: unknown): void
|
|
__demoMode: boolean
|
|
}>
|
|
inputPropsRef: MutableRefObject<{
|
|
displayValue?(item: unknown): string
|
|
}>
|
|
optionsPropsRef: MutableRefObject<{
|
|
static: boolean
|
|
hold: boolean
|
|
}>
|
|
labelRef: MutableRefObject<HTMLLabelElement | null>
|
|
inputRef: MutableRefObject<HTMLInputElement | null>
|
|
buttonRef: MutableRefObject<HTMLButtonElement | null>
|
|
optionsRef: MutableRefObject<HTMLUListElement | null>
|
|
|
|
disabled: boolean
|
|
options: { id: string; dataRef: ComboboxOptionDataRef }[]
|
|
activeOptionIndex: number | null
|
|
activationTrigger: ActivationTrigger
|
|
}
|
|
|
|
enum ActionTypes {
|
|
OpenCombobox,
|
|
CloseCombobox,
|
|
|
|
SetDisabled,
|
|
|
|
GoToOption,
|
|
|
|
RegisterOption,
|
|
UnregisterOption,
|
|
}
|
|
|
|
function adjustOrderedState(
|
|
state: StateDefinition,
|
|
adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i
|
|
) {
|
|
let currentActiveOption =
|
|
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
|
|
|
|
let sortedOptions = sortByDomNode(
|
|
adjustment(state.options.slice()),
|
|
(option) => option.dataRef.current.domRef.current
|
|
)
|
|
|
|
// If we inserted an option before the current active option then the active option index
|
|
// would be wrong. To fix this, we will re-lookup the correct index.
|
|
let adjustedActiveOptionIndex = currentActiveOption
|
|
? sortedOptions.indexOf(currentActiveOption)
|
|
: null
|
|
|
|
// Reset to `null` in case the currentActiveOption was removed.
|
|
if (adjustedActiveOptionIndex === -1) {
|
|
adjustedActiveOptionIndex = null
|
|
}
|
|
|
|
return {
|
|
options: sortedOptions,
|
|
activeOptionIndex: adjustedActiveOptionIndex,
|
|
}
|
|
}
|
|
|
|
type Actions =
|
|
| { type: ActionTypes.CloseCombobox }
|
|
| { type: ActionTypes.OpenCombobox }
|
|
| { type: ActionTypes.SetDisabled; disabled: boolean }
|
|
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
|
|
| {
|
|
type: ActionTypes.GoToOption
|
|
focus: Exclude<Focus, Focus.Specific>
|
|
trigger?: ActivationTrigger
|
|
}
|
|
| { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef }
|
|
| { type: ActionTypes.UnregisterOption; id: string }
|
|
|
|
let reducers: {
|
|
[P in ActionTypes]: (
|
|
state: StateDefinition,
|
|
action: Extract<Actions, { type: P }>
|
|
) => StateDefinition
|
|
} = {
|
|
[ActionTypes.CloseCombobox](state) {
|
|
if (state.disabled) return state
|
|
if (state.comboboxState === ComboboxStates.Closed) return state
|
|
return { ...state, activeOptionIndex: null, comboboxState: ComboboxStates.Closed }
|
|
},
|
|
[ActionTypes.OpenCombobox](state) {
|
|
if (state.disabled) return state
|
|
if (state.comboboxState === ComboboxStates.Open) return state
|
|
return { ...state, comboboxState: ComboboxStates.Open }
|
|
},
|
|
[ActionTypes.SetDisabled](state, action) {
|
|
if (state.disabled === action.disabled) return state
|
|
return { ...state, disabled: action.disabled }
|
|
},
|
|
[ActionTypes.GoToOption](state, action) {
|
|
if (state.disabled) return state
|
|
if (
|
|
state.optionsRef.current &&
|
|
!state.optionsPropsRef.current.static &&
|
|
state.comboboxState === ComboboxStates.Closed
|
|
) {
|
|
return state
|
|
}
|
|
|
|
let adjustedState = adjustOrderedState(state)
|
|
let activeOptionIndex = calculateActiveIndex(action, {
|
|
resolveItems: () => adjustedState.options,
|
|
resolveActiveIndex: () => adjustedState.activeOptionIndex,
|
|
resolveId: (item) => item.id,
|
|
resolveDisabled: (item) => item.dataRef.current.disabled,
|
|
})
|
|
|
|
return {
|
|
...state,
|
|
...adjustedState,
|
|
activeOptionIndex,
|
|
activationTrigger: action.trigger ?? ActivationTrigger.Other,
|
|
}
|
|
},
|
|
[ActionTypes.RegisterOption]: (state, action) => {
|
|
let adjustedState = adjustOrderedState(state, (options) => {
|
|
return [...options, { id: action.id, dataRef: action.dataRef }]
|
|
})
|
|
|
|
let nextState = {
|
|
...state,
|
|
...adjustedState,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
}
|
|
|
|
if (
|
|
state.comboboxPropsRef.current.__demoMode &&
|
|
state.comboboxPropsRef.current.value === undefined
|
|
) {
|
|
nextState.activeOptionIndex = 0
|
|
}
|
|
|
|
return nextState
|
|
},
|
|
[ActionTypes.UnregisterOption]: (state, action) => {
|
|
let adjustedState = adjustOrderedState(state, (options) => {
|
|
let idx = options.findIndex((a) => a.id === action.id)
|
|
if (idx !== -1) options.splice(idx, 1)
|
|
return options
|
|
})
|
|
|
|
return {
|
|
...state,
|
|
...adjustedState,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
}
|
|
},
|
|
}
|
|
|
|
let ComboboxContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
|
|
ComboboxContext.displayName = 'ComboboxContext'
|
|
|
|
function useComboboxContext(component: string) {
|
|
let context = useContext(ComboboxContext)
|
|
if (context === null) {
|
|
let err = new Error(`<${component} /> is missing a parent <Combobox /> component.`)
|
|
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
|
|
throw err
|
|
}
|
|
return context
|
|
}
|
|
|
|
let ComboboxActions = createContext<{
|
|
openCombobox(): void
|
|
closeCombobox(): void
|
|
registerOption(id: string, dataRef: ComboboxOptionDataRef): () => void
|
|
goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void
|
|
goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void
|
|
selectOption(id: string): void
|
|
selectActiveOption(): void
|
|
} | null>(null)
|
|
ComboboxActions.displayName = 'ComboboxActions'
|
|
|
|
function useComboboxActions() {
|
|
let context = useContext(ComboboxActions)
|
|
if (context === null) {
|
|
let err = new Error(`ComboboxActions is missing a parent <Combobox /> component.`)
|
|
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions)
|
|
throw err
|
|
}
|
|
return context
|
|
}
|
|
|
|
let ComboboxData = createContext<{
|
|
value: unknown
|
|
mode: ValueMode
|
|
} | null>(null)
|
|
ComboboxData.displayName = 'ComboboxData'
|
|
|
|
function useComboboxData() {
|
|
let context = useContext(ComboboxData)
|
|
if (context === null) {
|
|
let err = new Error(`ComboboxData is missing a parent <Combobox /> component.`)
|
|
if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxData)
|
|
throw err
|
|
}
|
|
return context
|
|
}
|
|
|
|
function stateReducer(state: StateDefinition, action: Actions) {
|
|
return match(action.type, reducers, state, action)
|
|
}
|
|
|
|
// ---
|
|
|
|
let DEFAULT_COMBOBOX_TAG = Fragment
|
|
interface ComboboxRenderPropArg<T> {
|
|
open: boolean
|
|
disabled: boolean
|
|
activeIndex: number | null
|
|
activeOption: T | null
|
|
}
|
|
|
|
let ComboboxRoot = forwardRefWithAs(function Combobox<
|
|
TTag extends ElementType = typeof DEFAULT_COMBOBOX_TAG,
|
|
TType = string,
|
|
TActualType = TType extends (infer U)[] ? U : TType
|
|
>(
|
|
props: Props<TTag, ComboboxRenderPropArg<TType>, 'value' | 'onChange' | 'disabled' | 'name'> & {
|
|
value: TType
|
|
onChange(value: TType): void
|
|
disabled?: boolean
|
|
__demoMode?: boolean
|
|
name?: string
|
|
},
|
|
ref: Ref<TTag>
|
|
) {
|
|
let { name, value, onChange, disabled = false, __demoMode = false, ...passThroughProps } = props
|
|
|
|
let comboboxPropsRef = useRef<StateDefinition['comboboxPropsRef']['current']>({
|
|
value,
|
|
onChange,
|
|
__demoMode,
|
|
})
|
|
let optionsPropsRef = useRef<StateDefinition['optionsPropsRef']['current']>({
|
|
static: false,
|
|
hold: false,
|
|
})
|
|
let inputPropsRef = useRef<StateDefinition['inputPropsRef']['current']>({
|
|
displayValue: undefined,
|
|
})
|
|
|
|
let reducerBag = useReducer(stateReducer, {
|
|
comboboxState: __demoMode ? ComboboxStates.Open : ComboboxStates.Closed,
|
|
comboboxPropsRef,
|
|
optionsPropsRef,
|
|
inputPropsRef,
|
|
labelRef: createRef(),
|
|
inputRef: createRef(),
|
|
buttonRef: createRef(),
|
|
optionsRef: createRef(),
|
|
disabled,
|
|
options: [],
|
|
activeOptionIndex: null,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
} as StateDefinition)
|
|
let [{ comboboxState, options, activeOptionIndex, optionsRef, inputRef, buttonRef }, dispatch] =
|
|
reducerBag
|
|
|
|
let dataBag = useMemo<Exclude<ContextType<typeof ComboboxData>, null>>(
|
|
() => ({ value, mode: Array.isArray(value) ? ValueMode.Multi : ValueMode.Single }),
|
|
[value]
|
|
)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
comboboxPropsRef.current.value = value
|
|
}, [value])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
comboboxPropsRef.current.onChange = (value: unknown) => {
|
|
return match(dataBag.mode, {
|
|
[ValueMode.Single]() {
|
|
return onChange(value as TType)
|
|
},
|
|
[ValueMode.Multi]() {
|
|
let copy = (dataBag.value as TActualType[]).slice()
|
|
|
|
let idx = copy.indexOf(value as TActualType)
|
|
if (idx === -1) {
|
|
copy.push(value as TActualType)
|
|
} else {
|
|
copy.splice(idx, 1)
|
|
}
|
|
|
|
return onChange(copy as unknown as TType)
|
|
},
|
|
})
|
|
}
|
|
}, [dataBag, onChange, comboboxPropsRef, dataBag])
|
|
|
|
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
|
|
|
|
// Handle outside click
|
|
useOutsideClick([buttonRef, inputRef, optionsRef], () => {
|
|
if (comboboxState !== ComboboxStates.Open) return
|
|
|
|
dispatch({ type: ActionTypes.CloseCombobox })
|
|
})
|
|
|
|
let activeOption =
|
|
activeOptionIndex === null ? null : (options[activeOptionIndex].dataRef.current.value as TType)
|
|
|
|
let slot = useMemo<ComboboxRenderPropArg<TType>>(
|
|
() => ({
|
|
open: comboboxState === ComboboxStates.Open,
|
|
disabled,
|
|
activeIndex: activeOptionIndex,
|
|
activeOption: activeOption,
|
|
}),
|
|
[comboboxState, disabled, options, activeOptionIndex]
|
|
)
|
|
|
|
let syncInputValue = useCallback(() => {
|
|
if (!inputRef.current) return
|
|
if (value === undefined) return
|
|
let displayValue = inputPropsRef.current.displayValue
|
|
|
|
if (typeof displayValue === 'function') {
|
|
inputRef.current.value = displayValue(value)
|
|
} else if (typeof value === 'string') {
|
|
inputRef.current.value = value
|
|
} else {
|
|
inputRef.current.value = ''
|
|
}
|
|
}, [value, inputRef, inputPropsRef])
|
|
|
|
let selectOption = useCallback(
|
|
(id: string) => {
|
|
let option = options.find((item) => item.id === id)
|
|
if (!option) return
|
|
|
|
let { dataRef } = option
|
|
comboboxPropsRef.current.onChange(dataRef.current.value)
|
|
syncInputValue()
|
|
},
|
|
[options, comboboxPropsRef, inputRef]
|
|
)
|
|
|
|
let selectActiveOption = useCallback(() => {
|
|
if (activeOptionIndex !== null) {
|
|
let { dataRef } = options[activeOptionIndex]
|
|
comboboxPropsRef.current.onChange(dataRef.current.value)
|
|
syncInputValue()
|
|
}
|
|
}, [activeOptionIndex, options, comboboxPropsRef, inputRef])
|
|
|
|
let actionsBag = useMemo<ContextType<typeof ComboboxActions>>(
|
|
() => ({
|
|
selectOption,
|
|
selectActiveOption,
|
|
openCombobox() {
|
|
dispatch({ type: ActionTypes.OpenCombobox })
|
|
},
|
|
closeCombobox() {
|
|
dispatch({ type: ActionTypes.CloseCombobox })
|
|
},
|
|
goToOption(focus, id, trigger) {
|
|
if (focus === Focus.Specific) {
|
|
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger })
|
|
}
|
|
|
|
return dispatch({ type: ActionTypes.GoToOption, focus, trigger })
|
|
},
|
|
registerOption(id, dataRef) {
|
|
dispatch({ type: ActionTypes.RegisterOption, id, dataRef })
|
|
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
|
|
},
|
|
}),
|
|
[selectOption, selectActiveOption, dispatch]
|
|
)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (comboboxState !== ComboboxStates.Closed) return
|
|
syncInputValue()
|
|
}, [syncInputValue, comboboxState])
|
|
|
|
// Ensure that we update the inputRef if the value changes
|
|
useIsoMorphicEffect(syncInputValue, [syncInputValue])
|
|
|
|
let renderConfiguration = {
|
|
props: ref === null ? passThroughProps : { ...passThroughProps, ref },
|
|
slot,
|
|
defaultTag: DEFAULT_COMBOBOX_TAG,
|
|
name: 'Combobox',
|
|
}
|
|
|
|
return (
|
|
<ComboboxActions.Provider value={actionsBag}>
|
|
<ComboboxData.Provider value={dataBag}>
|
|
<ComboboxContext.Provider value={reducerBag}>
|
|
<OpenClosedProvider
|
|
value={match(comboboxState, {
|
|
[ComboboxStates.Open]: State.Open,
|
|
[ComboboxStates.Closed]: State.Closed,
|
|
})}
|
|
>
|
|
{name != null && value != null ? (
|
|
<>
|
|
{objectToFormEntries({ [name]: value }).map(([name, value]) => (
|
|
<VisuallyHidden
|
|
{...compact({
|
|
key: name,
|
|
as: 'input',
|
|
type: 'hidden',
|
|
hidden: true,
|
|
readOnly: true,
|
|
name,
|
|
value,
|
|
})}
|
|
/>
|
|
))}
|
|
{render(renderConfiguration)}
|
|
</>
|
|
) : (
|
|
render(renderConfiguration)
|
|
)}
|
|
</OpenClosedProvider>
|
|
</ComboboxContext.Provider>
|
|
</ComboboxData.Provider>
|
|
</ComboboxActions.Provider>
|
|
)
|
|
})
|
|
|
|
// ---
|
|
|
|
let DEFAULT_INPUT_TAG = 'input' as const
|
|
interface InputRenderPropArg {
|
|
open: boolean
|
|
disabled: boolean
|
|
}
|
|
type InputPropsWeControl =
|
|
| 'id'
|
|
| 'role'
|
|
| 'type'
|
|
| 'aria-labelledby'
|
|
| 'aria-expanded'
|
|
| 'aria-activedescendant'
|
|
| 'onKeyDown'
|
|
| 'onChange'
|
|
| 'displayValue'
|
|
|
|
let Input = forwardRefWithAs(function Input<
|
|
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: Props<TTag, InputRenderPropArg, InputPropsWeControl> & {
|
|
displayValue?(item: TType): string
|
|
onChange(event: React.ChangeEvent<HTMLInputElement>): void
|
|
},
|
|
ref: Ref<HTMLInputElement>
|
|
) {
|
|
let { value, onChange, displayValue, ...passThroughProps } = props
|
|
let [state] = useComboboxContext('Combobox.Input')
|
|
let data = useComboboxData()
|
|
let actions = useComboboxActions()
|
|
|
|
let inputRef = useSyncRefs(state.inputRef, ref)
|
|
let inputPropsRef = state.inputPropsRef
|
|
|
|
let id = `headlessui-combobox-input-${useId()}`
|
|
let d = useDisposables()
|
|
|
|
let onChangeRef = useLatestValue(onChange)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
inputPropsRef.current.displayValue = displayValue
|
|
}, [displayValue, inputPropsRef])
|
|
|
|
let handleKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLUListElement>) => {
|
|
switch (event.key) {
|
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
|
|
|
|
case Keys.Enter:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
actions.selectActiveOption()
|
|
if (data.mode === ValueMode.Single) {
|
|
actions.closeCombobox()
|
|
}
|
|
break
|
|
|
|
case Keys.ArrowDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return match(state.comboboxState, {
|
|
[ComboboxStates.Open]: () => {
|
|
actions.goToOption(Focus.Next)
|
|
},
|
|
[ComboboxStates.Closed]: () => {
|
|
actions.openCombobox()
|
|
// TODO: We can't do this outside next frame because the options aren't rendered yet
|
|
// But doing this in next frame results in a flicker because the dom mutations are async here
|
|
// Basically:
|
|
// Sync -> no option list yet
|
|
// Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
|
|
|
|
// TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
|
|
d.nextFrame(() => {
|
|
if (!data.value) {
|
|
actions.goToOption(Focus.Next)
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
case Keys.ArrowUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return match(state.comboboxState, {
|
|
[ComboboxStates.Open]: () => {
|
|
actions.goToOption(Focus.Previous)
|
|
},
|
|
[ComboboxStates.Closed]: () => {
|
|
actions.openCombobox()
|
|
d.nextFrame(() => {
|
|
if (!data.value) {
|
|
actions.goToOption(Focus.Last)
|
|
}
|
|
})
|
|
},
|
|
})
|
|
|
|
case Keys.Home:
|
|
case Keys.PageUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.First)
|
|
|
|
case Keys.End:
|
|
case Keys.PageDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return actions.goToOption(Focus.Last)
|
|
|
|
case Keys.Escape:
|
|
event.preventDefault()
|
|
if (state.optionsRef.current && !state.optionsPropsRef.current.static) {
|
|
event.stopPropagation()
|
|
}
|
|
return actions.closeCombobox()
|
|
|
|
case Keys.Tab:
|
|
actions.selectActiveOption()
|
|
actions.closeCombobox()
|
|
break
|
|
}
|
|
},
|
|
[d, state, actions, data]
|
|
)
|
|
|
|
let handleChange = useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
actions.openCombobox()
|
|
onChangeRef.current?.(event)
|
|
},
|
|
[actions, onChangeRef]
|
|
)
|
|
|
|
// TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present
|
|
// Otherwise it's the ID of the non-label element
|
|
let labelledby = useComputed(() => {
|
|
if (!state.labelRef.current) return undefined
|
|
return [state.labelRef.current.id].join(' ')
|
|
}, [state.labelRef.current])
|
|
|
|
let slot = useMemo<InputRenderPropArg>(
|
|
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
|
|
[state]
|
|
)
|
|
|
|
let propsWeControl = {
|
|
ref: inputRef,
|
|
id,
|
|
role: 'combobox',
|
|
type: 'text',
|
|
'aria-controls': state.optionsRef.current?.id,
|
|
'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
|
|
'aria-activedescendant':
|
|
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
|
|
'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
|
|
'aria-labelledby': labelledby,
|
|
disabled: state.disabled,
|
|
onKeyDown: handleKeyDown,
|
|
onChange: handleChange,
|
|
}
|
|
|
|
return render({
|
|
props: { ...passThroughProps, ...propsWeControl },
|
|
slot,
|
|
defaultTag: DEFAULT_INPUT_TAG,
|
|
name: 'Combobox.Input',
|
|
})
|
|
})
|
|
|
|
// ---
|
|
|
|
let DEFAULT_BUTTON_TAG = 'button' as const
|
|
interface ButtonRenderPropArg {
|
|
open: boolean
|
|
disabled: boolean
|
|
}
|
|
type ButtonPropsWeControl =
|
|
| 'id'
|
|
| 'type'
|
|
| 'tabIndex'
|
|
| 'aria-haspopup'
|
|
| 'aria-controls'
|
|
| 'aria-expanded'
|
|
| 'aria-labelledby'
|
|
| 'disabled'
|
|
| 'onClick'
|
|
| 'onKeyDown'
|
|
|
|
let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
|
|
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
|
|
ref: Ref<HTMLButtonElement>
|
|
) {
|
|
let [state] = useComboboxContext('Combobox.Button')
|
|
let data = useComboboxData()
|
|
let actions = useComboboxActions()
|
|
let buttonRef = useSyncRefs(state.buttonRef, ref)
|
|
|
|
let id = `headlessui-combobox-button-${useId()}`
|
|
let d = useDisposables()
|
|
|
|
let handleKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLUListElement>) => {
|
|
switch (event.key) {
|
|
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
|
|
|
|
case Keys.ArrowDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (state.comboboxState === ComboboxStates.Closed) {
|
|
actions.openCombobox()
|
|
// TODO: We can't do this outside next frame because the options aren't rendered yet
|
|
// But doing this in next frame results in a flicker because the dom mutations are async here
|
|
// Basically:
|
|
// Sync -> no option list yet
|
|
// Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
|
|
|
|
// TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
|
|
d.nextFrame(() => {
|
|
if (!data.value) {
|
|
actions.goToOption(Focus.First)
|
|
}
|
|
})
|
|
}
|
|
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
|
|
|
|
case Keys.ArrowUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (state.comboboxState === ComboboxStates.Closed) {
|
|
actions.openCombobox()
|
|
d.nextFrame(() => {
|
|
if (!data.value) {
|
|
actions.goToOption(Focus.Last)
|
|
}
|
|
})
|
|
}
|
|
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
|
|
|
|
case Keys.Escape:
|
|
event.preventDefault()
|
|
if (state.optionsRef.current && !state.optionsPropsRef.current.static) {
|
|
event.stopPropagation()
|
|
}
|
|
actions.closeCombobox()
|
|
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
|
|
}
|
|
},
|
|
[d, state, actions, data]
|
|
)
|
|
|
|
let handleClick = useCallback(
|
|
(event: ReactMouseEvent) => {
|
|
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
|
|
if (state.comboboxState === ComboboxStates.Open) {
|
|
actions.closeCombobox()
|
|
} else {
|
|
event.preventDefault()
|
|
actions.openCombobox()
|
|
}
|
|
|
|
d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
|
|
},
|
|
[actions, d, state]
|
|
)
|
|
|
|
let labelledby = useComputed(() => {
|
|
if (!state.labelRef.current) return undefined
|
|
return [state.labelRef.current.id, id].join(' ')
|
|
}, [state.labelRef.current, id])
|
|
|
|
let slot = useMemo<ButtonRenderPropArg>(
|
|
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
|
|
[state]
|
|
)
|
|
let passthroughProps = props
|
|
let propsWeControl = {
|
|
ref: buttonRef,
|
|
id,
|
|
type: useResolveButtonType(props, state.buttonRef),
|
|
tabIndex: -1,
|
|
'aria-haspopup': true,
|
|
'aria-controls': state.optionsRef.current?.id,
|
|
'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
|
|
'aria-labelledby': labelledby,
|
|
disabled: state.disabled,
|
|
onClick: handleClick,
|
|
onKeyDown: handleKeyDown,
|
|
}
|
|
|
|
return render({
|
|
props: { ...passthroughProps, ...propsWeControl },
|
|
slot,
|
|
defaultTag: DEFAULT_BUTTON_TAG,
|
|
name: 'Combobox.Button',
|
|
})
|
|
})
|
|
|
|
// ---
|
|
|
|
let DEFAULT_LABEL_TAG = 'label' as const
|
|
interface LabelRenderPropArg {
|
|
open: boolean
|
|
disabled: boolean
|
|
}
|
|
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
|
|
|
|
let Label = forwardRefWithAs(function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
|
|
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>,
|
|
ref: Ref<HTMLLabelElement>
|
|
) {
|
|
let [state] = useComboboxContext('Combobox.Label')
|
|
let id = `headlessui-combobox-label-${useId()}`
|
|
let labelRef = useSyncRefs(state.labelRef, ref)
|
|
|
|
let handleClick = useCallback(
|
|
() => state.inputRef.current?.focus({ preventScroll: true }),
|
|
[state.inputRef]
|
|
)
|
|
|
|
let slot = useMemo<LabelRenderPropArg>(
|
|
() => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
|
|
[state]
|
|
)
|
|
let propsWeControl = { ref: labelRef, id, onClick: handleClick }
|
|
return render({
|
|
props: { ...props, ...propsWeControl },
|
|
slot,
|
|
defaultTag: DEFAULT_LABEL_TAG,
|
|
name: 'Combobox.Label',
|
|
})
|
|
})
|
|
|
|
// ---
|
|
|
|
let DEFAULT_OPTIONS_TAG = 'ul' as const
|
|
interface OptionsRenderPropArg {
|
|
open: boolean
|
|
}
|
|
type OptionsPropsWeControl =
|
|
| 'aria-activedescendant'
|
|
| 'aria-labelledby'
|
|
| 'hold'
|
|
| 'id'
|
|
| 'onKeyDown'
|
|
| 'role'
|
|
| 'tabIndex'
|
|
|
|
let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
|
|
|
|
let Options = forwardRefWithAs(function Options<
|
|
TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG
|
|
>(
|
|
props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> &
|
|
PropsForFeatures<typeof OptionsRenderFeatures> & {
|
|
hold?: boolean
|
|
},
|
|
ref: Ref<HTMLUListElement>
|
|
) {
|
|
let { hold = false, ...passthroughProps } = props
|
|
let [state] = useComboboxContext('Combobox.Options')
|
|
let { optionsPropsRef } = state
|
|
|
|
let optionsRef = useSyncRefs(state.optionsRef, ref)
|
|
|
|
let id = `headlessui-combobox-options-${useId()}`
|
|
|
|
let usesOpenClosedState = useOpenClosed()
|
|
let visible = (() => {
|
|
if (usesOpenClosedState !== null) {
|
|
return usesOpenClosedState === State.Open
|
|
}
|
|
|
|
return state.comboboxState === ComboboxStates.Open
|
|
})()
|
|
|
|
useIsoMorphicEffect(() => {
|
|
optionsPropsRef.current.static = props.static ?? false
|
|
}, [optionsPropsRef, props.static])
|
|
useIsoMorphicEffect(() => {
|
|
optionsPropsRef.current.hold = hold
|
|
}, [hold, optionsPropsRef])
|
|
|
|
useTreeWalker({
|
|
container: state.optionsRef.current,
|
|
enabled: state.comboboxState === ComboboxStates.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 = useComputed(
|
|
() => state.labelRef.current?.id ?? state.buttonRef.current?.id,
|
|
[state.labelRef.current, state.buttonRef.current]
|
|
)
|
|
|
|
let slot = useMemo<OptionsRenderPropArg>(
|
|
() => ({ open: state.comboboxState === ComboboxStates.Open }),
|
|
[state]
|
|
)
|
|
let propsWeControl = {
|
|
'aria-activedescendant':
|
|
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
|
|
'aria-labelledby': labelledby,
|
|
role: 'listbox',
|
|
id,
|
|
ref: optionsRef,
|
|
}
|
|
|
|
return render({
|
|
props: { ...passthroughProps, ...propsWeControl },
|
|
slot,
|
|
defaultTag: DEFAULT_OPTIONS_TAG,
|
|
features: OptionsRenderFeatures,
|
|
visible,
|
|
name: 'Combobox.Options',
|
|
})
|
|
})
|
|
|
|
// ---
|
|
|
|
let DEFAULT_OPTION_TAG = 'li' as const
|
|
interface OptionRenderPropArg {
|
|
active: boolean
|
|
selected: boolean
|
|
disabled: boolean
|
|
}
|
|
type ComboboxOptionPropsWeControl =
|
|
| 'id'
|
|
| 'role'
|
|
| 'tabIndex'
|
|
| 'aria-disabled'
|
|
| 'aria-selected'
|
|
| 'onPointerLeave'
|
|
| 'onMouseLeave'
|
|
| 'onPointerMove'
|
|
| 'onMouseMove'
|
|
|
|
let Option = forwardRefWithAs(function Option<
|
|
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: Props<TTag, OptionRenderPropArg, ComboboxOptionPropsWeControl | 'value'> & {
|
|
disabled?: boolean
|
|
value: TType
|
|
},
|
|
ref: Ref<HTMLLIElement>
|
|
) {
|
|
let { disabled = false, value, ...passthroughProps } = props
|
|
let [state] = useComboboxContext('Combobox.Option')
|
|
let data = useComboboxData()
|
|
let actions = useComboboxActions()
|
|
let id = `headlessui-combobox-option-${useId()}`
|
|
let active =
|
|
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
|
|
let selected = match(data.mode, {
|
|
[ValueMode.Multi]: () => (data.value as TType[]).includes(value),
|
|
[ValueMode.Single]: () => data.value === value,
|
|
})
|
|
let isFirstSelected = match(data.mode, {
|
|
[ValueMode.Multi]: () => {
|
|
let currentValues = data.value as TType[]
|
|
|
|
return (
|
|
state.options.find((option) =>
|
|
currentValues.includes(option.dataRef.current.value as TType)
|
|
)?.id === id
|
|
)
|
|
},
|
|
[ValueMode.Single]: () => selected,
|
|
})
|
|
let internalOptionRef = useRef<HTMLLIElement | null>(null)
|
|
let bag = useRef<ComboboxOptionDataRef['current']>({ disabled, value, domRef: internalOptionRef })
|
|
let optionRef = useSyncRefs(ref, internalOptionRef)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
bag.current.disabled = disabled
|
|
}, [bag, disabled])
|
|
useIsoMorphicEffect(() => {
|
|
bag.current.value = value
|
|
}, [bag, value])
|
|
useIsoMorphicEffect(() => {
|
|
bag.current.textValue = internalOptionRef.current?.textContent?.toLowerCase()
|
|
}, [bag, internalOptionRef])
|
|
|
|
let select = useCallback(() => actions.selectOption(id), [actions, id])
|
|
|
|
useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (state.comboboxState !== ComboboxStates.Open) return
|
|
if (!selected) return
|
|
if (state.activeOptionIndex !== null) return
|
|
|
|
match(data.mode, {
|
|
[ValueMode.Multi]: () => {
|
|
if (isFirstSelected) actions.goToOption(Focus.Specific, id)
|
|
},
|
|
[ValueMode.Single]: () => {
|
|
actions.goToOption(Focus.Specific, id)
|
|
},
|
|
})
|
|
}, [state.comboboxState, state.activeOptionIndex, selected, isFirstSelected, id, actions, data])
|
|
|
|
let enableScrollIntoView = useRef(state.comboboxPropsRef.current.__demoMode ? false : true)
|
|
useIsoMorphicEffect(() => {
|
|
if (!state.comboboxPropsRef.current.__demoMode) return
|
|
let d = disposables()
|
|
d.requestAnimationFrame(() => {
|
|
enableScrollIntoView.current = true
|
|
})
|
|
return d.dispose
|
|
}, [])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (state.comboboxState !== ComboboxStates.Open) return
|
|
if (!active) return
|
|
if (!enableScrollIntoView.current) return
|
|
if (state.activationTrigger === ActivationTrigger.Pointer) return
|
|
let d = disposables()
|
|
d.requestAnimationFrame(() => {
|
|
internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' })
|
|
})
|
|
return d.dispose
|
|
}, [internalOptionRef, active, state.comboboxState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex])
|
|
|
|
let handleClick = useCallback(
|
|
(event: { preventDefault: Function }) => {
|
|
if (disabled) return event.preventDefault()
|
|
select()
|
|
if (data.mode === ValueMode.Single) {
|
|
actions.closeCombobox()
|
|
disposables().nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
|
|
}
|
|
},
|
|
[actions, state.inputRef, disabled, select]
|
|
)
|
|
|
|
let handleFocus = useCallback(() => {
|
|
if (disabled) return actions.goToOption(Focus.Nothing)
|
|
actions.goToOption(Focus.Specific, id)
|
|
}, [disabled, id, actions])
|
|
|
|
let handleMove = useCallback(() => {
|
|
if (disabled) return
|
|
if (active) return
|
|
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
|
|
}, [disabled, active, id, actions])
|
|
|
|
let handleLeave = useCallback(() => {
|
|
if (disabled) return
|
|
if (!active) return
|
|
if (state.optionsPropsRef.current.hold) return
|
|
actions.goToOption(Focus.Nothing)
|
|
}, [disabled, active, actions, state.comboboxState, state.comboboxPropsRef])
|
|
|
|
let slot = useMemo<OptionRenderPropArg>(
|
|
() => ({ active, selected, disabled }),
|
|
[active, selected, disabled]
|
|
)
|
|
|
|
let propsWeControl = {
|
|
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 === true ? true : undefined,
|
|
disabled: undefined, // Never forward the `disabled` prop
|
|
onClick: handleClick,
|
|
onFocus: handleFocus,
|
|
onPointerMove: handleMove,
|
|
onMouseMove: handleMove,
|
|
onPointerLeave: handleLeave,
|
|
onMouseLeave: handleLeave,
|
|
}
|
|
|
|
return render({
|
|
props: { ...passthroughProps, ...propsWeControl },
|
|
slot,
|
|
defaultTag: DEFAULT_OPTION_TAG,
|
|
name: 'Combobox.Option',
|
|
})
|
|
})
|
|
|
|
// ---
|
|
|
|
export let Combobox = Object.assign(ComboboxRoot, { Input, Button, Label, Options, Option })
|