From 7fd628f2909f0e7eaaca0ecc4a100b418784b968 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:39:48 +0200 Subject: [PATCH 01/20] `useDidElementMove`: handle `HTMLElement` This change should be temporary, and it will allow us to use the `useDidElementMove` with ref objects and direct `HTMLElement`s. --- .../src/hooks/use-did-element-move.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-did-element-move.ts b/packages/@headlessui-react/src/hooks/use-did-element-move.ts index 9c9266f2f0..9ca028148a 100644 --- a/packages/@headlessui-react/src/hooks/use-did-element-move.ts +++ b/packages/@headlessui-react/src/hooks/use-did-element-move.ts @@ -1,21 +1,33 @@ import { useRef, type MutableRefObject } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' -export function useDidElementMove(enabled: boolean, element: MutableRefObject) { +export function useDidElementMove( + enabled: boolean, + ref: MutableRefObject | HTMLElement | null +) { let elementPosition = useRef({ left: 0, top: 0 }) + useIsoMorphicEffect(() => { - let el = element.current + let el = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current if (!el) return let DOMRect = el.getBoundingClientRect() if (DOMRect) elementPosition.current = DOMRect }, [enabled]) - if (element.current == null) return false + let element = + typeof window === 'undefined' + ? null + : ref === null + ? null + : ref instanceof HTMLElement + ? ref + : ref.current + if (element == null) return false if (!enabled) return false - if (element.current === document.activeElement) return false + if (element === document.activeElement) return false - let buttonRect = element.current.getBoundingClientRect() + let buttonRect = element.getBoundingClientRect() let didElementMove = buttonRect.top !== elementPosition.current.top || From ffdc6c4864295cd7ce60ae2631a21660482c6808 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:42:12 +0200 Subject: [PATCH 02/20] `useResolveButtonType`: handle `HTMLElement` This change should be temporary, and it will allow us to use the `useResolveButtonType` hook with ref objects and direct `HTMLElement`s. --- .../src/hooks/use-resolve-button-type.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts index 163e5da30b..71b2683f23 100644 --- a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts +++ b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts @@ -12,7 +12,7 @@ function resolveType(props: { type?: string; as?: TTag }) { export function useResolveButtonType( props: { type?: string; as?: TTag }, - ref: MutableRefObject + ref: MutableRefObject | HTMLElement | null ) { let [type, setType] = useState(() => resolveType(props)) @@ -22,9 +22,11 @@ export function useResolveButtonType( useIsoMorphicEffect(() => { if (type) return - if (!ref.current) return - if (ref.current instanceof HTMLButtonElement && !ref.current.hasAttribute('type')) { + let node = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current + if (!node) return + + if (node instanceof HTMLButtonElement && !node.hasAttribute('type')) { setType('button') } }, [type, ref]) From 7dedb661d21e0bf1acfcea7aeb40af4bb43991a9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:41:40 +0200 Subject: [PATCH 03/20] `useRefocusableInput`: handle `HTMLElement` This change should be temporary, and it will allow us to use the `useRefocusableInput` hook with ref objects and direct `HTMLElement`s. --- .../src/hooks/use-refocusable-input.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts index 2ed48dfbbc..627b4a5835 100644 --- a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts +++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts @@ -8,7 +8,9 @@ import { useEventListener } from './use-event-listener' * This hook will also keep the cursor position into account to make sure the * cursor is placed at the correct position as-if we didn't loose focus at all. */ -export function useRefocusableInput(ref: MutableRefObject) { +export function useRefocusableInput( + ref: MutableRefObject | HTMLInputElement | null +) { // Track the cursor position and the value of the input let info = useRef({ value: '', @@ -16,7 +18,9 @@ export function useRefocusableInput(ref: MutableRefObject { + let element = ref === null ? null : ref instanceof HTMLInputElement ? ref : ref.current + + useEventListener(element, 'blur', (event) => { let target = event.target if (!(target instanceof HTMLInputElement)) return @@ -28,7 +32,7 @@ export function useRefocusableInput(ref: MutableRefObject { - let input = ref.current + let input = ref === null ? null : ref instanceof HTMLInputElement ? ref : ref.current // If the input is already focused, we don't need to do anything if (document.activeElement === input) return From b194e65e654a67aa2e43f62e61b313c45e05c011 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:42:31 +0200 Subject: [PATCH 04/20] `useTransition`: handle `HTMLElement` Accept `HTMLElement| null` instead of `MutableRefObject` in the `useTransition` hook. --- .../src/hooks/use-transition.ts | 144 +++++++++--------- 1 file changed, 69 insertions(+), 75 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index 42b3f57640..ae2602e6fc 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -51,7 +51,7 @@ export function transitionDataAttributes(data: TransitionData) { export function useTransition( enabled: boolean, - elementRef: MutableRefObject, + element: HTMLElement | null, show: boolean, events?: { start?(show: boolean): void @@ -68,98 +68,92 @@ export function useTransition( let d = useDisposables() - useIsoMorphicEffect( - function retry() { - if (!enabled) return + useIsoMorphicEffect(() => { + if (!enabled) return + if (show) { + setVisible(true) + } + + if (!element) { if (show) { - setVisible(true) + addFlag(TransitionState.Enter | TransitionState.Closed) } + return + } - let node = elementRef.current - if (!node) { - // Retry if the DOM node isn't available yet - if (show) { - addFlag(TransitionState.Enter | TransitionState.Closed) - return d.nextFrame(() => retry()) + events?.start?.(show) + + return transition(element, { + inFlight, + prepare() { + if (cancelledRef.current) { + // Cancelled a cancellation, we're back to the original state. + cancelledRef.current = false + } else { + // If we were already in-flight, then we want to cancel the current + // transition. + cancelledRef.current = inFlight.current } - return - } - events?.start?.(show) + inFlight.current = true - return transition(node, { - inFlight, - prepare() { - if (cancelledRef.current) { - // Cancelled a cancellation, we're back to the original state. - cancelledRef.current = false - } else { - // If we were already in-flight, then we want to cancel the current - // transition. - cancelledRef.current = inFlight.current - } - - inFlight.current = true - - if (cancelledRef.current) return + if (cancelledRef.current) return + if (show) { + addFlag(TransitionState.Enter | TransitionState.Closed) + removeFlag(TransitionState.Leave) + } else { + addFlag(TransitionState.Leave) + removeFlag(TransitionState.Enter) + } + }, + run() { + if (cancelledRef.current) { + // If we cancelled a transition, then the `show` state is going to + // be inverted already, but that doesn't mean we have to go to that + // new state. + // + // What we actually want is to revert to the "idle" state (the + // stable state where an `Enter` transitions to, and a `Leave` + // transitions from.) + // + // Because of this, it might look like we are swapping the flags in + // the following branches, but that's not the case. if (show) { - addFlag(TransitionState.Enter | TransitionState.Closed) - removeFlag(TransitionState.Leave) - } else { + removeFlag(TransitionState.Enter | TransitionState.Closed) addFlag(TransitionState.Leave) - removeFlag(TransitionState.Enter) + } else { + removeFlag(TransitionState.Leave) + addFlag(TransitionState.Enter | TransitionState.Closed) } - }, - run() { - if (cancelledRef.current) { - // If we cancelled a transition, then the `show` state is going to - // be inverted already, but that doesn't mean we have to go to that - // new state. - // - // What we actually want is to revert to the "idle" state (the - // stable state where an `Enter` transitions to, and a `Leave` - // transitions from.) - // - // Because of this, it might look like we are swapping the flags in - // the following branches, but that's not the case. - if (show) { - removeFlag(TransitionState.Enter | TransitionState.Closed) - addFlag(TransitionState.Leave) - } else { - removeFlag(TransitionState.Leave) - addFlag(TransitionState.Enter | TransitionState.Closed) - } + } else { + if (show) { + removeFlag(TransitionState.Closed) } else { - if (show) { - removeFlag(TransitionState.Closed) - } else { - addFlag(TransitionState.Closed) - } + addFlag(TransitionState.Closed) } - }, - done() { - if (cancelledRef.current) { - if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) { - return - } + } + }, + done() { + if (cancelledRef.current) { + if (typeof element.getAnimations === 'function' && element.getAnimations().length > 0) { + return } + } - inFlight.current = false + inFlight.current = false - removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed) + removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed) - if (!show) { - setVisible(false) - } + if (!show) { + setVisible(false) + } - events?.end?.(show) - }, - }) - }, - [enabled, show, elementRef, d] - ) + events?.end?.(show) + }, + }) + }, [enabled, show, element, d]) if (!enabled) { return [ From 6f90b1e4a152927f01bbe3678e0ba22ffea14d7b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:45:19 +0200 Subject: [PATCH 05/20] ensure `containers` are a dependency of `useEffect` --- packages/@headlessui-react/src/hooks/use-outside-click.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 52b3062afd..4a006571ee 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -101,7 +101,7 @@ export function useOutsideClick( return cbRef.current(event, target) }, - [cbRef] + [cbRef, containers] ) let initialClickTarget = useRef(null) From a7bbcee8931528e9635ec940b366be9543b308cf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 17:21:54 +0200 Subject: [PATCH 06/20] `Menu`: track `button` and `items` elements in state So far we've been tracking the `button` and the the `items` DOM nodes in a ref. Typically, this is the way you do it, you keep track of it in a ref, later you can access it in a `useEffect` or similar by accessing the `ref.current`. There are some problems with this. There are places where we require the DOM element during render (for example when picking out the `.id` from the DOM node directly). Another issue is that we want to re-run some `useEffect`'s whenever the underlying DOM node changes. We currently work around that, but storing it directly in state would solve these issues because the component will re-render and we will have access to the new DOM node. --- .../src/components/menu/menu.tsx | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 804e4e1174..8da007529a 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -6,7 +6,7 @@ import { useHover } from '@react-aria/interactions' import React, { Fragment, createContext, - createRef, + useCallback, useContext, useEffect, useMemo, @@ -94,8 +94,8 @@ type MenuItemDataRef = MutableRefObject<{ interface StateDefinition { __demoMode: boolean menuState: MenuStates - buttonRef: MutableRefObject - itemsRef: MutableRefObject + buttonElement: HTMLButtonElement | null + itemsElement: HTMLElement | null items: { id: string; dataRef: MenuItemDataRef }[] searchQuery: string activeItemIndex: number | null @@ -111,6 +111,9 @@ enum ActionTypes { ClearSearch, RegisterItem, UnregisterItem, + + SetButtonElement, + SetItemsElement, } function adjustOrderedState( @@ -152,6 +155,8 @@ type Actions = | { type: ActionTypes.ClearSearch } | { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef } | { type: ActionTypes.UnregisterItem; id: string } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetItemsElement; element: HTMLElement | null } let reducers: { [P in ActionTypes]: ( @@ -334,6 +339,14 @@ let reducers: { activationTrigger: ActivationTrigger.Other, } }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetItemsElement]: (state, action) => { + if (state.itemsElement === action.element) return state + return { ...state, itemsElement: action.element } + }, } let MenuContext = createContext<[StateDefinition, Dispatch] | null>(null) @@ -379,24 +392,24 @@ function MenuFn( let reducerBag = useReducer(stateReducer, { __demoMode, menuState: __demoMode ? MenuStates.Open : MenuStates.Closed, - buttonRef: createRef(), - itemsRef: createRef(), + buttonElement: null, + itemsElement: null, items: [], searchQuery: '', activeItemIndex: null, activationTrigger: ActivationTrigger.Other, } as StateDefinition) - let [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag + let [{ menuState, itemsElement, buttonElement }, dispatch] = reducerBag let menuRef = useSyncRefs(ref) // Handle outside click let outsideClickEnabled = menuState === MenuStates.Open - useOutsideClick(outsideClickEnabled, [buttonRef, itemsRef], (event, target) => { + useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => { dispatch({ type: ActionTypes.CloseMenu }) if (!isFocusableElement(target, FocusableMode.Loose)) { event.preventDefault() - buttonRef.current?.focus() + buttonElement?.focus() } }) @@ -469,7 +482,11 @@ function ButtonFn( } = props let [state, dispatch] = useMenuContext('Menu.Button') let getFloatingReferenceProps = useFloatingReferenceProps() - let buttonRef = useSyncRefs(state.buttonRef, ref, useFloatingReference()) + let buttonRef = useSyncRefs( + ref, + useFloatingReference(), + useEvent((element) => dispatch({ type: ActionTypes.SetButtonElement, element })) + ) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { @@ -509,7 +526,7 @@ function ButtonFn( if (disabled) return if (state.menuState === MenuStates.Open) { flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) - state.buttonRef.current?.focus({ preventScroll: true }) + state.buttonElement?.focus({ preventScroll: true }) } else { event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) @@ -536,9 +553,9 @@ function ButtonFn( { ref: buttonRef, id, - type: useResolveButtonType(props, state.buttonRef), + type: useResolveButtonType(props, state.buttonElement), 'aria-haspopup': 'menu', - 'aria-controls': state.itemsRef.current?.id, + 'aria-controls': state.itemsElement?.id, 'aria-expanded': state.menuState === MenuStates.Open, disabled: disabled || undefined, autoFocus, @@ -603,8 +620,12 @@ function ItemsFn( let [state, dispatch] = useMenuContext('Menu.Items') let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() - let itemsRef = useSyncRefs(state.itemsRef, ref, anchor ? floatingRef : null) - let ownerDocument = useOwnerDocument(state.itemsRef) + let itemsRef = useSyncRefs( + ref, + anchor ? floatingRef : null, + useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })) + ) + let ownerDocument = useOwnerDocument(state.itemsElement) // Always enable `portal` functionality, when `anchor` is enabled if (anchor) { @@ -614,14 +635,14 @@ function ItemsFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - state.itemsRef, + state.itemsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.menuState === MenuStates.Open ) // Ensure we close the menu as soon as the button becomes hidden - useOnDisappear(visible, state.buttonRef, () => { + useOnDisappear(visible, state.buttonElement, () => { dispatch({ type: ActionTypes.CloseMenu }) }) @@ -632,7 +653,10 @@ function ItemsFn( // Mark other elements as inert when the menu is visible, and `modal` is enabled let inertOthersEnabled = state.__demoMode ? false : modal && state.menuState === MenuStates.Open useInertOthers(inertOthersEnabled, { - allowed: useEvent(() => [state.buttonRef.current, state.itemsRef.current]), + allowed: useCallback( + () => [state.buttonElement, state.itemsElement], + [state.buttonElement, state.itemsElement] + ), }) // We keep track whether the button moved or not, we only check this when the menu state becomes @@ -645,23 +669,23 @@ function ItemsFn( // This can be solved by only transitioning the `opacity` instead of everything, but if you _do_ // want to transition the y-axis for example you will run into the same issue again. let didButtonMoveEnabled = state.menuState !== MenuStates.Open - let didButtonMove = useDidElementMove(didButtonMoveEnabled, state.buttonRef) + let didButtonMove = useDidElementMove(didButtonMoveEnabled, state.buttonElement) // Now that we know that the button did move or not, we can either disable the panel and all of // its transitions, or rely on the `visible` state to hide the panel whenever necessary. let panelEnabled = didButtonMove ? false : visible useEffect(() => { - let container = state.itemsRef.current + let container = state.itemsElement if (!container) return if (state.menuState !== MenuStates.Open) return if (container === ownerDocument?.activeElement) return container.focus({ preventScroll: true }) - }, [state.menuState, state.itemsRef, ownerDocument, state.itemsRef.current]) + }, [state.menuState, state.itemsElement, ownerDocument]) useTreeWalker(state.menuState === MenuStates.Open, { - container: state.itemsRef.current, + container: state.itemsElement, accept(node) { if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -695,7 +719,7 @@ function ItemsFn( let { dataRef } = state.items[state.activeItemIndex] dataRef.current?.domRef.current?.click() } - restoreFocusIfNecessary(state.buttonRef.current) + restoreFocusIfNecessary(state.buttonElement) break case Keys.ArrowDown: @@ -724,7 +748,7 @@ function ItemsFn( event.preventDefault() event.stopPropagation() flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) - state.buttonRef.current?.focus({ preventScroll: true }) + state.buttonElement?.focus({ preventScroll: true }) break case Keys.Tab: @@ -732,7 +756,7 @@ function ItemsFn( event.stopPropagation() flushSync(() => dispatch({ type: ActionTypes.CloseMenu })) focusFrom( - state.buttonRef.current!, + state.buttonElement!, event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next ) break @@ -766,7 +790,7 @@ function ItemsFn( let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { 'aria-activedescendant': state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id, - 'aria-labelledby': state.buttonRef.current?.id, + 'aria-labelledby': state.buttonElement?.id, id, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, @@ -779,7 +803,7 @@ function ItemsFn( style: { ...theirProps.style, ...style, - '--button-width': useElementSize(state.buttonRef, true).width, + '--button-width': useElementSize(state.buttonElement, true).width, } as CSSProperties, ...transitionDataAttributes(transitionData), }) @@ -879,7 +903,7 @@ function ItemFn( let handleClick = useEvent((event: MouseEvent) => { if (disabled) return event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) - restoreFocusIfNecessary(state.buttonRef.current) + restoreFocusIfNecessary(state.buttonElement) }) let handleFocus = useEvent(() => { From 6298af956876b368ad7287968a8414cae700d6d1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 17:09:44 +0200 Subject: [PATCH 07/20] `Combobox`: track `input`, `button` and `options` elements in state --- .../src/components/combobox/combobox.tsx | 135 +++++++++++------- 1 file changed, 86 insertions(+), 49 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 20b16525aa..131284aa31 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -116,6 +116,10 @@ interface StateDefinition { isTyping: boolean + inputElement: HTMLInputElement | null + buttonElement: HTMLButtonElement | null + optionsElement: HTMLElement | null + __demoMode: boolean } @@ -132,6 +136,10 @@ enum ActionTypes { SetActivationTrigger, UpdateVirtualConfiguration, + + SetInputElement, + SetButtonElement, + SetOptionsElement, } function adjustOrderedState( @@ -192,6 +200,9 @@ type Actions = options: T[] disabled: ((value: any) => boolean) | null } + | { type: ActionTypes.SetInputElement; element: HTMLInputElement | null } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } let reducers: { [P in ActionTypes]: ( @@ -245,7 +256,7 @@ let reducers: { [ActionTypes.GoToOption](state, action) { if (state.dataRef.current?.disabled) return state if ( - state.dataRef.current?.optionsRef.current && + state.optionsElement && !state.dataRef.current?.optionsPropsRef.current.static && state.comboboxState === ComboboxState.Closed ) { @@ -419,6 +430,18 @@ let reducers: { virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, } }, + [ActionTypes.SetInputElement]: (state, action) => { + if (state.inputElement === action.element) return state + return { ...state, inputElement: action.element } + }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetOptionsElement]: (state, action) => { + if (state.optionsElement === action.element) return state + return { ...state, optionsElement: action.element } + }, } let ComboboxActionsContext = createContext<{ @@ -431,6 +454,10 @@ let ComboboxActionsContext = createContext<{ selectActiveOption(): void setActivationTrigger(trigger: ActivationTrigger): void onChange(value: unknown): void + + setInputElement(element: HTMLInputElement | null): void + setButtonElement(element: HTMLButtonElement | null): void + setOptionsElement(element: HTMLElement | null): void } | null>(null) ComboboxActionsContext.displayName = 'ComboboxActionsContext' @@ -455,7 +482,7 @@ function VirtualProvider(props: { let { options } = data.virtual! let [paddingStart, paddingEnd] = useMemo(() => { - let el = data.optionsRef.current + let el = data.optionsElement if (!el) return [0, 0] let styles = window.getComputedStyle(el) @@ -464,7 +491,7 @@ function VirtualProvider(props: { parseFloat(styles.paddingBlockStart || styles.paddingTop), parseFloat(styles.paddingBlockEnd || styles.paddingBottom), ] - }, [data.optionsRef.current]) + }, [data.optionsElement]) let virtualizer = useVirtualizer({ enabled: options.length !== 0, @@ -475,7 +502,7 @@ function VirtualProvider(props: { return 40 }, getScrollElement() { - return (data.optionsRef.current ?? null) as HTMLElement | null + return data.optionsElement }, overscan: 12, }) @@ -573,10 +600,6 @@ let ComboboxDataContext = createContext< static: boolean hold: boolean }> - - inputRef: MutableRefObject - buttonRef: MutableRefObject - optionsRef: MutableRefObject } & Omit, 'dataRef'>) | null >(null) @@ -688,6 +711,9 @@ function ComboboxFn) @@ -695,10 +721,6 @@ function ComboboxFn({ static: false, hold: false }) - let inputRef = useRef<_Data['inputRef']['current']>(null) - let buttonRef = useRef<_Data['buttonRef']['current']>(null) - let optionsRef = useRef<_Data['optionsRef']['current']>(null) - type TActualValue = true extends typeof multiple ? EnsureArray[number] : TValue let compare = useByComparator(by) @@ -733,9 +755,6 @@ function ComboboxFn - actions.closeCombobox() + useOutsideClick( + outsideClickEnabled, + [data.buttonElement, data.inputElement, data.optionsElement], + () => actions.closeCombobox() ) let slot = useMemo(() => { @@ -896,6 +917,18 @@ function ComboboxFn { + dispatch({ type: ActionTypes.SetInputElement, element }) + }) + + let setButtonElement = useEvent((element: HTMLButtonElement | null) => { + dispatch({ type: ActionTypes.SetButtonElement, element }) + }) + + let setOptionsElement = useEvent((element: HTMLElement | null) => { + dispatch({ type: ActionTypes.SetOptionsElement, element }) + }) + let actions = useMemo<_Actions>( () => ({ onChange, @@ -906,6 +939,9 @@ function ComboboxFn(null) + let inputRef = useSyncRefs(internalInputRef, ref, useFloatingReference(), actions.setInputElement) + let ownerDocument = useOwnerDocument(data.inputElement) let d = useDisposables() let clear = useEvent(() => { actions.onChange(null) - if (data.optionsRef.current) { - data.optionsRef.current.scrollTop = 0 + if (data.optionsElement) { + data.optionsElement.scrollTop = 0 } actions.goToOption(Focus.Nothing) }) @@ -1071,7 +1108,7 @@ function InputFn< // using an IME, we don't want to mess with the input at all. if (data.isTyping) return - let input = data.inputRef.current + let input = internalInputRef.current if (!input) return if (oldState === ComboboxState.Open && state === ComboboxState.Closed) { @@ -1121,7 +1158,7 @@ function InputFn< // using an IME, we don't want to mess with the input at all. if (data.isTyping) return - let input = data.inputRef.current + let input = internalInputRef.current if (!input) return // Capture current state @@ -1232,7 +1269,7 @@ function InputFn< case Keys.Escape: if (data.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsRef.current && !data.optionsPropsRef.current.static) { + if (data.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } @@ -1286,10 +1323,10 @@ function InputFn< (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) // Focus is moved into the list, we don't want to close yet. - if (data.optionsRef.current?.contains(relatedTarget)) return + if (data.optionsElement?.contains(relatedTarget)) return // Focus is moved to the button, we don't want to close yet. - if (data.buttonRef.current?.contains(relatedTarget)) return + if (data.buttonElement?.contains(relatedTarget)) return // Focus is moved, but the combobox is not open. This can mean two things: // @@ -1316,8 +1353,8 @@ function InputFn< let handleFocus = useEvent((event: ReactFocusEvent) => { let relatedTarget = (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) - if (data.buttonRef.current?.contains(relatedTarget)) return - if (data.optionsRef.current?.contains(relatedTarget)) return + if (data.buttonElement?.contains(relatedTarget)) return + if (data.optionsElement?.contains(relatedTarget)) return if (data.disabled) return if (!data.immediate) return @@ -1378,7 +1415,7 @@ function InputFn< id, role: 'combobox', type, - 'aria-controls': data.optionsRef.current?.id, + 'aria-controls': data.optionsElement?.id, 'aria-expanded': data.comboboxState === ComboboxState.Open, 'aria-activedescendant': data.activeOptionIndex === null @@ -1457,7 +1494,8 @@ function ButtonFn( ) { let data = useData('Combobox.Button') let actions = useActions('Combobox.Button') - let buttonRef = useSyncRefs(data.buttonRef, ref) + let buttonRef = useSyncRefs(ref, actions.setButtonElement) + let internalId = useId() let { id = `headlessui-combobox-button-${internalId}`, @@ -1466,7 +1504,7 @@ function ButtonFn( ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputRef) + let refocusInput = useRefocusableInput(data.inputElement) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { @@ -1505,7 +1543,7 @@ function ButtonFn( case Keys.Escape: if (data.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsRef.current && !data.optionsPropsRef.current.static) { + if (data.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } flushSync(() => actions.closeCombobox()) @@ -1561,10 +1599,10 @@ function ButtonFn( { ref: buttonRef, id, - type: useResolveButtonType(props, data.buttonRef), + type: useResolveButtonType(props, data.buttonElement), tabIndex: -1, 'aria-haspopup': 'listbox', - 'aria-controls': data.optionsRef.current?.id, + 'aria-controls': data.optionsElement?.id, 'aria-expanded': data.comboboxState === ComboboxState.Open, 'aria-labelledby': labelledBy, disabled: disabled || undefined, @@ -1635,20 +1673,20 @@ function OptionsFn( let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() - let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null) - let ownerDocument = useOwnerDocument(data.optionsRef) + let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement) + let ownerDocument = useOwnerDocument(data.optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - data.optionsRef, + data.optionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : data.comboboxState === ComboboxState.Open ) // Ensure we close the combobox as soon as the input becomes hidden - useOnDisappear(visible, data.inputRef, actions.closeCombobox) + useOnDisappear(visible, data.inputElement, actions.closeCombobox) // Enable scroll locking when the combobox is visible, and `modal` is enabled let scrollLockEnabled = data.__demoMode @@ -1661,11 +1699,10 @@ function OptionsFn( ? false : modal && data.comboboxState === ComboboxState.Open useInertOthers(inertOthersEnabled, { - allowed: useEvent(() => [ - data.inputRef.current, - data.buttonRef.current, - data.optionsRef.current, - ]), + allowed: useCallback( + () => [data.inputElement, data.buttonElement, data.optionsElement], + [data.inputElement, data.buttonElement, data.optionsElement] + ), }) useIsoMorphicEffect(() => { @@ -1676,7 +1713,7 @@ function OptionsFn( }, [data.optionsPropsRef, hold]) useTreeWalker(data.comboboxState === ComboboxState.Open, { - container: data.optionsRef.current, + container: data.optionsElement, accept(node) { if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -1687,7 +1724,7 @@ function OptionsFn( }, }) - let labelledBy = useLabelledBy([data.buttonRef.current?.id]) + let labelledBy = useLabelledBy([data.buttonElement?.id]) let slot = useMemo(() => { return { @@ -1728,8 +1765,8 @@ function OptionsFn( style: { ...theirProps.style, ...style, - '--input-width': useElementSize(data.inputRef, true).width, - '--button-width': useElementSize(data.buttonRef, true).width, + '--input-width': useElementSize(data.inputElement, true).width, + '--button-width': useElementSize(data.buttonElement, true).width, } as CSSProperties, onWheel: data.activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel, onMouseDown: handleMouseDown, @@ -1840,7 +1877,7 @@ function OptionFn< ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputRef) + let refocusInput = useRefocusableInput(data.inputElement) let active = data.virtual ? data.activeOptionIndex === data.calculateIndex(value) From ef6afd5362f1646eb67755239558bbe87f9e4ddd Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 17:09:50 +0200 Subject: [PATCH 08/20] `Disclosure`: track `button` and `panel` elements in state --- .../src/components/disclosure/disclosure.tsx | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 66f5938037..5fe2c49c41 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -57,10 +57,8 @@ enum DisclosureStates { interface StateDefinition { disclosureState: DisclosureStates - linkedPanel: boolean - - buttonRef: MutableRefObject - panelRef: MutableRefObject + buttonElement: HTMLButtonElement | null + panelElement: HTMLElement | null buttonId: string | null panelId: string | null @@ -73,8 +71,8 @@ enum ActionTypes { SetButtonId, SetPanelId, - LinkPanel, - UnlinkPanel, + SetButtonElement, + SetPanelElement, } type Actions = @@ -82,8 +80,8 @@ type Actions = | { type: ActionTypes.CloseDisclosure } | { type: ActionTypes.SetButtonId; buttonId: string | null } | { type: ActionTypes.SetPanelId; panelId: string | null } - | { type: ActionTypes.LinkPanel } - | { type: ActionTypes.UnlinkPanel } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetPanelElement; element: HTMLElement | null } let reducers: { [P in ActionTypes]: ( @@ -102,14 +100,6 @@ let reducers: { if (state.disclosureState === DisclosureStates.Closed) return state return { ...state, disclosureState: DisclosureStates.Closed } }, - [ActionTypes.LinkPanel](state) { - if (state.linkedPanel === true) return state - return { ...state, linkedPanel: true } - }, - [ActionTypes.UnlinkPanel](state) { - if (state.linkedPanel === false) return state - return { ...state, linkedPanel: false } - }, [ActionTypes.SetButtonId](state, action) { if (state.buttonId === action.buttonId) return state return { ...state, buttonId: action.buttonId } @@ -118,6 +108,14 @@ let reducers: { if (state.panelId === action.panelId) return state return { ...state, panelId: action.panelId } }, + [ActionTypes.SetButtonElement](state, action) { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetPanelElement](state, action) { + if (state.panelElement === action.element) return state + return { ...state, panelElement: action.element } + }, } let DisclosureContext = createContext<[StateDefinition, Dispatch] | null>(null) @@ -195,14 +193,10 @@ function DisclosureFn( ) ) - let panelRef = useRef(null) - let buttonRef = useRef(null) - let reducerBag = useReducer(stateReducer, { disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed, - linkedPanel: false, - buttonRef, - panelRef, + buttonElement: null, + panelElement: null, buttonId: null, panelId: null, } as StateDefinition) @@ -301,7 +295,13 @@ function ButtonFn( let isWithinPanel = panelContext === null ? false : panelContext === state.panelId let internalButtonRef = useRef(null) - let buttonRef = useSyncRefs(internalButtonRef, ref, !isWithinPanel ? state.buttonRef : null) + let buttonRef = useSyncRefs( + internalButtonRef, + ref, + !isWithinPanel + ? useEvent((element) => dispatch({ type: ActionTypes.SetButtonElement, element })) + : null + ) let mergeRefs = useMergeRefsFn() useEffect(() => { @@ -323,7 +323,7 @@ function ButtonFn( event.preventDefault() event.stopPropagation() dispatch({ type: ActionTypes.ToggleDisclosure }) - state.buttonRef.current?.focus() + state.buttonElement?.focus() break } } else { @@ -355,7 +355,7 @@ function ButtonFn( if (isWithinPanel) { dispatch({ type: ActionTypes.ToggleDisclosure }) - state.buttonRef.current?.focus() + state.buttonElement?.focus() } else { dispatch({ type: ActionTypes.ToggleDisclosure }) } @@ -397,7 +397,7 @@ function ButtonFn( id, type, 'aria-expanded': state.disclosureState === DisclosureStates.Open, - 'aria-controls': state.linkedPanel ? state.panelId : undefined, + 'aria-controls': state.panelElement ? state.panelId : undefined, disabled: disabled || undefined, autoFocus, onKeyDown: handleKeyDown, @@ -451,9 +451,12 @@ function PanelFn( let { close } = useDisclosureAPIContext('Disclosure.Panel') let mergeRefs = useMergeRefsFn() - let panelRef = useSyncRefs(ref, state.panelRef, (el) => { - startTransition(() => dispatch({ type: el ? ActionTypes.LinkPanel : ActionTypes.UnlinkPanel })) - }) + let panelRef = useSyncRefs( + ref, + useEvent((element) => { + startTransition(() => dispatch({ type: ActionTypes.SetPanelElement, element })) + }) + ) useEffect(() => { dispatch({ type: ActionTypes.SetPanelId, panelId: id }) @@ -465,7 +468,7 @@ function PanelFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - state.panelRef, + state.panelElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.disclosureState === DisclosureStates.Open From 5bd4d58894784d076da60defe1712670b619bd35 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 17:09:55 +0200 Subject: [PATCH 09/20] `Listbox`: track `button` and `options` elements in state --- .../src/components/listbox/listbox.tsx | 90 ++++++++++++------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 9a7d16602f..e8c40e4365 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -22,7 +22,6 @@ import React, { import { flushSync } from 'react-dom' import { useActivePress } from '../../hooks/use-active-press' import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator' -import { useComputed } from '../../hooks/use-computed' import { useControllable } from '../../hooks/use-controllable' import { useDefaultValue } from '../../hooks/use-default-value' import { useDidElementMove } from '../../hooks/use-did-element-move' @@ -116,6 +115,9 @@ interface StateDefinition { activeOptionIndex: number | null activationTrigger: ActivationTrigger + buttonElement: HTMLButtonElement | null + optionsElement: HTMLElement | null + __demoMode: boolean } @@ -129,6 +131,9 @@ enum ActionTypes { RegisterOption, UnregisterOption, + + SetButtonElement, + SetOptionsElement, } function adjustOrderedState( @@ -173,6 +178,8 @@ type Actions = | { type: ActionTypes.ClearSearch } | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef } | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } let reducers: { [P in ActionTypes]: ( @@ -381,6 +388,14 @@ let reducers: { activationTrigger: ActivationTrigger.Other, } }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetOptionsElement]: (state, action) => { + if (state.optionsElement === action.element) return state + return { ...state, optionsElement: action.element } + }, } let ListboxActionsContext = createContext<{ @@ -394,6 +409,8 @@ let ListboxActionsContext = createContext<{ onChange(value: unknown): void search(query: string): void clearSearch(): void + setButtonElement(element: HTMLButtonElement | null): void + setOptionsElement(element: HTMLElement | null): void } | null>(null) ListboxActionsContext.displayName = 'ListboxActionsContext' @@ -425,9 +442,6 @@ let ListboxDataContext = createContext< }> listRef: MutableRefObject> - - buttonRef: MutableRefObject - optionsRef: MutableRefObject } & Omit, 'dataRef'>) | null >(null) @@ -521,13 +535,13 @@ function ListboxFn< activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, optionsVisible: false, + buttonElement: null, + optionsElement: null, __demoMode, } as StateDefinition) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) - let buttonRef = useRef<_Data['buttonRef']['current']>(null) - let optionsRef = useRef<_Data['optionsRef']['current']>(null) let listRef = useRef<_Data['listRef']['current']>(new Map()) let compare = useByComparator(by) @@ -556,8 +570,6 @@ function ListboxFn< compare, isSelected, optionsPropsRef, - buttonRef, - optionsRef, listRef, }), [value, disabled, invalid, multiple, state, listRef] @@ -569,14 +581,18 @@ function ListboxFn< // Handle outside click let outsideClickEnabled = data.listboxState === ListboxStates.Open - useOutsideClick(outsideClickEnabled, [data.buttonRef, data.optionsRef], (event, target) => { - dispatch({ type: ActionTypes.CloseListbox }) + useOutsideClick( + outsideClickEnabled, + [data.buttonElement, data.optionsElement], + (event, target) => { + dispatch({ type: ActionTypes.CloseListbox }) - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - data.buttonRef.current?.focus() + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + data.buttonElement?.focus() + } } - }) + ) let slot = useMemo(() => { return { @@ -647,6 +663,12 @@ function ListboxFn< let search = useEvent((value: string) => dispatch({ type: ActionTypes.Search, value })) let clearSearch = useEvent(() => dispatch({ type: ActionTypes.ClearSearch })) + let setButtonElement = useEvent((element: HTMLButtonElement | null) => { + dispatch({ type: ActionTypes.SetButtonElement, element }) + }) + let setOptionsElement = useEvent((element: HTMLElement | null) => { + dispatch({ type: ActionTypes.SetOptionsElement, element }) + }) let actions = useMemo<_Actions>( () => ({ @@ -659,6 +681,8 @@ function ListboxFn< selectOption, search, clearSearch, + setButtonElement, + setOptionsElement, }), [] ) @@ -676,7 +700,7 @@ function ListboxFn< ( autoFocus = false, ...theirProps } = props - let buttonRef = useSyncRefs(data.buttonRef, ref, useFloatingReference()) + let buttonRef = useSyncRefs(ref, useFloatingReference(), actions.setButtonElement) let getFloatingReferenceProps = useFloatingReferenceProps() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -801,7 +825,7 @@ function ButtonFn( if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (data.listboxState === ListboxStates.Open) { flushSync(() => actions.closeListbox()) - data.buttonRef.current?.focus({ preventScroll: true }) + data.buttonElement?.focus({ preventScroll: true }) } else { event.preventDefault() actions.openListbox() @@ -836,9 +860,9 @@ function ButtonFn( { ref: buttonRef, id, - type: useResolveButtonType(props, data.buttonRef), + type: useResolveButtonType(props, data.buttonElement), 'aria-haspopup': 'listbox', - 'aria-controls': data.optionsRef.current?.id, + 'aria-controls': data.optionsElement?.id, 'aria-expanded': data.listboxState === ListboxStates.Open, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, @@ -916,19 +940,19 @@ function OptionsFn( let data = useData('Listbox.Options') let actions = useActions('Listbox.Options') - let ownerDocument = useOwnerDocument(data.optionsRef) + let ownerDocument = useOwnerDocument(data.optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - data.optionsRef, + data.optionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : data.listboxState === ListboxStates.Open ) // Ensure we close the listbox as soon as the button becomes hidden - useOnDisappear(visible, data.buttonRef, actions.closeListbox) + useOnDisappear(visible, data.buttonElement, actions.closeListbox) // Enable scroll locking when the listbox is visible, and `modal` is enabled let scrollLockEnabled = data.__demoMode @@ -941,7 +965,7 @@ function OptionsFn( ? false : modal && data.listboxState === ListboxStates.Open useInertOthers(inertOthersEnabled, { - allowed: useEvent(() => [data.buttonRef.current, data.optionsRef.current]), + allowed: useEvent(() => [data.buttonElement, data.optionsElement]), }) // We keep track whether the button moved or not, we only check this when the menu state becomes @@ -954,7 +978,7 @@ function OptionsFn( // This can be solved by only transitioning the `opacity` instead of everything, but if you _do_ // want to transition the y-axis for example you will run into the same issue again. let didElementMoveEnabled = data.listboxState !== ListboxStates.Open - let didButtonMove = useDidElementMove(didElementMoveEnabled, data.buttonRef) + let didButtonMove = useDidElementMove(didElementMoveEnabled, data.buttonElement) // Now that we know that the button did move or not, we can either disable the panel and all of // its transitions, or rely on the `visible` state to hide the panel whenever necessary. @@ -999,18 +1023,18 @@ function OptionsFn( let [floatingRef, style] = useFloatingPanel(anchorOptions) let getFloatingPanelProps = useFloatingPanelProps() - let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null) + let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement) let searchDisposables = useDisposables() useEffect(() => { - let container = data.optionsRef.current + let container = data.optionsElement if (!container) return if (data.listboxState !== ListboxStates.Open) return if (container === getOwnerDocument(container)?.activeElement) return container?.focus({ preventScroll: true }) - }, [data.listboxState, data.optionsRef, data.optionsRef.current]) + }, [data.listboxState, data.optionsElement]) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { searchDisposables.dispose() @@ -1036,7 +1060,7 @@ function OptionsFn( } if (data.mode === ValueMode.Single) { flushSync(() => actions.closeListbox()) - data.buttonRef.current?.focus({ preventScroll: true }) + data.buttonElement?.focus({ preventScroll: true }) } break @@ -1066,7 +1090,7 @@ function OptionsFn( event.preventDefault() event.stopPropagation() flushSync(() => actions.closeListbox()) - data.buttonRef.current?.focus({ preventScroll: true }) + data.buttonElement?.focus({ preventScroll: true }) return case Keys.Tab: @@ -1074,7 +1098,7 @@ function OptionsFn( event.stopPropagation() flushSync(() => actions.closeListbox()) focusFrom( - data.buttonRef.current!, + data.buttonElement!, event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next ) break @@ -1088,7 +1112,7 @@ function OptionsFn( } }) - let labelledby = useComputed(() => data.buttonRef.current?.id, [data.buttonRef.current]) + let labelledby = data.buttonElement?.id let slot = useMemo(() => { return { open: data.listboxState === ListboxStates.Open, @@ -1112,7 +1136,7 @@ function OptionsFn( style: { ...theirProps.style, ...style, - '--button-width': useElementSize(data.buttonRef, true).width, + '--button-width': useElementSize(data.buttonElement, true).width, } as CSSProperties, ...transitionDataAttributes(transitionData), }) @@ -1230,7 +1254,7 @@ function OptionFn< actions.onChange(value) if (data.mode === ValueMode.Single) { flushSync(() => actions.closeListbox()) - data.buttonRef.current?.focus({ preventScroll: true }) + data.buttonElement?.focus({ preventScroll: true }) } }) From d01b25b86701cebf018c96ef4d1a23f860413118 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 17:09:59 +0200 Subject: [PATCH 10/20] `Popover`: track `button` and `panel` elements in state --- .../src/components/popover/popover.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 034e22c87a..f501e75427 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -537,7 +537,7 @@ function ButtonFn( useFloatingReference(), isWithinPanel ? null - : (button) => { + : useEvent((button) => { if (button) { state.buttons.current.push(uniqueIdentifier) } else { @@ -552,7 +552,7 @@ function ButtonFn( } button && dispatch({ type: ActionTypes.SetButton, button }) - } + }) ) let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref) let ownerDocument = useOwnerDocument(internalButtonRef) @@ -763,13 +763,13 @@ function BackdropFn( ...theirProps } = props let [{ popoverState }, dispatch] = usePopoverContext('Popover.Backdrop') - let internalBackdropRef = useRef(null) - let backdropRef = useSyncRefs(ref, internalBackdropRef) + let [backdropElement, setBackdropElement] = useState(null) + let backdropRef = useSyncRefs(ref, setBackdropElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - internalBackdropRef, + backdropElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : popoverState === PopoverStates.Open @@ -865,9 +865,12 @@ function PanelFn( portal = true } - let panelRef = useSyncRefs(internalPanelRef, ref, anchor ? floatingRef : null, (panel) => { - dispatch({ type: ActionTypes.SetPanel, panel }) - }) + let panelRef = useSyncRefs( + internalPanelRef, + ref, + anchor ? floatingRef : null, + useEvent((panel) => dispatch({ type: ActionTypes.SetPanel, panel })) + ) let ownerDocument = useOwnerDocument(internalPanelRef) let mergeRefs = useMergeRefsFn() @@ -881,7 +884,7 @@ function PanelFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - internalPanelRef, + state.panel, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.popoverState === PopoverStates.Open From 2d14b2cf6bba9b19ce569e62b324299ebc027034 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 17:10:03 +0200 Subject: [PATCH 11/20] `Transition`: track the `container` element in state --- .../src/components/transition/transition.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx index a61a7a0617..31aaf07a4c 100644 --- a/packages/@headlessui-react/src/components/transition/transition.tsx +++ b/packages/@headlessui-react/src/components/transition/transition.tsx @@ -319,10 +319,13 @@ function TransitionChildFn(null) let container = useRef(null) let requiresRef = shouldForwardRef(props) - let transitionRef = useSyncRefs(...(requiresRef ? [container, ref] : ref === null ? [] : [ref])) + let transitionRef = useSyncRefs( + ...(requiresRef ? [container, ref, setContainerElement] : ref === null ? [] : [ref]) + ) let strategy = theirProps.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden let { show, appear, initial } = useTransitionContext() @@ -435,7 +438,7 @@ function TransitionChildFn` is done, but there is still a // child `` busy, then `visible` would be `false`, while // `state` would still be `TreeStates.Visible`. - let [, transitionData] = useTransition(enabled, container, show, { start, end }) + let [, transitionData] = useTransition(enabled, containerElement, show, { start, end }) let ourProps = compact({ ref: transitionRef, From 632b2d060e3002af60f2ee31dcca8157b4fd27ee Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 30 Jul 2024 22:37:49 +0200 Subject: [PATCH 12/20] remove incorrect leftover `style=""` attribute --- .../components/transition/__snapshots__/transition.test.tsx.snap | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap index 7d2fa9fb7c..e2aa844fe0 100644 --- a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap +++ b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap @@ -133,7 +133,6 @@ exports[`Setup API transition classes should be possible to passthrough the tran data-closed="" data-enter="" data-transition="" - style="" > Children From 477f259cd3a6d305cad1099d7cd2355bb9865022 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 15:44:42 +0200 Subject: [PATCH 13/20] simplify `useDidElementMove`, only accept `HTMLElement | null` This doesn't support the `MutableRefObject` anymore. --- .../src/hooks/use-did-element-move.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-did-element-move.ts b/packages/@headlessui-react/src/hooks/use-did-element-move.ts index 9ca028148a..aa32ecafe4 100644 --- a/packages/@headlessui-react/src/hooks/use-did-element-move.ts +++ b/packages/@headlessui-react/src/hooks/use-did-element-move.ts @@ -1,28 +1,16 @@ -import { useRef, type MutableRefObject } from 'react' +import { useRef } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' -export function useDidElementMove( - enabled: boolean, - ref: MutableRefObject | HTMLElement | null -) { +export function useDidElementMove(enabled: boolean, element: HTMLElement | null) { let elementPosition = useRef({ left: 0, top: 0 }) useIsoMorphicEffect(() => { - let el = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current - if (!el) return + if (!element) return - let DOMRect = el.getBoundingClientRect() + let DOMRect = element.getBoundingClientRect() if (DOMRect) elementPosition.current = DOMRect - }, [enabled]) + }, [enabled, element]) - let element = - typeof window === 'undefined' - ? null - : ref === null - ? null - : ref instanceof HTMLElement - ? ref - : ref.current if (element == null) return false if (!enabled) return false if (element === document.activeElement) return false From 6a90d34eb679e86dd91e23b509757bcdfaee6323 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Aug 2024 00:33:07 +0200 Subject: [PATCH 14/20] pass `HTMLElement | null` directly to `useResolveButtonType` --- .../src/components/disclosure/disclosure.tsx | 2 +- .../@headlessui-react/src/components/popover/popover.tsx | 2 +- packages/@headlessui-react/src/components/switch/switch.tsx | 6 ++++-- packages/@headlessui-react/src/components/tabs/tabs.tsx | 6 ++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 5fe2c49c41..4a20bb2e5e 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -376,7 +376,7 @@ function ButtonFn( } satisfies ButtonRenderPropArg }, [state, hover, active, focus, disabled, autoFocus]) - let type = useResolveButtonType(props, internalButtonRef) + let type = useResolveButtonType(props, state.buttonElement) let ourProps = isWithinPanel ? mergeProps( { diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index f501e75427..018368d167 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -643,7 +643,7 @@ function ButtonFn( } satisfies ButtonRenderPropArg }, [visible, hover, focus, active, disabled, autoFocus]) - let type = useResolveButtonType(props, internalButtonRef) + let type = useResolveButtonType(props, state.button) let ourProps = isWithinPanel ? mergeProps( { diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index 5a17ce0525..f0f64bdb6e 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -156,11 +156,13 @@ function SwitchFn( ...theirProps } = props let groupContext = useContext(GroupContext) + let [switchElement, setSwitchElement] = useState(null) let internalSwitchRef = useRef(null) let switchRef = useSyncRefs( internalSwitchRef, ref, - groupContext === null ? null : groupContext.setSwitch + groupContext === null ? null : groupContext.setSwitch, + setSwitchElement ) let defaultChecked = useDefaultValue(_defaultChecked) @@ -221,7 +223,7 @@ function SwitchFn( id, ref: switchRef, role: 'switch', - type: useResolveButtonType(props, internalSwitchRef), + type: useResolveButtonType(props, switchElement), tabIndex: props.tabIndex === -1 ? 0 : props.tabIndex ?? 0, 'aria-checked': checked, 'aria-labelledby': labelledBy, diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index e33f309b17..9969273f47 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -8,6 +8,7 @@ import React, { useMemo, useReducer, useRef, + useState, type ElementType, type MutableRefObject, type KeyboardEvent as ReactKeyboardEvent, @@ -431,8 +432,9 @@ function TabFn( let actions = useActions('Tab') let data = useData('Tab') + let [tabElement, setTabElement] = useState(null) let internalTabRef = useRef(null) - let tabRef = useSyncRefs(internalTabRef, ref) + let tabRef = useSyncRefs(internalTabRef, ref, setTabElement) useIsoMorphicEffect(() => actions.registerTab(internalTabRef), [actions, internalTabRef]) @@ -542,7 +544,7 @@ function TabFn( onClick: handleSelection, id, role: 'tab', - type: useResolveButtonType(props, internalTabRef), + type: useResolveButtonType(props, tabElement), 'aria-controls': panels[myIndex]?.current?.id, 'aria-selected': selected, tabIndex: selected ? 0 : -1, From d56dfcf26c0080cd40b4e3c877c07e6bf419a164 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 15:58:48 +0200 Subject: [PATCH 15/20] simplify `useResolveButtonType`, only handle `HTMLElement | null` We don't handle `MutableRefObject` anymore --- .../src/hooks/use-resolve-button-type.ts | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts index 71b2683f23..343266dbf9 100644 --- a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts +++ b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts @@ -1,35 +1,21 @@ -import { useState, type MutableRefObject } from 'react' -import { useIsoMorphicEffect } from './use-iso-morphic-effect' - -function resolveType(props: { type?: string; as?: TTag }) { - if (props.type) return props.type - - let tag = props.as ?? 'button' - if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button' - - return undefined -} +import { useMemo } from 'react' export function useResolveButtonType( props: { type?: string; as?: TTag }, - ref: MutableRefObject | HTMLElement | null + element: HTMLElement | null ) { - let [type, setType] = useState(() => resolveType(props)) - - useIsoMorphicEffect(() => { - setType(resolveType(props)) - }, [props.type, props.as]) - - useIsoMorphicEffect(() => { - if (type) return + return useMemo(() => { + // A type was provided + if (props.type) return props.type - let node = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current - if (!node) return + // Resolve the type based on the `as` prop + let tag = props.as ?? 'button' + if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button' - if (node instanceof HTMLButtonElement && !node.hasAttribute('type')) { - setType('button') - } - }, [type, ref]) + // Resolve the type based on the HTML element + if (element instanceof HTMLButtonElement && !element.hasAttribute('type')) return 'button' - return type + // Could not resolve the type + return undefined + }, [props.type, props.as, element]) } From 7128c24dece28e32000ac25a0721127fc39ef80a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 16:00:31 +0200 Subject: [PATCH 16/20] simplify `useRefocusableInput` --- .../src/hooks/use-refocusable-input.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts index 627b4a5835..e8e361ba71 100644 --- a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts +++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts @@ -1,4 +1,4 @@ -import { useRef, type MutableRefObject } from 'react' +import { useRef } from 'react' import { useEvent } from './use-event' import { useEventListener } from './use-event-listener' @@ -8,9 +8,7 @@ import { useEventListener } from './use-event-listener' * This hook will also keep the cursor position into account to make sure the * cursor is placed at the correct position as-if we didn't loose focus at all. */ -export function useRefocusableInput( - ref: MutableRefObject | HTMLInputElement | null -) { +export function useRefocusableInput(input: HTMLInputElement | null) { // Track the cursor position and the value of the input let info = useRef({ value: '', @@ -18,9 +16,7 @@ export function useRefocusableInput( selectionEnd: null as number | null, }) - let element = ref === null ? null : ref instanceof HTMLInputElement ? ref : ref.current - - useEventListener(element, 'blur', (event) => { + useEventListener(input, 'blur', (event) => { let target = event.target if (!(target instanceof HTMLInputElement)) return @@ -32,8 +28,6 @@ export function useRefocusableInput( }) return useEvent(() => { - let input = ref === null ? null : ref instanceof HTMLInputElement ? ref : ref.current - // If the input is already focused, we don't need to do anything if (document.activeElement === input) return From 4d71797eba488de12bef4d46da29408783ac52c3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 16:06:07 +0200 Subject: [PATCH 17/20] simplify `useElementSize` --- packages/@headlessui-react/src/hooks/use-element-size.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-element-size.ts b/packages/@headlessui-react/src/hooks/use-element-size.ts index 1928faee31..1a22bcff72 100644 --- a/packages/@headlessui-react/src/hooks/use-element-size.ts +++ b/packages/@headlessui-react/src/hooks/use-element-size.ts @@ -7,11 +7,7 @@ function computeSize(element: HTMLElement | null) { return { width, height } } -export function useElementSize( - ref: React.MutableRefObject | HTMLElement | null, - unit = false -) { - let element = ref === null ? null : 'current' in ref ? ref.current : ref +export function useElementSize(element: HTMLElement | null, unit = false) { let [identity, forceRerender] = useReducer(() => ({}), {}) // When the element changes during a re-render, we want to make sure we From 8d26d4c17390f2f72e66ff5a1aafc0eb6e566a82 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 1 Aug 2024 16:14:51 +0200 Subject: [PATCH 18/20] simplify `useOutsideClick` Only accept `HTMLElement | null` instead of `MutableRefObject` --- .../@headlessui-react/src/hooks/use-outside-click.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 4a006571ee..43d47d0e1f 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, type MutableRefObject } from 'react' +import { useCallback, useRef } from 'react' import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { isMobile } from '../utils/platform' import { useDocumentEvent } from './use-document-event' @@ -6,7 +6,7 @@ import { useIsTopLayer } from './use-is-top-layer' import { useLatestValue } from './use-latest-value' import { useWindowEvent } from './use-window-event' -type Container = MutableRefObject | HTMLElement | null +type Container = HTMLElement | null type ContainerCollection = Container[] | Set type ContainerInput = Container | ContainerCollection @@ -69,15 +69,14 @@ export function useOutsideClick( // Ignore if the target exists in one of the containers for (let container of _containers) { if (container === null) continue - let domNode = container instanceof HTMLElement ? container : container.current - if (domNode?.contains(target)) { + if (container.contains(target)) { return } // If the click crossed a shadow boundary, we need to check if the // container is inside the tree by using `composedPath` to "pierce" the // shadow boundary - if (event.composed && event.composedPath().includes(domNode as EventTarget)) { + if (event.composed && event.composedPath().includes(container as EventTarget)) { return } } From 7e61373b2557b37b0f944d7b49c862302f33ad80 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Aug 2024 01:41:43 +0200 Subject: [PATCH 19/20] do not rely on `HTMLButtonElement` being available --- packages/@headlessui-react/src/hooks/use-resolve-button-type.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts index 343266dbf9..462dd555f5 100644 --- a/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts +++ b/packages/@headlessui-react/src/hooks/use-resolve-button-type.ts @@ -13,7 +13,7 @@ export function useResolveButtonType( if (typeof tag === 'string' && tag.toLowerCase() === 'button') return 'button' // Resolve the type based on the HTML element - if (element instanceof HTMLButtonElement && !element.hasAttribute('type')) return 'button' + if (element?.tagName === 'BUTTON' && !element.hasAttribute('type')) return 'button' // Could not resolve the type return undefined From 697468dda87fc7dce0cac44de97241697b9e452a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Aug 2024 10:59:38 +0200 Subject: [PATCH 20/20] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a8d6645770..b470bb7c61 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Ensure `Transition` component state doesn't change when it becomes hidden ([#3372](https://github.com/tailwindlabs/headlessui/pull/3372)) +- Fix closing components using the `transition` prop, and after scrolling the page ([#3407](https://github.com/tailwindlabs/headlessui/pull/3407)) ## [2.1.2] - 2024-07-05