From e819c0a7b2e2aa6de32866ea956174d2e90f2219 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 23 May 2022 11:26:22 +0200 Subject: [PATCH] General/random internal cleanup (part 1) (#1484) * sort React imports * improve type signature of the `useEvent` hook * use more correct `useIsoMorphicEffect` check in `useEvent` * refactor `useCallback` to cleaner `useEvent` * convert `const` to `let` Just for consistency.. * cleanup `Tabs` code Created explicit functions that can be called from child components instead of calling `dispatch` directly. Introduced a `useData` and `useActions` hook to make child components easier. The seperation of `useData` allows us to pass down props directly instead of going via the `useReducer` hook and dispatching actions to make values up to date. * cleanup `Combobox` code * cleanup `RadioGroup` code --- .../src/components/combobox/combobox.tsx | 1100 ++++++++--------- .../components/description/description.tsx | 6 +- .../src/components/dialog/dialog.tsx | 32 +- .../src/components/disclosure/disclosure.tsx | 107 +- .../src/components/focus-trap/focus-trap.tsx | 2 +- .../src/components/label/label.tsx | 6 +- .../src/components/listbox/listbox.tsx | 242 ++-- .../src/components/menu/menu.tsx | 218 ++-- .../src/components/popover/popover.tsx | 2 +- .../src/components/portal/portal.tsx | 2 +- .../components/radio-group/radio-group.tsx | 169 ++- .../src/components/switch/switch.tsx | 39 +- .../src/components/tabs/tabs.tsx | 294 +++-- .../@headlessui-react/src/hooks/use-event.ts | 12 +- .../@headlessui-react/src/hooks/use-flags.ts | 11 +- .../src/hooks/use-iso-morphic-effect.ts | 2 +- .../src/hooks/use-sync-refs.ts | 20 +- .../src/internal/stack-context.tsx | 17 +- 18 files changed, 1063 insertions(+), 1218 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index cb94fab..4067dd2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -9,38 +9,39 @@ import React, { useRef, // Types - Dispatch, ElementType, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, MutableRefObject, Ref, - ContextType, } from 'react' +import { Props } from '../../types' +import { useComputed } from '../../hooks/use-computed' import { useDisposables } from '../../hooks/use-disposables' +import { useEvent } from '../../hooks/use-event' 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 { useOutsideClick } from '../../hooks/use-outside-click' +import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTreeWalker } from '../../hooks/use-tree-walker' -import { sortByDomNode } from '../../utils/focus-management' -import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' -import { objectToFormEntries } from '../../utils/form' -import { useEvent } from '../../hooks/use-event' -enum ComboboxStates { +import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index' +import { disposables } from '../../utils/disposables' +import { forwardRefWithAs, render, compact, PropsForFeatures, Features } from '../../utils/render' +import { isDisabledReactIssue7711 } from '../../utils/bugs' +import { match } from '../../utils/match' +import { objectToFormEntries } from '../../utils/form' +import { sortByDomNode } from '../../utils/focus-management' + +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' +import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' + +import { Keys } from '../keyboard' + +enum ComboboxState { Open, Closed, } @@ -55,57 +56,36 @@ enum ActivationTrigger { Other, } -type ComboboxOptionDataRef = MutableRefObject<{ +type ComboboxOptionDataRef = MutableRefObject<{ textValue?: string disabled: boolean - value: unknown + value: T domRef: MutableRefObject }> -interface StateDefinition { - comboboxState: ComboboxStates +interface StateDefinition { + dataRef: MutableRefObject - comboboxPropsRef: MutableRefObject<{ - value: unknown - mode: ValueMode - onChange(value: unknown): void - nullable: boolean - compare(a: unknown, z: unknown): boolean - __demoMode: boolean - }> - inputPropsRef: MutableRefObject<{ - displayValue?(item: unknown): string - }> - optionsPropsRef: MutableRefObject<{ - static: boolean - hold: boolean - }> - labelRef: MutableRefObject - inputRef: MutableRefObject - buttonRef: MutableRefObject - optionsRef: MutableRefObject + comboboxState: ComboboxState - disabled: boolean - options: { id: string; dataRef: ComboboxOptionDataRef }[] + options: { id: string; dataRef: ComboboxOptionDataRef }[] activeOptionIndex: number | null activationTrigger: ActivationTrigger } -enum ActionTypes { +enum Command { OpenCombobox, CloseCombobox, - SetDisabled, - GoToOption, RegisterOption, UnregisterOption, } -function adjustOrderedState( - state: StateDefinition, - adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i +function adjustOrderedState( + state: StateDefinition, + adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i ) { let currentActiveOption = state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null @@ -132,64 +112,46 @@ function adjustOrderedState( } } -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 - trigger?: ActivationTrigger - } - | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } - | { type: ActionTypes.UnregisterOption; id: string } +type Commands = + | { type: Command.CloseCombobox } + | { type: Command.OpenCombobox } + | { type: Command.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } + | { type: Command.GoToOption; focus: Exclude; trigger?: ActivationTrigger } + | { type: Command.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } + | { type: Command.UnregisterOption; id: string } let reducers: { - [P in ActionTypes]: ( - state: StateDefinition, - action: Extract - ) => StateDefinition + [P in Command]: ( + state: StateDefinition, + command: Extract, { 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 } + [Command.CloseCombobox](state) { + if (state.dataRef.current.disabled) return state + if (state.comboboxState === ComboboxState.Closed) return state + return { ...state, activeOptionIndex: null, comboboxState: ComboboxState.Closed } }, - [ActionTypes.OpenCombobox](state) { - if (state.disabled) return state - if (state.comboboxState === ComboboxStates.Open) return state + [Command.OpenCombobox](state) { + if (state.dataRef.current.disabled) return state + if (state.comboboxState === ComboboxState.Open) return state // Check if we have a selected value that we can make active let activeOptionIndex = state.activeOptionIndex - let { value, mode, compare } = state.comboboxPropsRef.current - let optionIdx = state.options.findIndex((option) => { - let optionValue = option.dataRef.current.value - let selected = match(mode, { - [ValueMode.Multi]: () => - (value as unknown[]).some((option) => compare(option, optionValue)), - [ValueMode.Single]: () => compare(value, optionValue), - }) - - return selected - }) + let { isSelected } = state.dataRef.current + let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) if (optionIdx !== -1) { activeOptionIndex = optionIdx } - return { ...state, comboboxState: ComboboxStates.Open, activeOptionIndex } + return { ...state, comboboxState: ComboboxState.Open, activeOptionIndex } }, - [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 + [Command.GoToOption](state, action) { + if (state.dataRef.current.disabled) return state if ( - state.optionsRef.current && - !state.optionsPropsRef.current.static && - state.comboboxState === ComboboxStates.Closed + state.dataRef.current.optionsRef.current && + !state.dataRef.current.optionsPropsRef.current.static && + state.comboboxState === ComboboxState.Closed ) { return state } @@ -223,20 +185,13 @@ let reducers: { activationTrigger: action.trigger ?? ActivationTrigger.Other, } }, - [ActionTypes.RegisterOption]: (state, action) => { + [Command.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) { - let { value, mode, compare } = state.comboboxPropsRef.current - let optionValue = action.dataRef.current.value - let selected = match(mode, { - [ValueMode.Multi]: () => - (value as unknown[]).some((option) => compare(option, optionValue)), - [ValueMode.Single]: () => compare(value, optionValue), - }) - if (selected) { + if (state.dataRef.current.isSelected(action.dataRef.current.value)) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) } } @@ -247,16 +202,13 @@ let reducers: { activationTrigger: ActivationTrigger.Other, } - if ( - state.comboboxPropsRef.current.__demoMode && - state.comboboxPropsRef.current.value === undefined - ) { + if (state.dataRef.current.__demoMode && state.dataRef.current.value === undefined) { nextState.activeOptionIndex = 0 } return nextState }, - [ActionTypes.UnregisterOption]: (state, action) => { + [Command.UnregisterOption]: (state, action) => { let adjustedState = adjustOrderedState(state, (options) => { let idx = options.findIndex((a) => a.id === action.id) if (idx !== -1) options.splice(idx, 1) @@ -271,58 +223,69 @@ let reducers: { }, } -let ComboboxContext = createContext<[StateDefinition, Dispatch] | 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 component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext) - throw err - } - return context -} - -let ComboboxActions = createContext<{ +let ComboboxActionsContext = createContext<{ openCombobox(): void closeCombobox(): void - registerOption(id: string, dataRef: ComboboxOptionDataRef): () => 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 + onChange(value: unknown): void } | null>(null) -ComboboxActions.displayName = 'ComboboxActions' +ComboboxActionsContext.displayName = 'ComboboxActionsContext' -function useComboboxActions() { - let context = useContext(ComboboxActions) +function useActions(component: string) { + let context = useContext(ComboboxActionsContext) if (context === null) { - let err = new Error(`ComboboxActions is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions) + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) throw err } return context } +type Actions = ReturnType -let ComboboxData = createContext<{ - value: unknown - mode: ValueMode - activeOptionIndex: number | null -} | null>(null) -ComboboxData.displayName = 'ComboboxData' +let ComboboxDataContext = createContext< + | ({ + value: unknown + disabled: boolean + mode: ValueMode + activeOptionIndex: number | null + nullable: boolean + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + __demoMode: boolean -function useComboboxData() { - let context = useContext(ComboboxData) + inputPropsRef: MutableRefObject<{ + displayValue?(item: unknown): string + }> + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + + labelRef: MutableRefObject + inputRef: MutableRefObject + buttonRef: MutableRefObject + optionsRef: MutableRefObject + } & Omit, 'dataRef'>) + | null +>(null) +ComboboxDataContext.displayName = 'ComboboxDataContext' + +function useData(component: string) { + let context = useContext(ComboboxDataContext) if (context === null) { - let err = new Error(`ComboboxData is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxData) + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useData) throw err } return context } +type Data = ReturnType -function stateReducer(state: StateDefinition, action: Actions) { +function stateReducer(state: StateDefinition, action: Commands) { return match(action.type, reducers, state, action) } @@ -360,7 +323,7 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< let { name, value, - onChange, + onChange: theirOnChange, by = (a, z) => a === z, disabled = false, __demoMode = false, @@ -368,69 +331,63 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< multiple = false, ...theirProps } = props - let defaultToFirstOption = useRef(false) - let comboboxPropsRef = useRef({ - value, - mode: multiple ? ValueMode.Multi : ValueMode.Single, - compare: useEvent( - typeof by === 'string' - ? (a: TType, z: TType) => { - let property = by as unknown as keyof TType - return a[property] === z[property] - } - : by - ), - onChange, - nullable, - __demoMode, - }) - - comboboxPropsRef.current.value = value - comboboxPropsRef.current.mode = multiple ? ValueMode.Multi : ValueMode.Single - comboboxPropsRef.current.nullable = nullable - - let optionsPropsRef = useRef({ - static: false, - hold: false, - }) - let inputPropsRef = useRef({ - displayValue: undefined, - }) - - let reducerBag = useReducer(stateReducer, { - comboboxState: __demoMode ? ComboboxStates.Open : ComboboxStates.Closed, - comboboxPropsRef, - optionsPropsRef, - inputPropsRef, - labelRef: createRef(), - inputRef: createRef(), - buttonRef: createRef(), - optionsRef: createRef(), - disabled, + let [state, dispatch] = useReducer(stateReducer, { + dataRef: createRef(), + comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, options: [], activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, - } as StateDefinition) - let [ - { - comboboxState, - options, - activeOptionIndex: _activeOptionIndex, - optionsRef, + } as StateDefinition) + + let defaultToFirstOption = useRef(false) + + let optionsPropsRef = useRef({ static: false, hold: false }) + let inputPropsRef = useRef({ displayValue: undefined }) + + let labelRef = useRef(null) + let inputRef = useRef(null) + let buttonRef = useRef(null) + let optionsRef = useRef(null) + + let compare = useEvent( + typeof by === 'string' + ? (a: TType, z: TType) => { + let property = by as unknown as keyof TType + return a[property] === z[property] + } + : by + ) + + let isSelected: (value: TType) => boolean = useCallback( + (compareValue) => + match(data.mode, { + [ValueMode.Multi]: () => + (value as unknown as TType[]).some((option) => compare(option, compareValue)), + [ValueMode.Single]: () => compare(value, compareValue), + }), + [value] + ) + + let data = useMemo( + () => ({ + ...state, + optionsPropsRef, + inputPropsRef, + labelRef, inputRef, buttonRef, - }, - dispatch, - ] = reducerBag - - let dataBag = useMemo, null>>( - () => ({ + optionsRef, value, + disabled, mode: multiple ? ValueMode.Multi : ValueMode.Single, get activeOptionIndex() { - if (defaultToFirstOption.current && _activeOptionIndex === null && options.length > 0) { - let localActiveOptionIndex = options.findIndex( + if ( + defaultToFirstOption.current && + state.activeOptionIndex === null && + state.options.length > 0 + ) { + let localActiveOptionIndex = state.options.findIndex( (option) => !option.dataRef.current.disabled ) @@ -439,170 +396,175 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< } } - return _activeOptionIndex + return state.activeOptionIndex }, + compare, + isSelected, + nullable, + __demoMode, }), - [value, _activeOptionIndex, options] + [value, disabled, multiple, nullable, __demoMode, state] ) - let activeOptionIndex = dataBag.activeOptionIndex - 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]) + state.dataRef.current = data + }, [data]) // Handle outside click - useOutsideClick([buttonRef, inputRef, optionsRef], () => { - if (comboboxState !== ComboboxStates.Open) return + useOutsideClick([data.buttonRef, data.inputRef, data.optionsRef], () => { + if (data.comboboxState !== ComboboxState.Open) return - dispatch({ type: ActionTypes.CloseCombobox }) + dispatch({ type: Command.CloseCombobox }) }) - let activeOption = - activeOptionIndex === null ? null : (options[activeOptionIndex].dataRef.current.value as TType) - let slot = useMemo>( () => ({ - open: comboboxState === ComboboxStates.Open, + open: data.comboboxState === ComboboxState.Open, disabled, - activeIndex: activeOptionIndex, - activeOption: activeOption, + activeIndex: data.activeOptionIndex, + activeOption: + data.activeOptionIndex === null + ? null + : (data.options[data.activeOptionIndex].dataRef.current.value as TType), }), - [comboboxState, disabled, options, activeOptionIndex] + [data, disabled] ) let syncInputValue = useCallback(() => { - if (!inputRef.current) return + if (!data.inputRef.current) return let displayValue = inputPropsRef.current.displayValue if (typeof displayValue === 'function') { - inputRef.current.value = displayValue(value) ?? '' + data.inputRef.current.value = displayValue(value) ?? '' } else if (typeof value === 'string') { - inputRef.current.value = value + data.inputRef.current.value = value } else { - inputRef.current.value = '' + data.inputRef.current.value = '' } - }, [value, inputRef, inputPropsRef]) + }, [value, data.inputRef, inputPropsRef]) - let selectOption = useCallback( - (id: string) => { - let option = options.find((item) => item.id === id) - if (!option) return + let selectOption = useEvent((id: string) => { + let option = data.options.find((item) => item.id === id) + if (!option) return - let { dataRef } = option - comboboxPropsRef.current.onChange(dataRef.current.value) - syncInputValue() - }, - [options, comboboxPropsRef, inputRef] - ) + onChange(option.dataRef.current.value) + syncInputValue() + }) - let selectActiveOption = useCallback(() => { - if (activeOptionIndex !== null) { - let { dataRef, id } = options[activeOptionIndex] - comboboxPropsRef.current.onChange(dataRef.current.value) + let selectActiveOption = useEvent(() => { + if (data.activeOptionIndex !== null) { + let { dataRef, id } = data.options[data.activeOptionIndex] + onChange(dataRef.current.value) syncInputValue() // 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 }) + dispatch({ type: Command.GoToOption, focus: Focus.Specific, id }) } - }, [activeOptionIndex, options, comboboxPropsRef, inputRef]) + }) - let actionsBag = useMemo>( - () => ({ - selectOption, - selectActiveOption, - openCombobox() { - dispatch({ type: ActionTypes.OpenCombobox }) - defaultToFirstOption.current = true - }, - closeCombobox() { - dispatch({ type: ActionTypes.CloseCombobox }) - defaultToFirstOption.current = false - }, - goToOption(focus, id, trigger) { - defaultToFirstOption.current = false + let openCombobox = useEvent(() => { + dispatch({ type: Command.OpenCombobox }) + defaultToFirstOption.current = true + }) - if (focus === Focus.Specific) { - return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger }) + let closeCombobox = useEvent(() => { + dispatch({ type: Command.CloseCombobox }) + defaultToFirstOption.current = false + }) + + let goToOption = useEvent((focus, id, trigger) => { + defaultToFirstOption.current = false + + if (focus === Focus.Specific) { + return dispatch({ type: Command.GoToOption, focus: Focus.Specific, id: id!, trigger }) + } + + return dispatch({ type: Command.GoToOption, focus, trigger }) + }) + + let registerOption = useEvent((id, dataRef) => { + dispatch({ type: Command.RegisterOption, id, dataRef }) + return () => dispatch({ type: Command.UnregisterOption, id }) + }) + + 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.indexOf(value as TActualType) + if (idx === -1) { + copy.push(value as TActualType) + } else { + copy.splice(idx, 1) } - return dispatch({ type: ActionTypes.GoToOption, focus, trigger }) - }, - registerOption(id, dataRef) { - dispatch({ type: ActionTypes.RegisterOption, id, dataRef }) - return () => dispatch({ type: ActionTypes.UnregisterOption, id }) + return theirOnChange(copy as unknown as TType) }, + }) + }) + + let actions = useMemo( + () => ({ + onChange, + registerOption, + goToOption, + closeCombobox, + openCombobox, + selectActiveOption, + selectOption, }), - [selectOption, selectActiveOption, dispatch] + [] ) useIsoMorphicEffect(() => { - if (comboboxState !== ComboboxStates.Closed) return + if (data.comboboxState !== ComboboxState.Closed) return syncInputValue() - }, [syncInputValue, comboboxState]) + }, [syncInputValue, data.comboboxState]) // Ensure that we update the inputRef if the value changes useIsoMorphicEffect(syncInputValue, [syncInputValue]) let ourProps = ref === null ? {} : { ref } return ( - - - - - {name != null && - value != null && - objectToFormEntries({ [name]: value }).map(([name, value]) => ( - - ))} - {render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_COMBOBOX_TAG, - name: 'Combobox', - })} - - - - + + + + {name != null && + value != null && + objectToFormEntries({ [name]: value }).map(([name, value]) => ( + + ))} + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_COMBOBOX_TAG, + name: 'Combobox', + })} + + + ) }) @@ -636,148 +598,139 @@ let Input = forwardRefWithAs(function Input< ref: Ref ) { let { value, onChange, displayValue, type = 'text', ...theirProps } = props - let [state] = useComboboxContext('Combobox.Input') - let data = useComboboxData() - let actions = useComboboxActions() + let data = useData('Combobox.Input') + let actions = useActions('Combobox.Input') - let inputRef = useSyncRefs(state.inputRef, ref) - let inputPropsRef = state.inputPropsRef + let inputRef = useSyncRefs(data.inputRef, ref) + let inputPropsRef = data.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) => { - switch (event.key) { - // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 - case Keys.Backspace: - case Keys.Delete: - if (data.mode !== ValueMode.Single) return - if (!state.comboboxPropsRef.current.nullable) return + case Keys.Backspace: + case Keys.Delete: + if (data.mode !== ValueMode.Single) return + if (!data.nullable) return - let input = event.currentTarget - d.requestAnimationFrame(() => { - if (input.value === '') { - state.comboboxPropsRef.current.onChange(null) - if (state.optionsRef.current) { - state.optionsRef.current.scrollTop = 0 - } - actions.goToOption(Focus.Nothing) + let input = event.currentTarget + d.requestAnimationFrame(() => { + if (input.value === '') { + actions.onChange(null) + if (data.optionsRef.current) { + data.optionsRef.current.scrollTop = 0 } - }) - break - - case Keys.Enter: - if (state.comboboxState !== ComboboxStates.Open) return - - event.preventDefault() - event.stopPropagation() - - if (data.activeOptionIndex === null) { - actions.closeCombobox() - return + actions.goToOption(Focus.Nothing) } + }) + break - actions.selectActiveOption() - if (data.mode === ValueMode.Single) { - actions.closeCombobox() - } - break + case Keys.Enter: + if (data.comboboxState !== ComboboxState.Open) return - 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 + event.preventDefault() + event.stopPropagation() - // 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() + if (data.activeOptionIndex === null) { actions.closeCombobox() - break - } - }, - [d, state, actions, data] - ) + return + } - let handleChange = useCallback( - (event: React.ChangeEvent) => { - actions.openCombobox() - onChangeRef.current?.(event) - }, - [actions, onChangeRef] - ) + actions.selectActiveOption() + if (data.mode === ValueMode.Single) { + actions.closeCombobox() + } + break - // TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present + case Keys.ArrowDown: + event.preventDefault() + event.stopPropagation() + return match(data.comboboxState, { + [ComboboxState.Open]: () => { + actions.goToOption(Focus.Next) + }, + [ComboboxState.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(data.comboboxState, { + [ComboboxState.Open]: () => { + actions.goToOption(Focus.Previous) + }, + [ComboboxState.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 (data.optionsRef.current && !data.optionsPropsRef.current.static) { + event.stopPropagation() + } + return actions.closeCombobox() + + case Keys.Tab: + actions.selectActiveOption() + actions.closeCombobox() + break + } + }) + + let handleChange = useEvent((event: React.ChangeEvent) => { + actions.openCombobox() + onChange?.(event) + }) + + // TODO: Verify this. The spec says that, for the input/combobox, the label 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]) + if (!data.labelRef.current) return undefined + return [data.labelRef.current.id].join(' ') + }, [data.labelRef.current]) let slot = useMemo( - () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), - [state] + () => ({ open: data.comboboxState === ComboboxState.Open, disabled: data.disabled }), + [data] ) let ourProps = { @@ -785,13 +738,13 @@ let Input = forwardRefWithAs(function Input< id, role: 'combobox', type, - 'aria-controls': state.optionsRef.current?.id, - 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-controls': data.optionsRef.current?.id, + 'aria-expanded': data.disabled ? undefined : data.comboboxState === ComboboxState.Open, 'aria-activedescendant': - data.activeOptionIndex === null ? undefined : state.options[data.activeOptionIndex]?.id, + data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, 'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined, 'aria-labelledby': labelledby, - disabled: state.disabled, + disabled: data.disabled, onKeyDown: handleKeyDown, onChange: handleChange, } @@ -828,102 +781,95 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state] = useComboboxContext('Combobox.Button') - let data = useComboboxData() - let actions = useComboboxActions() - let buttonRef = useSyncRefs(state.buttonRef, ref) + let data = useData('Combobox.Button') + let actions = useActions('Combobox.Button') + let buttonRef = useSyncRefs(data.buttonRef, ref) let id = `headlessui-combobox-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-12 + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + 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 })) - - default: - return - } - }, - [d, state, actions, data] - ) - - let handleClick = useCallback( - (event: ReactMouseEvent) => { - if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - if (state.comboboxState === ComboboxStates.Open) { - actions.closeCombobox() - } else { + case Keys.ArrowDown: event.preventDefault() - actions.openCombobox() - } + event.stopPropagation() + if (data.comboboxState === ComboboxState.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 - d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) - }, - [actions, d, state] - ) + // 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(() => data.inputRef.current?.focus({ preventScroll: true })) + + case Keys.ArrowUp: + event.preventDefault() + event.stopPropagation() + if (data.comboboxState === ComboboxState.Closed) { + actions.openCombobox() + d.nextFrame(() => { + if (!data.value) { + actions.goToOption(Focus.Last) + } + }) + } + return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true })) + + case Keys.Escape: + event.preventDefault() + if (data.optionsRef.current && !data.optionsPropsRef.current.static) { + event.stopPropagation() + } + actions.closeCombobox() + return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true })) + + default: + return + } + }) + + let handleClick = useEvent((event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + if (data.comboboxState === ComboboxState.Open) { + actions.closeCombobox() + } else { + event.preventDefault() + actions.openCombobox() + } + + d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true })) + }) let labelledby = useComputed(() => { - if (!state.labelRef.current) return undefined - return [state.labelRef.current.id, id].join(' ') - }, [state.labelRef.current, id]) + if (!data.labelRef.current) return undefined + return [data.labelRef.current.id, id].join(' ') + }, [data.labelRef.current, id]) let slot = useMemo( - () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), - [state] + () => ({ open: data.comboboxState === ComboboxState.Open, disabled: data.disabled }), + [data] ) let theirProps = props let ourProps = { ref: buttonRef, id, - type: useResolveButtonType(props, state.buttonRef), + type: useResolveButtonType(props, data.buttonRef), tabIndex: -1, 'aria-haspopup': true, - 'aria-controls': state.optionsRef.current?.id, - 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-controls': data.optionsRef.current?.id, + 'aria-expanded': data.disabled ? undefined : data.comboboxState === ComboboxState.Open, 'aria-labelledby': labelledby, - disabled: state.disabled, + disabled: data.disabled, onClick: handleClick, onKeyDown: handleKeyDown, } @@ -950,18 +896,15 @@ let Label = forwardRefWithAs(function Label, ref: Ref ) { - let [state] = useComboboxContext('Combobox.Label') + let data = useData('Combobox.Label') let id = `headlessui-combobox-label-${useId()}` - let labelRef = useSyncRefs(state.labelRef, ref) + let labelRef = useSyncRefs(data.labelRef, ref) - let handleClick = useCallback( - () => state.inputRef.current?.focus({ preventScroll: true }), - [state.inputRef] - ) + let handleClick = useEvent(() => data.inputRef.current?.focus({ preventScroll: true })) let slot = useMemo( - () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), - [state] + () => ({ open: data.comboboxState === ComboboxState.Open, disabled: data.disabled }), + [data] ) let theirProps = props @@ -1003,11 +946,9 @@ let Options = forwardRefWithAs(function Options< ref: Ref ) { let { hold = false, ...theirProps } = props - let [state] = useComboboxContext('Combobox.Options') - let data = useComboboxData() - let { optionsPropsRef } = state + let data = useData('Combobox.Options') - let optionsRef = useSyncRefs(state.optionsRef, ref) + let optionsRef = useSyncRefs(data.optionsRef, ref) let id = `headlessui-combobox-options-${useId()}` @@ -1017,19 +958,19 @@ let Options = forwardRefWithAs(function Options< return usesOpenClosedState === State.Open } - return state.comboboxState === ComboboxStates.Open + return data.comboboxState === ComboboxState.Open })() useIsoMorphicEffect(() => { - optionsPropsRef.current.static = props.static ?? false - }, [optionsPropsRef, props.static]) + data.optionsPropsRef.current.static = props.static ?? false + }, [data.optionsPropsRef, props.static]) useIsoMorphicEffect(() => { - optionsPropsRef.current.hold = hold - }, [hold, optionsPropsRef]) + data.optionsPropsRef.current.hold = hold + }, [data.optionsPropsRef, hold]) useTreeWalker({ - container: state.optionsRef.current, - enabled: state.comboboxState === ComboboxStates.Open, + container: data.optionsRef.current, + enabled: data.comboboxState === ComboboxState.Open, accept(node) { if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -1041,17 +982,17 @@ let Options = forwardRefWithAs(function Options< }) let labelledby = useComputed( - () => state.labelRef.current?.id ?? state.buttonRef.current?.id, - [state.labelRef.current, state.buttonRef.current] + () => data.labelRef.current?.id ?? data.buttonRef.current?.id, + [data.labelRef.current, data.buttonRef.current] ) let slot = useMemo( - () => ({ open: state.comboboxState === ComboboxStates.Open }), - [state] + () => ({ open: data.comboboxState === ComboboxState.Open }), + [data] ) let ourProps = { 'aria-activedescendant': - data.activeOptionIndex === null ? undefined : state.options[data.activeOptionIndex]?.id, + data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, 'aria-labelledby': labelledby, role: 'listbox', id, @@ -1077,16 +1018,7 @@ interface OptionRenderPropArg { selected: boolean disabled: boolean } -type ComboboxOptionPropsWeControl = - | 'id' - | 'role' - | 'tabIndex' - | 'aria-disabled' - | 'aria-selected' - | 'onPointerLeave' - | 'onMouseLeave' - | 'onPointerMove' - | 'onMouseMove' +type ComboboxOptionPropsWeControl = 'id' | 'role' | 'tabIndex' | 'aria-disabled' | 'aria-selected' let Option = forwardRefWithAs(function Option< TTag extends ElementType = typeof DEFAULT_OPTION_TAG, @@ -1101,40 +1033,29 @@ let Option = forwardRefWithAs(function Option< ref: Ref ) { let { disabled = false, value, ...theirProps } = props - let [state] = useComboboxContext('Combobox.Option') - let data = useComboboxData() - let actions = useComboboxActions() + let data = useData('Combobox.Option') + let actions = useActions('Combobox.Option') + let id = `headlessui-combobox-option-${useId()}` let active = - data.activeOptionIndex !== null ? state.options[data.activeOptionIndex].id === id : false + data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false - let selected = match(data.mode, { - [ValueMode.Multi]: () => - (data.value as TType[]).some((option) => - state.comboboxPropsRef.current.compare(option, value) - ), - [ValueMode.Single]: () => state.comboboxPropsRef.current.compare(data.value, value), - }) + let selected = data.isSelected(value) let internalOptionRef = useRef(null) - let bag = useRef({ disabled, value, domRef: internalOptionRef }) + let bag = useLatestValue['current']>({ + disabled, + value, + domRef: internalOptionRef, + textValue: internalOptionRef.current?.textContent?.toLowerCase(), + }) 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]) + let select = useEvent(() => actions.selectOption(id)) useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) - let enableScrollIntoView = useRef(state.comboboxPropsRef.current.__demoMode ? false : true) + let enableScrollIntoView = useRef(data.__demoMode ? false : true) useIsoMorphicEffect(() => { - if (!state.comboboxPropsRef.current.__demoMode) return + if (!data.__demoMode) return let d = disposables() d.requestAnimationFrame(() => { enableScrollIntoView.current = true @@ -1143,46 +1064,43 @@ let Option = forwardRefWithAs(function Option< }, []) useIsoMorphicEffect(() => { - if (state.comboboxState !== ComboboxStates.Open) return + if (data.comboboxState !== ComboboxState.Open) return if (!active) return if (!enableScrollIntoView.current) return - if (state.activationTrigger === ActivationTrigger.Pointer) return + if (data.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 */ data.activeOptionIndex]) + }, [internalOptionRef, active, data.comboboxState, data.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex]) - let 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 handleClick = useEvent((event: { preventDefault: Function }) => { + if (disabled) return event.preventDefault() + select() + if (data.mode === ValueMode.Single) { + actions.closeCombobox() + disposables().nextFrame(() => data.inputRef.current?.focus({ preventScroll: true })) + } + }) - let handleFocus = useCallback(() => { + let handleFocus = useEvent(() => { if (disabled) return actions.goToOption(Focus.Nothing) actions.goToOption(Focus.Specific, id) - }, [disabled, id, actions]) + }) - let handleMove = useCallback(() => { + let handleMove = useEvent(() => { if (disabled) return if (active) return actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) - }, [disabled, active, id, actions]) + }) - let handleLeave = useCallback(() => { + let handleLeave = useEvent(() => { if (disabled) return if (!active) return - if (state.optionsPropsRef.current.hold) return + if (data.optionsPropsRef.current.hold) return actions.goToOption(Focus.Nothing) - }, [disabled, active, actions, state.comboboxState, state.comboboxPropsRef]) + }) let slot = useMemo( () => ({ active, selected, disabled }), diff --git a/packages/@headlessui-react/src/components/description/description.tsx b/packages/@headlessui-react/src/components/description/description.tsx index ce8a5a0..eb28858 100644 --- a/packages/@headlessui-react/src/components/description/description.tsx +++ b/packages/@headlessui-react/src/components/description/description.tsx @@ -1,6 +1,5 @@ import React, { createContext, - useCallback, useContext, useMemo, useState, @@ -16,6 +15,7 @@ import { useId } from '../../hooks/use-id' import { forwardRefWithAs, render } from '../../utils/render' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useSyncRefs } from '../../hooks/use-sync-refs' +import { useEvent } from '../../hooks/use-event' // --- @@ -58,7 +58,7 @@ export function useDescriptions(): [ // The provider component useMemo(() => { return function DescriptionProvider(props: DescriptionProviderProps) { - let register = useCallback((value: string) => { + let register = useEvent((value: string) => { setDescriptionIds((existing) => [...existing, value]) return () => @@ -68,7 +68,7 @@ export function useDescriptions(): [ if (idx !== -1) clone.splice(idx, 1) return clone }) - }, []) + }) let contextBag = useMemo( () => ({ register, slot: props.slot, name: props.name, props: props.props }), diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 1881c12..8d4b42c 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -1,7 +1,7 @@ // WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#dialog_modal import React, { createContext, - useCallback, + createRef, useContext, useEffect, useMemo, @@ -15,7 +15,6 @@ import React, { MouseEvent as ReactMouseEvent, MutableRefObject, Ref, - createRef, } from 'react' import { Props } from '../../types' @@ -38,6 +37,7 @@ import { getOwnerDocument } from '../../utils/owner' import { useOwnerDocument } from '../../hooks/use-owner' import { useEventListener } from '../../hooks/use-event-listener' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' +import { useEvent } from '../../hooks/use-event' enum DialogStates { Open, @@ -184,12 +184,9 @@ let DialogRoot = forwardRefWithAs(function Dialog< panelRef: createRef(), } as StateDefinition) - let close = useCallback(() => onClose(false), [onClose]) + let close = useEvent(() => onClose(false)) - let setTitleId = useCallback( - (id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id }), - [dispatch] - ) + let setTitleId = useEvent((id: string | null) => dispatch({ type: ActionTypes.SetTitleId, id })) let ready = useServerHandoffComplete() let enabled = ready ? (__demoMode ? false : dialogState === DialogStates.Open) : false @@ -323,7 +320,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< { + onUpdate={useEvent((message, type, element) => { if (type !== 'Dialog') return match(message, { @@ -336,7 +333,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< setNestedDialogCount((count) => count - 1) }, }) - }, [])} + })} > @@ -393,16 +390,13 @@ let Overlay = forwardRefWithAs(function Overlay< let id = `headlessui-dialog-overlay-${useId()}` - let handleClick = useCallback( - (event: ReactMouseEvent) => { - if (event.target !== event.currentTarget) return - if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - event.preventDefault() - event.stopPropagation() - close() - }, - [close] - ) + let handleClick = useEvent((event: ReactMouseEvent) => { + if (event.target !== event.currentTarget) return + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + event.preventDefault() + event.stopPropagation() + close() + }) let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index ac85802..f9e37fc 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -2,7 +2,6 @@ import React, { Fragment, createContext, - useCallback, useContext, useEffect, useMemo, @@ -10,13 +9,13 @@ import React, { useRef, // Types + ContextType, Dispatch, ElementType, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, - Ref, MutableRefObject, - ContextType, + Ref, } from 'react' import { Props } from '../../types' @@ -29,6 +28,7 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { getOwnerDocument } from '../../utils/owner' +import { useEvent } from '../../hooks/use-event' enum DisclosureStates { Open, @@ -188,24 +188,21 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch]) useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch]) - let close = useCallback( - (focusableElement?: HTMLElement | MutableRefObject) => { - dispatch({ type: ActionTypes.CloseDisclosure }) - let ownerDocument = getOwnerDocument(internalDisclosureRef) - if (!ownerDocument) return + let close = useEvent((focusableElement?: HTMLElement | MutableRefObject) => { + dispatch({ type: ActionTypes.CloseDisclosure }) + let ownerDocument = getOwnerDocument(internalDisclosureRef) + if (!ownerDocument) return - let restoreElement = (() => { - if (!focusableElement) return ownerDocument.getElementById(buttonId) - if (focusableElement instanceof HTMLElement) return focusableElement - if (focusableElement.current instanceof HTMLElement) return focusableElement.current + let restoreElement = (() => { + if (!focusableElement) return ownerDocument.getElementById(buttonId) + if (focusableElement instanceof HTMLElement) return focusableElement + if (focusableElement.current instanceof HTMLElement) return focusableElement.current - return ownerDocument.getElementById(buttonId) - })() + return ownerDocument.getElementById(buttonId) + })() - restoreElement?.focus() - }, - [dispatch, buttonId] - ) + restoreElement?.focus() + }) let api = useMemo>(() => ({ close }), [close]) @@ -265,35 +262,32 @@ let Button = forwardRefWithAs(function Button(null) let buttonRef = useSyncRefs(internalButtonRef, ref, !isWithinPanel ? state.buttonRef : null) - let handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (isWithinPanel) { - if (state.disclosureState === DisclosureStates.Closed) return + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + if (isWithinPanel) { + if (state.disclosureState === DisclosureStates.Closed) return - switch (event.key) { - case Keys.Space: - case Keys.Enter: - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.ToggleDisclosure }) - state.buttonRef.current?.focus() - break - } - } else { - switch (event.key) { - case Keys.Space: - case Keys.Enter: - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.ToggleDisclosure }) - break - } + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.ToggleDisclosure }) + state.buttonRef.current?.focus() + break } - }, - [dispatch, isWithinPanel, state.disclosureState, state.buttonRef] - ) + } else { + switch (event.key) { + case Keys.Space: + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.ToggleDisclosure }) + break + } + } + }) - let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { + let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Space: // Required for firefox, event.preventDefault() in handleKeyDown for @@ -302,22 +296,19 @@ let Button = forwardRefWithAs(function Button { - if (isDisabledReactIssue7711(event.currentTarget)) return - if (props.disabled) return + let handleClick = useEvent((event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return + if (props.disabled) return - if (isWithinPanel) { - dispatch({ type: ActionTypes.ToggleDisclosure }) - state.buttonRef.current?.focus() - } else { - dispatch({ type: ActionTypes.ToggleDisclosure }) - } - }, - [dispatch, props.disabled, state.buttonRef, isWithinPanel] - ) + if (isWithinPanel) { + dispatch({ type: ActionTypes.ToggleDisclosure }) + state.buttonRef.current?.focus() + } else { + dispatch({ type: ActionTypes.ToggleDisclosure }) + } + }) let slot = useMemo( () => ({ open: state.disclosureState === DisclosureStates.Open }), diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 1005f18..0cdf662 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -1,6 +1,6 @@ import React, { - useRef, useEffect, + useRef, // Types ElementType, diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index 15f75d8..503c6ad 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -1,6 +1,5 @@ import React, { createContext, - useCallback, useContext, useMemo, useState, @@ -16,6 +15,7 @@ import { useId } from '../../hooks/use-id' import { forwardRefWithAs, render } from '../../utils/render' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useSyncRefs } from '../../hooks/use-sync-refs' +import { useEvent } from '../../hooks/use-event' // --- @@ -53,7 +53,7 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) => // The provider component useMemo(() => { return function LabelProvider(props: LabelProviderProps) { - let register = useCallback((value: string) => { + let register = useEvent((value: string) => { setLabelIds((existing) => [...existing, value]) return () => @@ -63,7 +63,7 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) => if (idx !== -1) clone.splice(idx, 1) return clone }) - }, []) + }) let contextBag = useMemo( () => ({ register, slot: props.slot, name: props.name, props: props.props }), diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index d8dec57..b664331 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -2,8 +2,8 @@ import React, { Fragment, createContext, createRef, - useCallback, useContext, + useEffect, useMemo, useReducer, useRef, @@ -15,7 +15,6 @@ import React, { MouseEvent as ReactMouseEvent, MutableRefObject, Ref, - useEffect, } from 'react' import { useDisposables } from '../../hooks/use-disposables' @@ -473,36 +472,33 @@ let Button = forwardRefWithAs(function Button) => { - switch (event.key) { - // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 + let handleKeyDown = useEvent((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.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] - ) + case Keys.ArrowUp: + event.preventDefault() + dispatch({ type: ActionTypes.OpenListbox }) + d.nextFrame(() => { + if (!state.propsRef.current.value) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + }) + break + } + }) - let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { + let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Space: // Required for firefox, event.preventDefault() in handleKeyDown for @@ -511,21 +507,18 @@ let Button = forwardRefWithAs(function Button { - 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 handleClick = useEvent((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 }) + } + }) let labelledby = useComputed(() => { if (!state.labelRef.current) return undefined @@ -577,10 +570,7 @@ let Label = forwardRefWithAs(function Label state.buttonRef.current?.focus({ preventScroll: true }), - [state.buttonRef] - ) + let handleClick = useEvent(() => state.buttonRef.current?.focus({ preventScroll: true })) let slot = useMemo( () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }), @@ -647,78 +637,75 @@ let Options = forwardRefWithAs(function Options< container.focus({ preventScroll: true }) }, [state.listboxState, state.optionsRef]) - let handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - searchDisposables.dispose() + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + searchDisposables.dispose() - switch (event.key) { - // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + 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: + // @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() - if (state.activeOptionIndex !== null) { - let { dataRef } = state.options[state.activeOptionIndex] - state.propsRef.current.onChange(dataRef.current.value) - } - if (state.propsRef.current.mode === ValueMode.Single) { - dispatch({ type: ActionTypes.CloseListbox }) - 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() + if (state.activeOptionIndex !== null) { + let { dataRef } = state.options[state.activeOptionIndex] + state.propsRef.current.onChange(dataRef.current.value) + } + if (state.propsRef.current.mode === ValueMode.Single) { dispatch({ type: ActionTypes.CloseListbox }) - return d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + } + break - case Keys.Tab: - event.preventDefault() - event.stopPropagation() - break + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) - 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] - ) + 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 + } + }) let labelledby = useComputed( () => state.labelRef.current?.id ?? state.buttonRef.current?.id, @@ -826,31 +813,28 @@ let Option = forwardRefWithAs(function Option< bag.current.textValue = internalOptionRef.current?.textContent?.toLowerCase() }, [bag, internalOptionRef]) - let select = useCallback(() => state.propsRef.current.onChange(value), [state.propsRef, value]) + let select = useEvent(() => state.propsRef.current.onChange(value)) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) return () => dispatch({ type: ActionTypes.UnregisterOption, id }) }, [bag, id]) - let handleClick = useCallback( - (event: { preventDefault: Function }) => { - if (disabled) return event.preventDefault() - select() - if (state.propsRef.current.mode === ValueMode.Single) { - dispatch({ type: ActionTypes.CloseListbox }) - disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) - } - }, - [dispatch, state.buttonRef, disabled, select] - ) + let handleClick = useEvent((event: { preventDefault: Function }) => { + if (disabled) return event.preventDefault() + select() + if (state.propsRef.current.mode === ValueMode.Single) { + dispatch({ type: ActionTypes.CloseListbox }) + disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + } + }) - let handleFocus = useCallback(() => { + let handleFocus = useEvent(() => { if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) - }, [disabled, id, dispatch]) + }) - let handleMove = useCallback(() => { + let handleMove = useEvent(() => { if (disabled) return if (active) return dispatch({ @@ -859,13 +843,13 @@ let Option = forwardRefWithAs(function Option< id, trigger: ActivationTrigger.Pointer, }) - }, [disabled, active, id, dispatch]) + }) - let handleLeave = useCallback(() => { + let handleLeave = useEvent(() => { if (disabled) return if (!active) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) - }, [disabled, active, dispatch]) + }) let slot = useMemo( () => ({ active, selected, disabled }), diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 094a3c4..1dae520 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -3,7 +3,6 @@ import React, { Fragment, createContext, createRef, - useCallback, useContext, useEffect, useMemo, @@ -36,6 +35,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker' import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOwnerDocument } from '../../hooks/use-owner' +import { useEvent } from '../../hooks/use-event' enum MenuStates { Open, @@ -303,32 +303,29 @@ let Button = forwardRefWithAs(function Button) => { - switch (event.key) { - // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 + let handleKeyDown = useEvent((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() - event.stopPropagation() - dispatch({ type: ActionTypes.OpenMenu }) - d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })) - break + case Keys.Space: + case Keys.Enter: + case Keys.ArrowDown: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.OpenMenu }) + d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })) + break - case Keys.ArrowUp: - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.OpenMenu }) - d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })) - break - } - }, - [dispatch, d] - ) + case Keys.ArrowUp: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.OpenMenu }) + d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })) + break + } + }) - let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { + let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Space: // Required for firefox, event.preventDefault() in handleKeyDown for @@ -337,23 +334,20 @@ let Button = forwardRefWithAs(function Button { - if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - if (props.disabled) return - if (state.menuState === MenuStates.Open) { - dispatch({ type: ActionTypes.CloseMenu }) - d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) - } else { - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.OpenMenu }) - } - }, - [dispatch, d, state, props.disabled] - ) + let handleClick = useEvent((event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + if (props.disabled) return + if (state.menuState === MenuStates.Open) { + dispatch({ type: ActionTypes.CloseMenu }) + d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + } else { + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.OpenMenu }) + } + }) let slot = useMemo( () => ({ open: state.menuState === MenuStates.Open }), @@ -440,78 +434,75 @@ let Items = forwardRefWithAs(function Items) => { - searchDisposables.dispose() + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + searchDisposables.dispose() - switch (event.key) { - // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + 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: + // @ts-expect-error Fallthrough is expected here + case Keys.Space: + if (state.searchQuery !== '') { event.preventDefault() event.stopPropagation() - dispatch({ type: ActionTypes.CloseMenu }) - if (state.activeItemIndex !== null) { - let { dataRef } = state.items[state.activeItemIndex] - dataRef.current?.domRef.current?.click() - } - disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) - break + return dispatch({ type: ActionTypes.Search, value: event.key }) + } + // When in type ahead mode, fallthrough + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.CloseMenu }) + if (state.activeItemIndex !== null) { + let { dataRef } = state.items[state.activeItemIndex] + dataRef.current?.domRef.current?.click() + } + disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + break - case Keys.ArrowDown: - event.preventDefault() - event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next }) + case Keys.ArrowDown: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next }) - case Keys.ArrowUp: - event.preventDefault() - event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous }) + case Keys.ArrowUp: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous }) - case Keys.Home: - case Keys.PageUp: - event.preventDefault() - event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }) + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }) - case Keys.End: - case Keys.PageDown: - event.preventDefault() - event.stopPropagation() - return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }) + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last }) - case Keys.Escape: - event.preventDefault() - event.stopPropagation() - dispatch({ type: ActionTypes.CloseMenu }) - disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) - break + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.CloseMenu }) + disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + break - case Keys.Tab: - event.preventDefault() - event.stopPropagation() - break + 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 - } - }, - [dispatch, searchDisposables, state, ownerDocument] - ) + default: + if (event.key.length === 1) { + dispatch({ type: ActionTypes.Search, value: event.key }) + searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350) + } + break + } + }) - let handleKeyUp = useCallback((event: ReactKeyboardEvent) => { + let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Space: // Required for firefox, event.preventDefault() in handleKeyDown for @@ -520,7 +511,7 @@ let Items = forwardRefWithAs(function Items( () => ({ open: state.menuState === MenuStates.Open }), @@ -607,21 +598,18 @@ let Item = forwardRefWithAs(function Item dispatch({ type: ActionTypes.UnregisterItem, id }) }, [bag, id]) - let handleClick = useCallback( - (event: MouseEvent) => { - if (disabled) return event.preventDefault() - dispatch({ type: ActionTypes.CloseMenu }) - disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) - }, - [dispatch, state.buttonRef, disabled] - ) + let handleClick = useEvent((event: MouseEvent) => { + if (disabled) return event.preventDefault() + dispatch({ type: ActionTypes.CloseMenu }) + disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) + }) - let handleFocus = useCallback(() => { + let handleFocus = useEvent(() => { if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id }) - }, [disabled, id, dispatch]) + }) - let handleMove = useCallback(() => { + let handleMove = useEvent(() => { if (disabled) return if (active) return dispatch({ @@ -630,13 +618,13 @@ let Item = forwardRefWithAs(function Item { + let handleLeave = useEvent(() => { if (disabled) return if (!active) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) - }, [disabled, active, dispatch]) + }) let slot = useMemo(() => ({ active, disabled }), [active, disabled]) let ourProps = { diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index e94e8b8..a1fa990 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -12,9 +12,9 @@ import React, { ContextType, Dispatch, ElementType, + FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, - FocusEvent as ReactFocusEvent, MutableRefObject, Ref, } from 'react' diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index b3ee03e..c6b55d8 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -3,13 +3,13 @@ import React, { createContext, useContext, useEffect, + useRef, useState, // Types ElementType, MutableRefObject, Ref, - useRef, } from 'react' import { createPortal } from 'react-dom' diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index 0d061ed..cd848b7 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -1,16 +1,15 @@ import React, { createContext, - useCallback, useContext, useMemo, useReducer, useRef, // Types - ElementType, - MutableRefObject, - KeyboardEvent as ReactKeyboardEvent, ContextType, + ElementType, + KeyboardEvent as ReactKeyboardEvent, + MutableRefObject, Ref, } from 'react' @@ -31,14 +30,14 @@ import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' import { useEvent } from '../../hooks/use-event' -interface Option { +interface Option { id: string element: MutableRefObject - propsRef: MutableRefObject<{ value: unknown; disabled: boolean }> + propsRef: MutableRefObject<{ value: T; disabled: boolean }> } -interface StateDefinition { - options: Option[] +interface StateDefinition { + options: Option[] } enum ActionTypes { @@ -97,7 +96,7 @@ function useRadioGroupContext(component: string) { return context } -function stateReducer(state: StateDefinition, action: Actions) { +function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } @@ -133,9 +132,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< } : by ) - let [{ options }, dispatch] = useReducer(stateReducer, { - options: [], - } as StateDefinition) + let [state, dispatch] = useReducer(stateReducer, { options: [] } as StateDefinition) + let options = state.options as unknown as Option[] let [labelledby, LabelProvider] = useLabels() let [describedby, DescriptionProvider] = useDescriptions() let id = `headlessui-radiogroup-${useId()}` @@ -155,20 +153,17 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< [options, value] ) - let triggerChange = useCallback( - (nextValue) => { - if (disabled) return false - if (compare(nextValue, value)) return false - let nextOption = options.find((option) => - compare(option.propsRef.current.value as TType, nextValue) - )?.propsRef.current - if (nextOption?.disabled) return false + let triggerChange = useEvent((nextValue: TType) => { + if (disabled) return false + if (compare(nextValue, value)) return false + let nextOption = options.find((option) => + compare(option.propsRef.current.value as TType, nextValue) + )?.propsRef.current + if (nextOption?.disabled) return false - onChange(nextValue) - return true - }, - [onChange, value, disabled, options] - ) + onChange(nextValue) + return true + }) useTreeWalker({ container: internalRadioGroupRef.current, @@ -182,78 +177,72 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< }, }) - let handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - let container = internalRadioGroupRef.current - if (!container) return + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + let container = internalRadioGroupRef.current + if (!container) return - let ownerDocument = getOwnerDocument(container) + let ownerDocument = getOwnerDocument(container) - let all = options - .filter((option) => option.propsRef.current.disabled === false) - .map((radio) => radio.element.current) as HTMLElement[] + let all = options + .filter((option) => option.propsRef.current.disabled === false) + .map((radio) => radio.element.current) as HTMLElement[] - switch (event.key) { - case Keys.Enter: - attemptSubmit(event.currentTarget) - break - case Keys.ArrowLeft: - case Keys.ArrowUp: - { - event.preventDefault() - event.stopPropagation() + switch (event.key) { + case Keys.Enter: + attemptSubmit(event.currentTarget) + break + case Keys.ArrowLeft: + case Keys.ArrowUp: + { + event.preventDefault() + event.stopPropagation() - let result = focusIn(all, Focus.Previous | Focus.WrapAround) - - if (result === FocusResult.Success) { - let activeOption = options.find( - (option) => option.element.current === ownerDocument?.activeElement - ) - if (activeOption) triggerChange(activeOption.propsRef.current.value) - } - } - break - - case Keys.ArrowRight: - case Keys.ArrowDown: - { - event.preventDefault() - event.stopPropagation() - - let result = focusIn(all, Focus.Next | Focus.WrapAround) - - if (result === FocusResult.Success) { - let activeOption = options.find( - (option) => option.element.current === ownerDocument?.activeElement - ) - if (activeOption) triggerChange(activeOption.propsRef.current.value) - } - } - break - - case Keys.Space: - { - event.preventDefault() - event.stopPropagation() + let result = focusIn(all, Focus.Previous | Focus.WrapAround) + if (result === FocusResult.Success) { let activeOption = options.find( (option) => option.element.current === ownerDocument?.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } - break - } - }, - [internalRadioGroupRef, options, triggerChange] - ) + } + break - let registerOption = useCallback( - (option: Option) => { - dispatch({ type: ActionTypes.RegisterOption, ...option }) - return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id }) - }, - [dispatch] - ) + case Keys.ArrowRight: + case Keys.ArrowDown: + { + event.preventDefault() + event.stopPropagation() + + let result = focusIn(all, Focus.Next | Focus.WrapAround) + + if (result === FocusResult.Success) { + let activeOption = options.find( + (option) => option.element.current === ownerDocument?.activeElement + ) + if (activeOption) triggerChange(activeOption.propsRef.current.value) + } + } + break + + case Keys.Space: + { + event.preventDefault() + event.stopPropagation() + + let activeOption = options.find( + (option) => option.element.current === ownerDocument?.activeElement + ) + if (activeOption) triggerChange(activeOption.propsRef.current.value) + } + break + } + }) + + let registerOption = useEvent((option: Option) => { + dispatch({ type: ActionTypes.RegisterOption, ...option }) + return () => dispatch({ type: ActionTypes.UnregisterOption, id: option.id }) + }) let api = useMemo>( () => ({ @@ -378,15 +367,15 @@ let Option = forwardRefWithAs(function Option< [id, registerOption, internalOptionRef, props] ) - let handleClick = useCallback(() => { + let handleClick = useEvent(() => { if (!change(value)) return addFlag(OptionState.Active) internalOptionRef.current?.focus() - }, [addFlag, change, value]) + }) - let handleFocus = useCallback(() => addFlag(OptionState.Active), [addFlag]) - let handleBlur = useCallback(() => removeFlag(OptionState.Active), [removeFlag]) + let handleFocus = useEvent(() => addFlag(OptionState.Active)) + let handleBlur = useEvent(() => removeFlag(OptionState.Active)) let isFirstOption = firstOption?.id === id let isDisabled = radioGroupDisabled || disabled diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index c8615a2..7a44ea1 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -1,11 +1,10 @@ import React, { Fragment, createContext, - useCallback, useContext, useMemo, - useState, useRef, + useState, // Types ElementType, @@ -25,6 +24,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useSyncRefs } from '../../hooks/use-sync-refs' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit } from '../../utils/form' +import { useEvent } from '../../hooks/use-event' interface StateDefinition { switch: HTMLButtonElement | null @@ -121,32 +121,23 @@ let SwitchRoot = forwardRefWithAs(function Switch< groupContext === null ? null : groupContext.setSwitch ) - let toggle = useCallback(() => onChange(!checked), [onChange, checked]) - let handleClick = useCallback( - (event: ReactMouseEvent) => { - if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + let toggle = useEvent(() => onChange(!checked)) + let handleClick = useEvent((event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + event.preventDefault() + toggle() + }) + let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { + if (event.key === Keys.Space) { event.preventDefault() toggle() - }, - [toggle] - ) - let handleKeyUp = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === Keys.Space) { - event.preventDefault() - toggle() - } else if (event.key === Keys.Enter) { - attemptSubmit(event.currentTarget) - } - }, - [toggle] - ) + } else if (event.key === Keys.Enter) { + attemptSubmit(event.currentTarget) + } + }) // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button. - let handleKeyPress = useCallback( - (event: ReactKeyboardEvent) => event.preventDefault(), - [] - ) + let handleKeyPress = useEvent((event: ReactKeyboardEvent) => event.preventDefault()) let slot = useMemo(() => ({ checked }), [checked]) let ourProps = { diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index e0d4eac..6973bbc 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -1,20 +1,17 @@ import React, { Fragment, createContext, - useCallback, useContext, useMemo, useReducer, useRef, - useEffect, // Types - ElementType, - MutableRefObject, - MouseEvent as ReactMouseEvent, - KeyboardEvent as ReactKeyboardEvent, - Dispatch, ContextType, + ElementType, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + MutableRefObject, Ref, } from 'react' @@ -29,21 +26,17 @@ import { useSyncRefs } from '../../hooks/use-sync-refs' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useLatestValue } from '../../hooks/use-latest-value' import { FocusSentinel } from '../../internal/focus-sentinel' +import { useEvent } from '../../hooks/use-event' interface StateDefinition { selectedIndex: number - orientation: 'horizontal' | 'vertical' - activation: 'auto' | 'manual' - tabs: MutableRefObject[] panels: MutableRefObject[] } enum ActionTypes { SetSelectedIndex, - SetOrientation, - SetActivation, RegisterTab, UnregisterTab, @@ -56,8 +49,6 @@ enum ActionTypes { type Actions = | { type: ActionTypes.SetSelectedIndex; index: number } - | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } - | { type: ActionTypes.SetActivation; activation: StateDefinition['activation'] } | { type: ActionTypes.RegisterTab; tab: MutableRefObject } | { type: ActionTypes.UnregisterTab; tab: MutableRefObject } | { type: ActionTypes.RegisterPanel; panel: MutableRefObject } @@ -95,14 +86,6 @@ let reducers: { return { ...state, selectedIndex: state.tabs.indexOf(next) } }, - [ActionTypes.SetOrientation](state, action) { - if (state.orientation === action.orientation) return state - return { ...state, orientation: action.orientation } - }, - [ActionTypes.SetActivation](state, action) { - if (state.activation === action.activation) return state - return { ...state, activation: action.activation } - }, [ActionTypes.RegisterTab](state, action) { if (state.tabs.includes(action.tab)) return state return { ...state, tabs: sortByDomNode([...state.tabs, action.tab], (tab) => tab.current) } @@ -128,11 +111,6 @@ let reducers: { }, } -let TabsContext = createContext< - [StateDefinition, { change(index: number): void; dispatch: Dispatch }] | null ->(null) -TabsContext.displayName = 'TabsContext' - let TabsSSRContext = createContext | null>( null ) @@ -148,11 +126,38 @@ function useSSRTabsCounter(component: string) { return context } -function useTabsContext(component: string) { - let context = useContext(TabsContext) +let TabsDataContext = createContext< + | ({ + orientation: 'horizontal' | 'vertical' + activation: 'auto' | 'manual' + } & StateDefinition) + | null +>(null) +TabsDataContext.displayName = 'TabsDataContext' + +function useData(component: string) { + let context = useContext(TabsDataContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useTabsContext) + if (Error.captureStackTrace) Error.captureStackTrace(err, useData) + throw err + } + return context +} + +let TabsActionsContext = createContext<{ + registerTab(tab: MutableRefObject): () => void + registerPanel(panel: MutableRefObject): () => void + change(index: number): void + forceRerender(): void +} | null>(null) +TabsActionsContext.displayName = 'TabsActionsContext' + +function useActions(component: string) { + let context = useContext(TabsActionsContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) throw err } return context @@ -195,79 +200,72 @@ let Tabs = forwardRefWithAs(function Tabs ({ selectedIndex: state.selectedIndex }), [state.selectedIndex]) let onChangeRef = useLatestValue(onChange || (() => {})) let stableTabsRef = useLatestValue(state.tabs) - useEffect(() => { - dispatch({ type: ActionTypes.SetOrientation, orientation }) - }, [orientation]) - - useEffect(() => { - dispatch({ type: ActionTypes.SetActivation, activation }) - }, [activation]) - - useIsoMorphicEffect(() => { - let indexToSet = selectedIndex ?? defaultIndex - dispatch({ type: ActionTypes.SetSelectedIndex, index: indexToSet }) - }, [selectedIndex /* Deliberately skipping defaultIndex */]) - - let lastChangedIndex = useRef(state.selectedIndex) - useEffect(() => { - lastChangedIndex.current = state.selectedIndex - }, [state.selectedIndex]) - - let providerBag = useMemo>( - () => [ - state, - { - dispatch, - change(index: number) { - if (lastChangedIndex.current !== index) onChangeRef.current(index) - lastChangedIndex.current = index - - dispatch({ type: ActionTypes.SetSelectedIndex, index }) - }, - }, - ], - [state, dispatch] + let tabsData = useMemo>( + () => ({ orientation, activation, ...state }), + [orientation, activation, state] ) - let SSRCounter = useRef({ - tabs: [], - panels: [], - }) + let lastChangedIndex = useLatestValue(state.selectedIndex) + let tabsActions: ContextType = useMemo( + () => ({ + registerTab(tab) { + dispatch({ type: ActionTypes.RegisterTab, tab }) + return () => dispatch({ type: ActionTypes.UnregisterTab, tab }) + }, + registerPanel(panel) { + dispatch({ type: ActionTypes.RegisterPanel, panel }) + return () => dispatch({ type: ActionTypes.UnregisterPanel, panel }) + }, + forceRerender() { + dispatch({ type: ActionTypes.ForceRerender }) + }, + change(index: number) { + if (lastChangedIndex.current !== index) onChangeRef.current(index) + lastChangedIndex.current = index - let ourProps = { - ref: tabsRef, - } + dispatch({ type: ActionTypes.SetSelectedIndex, index }) + }, + }), + [dispatch] + ) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.SetSelectedIndex, index: selectedIndex ?? defaultIndex }) + }, [selectedIndex /* Deliberately skipping defaultIndex */]) + + let SSRCounter = useRef({ tabs: [], panels: [] }) + let ourProps = { ref: tabsRef } return ( - - { - for (let tab of stableTabsRef.current) { - if (tab.current?.tabIndex === 0) { - tab.current?.focus() - return true + + + { + for (let tab of stableTabsRef.current) { + if (tab.current?.tabIndex === 0) { + tab.current?.focus() + return true + } } - } - return false - }} - /> - {render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_TABS_TAG, - name: 'Tabs', - })} - + return false + }} + /> + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_TABS_TAG, + name: 'Tabs', + })} + + ) }) @@ -284,7 +282,7 @@ let List = forwardRefWithAs(function List & {}, ref: Ref ) { - let [{ selectedIndex, orientation }] = useTabsContext('Tab.List') + let { orientation, selectedIndex } = useData('Tab.List') let listRef = useSyncRefs(ref) let slot = { selectedIndex } @@ -319,20 +317,17 @@ let TabRoot = forwardRefWithAs(function Tab(null) + let internalTabRef = useRef(null) let tabRef = useSyncRefs(internalTabRef, ref, (element) => { if (!element) return - dispatch({ type: ActionTypes.ForceRerender }) + actions.forceRerender() }) - useIsoMorphicEffect(() => { - dispatch({ type: ActionTypes.RegisterTab, tab: internalTabRef }) - return () => dispatch({ type: ActionTypes.UnregisterTab, tab: internalTabRef }) - }, [dispatch, internalTabRef]) + useIsoMorphicEffect(() => actions.registerTab(internalTabRef), [actions, internalTabRef]) let mySSRIndex = SSRContext.current.tabs.indexOf(id) if (mySSRIndex === -1) mySSRIndex = SSRContext.current.tabs.push(id) - 1 @@ -341,65 +336,62 @@ let TabRoot = forwardRefWithAs(function Tab) => { - let list = tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[] + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + let list = tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[] - if (event.key === Keys.Space || event.key === Keys.Enter) { + if (event.key === Keys.Space || event.key === Keys.Enter) { + event.preventDefault() + event.stopPropagation() + + actions.change(myIndex) + return + } + + switch (event.key) { + case Keys.Home: + case Keys.PageUp: event.preventDefault() event.stopPropagation() - change(myIndex) + return focusIn(list, Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + + return focusIn(list, Focus.Last) + } + + return match(orientation, { + vertical() { + if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround) return - } + }, + horizontal() { + if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround) + if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround) + return + }, + }) + }) - switch (event.key) { - case Keys.Home: - case Keys.PageUp: - event.preventDefault() - event.stopPropagation() - - return focusIn(list, Focus.First) - - case Keys.End: - case Keys.PageDown: - event.preventDefault() - event.stopPropagation() - - return focusIn(list, Focus.Last) - } - - return match(orientation, { - vertical() { - if (event.key === Keys.ArrowUp) return focusIn(list, Focus.Previous | Focus.WrapAround) - if (event.key === Keys.ArrowDown) return focusIn(list, Focus.Next | Focus.WrapAround) - return - }, - horizontal() { - if (event.key === Keys.ArrowLeft) return focusIn(list, Focus.Previous | Focus.WrapAround) - if (event.key === Keys.ArrowRight) return focusIn(list, Focus.Next | Focus.WrapAround) - return - }, - }) - }, - [tabs, orientation, myIndex, change] - ) - - let handleFocus = useCallback(() => { + let handleFocus = useEvent(() => { internalTabRef.current?.focus() - }, [internalTabRef]) + }) - let handleSelection = useCallback(() => { + let handleSelection = useEvent(() => { internalTabRef.current?.focus() - change(myIndex) - }, [change, myIndex, internalTabRef]) + actions.change(myIndex) + }) // This is important because we want to only focus the tab when it gets focus // OR it finished the click event (mouseup). However, if you perform a `click`, // then you will first get the `focus` and then get the `click` event. - let handleMouseDown = useCallback((event: ReactMouseEvent) => { + let handleMouseDown = useEvent((event: ReactMouseEvent) => { event.preventDefault() - }, []) + }) let slot = useMemo(() => ({ selected }), [selected]) @@ -438,7 +430,7 @@ let Panels = forwardRefWithAs(function Panels, ref: Ref ) { - let [{ selectedIndex }] = useTabsContext('Tab.Panels') + let { selectedIndex } = useData('Tab.Panels') let panelsRef = useSyncRefs(ref) let slot = useMemo(() => ({ selectedIndex }), [selectedIndex]) @@ -469,20 +461,18 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { - let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext('Tab.Panel') + let { selectedIndex, tabs, panels } = useData('Tab.Panel') + let actions = useActions('Tab.Panel') let SSRContext = useSSRTabsCounter('Tab.Panel') let id = `headlessui-tabs-panel-${useId()}` let internalPanelRef = useRef(null) let panelRef = useSyncRefs(internalPanelRef, ref, (element) => { if (!element) return - dispatch({ type: ActionTypes.ForceRerender }) + actions.forceRerender() }) - useIsoMorphicEffect(() => { - dispatch({ type: ActionTypes.RegisterPanel, panel: internalPanelRef }) - return () => dispatch({ type: ActionTypes.UnregisterPanel, panel: internalPanelRef }) - }, [dispatch, internalPanelRef]) + useIsoMorphicEffect(() => actions.registerPanel(internalPanelRef), [actions, internalPanelRef]) let mySSRIndex = SSRContext.current.panels.indexOf(id) if (mySSRIndex === -1) mySSRIndex = SSRContext.current.panels.push(id) - 1 diff --git a/packages/@headlessui-react/src/hooks/use-event.ts b/packages/@headlessui-react/src/hooks/use-event.ts index 893893e..47115aa 100644 --- a/packages/@headlessui-react/src/hooks/use-event.ts +++ b/packages/@headlessui-react/src/hooks/use-event.ts @@ -1,9 +1,13 @@ 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(cb: (...args: T[]) => R) { - let cache = React.useRef(cb) - cache.current = cb - return React.useCallback((...args: T[]) => cache.current(...args), [cache]) + function useEvent< + F extends (...args: any[]) => any, + P extends any[] = Parameters, + R = ReturnType + >(cb: (...args: P) => R) { + let cache = useLatestValue(cb) + return React.useCallback((...args: P) => cache.current(...args), [cache]) } diff --git a/packages/@headlessui-react/src/hooks/use-flags.ts b/packages/@headlessui-react/src/hooks/use-flags.ts index 7096093..70069ea 100644 --- a/packages/@headlessui-react/src/hooks/use-flags.ts +++ b/packages/@headlessui-react/src/hooks/use-flags.ts @@ -1,12 +1,13 @@ -import { useState, useCallback } from 'react' +import { useState } from 'react' +import { useEvent } from './use-event' export function useFlags(initialFlags = 0) { let [flags, setFlags] = useState(initialFlags) - let addFlag = useCallback((flag: number) => setFlags((flags) => flags | flag), [setFlags]) - let hasFlag = useCallback((flag: number) => Boolean(flags & flag), [flags]) - let removeFlag = useCallback((flag: number) => setFlags((flags) => flags & ~flag), [setFlags]) - let toggleFlag = useCallback((flag: number) => setFlags((flags) => flags ^ flag), [setFlags]) + let addFlag = useEvent((flag: number) => setFlags((flags) => flags | flag)) + let hasFlag = useEvent((flag: number) => Boolean(flags & flag)) + let removeFlag = useEvent((flag: number) => setFlags((flags) => flags & ~flag)) + let toggleFlag = useEvent((flag: number) => setFlags((flags) => flags ^ flag)) return { addFlag, hasFlag, removeFlag, toggleFlag } } diff --git a/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts b/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts index c2bf43f..7c7eae2 100644 --- a/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts +++ b/packages/@headlessui-react/src/hooks/use-iso-morphic-effect.ts @@ -1,3 +1,3 @@ import { useLayoutEffect, useEffect } from 'react' -export const useIsoMorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect +export let useIsoMorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect diff --git a/packages/@headlessui-react/src/hooks/use-sync-refs.ts b/packages/@headlessui-react/src/hooks/use-sync-refs.ts index 2b3cc8c..51482a8 100644 --- a/packages/@headlessui-react/src/hooks/use-sync-refs.ts +++ b/packages/@headlessui-react/src/hooks/use-sync-refs.ts @@ -1,4 +1,5 @@ -import { useRef, useEffect, useCallback } from 'react' +import { useRef, useEffect } from 'react' +import { useEvent } from './use-event' let Optional = Symbol() @@ -15,16 +16,13 @@ export function useSyncRefs( cache.current = refs }, [refs]) - let syncRefs = useCallback( - (value: TType) => { - for (let ref of cache.current) { - if (ref == null) continue - if (typeof ref === 'function') ref(value) - else ref.current = value - } - }, - [cache] - ) + 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) => diff --git a/packages/@headlessui-react/src/internal/stack-context.tsx b/packages/@headlessui-react/src/internal/stack-context.tsx index 525470f..6db45db 100644 --- a/packages/@headlessui-react/src/internal/stack-context.tsx +++ b/packages/@headlessui-react/src/internal/stack-context.tsx @@ -1,6 +1,5 @@ import React, { createContext, - useCallback, useContext, // Types @@ -8,6 +7,7 @@ import React, { ReactNode, } from 'react' import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect' +import { useEvent } from '../hooks/use-event' type OnUpdate = ( message: StackMessage, @@ -40,16 +40,13 @@ export function StackProvider({ }) { let parentUpdate = useStackContext() - let notify = useCallback( - (...args: Parameters) => { - // Notify our layer - onUpdate?.(...args) + let notify = useEvent((...args: Parameters) => { + // Notify our layer + onUpdate?.(...args) - // Notify the parent - parentUpdate(...args) - }, - [parentUpdate, onUpdate] - ) + // Notify the parent + parentUpdate(...args) + }) useIsoMorphicEffect(() => { notify(StackMessage.Add, type, element)