2537 lines
listbox.tsx
import React, { Fragment, createContext, createRef, useCallback, useContext, useEffect, useMemo, useReducer, useRef, // Types ElementType, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, MutableRefObject, Ref, } 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 { EnsureArray, Props } from '../../types' import { Features, forwardRefWithAs, PropsForFeatures, render, compact, HasDisplayName, RefProp, } 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 { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' import { useEvent } from '../../hooks/use-event' import { useControllable } from '../../hooks/use-controllable' import { useLatestValue } from '../../hooks/use-latest-value' import { useTrackedPointer } from '../../hooks/use-tracked-pointer' enum ListboxStates { Open, Closed, } enum ValueMode { Single, Multi, } enum ActivationTrigger { Pointer, Other, } type ListboxOptionDataRef<T> = MutableRefObject<{ textValue?: string disabled: boolean value: T domRef: MutableRefObject<HTMLElement | null> }> interface StateDefinition<T> { dataRef: MutableRefObject<_Data> labelId: string | null listboxState: ListboxStates options: { id: string; dataRef: ListboxOptionDataRef<T> }[] searchQuery: string activeOptionIndex: number | null activationTrigger: ActivationTrigger } enum ActionTypes { OpenListbox, CloseListbox, GoToOption, Search, ClearSearch, RegisterOption, UnregisterOption, RegisterLabel, } 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 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<T> = | { type: ActionTypes.CloseListbox } | { type: ActionTypes.OpenListbox } | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } | { type: ActionTypes.GoToOption focus: Exclude<Focus, Focus.Specific> trigger?: ActivationTrigger } | { type: ActionTypes.Search; value: string } | { type: ActionTypes.ClearSearch } | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef<T> } | { type: ActionTypes.RegisterLabel; id: string | null } | { type: ActionTypes.UnregisterOption; id: string } let reducers: { [P in ActionTypes]: <T>( state: StateDefinition<T>, action: Extract<Actions<T>, { type: P }> ) => StateDefinition<T> } = { [ActionTypes.CloseListbox](state) { if (state.dataRef.current.disabled) return state if (state.listboxState === ListboxStates.Closed) return state return { ...state, activeOptionIndex: null, listboxState: ListboxStates.Closed } }, [ActionTypes.OpenListbox](state) { if (state.dataRef.current.disabled) return state if (state.listboxState === ListboxStates.Open) return state // Check if we have a selected value that we can make active let activeOptionIndex = state.activeOptionIndex let { isSelected } = state.dataRef.current let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) if (optionIdx !== -1) { activeOptionIndex = optionIdx } return { ...state, listboxState: ListboxStates.Open, activeOptionIndex } }, [ActionTypes.GoToOption](state, action) { if (state.dataRef.current.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.dataRef.current.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.dataRef.current.disabled) return state if (state.listboxState === ListboxStates.Closed) return state if (state.searchQuery === '') return state return { ...state, searchQuery: '' } }, [ActionTypes.RegisterOption]: (state, action) => { let option = { id: action.id, dataRef: action.dataRef } let adjustedState = adjustOrderedState(state, (options) => [...options, option]) // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { if (state.dataRef.current.isSelected(action.dataRef.current.value)) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) } } 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, } }, [ActionTypes.RegisterLabel]: (state, action) => { return { ...state, labelId: action.id, } }, } let ListboxActionsContext = createContext<{ openListbox(): void closeListbox(): void registerOption(id: string, dataRef: ListboxOptionDataRef<unknown>): () => void registerLabel(id: string): () => void goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void selectOption(id: string): void selectActiveOption(): void onChange(value: unknown): void search(query: string): void clearSearch(): void } | null>(null) ListboxActionsContext.displayName = 'ListboxActionsContext' function useActions(component: string) { let context = useContext(ListboxActionsContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent <Listbox /> component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) throw err } return context } type _Actions = ReturnType<typeof useActions> let ListboxDataContext = createContext< | ({ value: unknown disabled: boolean mode: ValueMode orientation: 'horizontal' | 'vertical' activeOptionIndex: number | null compare(a: unknown, z: unknown): boolean isSelected(value: unknown): boolean optionsPropsRef: MutableRefObject<{ static: boolean hold: boolean }> labelRef: MutableRefObject<HTMLLabelElement | null> buttonRef: MutableRefObject<HTMLButtonElement | null> optionsRef: MutableRefObject<HTMLUListElement | null> } & Omit<StateDefinition<unknown>, 'dataRef'>) | null >(null) ListboxDataContext.displayName = 'ListboxDataContext' function useData(component: string) { let context = useContext(ListboxDataContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent <Listbox /> 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_LISTBOX_TAG = Fragment interface ListboxRenderPropArg<T> { open: boolean disabled: boolean value: T } export type ListboxProps<TTag extends ElementType, TType, TActualType> = Props< TTag, ListboxRenderPropArg<TType>, 'value' | 'defaultValue' | 'onChange' | 'by' | 'disabled' | 'horizontal' | 'name' | 'multiple' > & { value?: TType defaultValue?: TType onChange?(value: TType): void by?: (keyof TActualType & string) | ((a: TActualType, z: TActualType) => boolean) disabled?: boolean horizontal?: boolean form?: string name?: string multiple?: boolean } function ListboxFn< TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string, TActualType = TType extends (infer U)[] ? U : TType >(props: ListboxProps<TTag, TType, TActualType>, ref: Ref<HTMLElement>) { let { value: controlledValue, defaultValue, form: formName, name, onChange: controlledOnChange, by = (a: TActualType, z: TActualType) => a === z, disabled = false, horizontal = false, multiple = false, ...theirProps } = props const orientation = horizontal ? 'horizontal' : 'vertical' let listboxRef = useSyncRefs(ref) let [value = multiple ? [] : undefined, theirOnChange] = useControllable<any>( controlledValue, controlledOnChange, defaultValue ) let [state, dispatch] = useReducer(stateReducer, { dataRef: createRef(), listboxState: ListboxStates.Closed, options: [], searchQuery: '', labelId: null, activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, } as StateDefinition<TType>) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) let labelRef = useRef<_Data['labelRef']['current']>(null) let buttonRef = useRef<_Data['buttonRef']['current']>(null) let optionsRef = useRef<_Data['optionsRef']['current']>(null) let compare = useEvent( typeof by === 'string' ? (a, z) => { let property = by as unknown as keyof TActualType return a?.[property] === z?.[property] } : by ) let isSelected: (value: TActualType) => boolean = useCallback( (compareValue) => match(data.mode, { [ValueMode.Multi]: () => (value as unknown as EnsureArray<TType>).some((option) => compare(option, compareValue)), [ValueMode.Single]: () => compare(value as TActualType, compareValue), }), [value] ) let data = useMemo<_Data>( () => ({ ...state, value, disabled, mode: multiple ? ValueMode.Multi : ValueMode.Single, orientation, compare, isSelected, optionsPropsRef, labelRef, buttonRef, optionsRef, }), [value, disabled, multiple, state] ) useIsoMorphicEffect(() => { state.dataRef.current = data }, [data]) // Handle outside click useOutsideClick( [data.buttonRef, data.optionsRef], (event, target) => { dispatch({ type: ActionTypes.CloseListbox }) if (!isFocusableElement(target, FocusableMode.Loose)) { event.preventDefault() data.buttonRef.current?.focus() } }, data.listboxState === ListboxStates.Open ) let slot = useMemo<ListboxRenderPropArg<TType>>( () => ({ open: data.listboxState === ListboxStates.Open, disabled, value }), [data, disabled, value] ) let selectOption = useEvent((id: string) => { let option = data.options.find((item) => item.id === id) if (!option) return onChange(option.dataRef.current.value) }) let selectActiveOption = useEvent(() => { if (data.activeOptionIndex !== null) { let { dataRef, id } = 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. dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) } }) let openListbox = useEvent(() => dispatch({ type: ActionTypes.OpenListbox })) let closeListbox = useEvent(() => dispatch({ type: ActionTypes.CloseListbox })) let goToOption = useEvent((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 }) }) let registerOption = useEvent((id, dataRef) => { dispatch({ type: ActionTypes.RegisterOption, id, dataRef }) return () => dispatch({ type: ActionTypes.UnregisterOption, id }) }) let registerLabel = useEvent((id) => { dispatch({ type: ActionTypes.RegisterLabel, id }) return () => dispatch({ type: ActionTypes.RegisterLabel, id: null }) }) let onChange = useEvent((value: unknown) => { return match(data.mode, { [ValueMode.Single]() { return theirOnChange?.(value as TType) }, [ValueMode.Multi]() { let copy = (data.value as TActualType[]).slice() let idx = copy.findIndex((item) => compare(item, value as TActualType)) if (idx === -1) { copy.push(value as TActualType) } else { copy.splice(idx, 1) } return theirOnChange?.(copy as unknown as TType[]) }, }) }) let search = useEvent((value: string) => dispatch({ type: ActionTypes.Search, value })) let clearSearch = useEvent(() => dispatch({ type: ActionTypes.ClearSearch })) let actions = useMemo<_Actions>( () => ({ onChange, registerOption, registerLabel, goToOption, closeListbox, openListbox, selectActiveOption, selectOption, search, clearSearch, }), [] ) let ourProps = { ref: listboxRef } let form = useRef<HTMLFormElement | null>(null) let d = useDisposables() useEffect(() => { if (!form.current) return if (defaultValue === undefined) return d.addEventListener(form.current, 'reset', () => { onChange(defaultValue) }) }, [form, onChange /* Explicitly ignoring `defaultValue` */]) return ( <ListboxActionsContext.Provider value={actions}> <ListboxDataContext.Provider value={data}> <OpenClosedProvider value={match(data.listboxState, { [ListboxStates.Open]: State.Open, [ListboxStates.Closed]: State.Closed, })} > {name != null && value != null && objectToFormEntries({ [name]: value }).map(([name, value], idx) => ( <Hidden features={HiddenFeatures.Hidden} ref={ idx === 0 ? (element: HTMLInputElement | null) => { form.current = element?.closest('form') ?? null } : undefined } {...compact({ key: name, as: 'input', type: 'hidden', hidden: true, readOnly: true, form: formName, name, value, })} /> ))} {render({ ourProps, theirProps, slot, defaultTag: DEFAULT_LISTBOX_TAG, name: 'Listbox' })} </OpenClosedProvider> </ListboxDataContext.Provider> </ListboxActionsContext.Provider> ) } // --- let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean disabled: boolean value: any } type ButtonPropsWeControl = | 'aria-controls' | 'aria-expanded' | 'aria-haspopup' | 'aria-labelledby' | 'disabled' export type ListboxButtonProps<TTag extends ElementType> = Props< TTag, ButtonRenderPropArg, ButtonPropsWeControl > function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>( props: ListboxButtonProps<TTag>, ref: Ref<HTMLButtonElement> ) { let internalId = useId() let { id = `headlessui-listbox-button-${internalId}`, ...theirProps } = props let data = useData('Listbox.Button') let actions = useActions('Listbox.Button') let buttonRef = useSyncRefs(data.buttonRef, ref) let d = useDisposables() let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => { switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13 case Keys.Space: case Keys.Enter: case Keys.ArrowDown: event.preventDefault() actions.openListbox() d.nextFrame(() => { if (!data.value) actions.goToOption(Focus.First) }) break case Keys.ArrowUp: event.preventDefault() actions.openListbox() d.nextFrame(() => { if (!data.value) actions.goToOption(Focus.Last) }) break } }) let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => { 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 = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (data.listboxState === ListboxStates.Open) { actions.closeListbox() d.nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true })) } else { event.preventDefault() actions.openListbox() } }) let labelledby = useComputed(() => { if (!data.labelId) return undefined return [data.labelId, id].join(' ') }, [data.labelId, id]) let slot = useMemo<ButtonRenderPropArg>( () => ({ open: data.listboxState === ListboxStates.Open, disabled: data.disabled, value: data.value, }), [data] ) let ourProps = { ref: buttonRef, id, type: useResolveButtonType(props, data.buttonRef), 'aria-haspopup': 'listbox', 'aria-controls': data.optionsRef.current?.id, 'aria-expanded': data.disabled ? undefined : data.listboxState === ListboxStates.Open, 'aria-labelledby': labelledby, disabled: data.disabled, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onClick: handleClick, } return render({ ourProps, theirProps, slot, defaultTag: DEFAULT_BUTTON_TAG, name: 'Listbox.Button', }) } // --- let DEFAULT_LABEL_TAG = 'label' as const interface LabelRenderPropArg { open: boolean disabled: boolean } export type ListboxLabelProps<TTag extends ElementType> = Props<TTag, LabelRenderPropArg> function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>( props: ListboxLabelProps<TTag>, ref: Ref<HTMLElement> ) { let internalId = useId() let { id = `headlessui-listbox-label-${internalId}`, ...theirProps } = props let data = useData('Listbox.Label') let actions = useActions('Listbox.Label') let labelRef = useSyncRefs(data.labelRef, ref) useIsoMorphicEffect(() => actions.registerLabel(id), [id]) let handleClick = useEvent(() => data.buttonRef.current?.focus({ preventScroll: true })) let slot = useMemo<LabelRenderPropArg>( () => ({ open: data.listboxState === ListboxStates.Open, disabled: data.disabled }), [data] ) let ourProps = { ref: labelRef, id, onClick: handleClick } return render({ ourProps, theirProps, 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-multiselectable' | 'aria-orientation' | 'role' | 'tabIndex' let OptionsRenderFeatures = Features.RenderStrategy | Features.Static export type ListboxOptionsProps<TTag extends ElementType> = Props< TTag, OptionsRenderPropArg, OptionsPropsWeControl > & PropsForFeatures<typeof OptionsRenderFeatures> function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>( props: ListboxOptionsProps<TTag>, ref: Ref<HTMLElement> ) { let internalId = useId() let { id = `headlessui-listbox-options-${internalId}`, ...theirProps } = props let data = useData('Listbox.Options') let actions = useActions('Listbox.Options') let optionsRef = useSyncRefs(data.optionsRef, ref) let d = useDisposables() let searchDisposables = useDisposables() let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { return (usesOpenClosedState & State.Open) === State.Open } return data.listboxState === ListboxStates.Open })() useEffect(() => { let container = data.optionsRef.current if (!container) return if (data.listboxState !== ListboxStates.Open) return if (container === getOwnerDocument(container)?.activeElement) return container.focus({ preventScroll: true }) }, [data.listboxState, data.optionsRef]) let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLUListElement>) => { searchDisposables.dispose() switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 // @ts-expect-error Fallthrough is expected here case Keys.Space: if (data.searchQuery !== '') { event.preventDefault() event.stopPropagation() return actions.search(event.key) } // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() event.stopPropagation() if (data.activeOptionIndex !== null) { let { dataRef } = data.options[data.activeOptionIndex] actions.onChange(dataRef.current.value) } if (data.mode === ValueMode.Single) { actions.closeListbox() disposables().nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true })) } break case match(data.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): event.preventDefault() event.stopPropagation() return actions.goToOption(Focus.Next) case match(data.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): event.preventDefault() event.stopPropagation() return actions.goToOption(Focus.Previous) 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() event.stopPropagation() actions.closeListbox() return d.nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true })) case Keys.Tab: event.preventDefault() event.stopPropagation() break default: if (event.key.length === 1) { actions.search(event.key) searchDisposables.setTimeout(() => actions.clearSearch(), 350) } break } }) let labelledby = useComputed( () => data.labelRef.current?.id ?? data.buttonRef.current?.id, [data.labelRef.current, data.buttonRef.current] ) let slot = useMemo<OptionsRenderPropArg>( () => ({ open: data.listboxState === ListboxStates.Open }), [data] ) let ourProps = { 'aria-activedescendant': data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, 'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined, 'aria-labelledby': labelledby, 'aria-orientation': data.orientation, id, onKeyDown: handleKeyDown, role: 'listbox', tabIndex: 0, ref: optionsRef, } return render({ ourProps, theirProps, 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 OptionPropsWeControl = 'aria-disabled' | 'aria-selected' | 'role' | 'tabIndex' export type ListboxOptionProps<TTag extends ElementType, TType> = Props< TTag, OptionRenderPropArg, OptionPropsWeControl, { disabled?: boolean value: TType } > function OptionFn< 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<typeof ListboxRoot>[0]['value'] >(props: ListboxOptionProps<TTag, TType>, ref: Ref<HTMLElement>) { let internalId = useId() let { id = `headlessui-listbox-option-${internalId}`, disabled = false, value, ...theirProps } = props let data = useData('Listbox.Option') let actions = useActions('Listbox.Option') let active = data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false let selected = data.isSelected(value) let internalOptionRef = useRef<HTMLLIElement | null>(null) let bag = useLatestValue<ListboxOptionDataRef<TType>['current']>({ disabled, value, domRef: internalOptionRef, get textValue() { return internalOptionRef.current?.textContent?.toLowerCase() }, }) let optionRef = useSyncRefs(ref, internalOptionRef) useIsoMorphicEffect(() => { if (data.listboxState !== ListboxStates.Open) return if (!active) return if (data.activationTrigger === ActivationTrigger.Pointer) return let d = disposables() d.requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) return d.dispose }, [ internalOptionRef, active, data.listboxState, 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, ]) useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) let handleClick = useEvent((event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() actions.onChange(value) if (data.mode === ValueMode.Single) { actions.closeListbox() disposables().nextFrame(() => data.buttonRef.current?.focus({ preventScroll: true })) } }) let handleFocus = useEvent(() => { if (disabled) return actions.goToOption(Focus.Nothing) actions.goToOption(Focus.Specific, id) }) 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 actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) }) let handleLeave = useEvent((evt) => { if (!pointer.wasMoved(evt)) return if (disabled) return if (!active) return actions.goToOption(Focus.Nothing) }) let slot = useMemo<OptionRenderPropArg>( () => ({ active, selected, disabled }), [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 onClick: handleClick, onFocus: handleFocus, onPointerEnter: handleEnter, onMouseEnter: handleEnter, onPointerMove: handleMove, onMouseMove: handleMove, onPointerLeave: handleLeave, onMouseLeave: handleLeave, } return render({ ourProps, theirProps, slot, defaultTag: DEFAULT_OPTION_TAG, name: 'Listbox.Option', }) } // --- interface ComponentListbox extends HasDisplayName { < TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string, TActualType = TType extends (infer U)[] ? U : TType >( props: ListboxProps<TTag, TType, TActualType> & RefProp<typeof ListboxFn> ): JSX.Element } interface ComponentListboxButton extends HasDisplayName { <TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>( props: ListboxButtonProps<TTag> & RefProp<typeof ButtonFn> ): JSX.Element } interface ComponentListboxLabel extends HasDisplayName { <TTag extends ElementType = typeof DEFAULT_LABEL_TAG>( props: ListboxLabelProps<TTag> & RefProp<typeof LabelFn> ): JSX.Element } interface ComponentListboxOptions extends HasDisplayName { <TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>( props: ListboxOptionsProps<TTag> & RefProp<typeof OptionsFn> ): JSX.Element } interface ComponentListboxOption extends HasDisplayName { < TTag extends ElementType = typeof DEFAULT_OPTION_TAG, TType = Parameters<typeof ListboxRoot>[0]['value'] >( props: ListboxOptionProps<TTag, TType> & RefProp<typeof OptionFn> ): JSX.Element } let ListboxRoot = forwardRefWithAs(ListboxFn) as unknown as ComponentListbox let Button = forwardRefWithAs(ButtonFn) as unknown as ComponentListboxButton let Label = forwardRefWithAs(LabelFn) as unknown as ComponentListboxLabel let Options = forwardRefWithAs(OptionsFn) as unknown as ComponentListboxOptions let Option = forwardRefWithAs(OptionFn) as unknown as ComponentListboxOption export let Listbox = Object.assign(ListboxRoot, { Button, Label, Options, Option })
use-disposables.ts
import { useState, useEffect } from 'react' import { disposables } from '../utils/disposables' export function useDisposables() { // Using useState instead of useRef so that we can use the initializer function. let [d] = useState(disposables) useEffect(() => () => d.dispose(), [d]) return d }
use-id.ts
import React from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useServerHandoffComplete } from './use-server-handoff-complete' import { env } from '../utils/env' // We used a "simple" approach first which worked for SSR and rehydration on the client. However we // didn't take care of the Suspense case. To fix this we used the approach the @reach-ui/auto-id // uses. // // Credits: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx export let useId = // Prefer React's `useId` if it's available. // @ts-expect-error - `useId` doesn't exist in React < 18. React.useId ?? function useId() { let ready = useServerHandoffComplete() let [id, setId] = React.useState(ready ? () => env.nextId() : null) useIsoMorphicEffect(() => { if (id === null) setId(env.nextId()) }, [id]) return id != null ? '' + id : undefined }
use-iso-morphic-effect.ts
import { useLayoutEffect, useEffect, EffectCallback, DependencyList } from 'react' import { env } from '../utils/env' export let useIsoMorphicEffect = (effect: EffectCallback, deps?: DependencyList | undefined) => { if (env.isServer) { useEffect(effect, deps) } else { useLayoutEffect(effect, deps) } }
use-computed.ts
import { useState } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useLatestValue } from './use-latest-value' export function useComputed<T>(cb: () => T, dependencies: React.DependencyList) { let [value, setValue] = useState(cb) let cbRef = useLatestValue(cb) useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies]) return value }
use-sync-refs.ts
import { useRef, useEffect } from 'react' import { useEvent } from './use-event' let Optional = Symbol() export function optionalRef<T>(cb: (ref: T) => void, isOptional = true) { return Object.assign(cb, { [Optional]: isOptional }) } export function useSyncRefs<TType>( ...refs: (React.MutableRefObject<TType | null> | ((instance: TType) => void) | null)[] ) { let cache = useRef(refs) useEffect(() => { cache.current = refs }, [refs]) let syncRefs = useEvent((value: TType) => { for (let ref of cache.current) { if (ref == null) continue if (typeof ref === 'function') ref(value) else ref.current = value } }) return refs.every( (ref) => ref == null || // @ts-expect-error ref?.[Optional] ) ? undefined : syncRefs }
use-resolve-button-type.ts
import { useState, MutableRefObject } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' function resolveType<TTag>(props: { type?: string; as?: TTag }) { if (props.type) return props.type let tag = props.as ?? 'button' if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button' return undefined } export function useResolveButtonType<TTag>( props: { type?: string; as?: TTag }, ref: MutableRefObject<HTMLElement | null> ) { let [type, setType] = useState(() => resolveType(props)) useIsoMorphicEffect(() => { setType(resolveType(props)) }, [props.type, props.as]) useIsoMorphicEffect(() => { if (type) return if (!ref.current) return if (ref.current instanceof HTMLButtonElement && !ref.current.hasAttribute('type')) { setType('button') } }, [type, ref]) return type }
use-outside-click.ts
import { MutableRefObject, useEffect, useRef } from 'react' import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { useDocumentEvent } from './use-document-event' type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null type ContainerCollection = Container[] | Set<Container> type ContainerInput = Container | ContainerCollection export function useOutsideClick( containers: ContainerInput | (() => ContainerInput), cb: (event: MouseEvent | PointerEvent | FocusEvent, target: HTMLElement) => void, enabled: boolean = true ) { // TODO: remove this once the React bug has been fixed: https://github.com/facebook/react/issues/24657 let enabledRef = useRef(false) useEffect( process.env.NODE_ENV === 'test' ? () => { enabledRef.current = enabled } : () => { requestAnimationFrame(() => { enabledRef.current = enabled }) }, [enabled] ) function handleOutsideClick<E extends MouseEvent | PointerEvent | FocusEvent>( event: E, resolveTarget: (event: E) => HTMLElement | null ) { if (!enabledRef.current) return // Check whether the event got prevented already. This can happen if you use the // useOutsideClick hook in both a Dialog and a Menu and the inner Menu "cancels" the default // behaviour so that only the Menu closes and not the Dialog (yet) if (event.defaultPrevented) return let _containers = (function resolve(containers): ContainerCollection { if (typeof containers === 'function') { return resolve(containers()) } if (Array.isArray(containers)) { return containers } if (containers instanceof Set) { return containers } return [containers] })(containers) let target = resolveTarget(event) if (target === null) { return } // Ignore if the target doesn't exist in the DOM anymore if (!target.getRootNode().contains(target)) return // Ignore if the target exists in one of the containers for (let container of _containers) { if (container === null) continue let domNode = container instanceof HTMLElement ? container : container.current if (domNode?.contains(target)) { return } // If the click crossed a shadow boundary, we need to check if the container // is inside the tree by using `composedPath` to "pierce" the shadow boundary if (event.composed && event.composedPath().includes(domNode as EventTarget)) { return } } // This allows us to check whether the event was defaultPrevented when you are nesting this // inside a `<Dialog />` for example. if ( // This check alllows us to know whether or not we clicked on a "focusable" element like a // button or an input. This is a backwards compatibility check so that you can open a <Menu // /> and click on another <Menu /> which should close Menu A and open Menu B. We might // revisit that so that you will require 2 clicks instead. !isFocusableElement(target, FocusableMode.Loose) && // This could be improved, but the `Combobox.Button` adds tabIndex={-1} to make it // unfocusable via the keyboard so that tabbing to the next item from the input doesn't // first go to the button. target.tabIndex !== -1 ) { event.preventDefault() } return cb(event, target) } let initialClickTarget = useRef<EventTarget | null>(null) useDocumentEvent( 'mousedown', (event) => { if (enabledRef.current) { initialClickTarget.current = event.composedPath?.()?.[0] || event.target } }, true ) useDocumentEvent( 'click', (event) => { if (!initialClickTarget.current) { return } handleOutsideClick(event, () => { return initialClickTarget.current as HTMLElement }) initialClickTarget.current = null }, // We will use the `capture` phase so that layers in between with `event.stopPropagation()` // don't "cancel" this outside click check. E.g.: A `Menu` inside a `DialogPanel` if the `Menu` // is open, and you click outside of it in the `DialogPanel` the `Menu` should close. However, // the `DialogPanel` has a `onClick(e) { e.stopPropagation() }` which would cancel this. true ) // When content inside an iframe is clicked `window` will receive a blur event // This can happen when an iframe _inside_ a window is clicked // Or, if headless UI is *in* the iframe, when a content in a window containing that iframe is clicked // In this case we care only about the first case so we check to see if the active element is the iframe // If so this was because of a click, focus, or other interaction with the child iframe // and we can consider it an "outside click" useDocumentEvent( 'blur', (event) => handleOutsideClick(event, () => window.document.activeElement instanceof HTMLIFrameElement ? window.document.activeElement : null ), true ) }
use-event.ts
import React from 'react' import { useLatestValue } from './use-latest-value' export let useEvent = // TODO: Add React.useEvent ?? once the useEvent hook is available function useEvent< F extends (...args: any[]) => any, P extends any[] = Parameters<F>, R = ReturnType<F> >(cb: (...args: P) => R) { let cache = useLatestValue(cb) return React.useCallback((...args: P) => cache.current(...args), [cache]) }
use-controllable.ts
import { useRef, useState } from 'react' import { useEvent } from './use-event' export function useControllable<T>( controlledValue: T | undefined, onChange?: (value: T) => void, defaultValue?: T ) { let [internalValue, setInternalValue] = useState(defaultValue) let isControlled = controlledValue !== undefined let wasControlled = useRef(isControlled) let didWarnOnUncontrolledToControlled = useRef(false) let didWarnOnControlledToUncontrolled = useRef(false) if (isControlled && !wasControlled.current && !didWarnOnUncontrolledToControlled.current) { didWarnOnUncontrolledToControlled.current = true wasControlled.current = isControlled console.error( 'A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.' ) } else if (!isControlled && wasControlled.current && !didWarnOnControlledToUncontrolled.current) { didWarnOnControlledToUncontrolled.current = true wasControlled.current = isControlled console.error( 'A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.' ) } return [ (isControlled ? controlledValue : internalValue)!, useEvent((value) => { if (isControlled) { return onChange?.(value) } else { setInternalValue(value) return onChange?.(value) } }), ] as const }
use-latest-value.ts
import { useRef } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' export function useLatestValue<T>(value: T) { let cache = useRef(value) useIsoMorphicEffect(() => { cache.current = value }, [value]) return cache }
use-tracked-pointer.ts
import { useRef } from 'react' type PointerPosition = [x: number, y: number] function eventToPosition(evt: PointerEvent): PointerPosition { return [evt.screenX, evt.screenY] } export function useTrackedPointer() { let lastPos = useRef<PointerPosition>([-1, -1]) return { wasMoved(evt: PointerEvent) { // FIXME: Remove this once we use browser testing in all the relevant places. // NOTE: This is replaced with a compile-time define during the build process // This hack exists to work around a few failing tests caused by our inability to "move" the virtual pointer in JSDOM pointer events. if (process.env.TEST_BYPASS_TRACKED_POINTER) { return true } let newPos = eventToPosition(evt) if (lastPos.current[0] === newPos[0] && lastPos.current[1] === newPos[1]) { return false } lastPos.current = newPos return true }, update(evt: PointerEvent) { lastPos.current = eventToPosition(evt) }, } }
render.ts
import { Fragment, cloneElement, createElement, forwardRef, isValidElement, // Types ElementType, ReactElement, Ref, } from 'react' import { Props, XOR, __, Expand } from '../types' import { classNames } from './class-names' import { match } from './match' export enum Features { /** No features at all */ None = 0, /** * When used, this will allow us to use one of the render strategies. * * **The render strategies are:** * - **Unmount** _(Will unmount the component.)_ * - **Hidden** _(Will hide the component using the [hidden] attribute.)_ */ RenderStrategy = 1, /** * When used, this will allow the user of our component to be in control. This can be used when * you want to transition based on some state. */ Static = 2, } export enum RenderStrategy { Unmount, Hidden, } type PropsForFeature<TPassedInFeatures extends Features, TForFeature extends Features, TProps> = { [P in TPassedInFeatures]: P extends TForFeature ? TProps : __ }[TPassedInFeatures] export type PropsForFeatures<T extends Features> = XOR< PropsForFeature<T, Features.Static, { static?: boolean }>, PropsForFeature<T, Features.RenderStrategy, { unmount?: boolean }> > export function render<TFeature extends Features, TTag extends ElementType, TSlot>({ ourProps, theirProps, slot, defaultTag, features, visible = true, name, }: { ourProps: Expand<Props<TTag, TSlot, any> & PropsForFeatures<TFeature>> & { ref?: Ref<HTMLElement | ElementType> } theirProps: Expand<Props<TTag, TSlot, any>> slot?: TSlot defaultTag: ElementType features?: TFeature visible?: boolean name: string }) { let props = mergeProps(theirProps, ourProps) // Visible always render if (visible) return _render(props, slot, defaultTag, name) let featureFlags = features ?? Features.None if (featureFlags & Features.Static) { let { static: isStatic = false, ...rest } = props as PropsForFeatures<Features.Static> // When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else if (isStatic) return _render(rest, slot, defaultTag, name) } if (featureFlags & Features.RenderStrategy) { let { unmount = true, ...rest } = props as PropsForFeatures<Features.RenderStrategy> let strategy = unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden return match(strategy, { [RenderStrategy.Unmount]() { return null }, [RenderStrategy.Hidden]() { return _render( { ...rest, ...{ hidden: true, style: { display: 'none' } } }, slot, defaultTag, name ) }, }) } // No features enabled, just render return _render(props, slot, defaultTag, name) } function _render<TTag extends ElementType, TSlot>( props: Props<TTag, TSlot> & { ref?: unknown }, slot: TSlot = {} as TSlot, tag: ElementType, name: string ) { let { as: Component = tag, children, refName = 'ref', ...rest } = omit(props, ['unmount', 'static']) // This allows us to use `<HeadlessUIComponent as={MyComponent} refName="innerRef" />` let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {} let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as | ReactElement | ReactElement[] // Allow for className to be a function with the slot as the contents if ('className' in rest && rest.className && typeof rest.className === 'function') { rest.className = rest.className(slot) } let dataAttributes: Record<string, string> = {} if (slot) { let exposeState = false let states = [] for (let [k, v] of Object.entries(slot)) { if (typeof v === 'boolean') { exposeState = true } if (v === true) { states.push(k) } } if (exposeState) dataAttributes[`data-headlessui-state`] = states.join(' ') } if (Component === Fragment) { if (Object.keys(compact(rest)).length > 0) { if ( !isValidElement(resolvedChildren) || (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) ) { throw new Error( [ 'Passing props on "Fragment"!', '', `The current component <${name} /> is rendering a "Fragment".`, `However we need to passthrough the following props:`, Object.keys(rest) .map((line) => ` - ${line}`) .join('\n'), '', 'You can apply a few solutions:', [ 'Add an `as="..."` prop, to ensure that we render an actual element instead of a "Fragment".', 'Render a single element as the child so that we can forward the props onto that element.', ] .map((line) => ` - ${line}`) .join('\n'), ].join('\n') ) } // Merge class name prop in SSR // @ts-ignore We know that the props may not have className. It'll be undefined then which is fine. let newClassName = classNames(resolvedChildren.props?.className, rest.className) let classNameProps = newClassName ? { className: newClassName } : {} return cloneElement( resolvedChildren, Object.assign( {}, // Filter out undefined values so that they don't override the existing values mergeProps(resolvedChildren.props as any, compact(omit(rest, ['ref']))), dataAttributes, refRelatedProps, mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref), classNameProps ) ) } } return createElement( Component, Object.assign( {}, omit(rest, ['ref']), Component !== Fragment && refRelatedProps, Component !== Fragment && dataAttributes ), resolvedChildren ) } function mergeRefs(...refs: any[]) { return { ref: refs.every((ref) => ref == null) ? undefined : (value: any) => { for (let ref of refs) { if (ref == null) continue if (typeof ref === 'function') ref(value) else ref.current = value } }, } } function mergeProps(...listOfProps: Props<any, any>[]) { if (listOfProps.length === 0) return {} if (listOfProps.length === 1) return listOfProps[0] let target: Props<any, any> = {} let eventHandlers: Record< string, ((event: { defaultPrevented: boolean }, ...args: any[]) => void | undefined)[] > = {} for (let props of listOfProps) { for (let prop in props) { // Collect event handlers if (prop.startsWith('on') && typeof props[prop] === 'function') { eventHandlers[prop] ??= [] eventHandlers[prop].push(props[prop]) } else { // Override incoming prop target[prop] = props[prop] } } } // Do not attach any event handlers when there is a `disabled` or `aria-disabled` prop set. if (target.disabled || target['aria-disabled']) { return Object.assign( target, // Set all event listeners that we collected to `undefined`. This is // important because of the `cloneElement` from above, which merges the // existing and new props, they don't just override therefore we have to // explicitly nullify them. Object.fromEntries(Object.keys(eventHandlers).map((eventName) => [eventName, undefined])) ) } // Merge event handlers for (let eventName in eventHandlers) { Object.assign(target, { [eventName](event: { nativeEvent?: Event; defaultPrevented: boolean }, ...args: any[]) { let handlers = eventHandlers[eventName] for (let handler of handlers) { if ( (event instanceof Event || event?.nativeEvent instanceof Event) && event.defaultPrevented ) { return } handler(event, ...args) } }, }) } return target } export type HasDisplayName = { displayName: string } export type RefProp<T extends Function> = T extends (props: any, ref: Ref<infer RefType>) => any ? { ref?: Ref<RefType> } : never /** * This is a hack, but basically we want to keep the full 'API' of the component, but we do want to * wrap it in a forwardRef so that we _can_ passthrough the ref */ export function forwardRefWithAs<T extends { name: string; displayName?: string }>( component: T ): T & { displayName: string } { return Object.assign(forwardRef(component as unknown as any) as any, { displayName: component.displayName ?? component.name, }) } export function compact<T extends Record<any, any>>(object: T) { let clone = Object.assign({}, object) for (let key in clone) { if (clone[key] === undefined) delete clone[key] } return clone } function omit<T extends Record<any, any>>(object: T, keysToOmit: string[] = []) { let clone = Object.assign({}, object) as T for (let key of keysToOmit) { if (key in clone) delete clone[key] } return clone }
match.ts
export function match<TValue extends string | number = string, TReturnValue = unknown>( value: TValue, lookup: Record<TValue, TReturnValue | ((...args: any[]) => TReturnValue)>, ...args: any[] ): TReturnValue { if (value in lookup) { let returnValue = lookup[value] return typeof returnValue === 'function' ? returnValue(...args) : returnValue } let error = new Error( `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( lookup ) .map((key) => `"${key}"`) .join(', ')}.` ) if (Error.captureStackTrace) Error.captureStackTrace(error, match) throw error }
disposables.ts
import { microTask } from './micro-task' export type Disposables = ReturnType<typeof disposables> export function disposables() { let _disposables: Function[] = [] let api = { addEventListener<TEventName extends keyof WindowEventMap>( element: HTMLElement | Window | Document, name: TEventName, listener: (event: WindowEventMap[TEventName]) => any, options?: boolean | AddEventListenerOptions ) { element.addEventListener(name, listener as any, options) return api.add(() => element.removeEventListener(name, listener as any, options)) }, requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) { let raf = requestAnimationFrame(...args) return api.add(() => cancelAnimationFrame(raf)) }, nextFrame(...args: Parameters<typeof requestAnimationFrame>) { return api.requestAnimationFrame(() => { return api.requestAnimationFrame(...args) }) }, setTimeout(...args: Parameters<typeof setTimeout>) { let timer = setTimeout(...args) return api.add(() => clearTimeout(timer)) }, microTask(...args: Parameters<typeof microTask>) { let task = { current: true } microTask(() => { if (task.current) { args[0]() } }) return api.add(() => { task.current = false }) }, style(node: HTMLElement, property: string, value: string) { let previous = node.style.getPropertyValue(property) Object.assign(node.style, { [property]: value }) return this.add(() => { Object.assign(node.style, { [property]: previous }) }) }, group(cb: (d: typeof this) => void) { let d = disposables() cb(d) return this.add(() => d.dispose()) }, add(cb: () => void) { _disposables.push(cb) return () => { let idx = _disposables.indexOf(cb) if (idx >= 0) { for (let dispose of _disposables.splice(idx, 1)) { dispose() } } } }, dispose() { for (let dispose of _disposables.splice(0)) { dispose() } }, } return api }
calculate-active-index.ts
function assertNever(x: never): never { throw new Error('Unexpected object: ' + x) } export enum Focus { /** Focus the first non-disabled item. */ First, /** Focus the previous non-disabled item. */ Previous, /** Focus the next non-disabled item. */ Next, /** Focus the last non-disabled item. */ Last, /** Focus a specific item based on the `id` of the item. */ Specific, /** Focus no items at all. */ Nothing, } export function calculateActiveIndex<TItem>( action: { focus: Focus.Specific; id: string } | { focus: Exclude<Focus, Focus.Specific> }, resolvers: { resolveItems(): TItem[] resolveActiveIndex(): number | null resolveId(item: TItem): string resolveDisabled(item: TItem): boolean } ) { let items = resolvers.resolveItems() if (items.length <= 0) return null let currentActiveIndex = resolvers.resolveActiveIndex() let activeIndex = currentActiveIndex ?? -1 let nextActiveIndex = (() => { switch (action.focus) { case Focus.First: return items.findIndex((item) => !resolvers.resolveDisabled(item)) case Focus.Previous: { let idx = items .slice() .reverse() .findIndex((item, idx, all) => { if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false return !resolvers.resolveDisabled(item) }) if (idx === -1) return idx return items.length - 1 - idx } case Focus.Next: return items.findIndex((item, idx) => { if (idx <= activeIndex) return false return !resolvers.resolveDisabled(item) }) case Focus.Last: { let idx = items .slice() .reverse() .findIndex((item) => !resolvers.resolveDisabled(item)) if (idx === -1) return idx return items.length - 1 - idx } case Focus.Specific: return items.findIndex((item) => resolvers.resolveId(item) === action.id) case Focus.Nothing: return null default: assertNever(action) } })() return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex }
bugs.ts
// See: https://github.com/facebook/react/issues/7711 // See: https://github.com/facebook/react/pull/20612 // See: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-fe-disabled (2.) export function isDisabledReactIssue7711(element: Element): boolean { let parent = element.parentElement let legend = null while (parent && !(parent instanceof HTMLFieldSetElement)) { if (parent instanceof HTMLLegendElement) legend = parent parent = parent.parentElement } let isParentDisabled = parent?.getAttribute('disabled') === '' ?? false if (isParentDisabled && isFirstLegend(legend)) return false return isParentDisabled } function isFirstLegend(element: HTMLLegendElement | null): boolean { if (!element) return false let previous = element.previousElementSibling while (previous !== null) { if (previous instanceof HTMLLegendElement) return false previous = previous.previousElementSibling } return true }
focus-management.ts
import { disposables } from './disposables' import { match } from './match' import { getOwnerDocument } from './owner' // Credit: // - https://stackoverflow.com/a/30753870 let focusableSelector = [ '[contentEditable=true]', '[tabindex]', 'a[href]', 'area[href]', 'button:not([disabled])', 'iframe', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', ] .map( process.env.NODE_ENV === 'test' ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. (selector) => `${selector}:not([tabindex='-1']):not([style*='display: none'])` : (selector) => `${selector}:not([tabindex='-1'])` ) .join(',') export enum Focus { /** Focus the first non-disabled element */ First = 1 << 0, /** Focus the previous non-disabled element */ Previous = 1 << 1, /** Focus the next non-disabled element */ Next = 1 << 2, /** Focus the last non-disabled element */ Last = 1 << 3, /** Wrap tab around */ WrapAround = 1 << 4, /** Prevent scrolling the focusable elements into view */ NoScroll = 1 << 5, } export enum FocusResult { /** Something went wrong while trying to focus. */ Error, /** When `Focus.WrapAround` is enabled, going from position `N` to `N+1` where `N` is the last index in the array, then we overflow. */ Overflow, /** Focus was successful. */ Success, /** When `Focus.WrapAround` is enabled, going from position `N` to `N-1` where `N` is the first index in the array, then we underflow. */ Underflow, } enum Direction { Previous = -1, Next = 1, } export function getFocusableElements(container: HTMLElement | null = document.body) { if (container == null) return [] return Array.from(container.querySelectorAll<HTMLElement>(focusableSelector)).sort( // We want to move `tabIndex={0}` to the end of the list, this is what the browser does as well. (a, z) => Math.sign((a.tabIndex || Number.MAX_SAFE_INTEGER) - (z.tabIndex || Number.MAX_SAFE_INTEGER)) ) } export enum FocusableMode { /** The element itself must be focusable. */ Strict, /** The element should be inside of a focusable element. */ Loose, } export function isFocusableElement( element: HTMLElement, mode: FocusableMode = FocusableMode.Strict ) { if (element === getOwnerDocument(element)?.body) return false return match(mode, { [FocusableMode.Strict]() { return element.matches(focusableSelector) }, [FocusableMode.Loose]() { let next: HTMLElement | null = element while (next !== null) { if (next.matches(focusableSelector)) return true next = next.parentElement } return false }, }) } export function restoreFocusIfNecessary(element: HTMLElement | null) { let ownerDocument = getOwnerDocument(element) disposables().nextFrame(() => { if ( ownerDocument && !isFocusableElement(ownerDocument.activeElement as HTMLElement, FocusableMode.Strict) ) { focusElement(element) } }) } // The method of triggering an action, this is used to determine how we should // restore focus after an action has been performed. enum ActivationMethod { /* If the action was triggered by a keyboard event. */ Keyboard = 0, /* If the action was triggered by a mouse / pointer / ... event.*/ Mouse = 1, } // We want to be able to set and remove the `data-headlessui-mouse` attribute on the `html` element. if (typeof window !== 'undefined' && typeof document !== 'undefined') { document.addEventListener( 'keydown', (event) => { if (event.metaKey || event.altKey || event.ctrlKey) { return } document.documentElement.dataset.headlessuiFocusVisible = '' }, true ) document.addEventListener( 'click', (event) => { // Event originated from an actual mouse click if (event.detail === ActivationMethod.Mouse) { delete document.documentElement.dataset.headlessuiFocusVisible } // Event originated from a keyboard event that triggered the `click` event else if (event.detail === ActivationMethod.Keyboard) { document.documentElement.dataset.headlessuiFocusVisible = '' } }, true ) } export function focusElement(element: HTMLElement | null) { element?.focus({ preventScroll: true }) } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select let selectableSelector = ['textarea', 'input'].join(',') function isSelectableElement( element: Element | null ): element is HTMLInputElement | HTMLTextAreaElement { return element?.matches?.(selectableSelector) ?? false } export function sortByDomNode<T>( nodes: T[], resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null ): T[] { return nodes.slice().sort((aItem, zItem) => { let a = resolveKey(aItem) let z = resolveKey(zItem) if (a === null || z === null) return 0 let position = a.compareDocumentPosition(z) if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1 return 0 }) } export function focusFrom(current: HTMLElement | null, focus: Focus) { return focusIn(getFocusableElements(), focus, { relativeTo: current }) } export function focusIn( container: HTMLElement | HTMLElement[], focus: Focus, { sorted = true, relativeTo = null, skipElements = [], }: Partial<{ sorted: boolean; relativeTo: HTMLElement | null; skipElements: HTMLElement[] }> = {} ) { let ownerDocument = Array.isArray(container) ? container.length > 0 ? container[0].ownerDocument : document : container.ownerDocument let elements = Array.isArray(container) ? sorted ? sortByDomNode(container) : container : getFocusableElements(container) if (skipElements.length > 0 && elements.length > 1) { elements = elements.filter((x) => !skipElements.includes(x)) } relativeTo = relativeTo ?? (ownerDocument.activeElement as HTMLElement) let direction = (() => { if (focus & (Focus.First | Focus.Next)) return Direction.Next if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last') })() let startIndex = (() => { if (focus & Focus.First) return 0 if (focus & Focus.Previous) return Math.max(0, elements.indexOf(relativeTo)) - 1 if (focus & Focus.Next) return Math.max(0, elements.indexOf(relativeTo)) + 1 if (focus & Focus.Last) return elements.length - 1 throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last') })() let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {} let offset = 0 let total = elements.length let next = undefined do { // Guard against infinite loops if (offset >= total || offset + total <= 0) return FocusResult.Error let nextIdx = startIndex + offset if (focus & Focus.WrapAround) { nextIdx = (nextIdx + total) % total } else { if (nextIdx < 0) return FocusResult.Underflow if (nextIdx >= total) return FocusResult.Overflow } next = elements[nextIdx] // Try the focus the next element, might not work if it is "hidden" to the user. next?.focus(focusOptions) // Try the next one in line offset += direction } while (next !== ownerDocument.activeElement) // By default if you <Tab> to a text input or a textarea, the browser will // select all the text once the focus is inside these DOM Nodes. However, // since we are manually moving focus this behaviour is not happening. This // code will make sure that the text gets selected as-if you did it manually. // Note: We only do this when going forward / backward. Not for the // Focus.First or Focus.Last actions. This is similar to the `autoFocus` // behaviour on an input where the input will get focus but won't be // selected. if (focus & (Focus.Next | Focus.Previous) && isSelectableElement(next)) { next.select() } return FocusResult.Success }
form.ts
type Entries = [string, string][] export function objectToFormEntries( source: Record<string, any> = {}, parentKey: string | null = null, entries: Entries = [] ): Entries { for (let [key, value] of Object.entries(source)) { append(entries, composeKey(parentKey, key), value) } return entries } function composeKey(parent: string | null, key: string): string { return parent ? parent + '[' + key + ']' : key } function append(entries: Entries, key: string, value: any): void { if (Array.isArray(value)) { for (let [subkey, subvalue] of value.entries()) { append(entries, composeKey(key, subkey.toString()), subvalue) } } else if (value instanceof Date) { entries.push([key, value.toISOString()]) } else if (typeof value === 'boolean') { entries.push([key, value ? '1' : '0']) } else if (typeof value === 'string') { entries.push([key, value]) } else if (typeof value === 'number') { entries.push([key, `${value}`]) } else if (value === null || value === undefined) { entries.push([key, '']) } else { objectToFormEntries(value, key, entries) } } export function attemptSubmit(element: HTMLElement) { let form = (element as any)?.form ?? element.closest('form') if (!form) return for (let element of form.elements) { if ( (element.tagName === 'INPUT' && element.type === 'submit') || (element.tagName === 'BUTTON' && element.type === 'submit') || (element.nodeName === 'INPUT' && element.type === 'image') ) { // If you press `enter` in a normal input[type='text'] field, then the form will submit by // searching for the a submit element and "click" it. We could also use the // `form.requestSubmit()` function, but this has a downside where an `event.preventDefault()` // inside a `click` listener on the submit button won't stop the form from submitting. element.click() return } } }
owner.ts
import { MutableRefObject } from 'react' import { env } from './env' export function getOwnerDocument<T extends Element | MutableRefObject<Element | null>>( element: T | null | undefined ) { if (env.isServer) return null if (element instanceof Node) return element.ownerDocument if (element?.hasOwnProperty('current')) { if (element.current instanceof Node) return element.current.ownerDocument } return document }
keyboard.ts
// TODO: This must already exist somewhere, right? 🤔 // Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values export enum Keys { Space = ' ', Enter = 'Enter', Escape = 'Escape', Backspace = 'Backspace', Delete = 'Delete', ArrowLeft = 'ArrowLeft', ArrowUp = 'ArrowUp', ArrowRight = 'ArrowRight', ArrowDown = 'ArrowDown', Home = 'Home', End = 'End', PageUp = 'PageUp', PageDown = 'PageDown', Tab = 'Tab', }
types.ts
import { ReactNode, ReactElement, JSXElementConstructor } from 'react' export type ReactTag = keyof JSX.IntrinsicElements | JSXElementConstructor<any> // A unique placeholder we can use as a default. This is nice because we can use this instead of // defaulting to null / never / ... and possibly collide with actual data. // Ideally we use a unique symbol here. let __ = '1D45E01E-AF44-47C4-988A-19A94EBAF55C' as const export type __ = typeof __ export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never export type PropsOf<TTag extends ReactTag> = TTag extends React.ElementType ? Omit<React.ComponentProps<TTag>, 'ref'> : never type PropsWeControl = 'as' | 'children' | 'refName' | 'className' // Resolve the props of the component, but ensure to omit certain props that we control type CleanProps<TTag extends ReactTag, TOmitableProps extends PropertyKey = never> = Omit< PropsOf<TTag>, TOmitableProps | PropsWeControl > // Add certain props that we control type OurProps<TTag extends ReactTag, TSlot> = { as?: TTag children?: ReactNode | ((bag: TSlot) => ReactElement) refName?: string } type HasProperty<T extends object, K extends PropertyKey> = T extends never ? never : K extends keyof T ? true : never // Conditionally override the `className`, to also allow for a function // if and only if the PropsOf<TTag> already defines `className`. // This will allow us to have a TS error on as={Fragment} type ClassNameOverride<TTag extends ReactTag, TSlot = {}> = // Order is important here, because `never extends true` is `true`... true extends HasProperty<PropsOf<TTag>, 'className'> ? { className?: PropsOf<TTag>['className'] | ((bag: TSlot) => string) } : {} // Provide clean TypeScript props, which exposes some of our custom API's. export type Props< TTag extends ReactTag, TSlot = {}, TOmitableProps extends PropertyKey = never, Overrides = {} > = CleanProps<TTag, TOmitableProps | keyof Overrides> & OurProps<TTag, TSlot> & ClassNameOverride<TTag, TSlot> & Overrides type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never } export type XOR<T, U> = T | U extends __ ? never : T extends __ ? U : U extends __ ? T : T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U export type ByComparator<T> = | (T extends null ? string : keyof T & string) | ((a: T, b: T) => boolean) export type EnsureArray<T> = T extends any[] ? T : Expand<T>[]
open-closed.tsx
import React, { createContext, useContext, // Types ReactNode, ReactElement, } from 'react' let Context = createContext<State | null>(null) Context.displayName = 'OpenClosedContext' export enum State { Open = 1 << 0, Closed = 1 << 1, Closing = 1 << 2, Opening = 1 << 3, } export function useOpenClosed() { return useContext(Context) } interface Props { value: State children: ReactNode } export function OpenClosedProvider({ value, children }: Props): ReactElement { return <Context.Provider value={value}>{children}</Context.Provider> }
hidden.tsx
import { ElementType, Ref } from 'react' import { Props } from '../types' import { forwardRefWithAs, render, HasDisplayName, RefProp } from '../utils/render' let DEFAULT_VISUALLY_HIDDEN_TAG = 'div' as const export enum Features { // The default, no features. None = 1 << 0, // Whether the element should be focusable or not. Focusable = 1 << 1, // Whether it should be completely hidden, even to assistive technologies. Hidden = 1 << 2, } export type HiddenProps<TTag extends ElementType> = Props<TTag, {}, never, { features?: Features }> function VisuallyHidden<TTag extends ElementType = typeof DEFAULT_VISUALLY_HIDDEN_TAG>( props: HiddenProps<TTag>, ref: Ref<HTMLElement> ) { let { features = Features.None, ...theirProps } = props let ourProps = { ref, 'aria-hidden': (features & Features.Focusable) === Features.Focusable ? true : undefined, style: { position: 'fixed', top: 1, left: 1, width: 1, height: 0, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: '0', ...((features & Features.Hidden) === Features.Hidden && !((features & Features.Focusable) === Features.Focusable) && { display: 'none' }), }, } return render({ ourProps, theirProps, slot: {}, defaultTag: DEFAULT_VISUALLY_HIDDEN_TAG, name: 'Hidden', }) } interface ComponentHidden extends HasDisplayName { <TTag extends ElementType = typeof DEFAULT_VISUALLY_HIDDEN_TAG>( props: HiddenProps<TTag> & RefProp<typeof VisuallyHidden> ): JSX.Element } export let Hidden = forwardRefWithAs(VisuallyHidden) as unknown as ComponentHidden