import React, { Fragment, createContext, createRef, useCallback, useContext, useMemo, useReducer, useRef, // Types Dispatch, ElementType, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, MutableRefObject, Ref, useEffect, } 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 { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management' import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' import { VisuallyHidden } from '../../internal/visually-hidden' import { objectToFormEntries } from '../../utils/form' enum ListboxStates { Open, Closed, } enum ActivationTrigger { Pointer, Other, } type ListboxOptionDataRef = MutableRefObject<{ textValue?: string disabled: boolean value: unknown domRef: MutableRefObject }> interface StateDefinition { listboxState: ListboxStates orientation: 'horizontal' | 'vertical' propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }> labelRef: MutableRefObject buttonRef: MutableRefObject optionsRef: MutableRefObject disabled: boolean options: { id: string; dataRef: ListboxOptionDataRef }[] searchQuery: string activeOptionIndex: number | null activationTrigger: ActivationTrigger } enum ActionTypes { OpenListbox, CloseListbox, SetDisabled, SetOrientation, GoToOption, Search, ClearSearch, 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.CloseListbox } | { type: ActionTypes.OpenListbox } | { type: ActionTypes.SetDisabled; disabled: boolean } | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } | { type: ActionTypes.GoToOption focus: Exclude trigger?: ActivationTrigger } | { type: ActionTypes.Search; value: string } | { type: ActionTypes.ClearSearch } | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef } | { type: ActionTypes.UnregisterOption; id: string } let reducers: { [P in ActionTypes]: ( state: StateDefinition, action: Extract ) => StateDefinition } = { [ActionTypes.CloseListbox](state) { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed } }, [ActionTypes.OpenListbox](state) { if (state.disabled) return state if (state.listboxState === ListboxStates.Open) return state return { ...state, listboxState: ListboxStates.Open } }, [ActionTypes.SetDisabled](state, action) { if (state.disabled === action.disabled) return state return { ...state, disabled: action.disabled } }, [ActionTypes.SetOrientation](state, action) { if (state.orientation === action.orientation) return state return { ...state, orientation: action.orientation } }, [ActionTypes.GoToOption](state, action) { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state let adjustedState = adjustOrderedState(state) let activeOptionIndex = calculateActiveIndex(action, { resolveItems: () => adjustedState.options, resolveActiveIndex: () => adjustedState.activeOptionIndex, resolveId: (option) => option.id, resolveDisabled: (option) => option.dataRef.current.disabled, }) return { ...state, ...adjustedState, searchQuery: '', activeOptionIndex, activationTrigger: action.trigger ?? ActivationTrigger.Other, } }, [ActionTypes.Search]: (state, action) => { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state let wasAlreadySearching = state.searchQuery !== '' let offset = wasAlreadySearching ? 0 : 1 let searchQuery = state.searchQuery + action.value.toLowerCase() let reOrderedOptions = state.activeOptionIndex !== null ? state.options .slice(state.activeOptionIndex + offset) .concat(state.options.slice(0, state.activeOptionIndex + offset)) : state.options let matchingOption = reOrderedOptions.find( (option) => !option.dataRef.current.disabled && option.dataRef.current.textValue?.startsWith(searchQuery) ) let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1 if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery } return { ...state, searchQuery, activeOptionIndex: matchIdx, activationTrigger: ActivationTrigger.Other, } }, [ActionTypes.ClearSearch](state) { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state if (state.searchQuery === '') return state return { ...state, searchQuery: '' } }, [ActionTypes.RegisterOption]: (state, action) => { let adjustedState = adjustOrderedState(state, (options) => [ ...options, { id: action.id, dataRef: action.dataRef }, ]) return { ...state, ...adjustedState } }, [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 ListboxContext = createContext<[StateDefinition, Dispatch] | null>(null) ListboxContext.displayName = 'ListboxContext' function useListboxContext(component: string) { let context = useContext(ListboxContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext) throw err } return context } function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } // --- let DEFAULT_LISTBOX_TAG = Fragment interface ListboxRenderPropArg { open: boolean disabled: boolean } let ListboxRoot = forwardRefWithAs(function Listbox< TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string >( props: Props< TTag, ListboxRenderPropArg, 'value' | 'onChange' | 'disabled' | 'horizontal' | 'name' > & { value: TType onChange(value: TType): void disabled?: boolean horizontal?: boolean name?: string }, ref: Ref ) { let { value, name, onChange, disabled = false, horizontal = false, ...passThroughProps } = props const orientation = horizontal ? 'horizontal' : 'vertical' let listboxRef = useSyncRefs(ref) let reducerBag = useReducer(stateReducer, { listboxState: ListboxStates.Closed, propsRef: { current: { value, onChange } }, labelRef: createRef(), buttonRef: createRef(), optionsRef: createRef(), disabled, orientation, options: [], searchQuery: '', activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, } as StateDefinition) let [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag useIsoMorphicEffect(() => { propsRef.current.value = value }, [value, propsRef]) useIsoMorphicEffect(() => { propsRef.current.onChange = onChange }, [onChange, propsRef]) useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) useIsoMorphicEffect( () => dispatch({ type: ActionTypes.SetOrientation, orientation }), [orientation] ) // Handle outside click useOutsideClick([buttonRef, optionsRef], (event, target) => { if (listboxState !== ListboxStates.Open) return dispatch({ type: ActionTypes.CloseListbox }) if (!isFocusableElement(target, FocusableMode.Loose)) { event.preventDefault() buttonRef.current?.focus() } }) let slot = useMemo( () => ({ open: listboxState === ListboxStates.Open, disabled }), [listboxState, disabled] ) let renderConfiguration = { props: { ref: listboxRef, ...passThroughProps }, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox', } return ( {name != null && value != null ? ( <> {objectToFormEntries({ [name]: value }).map(([name, value]) => ( ))} {render(renderConfiguration)} ) : ( render(renderConfiguration) )} ) }) // --- let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean disabled: boolean } type ButtonPropsWeControl = | 'id' | 'type' | 'aria-haspopup' | 'aria-controls' | 'aria-expanded' | 'aria-labelledby' | 'disabled' | 'onKeyDown' | 'onClick' let Button = forwardRefWithAs(function Button( props: Props, ref: Ref ) { let [state, dispatch] = useListboxContext('Listbox.Button') let buttonRef = useSyncRefs(state.buttonRef, ref) let id = `headlessui-listbox-button-${useId()}` let d = useDisposables() let handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 case Keys.Space: case Keys.Enter: case Keys.ArrowDown: event.preventDefault() dispatch({ type: ActionTypes.OpenListbox }) d.nextFrame(() => { if (!state.propsRef.current.value) dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) }) break case Keys.ArrowUp: event.preventDefault() dispatch({ type: ActionTypes.OpenListbox }) d.nextFrame(() => { if (!state.propsRef.current.value) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) }) break } }, [dispatch, state, d] ) let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Space: // Required for firefox, event.preventDefault() in handleKeyDown for // the Space key doesn't cancel the handleKeyUp, which in turn // triggers a *click*. event.preventDefault() break } }, []) let handleClick = useCallback( (event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (state.listboxState === ListboxStates.Open) { dispatch({ type: ActionTypes.CloseListbox }) d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) } else { event.preventDefault() dispatch({ type: ActionTypes.OpenListbox }) } }, [dispatch, 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( () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }), [state] ) let passthroughProps = props let propsWeControl = { ref: buttonRef, id, type: useResolveButtonType(props, state.buttonRef), 'aria-haspopup': true, 'aria-controls': state.optionsRef.current?.id, 'aria-expanded': state.disabled ? undefined : state.listboxState === ListboxStates.Open, 'aria-labelledby': labelledby, disabled: state.disabled, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick: handleClick, } return render({ props: { ...passthroughProps, ...propsWeControl }, slot, defaultTag: DEFAULT_BUTTON_TAG, name: 'Listbox.Button', }) }) // --- let DEFAULT_LABEL_TAG = 'label' as const interface LabelRenderPropArg { open: boolean disabled: boolean } type LabelPropsWeControl = 'id' | 'ref' | 'onClick' let Label = forwardRefWithAs(function Label( props: Props, ref: Ref ) { let [state] = useListboxContext('Listbox.Label') let id = `headlessui-listbox-label-${useId()}` let labelRef = useSyncRefs(state.labelRef, ref) let handleClick = useCallback( () => state.buttonRef.current?.focus({ preventScroll: true }), [state.buttonRef] ) let slot = useMemo( () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }), [state] ) let propsWeControl = { ref: labelRef, id, onClick: handleClick } return render({ props: { ...props, ...propsWeControl }, slot, defaultTag: DEFAULT_LABEL_TAG, name: 'Listbox.Label', }) }) // --- let DEFAULT_OPTIONS_TAG = 'ul' as const interface OptionsRenderPropArg { open: boolean } type OptionsPropsWeControl = | 'aria-activedescendant' | 'aria-labelledby' | 'aria-orientation' | 'id' | 'onKeyDown' | 'role' | 'tabIndex' let OptionsRenderFeatures = Features.RenderStrategy | Features.Static let Options = forwardRefWithAs(function Options< TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG >( props: Props & PropsForFeatures, ref: Ref ) { let [state, dispatch] = useListboxContext('Listbox.Options') let optionsRef = useSyncRefs(state.optionsRef, ref) let id = `headlessui-listbox-options-${useId()}` let d = useDisposables() let searchDisposables = useDisposables() let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { return usesOpenClosedState === State.Open } return state.listboxState === ListboxStates.Open })() useEffect(() => { let container = state.optionsRef.current if (!container) return if (state.listboxState !== ListboxStates.Open) return if (container === document.activeElement) return container.focus({ preventScroll: true }) }, [state.listboxState, state.optionsRef]) let handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { searchDisposables.dispose() switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 // @ts-expect-error Fallthrough is expected here case Keys.Space: if (state.searchQuery !== '') { event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.Search, value: event.key }) } // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() event.stopPropagation() dispatch({ type: ActionTypes.CloseListbox }) if (state.activeOptionIndex !== null) { let { dataRef } = state.options[state.activeOptionIndex] state.propsRef.current.onChange(dataRef.current.value) } disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) break case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous }) case Keys.Home: case Keys.PageUp: event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) case Keys.End: case Keys.PageDown: event.preventDefault() event.stopPropagation() return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) case Keys.Escape: event.preventDefault() event.stopPropagation() dispatch({ type: ActionTypes.CloseListbox }) return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) case Keys.Tab: event.preventDefault() event.stopPropagation() break default: if (event.key.length === 1) { dispatch({ type: ActionTypes.Search, value: event.key }) searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350) } break } }, [d, dispatch, searchDisposables, state] ) let labelledby = useComputed( () => state.labelRef.current?.id ?? state.buttonRef.current?.id, [state.labelRef.current, state.buttonRef.current] ) let slot = useMemo( () => ({ open: state.listboxState === ListboxStates.Open }), [state] ) let propsWeControl = { 'aria-activedescendant': state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, 'aria-labelledby': labelledby, 'aria-orientation': state.orientation, id, onKeyDown: handleKeyDown, role: 'listbox', tabIndex: 0, ref: optionsRef, } let passthroughProps = props return render({ props: { ...passthroughProps, ...propsWeControl }, slot, defaultTag: DEFAULT_OPTIONS_TAG, features: OptionsRenderFeatures, visible, name: 'Listbox.Options', }) }) // --- let DEFAULT_OPTION_TAG = 'li' as const interface OptionRenderPropArg { active: boolean selected: boolean disabled: boolean } type ListboxOptionPropsWeControl = | 'id' | 'role' | 'tabIndex' | 'aria-disabled' | 'aria-selected' | 'onPointerLeave' | 'onMouseLeave' | 'onPointerMove' | 'onMouseMove' | 'onFocus' 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 Listbox itself. // But today is not that day.. TType = Parameters[0]['value'] >( props: Props & { disabled?: boolean value: TType }, ref: Ref ) { let { disabled = false, value, ...passthroughProps } = props let [state, dispatch] = useListboxContext('Listbox.Option') let id = `headlessui-listbox-option-${useId()}` let active = state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false let selected = state.propsRef.current.value === value let internalOptionRef = useRef(null) let optionRef = useSyncRefs(ref, internalOptionRef) useIsoMorphicEffect(() => { if (state.listboxState !== ListboxStates.Open) return if (!active) return if (state.activationTrigger === ActivationTrigger.Pointer) return let d = disposables() d.requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) return d.dispose }, [internalOptionRef, active, state.listboxState, 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 bag = useRef({ disabled, value, domRef: 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(() => state.propsRef.current.onChange(value), [state.propsRef, value]) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) return () => dispatch({ type: ActionTypes.UnregisterOption, id }) }, [bag, id]) useIsoMorphicEffect(() => { if (state.listboxState !== ListboxStates.Open) return if (!selected) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) }, [state.listboxState]) let handleClick = useCallback( (event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() select() dispatch({ type: ActionTypes.CloseListbox }) disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) }, [dispatch, state.buttonRef, disabled, select] ) let handleFocus = useCallback(() => { if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) }, [disabled, id, dispatch]) let handleMove = useCallback(() => { if (disabled) return if (active) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id, trigger: ActivationTrigger.Pointer, }) }, [disabled, active, id, dispatch]) let handleLeave = useCallback(() => { if (disabled) return if (!active) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) }, [disabled, active, dispatch]) let slot = useMemo( () => ({ active, selected, disabled }), [active, selected, disabled] ) let propsWeControl = { id, ref: optionRef, role: 'option', tabIndex: disabled === true ? undefined : -1, 'aria-disabled': disabled === true ? true : undefined, '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: 'Listbox.Option', }) }) // --- export let Listbox = Object.assign(ListboxRoot, { Button, Label, Options, Option })