Skip to content

Commit 2260422

Browse files
authored
Fix closing components using the transition prop, and after scrolling the page (#3407)
* `useDidElementMove`: handle `HTMLElement` This change should be temporary, and it will allow us to use the `useDidElementMove` with ref objects and direct `HTMLElement`s. * `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. * `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. * `useTransition`: handle `HTMLElement` Accept `HTMLElement| null` instead of `MutableRefObject<HTMLElement | null>` in the `useTransition` hook. * ensure `containers` are a dependency of `useEffect` * `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. * `Combobox`: track `input`, `button` and `options` elements in state * `Disclosure`: track `button` and `panel` elements in state * `Listbox`: track `button` and `options` elements in state * `Popover`: track `button` and `panel` elements in state * `Transition`: track the `container` element in state * remove incorrect leftover `style=""` attribute * simplify `useDidElementMove`, only accept `HTMLElement | null` This doesn't support the `MutableRefObject<HTMLElement | null>` anymore. * pass `HTMLElement | null` directly to `useResolveButtonType` * simplify `useResolveButtonType`, only handle `HTMLElement | null` We don't handle `MutableRefObject<HTMLElement | null>` anymore * simplify `useRefocusableInput` * simplify `useElementSize` * simplify `useOutsideClick` Only accept `HTMLElement | null` instead of `MutableRefObject<HTMLElement | null>` * do not rely on `HTMLButtonElement` being available * update changelog
1 parent ca6a455 commit 2260422

File tree

16 files changed

+355
-282
lines changed

16 files changed

+355
-282
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Ensure `Transition` component state doesn't change when it becomes hidden ([#3372](https://github.com/tailwindlabs/headlessui/pull/3372))
13+
- Fix closing components using the `transition` prop, and after scrolling the page ([#3407](https://github.com/tailwindlabs/headlessui/pull/3407))
1314

1415
## [2.1.2] - 2024-07-05
1516

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 86 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ interface StateDefinition<T> {
116116

117117
isTyping: boolean
118118

119+
inputElement: HTMLInputElement | null
120+
buttonElement: HTMLButtonElement | null
121+
optionsElement: HTMLElement | null
122+
119123
__demoMode: boolean
120124
}
121125

@@ -132,6 +136,10 @@ enum ActionTypes {
132136
SetActivationTrigger,
133137

134138
UpdateVirtualConfiguration,
139+
140+
SetInputElement,
141+
SetButtonElement,
142+
SetOptionsElement,
135143
}
136144

137145
function adjustOrderedState<T>(
@@ -192,6 +200,9 @@ type Actions<T> =
192200
options: T[]
193201
disabled: ((value: any) => boolean) | null
194202
}
203+
| { type: ActionTypes.SetInputElement; element: HTMLInputElement | null }
204+
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
205+
| { type: ActionTypes.SetOptionsElement; element: HTMLElement | null }
195206

196207
let reducers: {
197208
[P in ActionTypes]: <T>(
@@ -245,7 +256,7 @@ let reducers: {
245256
[ActionTypes.GoToOption](state, action) {
246257
if (state.dataRef.current?.disabled) return state
247258
if (
248-
state.dataRef.current?.optionsRef.current &&
259+
state.optionsElement &&
249260
!state.dataRef.current?.optionsPropsRef.current.static &&
250261
state.comboboxState === ComboboxState.Closed
251262
) {
@@ -419,6 +430,18 @@ let reducers: {
419430
virtual: { options: action.options, disabled: action.disabled ?? (() => false) },
420431
}
421432
},
433+
[ActionTypes.SetInputElement]: (state, action) => {
434+
if (state.inputElement === action.element) return state
435+
return { ...state, inputElement: action.element }
436+
},
437+
[ActionTypes.SetButtonElement]: (state, action) => {
438+
if (state.buttonElement === action.element) return state
439+
return { ...state, buttonElement: action.element }
440+
},
441+
[ActionTypes.SetOptionsElement]: (state, action) => {
442+
if (state.optionsElement === action.element) return state
443+
return { ...state, optionsElement: action.element }
444+
},
422445
}
423446

424447
let ComboboxActionsContext = createContext<{
@@ -431,6 +454,10 @@ let ComboboxActionsContext = createContext<{
431454
selectActiveOption(): void
432455
setActivationTrigger(trigger: ActivationTrigger): void
433456
onChange(value: unknown): void
457+
458+
setInputElement(element: HTMLInputElement | null): void
459+
setButtonElement(element: HTMLButtonElement | null): void
460+
setOptionsElement(element: HTMLElement | null): void
434461
} | null>(null)
435462
ComboboxActionsContext.displayName = 'ComboboxActionsContext'
436463

@@ -455,7 +482,7 @@ function VirtualProvider(props: {
455482
let { options } = data.virtual!
456483

457484
let [paddingStart, paddingEnd] = useMemo(() => {
458-
let el = data.optionsRef.current
485+
let el = data.optionsElement
459486
if (!el) return [0, 0]
460487

461488
let styles = window.getComputedStyle(el)
@@ -464,7 +491,7 @@ function VirtualProvider(props: {
464491
parseFloat(styles.paddingBlockStart || styles.paddingTop),
465492
parseFloat(styles.paddingBlockEnd || styles.paddingBottom),
466493
]
467-
}, [data.optionsRef.current])
494+
}, [data.optionsElement])
468495

469496
let virtualizer = useVirtualizer({
470497
enabled: options.length !== 0,
@@ -475,7 +502,7 @@ function VirtualProvider(props: {
475502
return 40
476503
},
477504
getScrollElement() {
478-
return (data.optionsRef.current ?? null) as HTMLElement | null
505+
return data.optionsElement
479506
},
480507
overscan: 12,
481508
})
@@ -573,10 +600,6 @@ let ComboboxDataContext = createContext<
573600
static: boolean
574601
hold: boolean
575602
}>
576-
577-
inputRef: MutableRefObject<HTMLInputElement | null>
578-
buttonRef: MutableRefObject<HTMLButtonElement | null>
579-
optionsRef: MutableRefObject<HTMLElement | null>
580603
} & Omit<StateDefinition<unknown>, 'dataRef'>)
581604
| null
582605
>(null)
@@ -688,17 +711,16 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
688711
: null,
689712
activeOptionIndex: null,
690713
activationTrigger: ActivationTrigger.Other,
714+
inputElement: null,
715+
buttonElement: null,
716+
optionsElement: null,
691717
__demoMode,
692718
} as StateDefinition<TValue>)
693719

694720
let defaultToFirstOption = useRef(false)
695721

696722
let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false })
697723

698-
let inputRef = useRef<_Data['inputRef']['current']>(null)
699-
let buttonRef = useRef<_Data['buttonRef']['current']>(null)
700-
let optionsRef = useRef<_Data['optionsRef']['current']>(null)
701-
702724
type TActualValue = true extends typeof multiple ? EnsureArray<TValue>[number] : TValue
703725
let compare = useByComparator<TActualValue>(by)
704726

@@ -733,9 +755,6 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
733755
...state,
734756
immediate,
735757
optionsPropsRef,
736-
inputRef,
737-
buttonRef,
738-
optionsRef,
739758
value,
740759
defaultValue,
741760
disabled,
@@ -791,8 +810,10 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
791810

792811
// Handle outside click
793812
let outsideClickEnabled = data.comboboxState === ComboboxState.Open
794-
useOutsideClick(outsideClickEnabled, [data.buttonRef, data.inputRef, data.optionsRef], () =>
795-
actions.closeCombobox()
813+
useOutsideClick(
814+
outsideClickEnabled,
815+
[data.buttonElement, data.inputElement, data.optionsElement],
816+
() => actions.closeCombobox()
796817
)
797818

798819
let slot = useMemo(() => {
@@ -896,6 +917,18 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
896917
dispatch({ type: ActionTypes.SetActivationTrigger, trigger })
897918
})
898919

920+
let setInputElement = useEvent((element: HTMLInputElement | null) => {
921+
dispatch({ type: ActionTypes.SetInputElement, element })
922+
})
923+
924+
let setButtonElement = useEvent((element: HTMLButtonElement | null) => {
925+
dispatch({ type: ActionTypes.SetButtonElement, element })
926+
})
927+
928+
let setOptionsElement = useEvent((element: HTMLElement | null) => {
929+
dispatch({ type: ActionTypes.SetOptionsElement, element })
930+
})
931+
899932
let actions = useMemo<_Actions>(
900933
() => ({
901934
onChange,
@@ -906,6 +939,9 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
906939
openCombobox,
907940
setActivationTrigger,
908941
selectActiveOption,
942+
setInputElement,
943+
setButtonElement,
944+
setOptionsElement,
909945
}),
910946
[]
911947
)
@@ -923,7 +959,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
923959
<LabelProvider
924960
value={labelledby}
925961
props={{
926-
htmlFor: data.inputRef.current?.id,
962+
htmlFor: data.inputElement?.id,
927963
}}
928964
slot={{
929965
open: data.comboboxState === ComboboxState.Open,
@@ -1019,15 +1055,16 @@ function InputFn<
10191055
...theirProps
10201056
} = props
10211057

1022-
let inputRef = useSyncRefs(data.inputRef, ref, useFloatingReference())
1023-
let ownerDocument = useOwnerDocument(data.inputRef)
1058+
let internalInputRef = useRef<HTMLInputElement | null>(null)
1059+
let inputRef = useSyncRefs(internalInputRef, ref, useFloatingReference(), actions.setInputElement)
1060+
let ownerDocument = useOwnerDocument(data.inputElement)
10241061

10251062
let d = useDisposables()
10261063

10271064
let clear = useEvent(() => {
10281065
actions.onChange(null)
1029-
if (data.optionsRef.current) {
1030-
data.optionsRef.current.scrollTop = 0
1066+
if (data.optionsElement) {
1067+
data.optionsElement.scrollTop = 0
10311068
}
10321069
actions.goToOption(Focus.Nothing)
10331070
})
@@ -1071,7 +1108,7 @@ function InputFn<
10711108
// using an IME, we don't want to mess with the input at all.
10721109
if (data.isTyping) return
10731110

1074-
let input = data.inputRef.current
1111+
let input = internalInputRef.current
10751112
if (!input) return
10761113

10771114
if (oldState === ComboboxState.Open && state === ComboboxState.Closed) {
@@ -1121,7 +1158,7 @@ function InputFn<
11211158
// using an IME, we don't want to mess with the input at all.
11221159
if (data.isTyping) return
11231160

1124-
let input = data.inputRef.current
1161+
let input = internalInputRef.current
11251162
if (!input) return
11261163

11271164
// Capture current state
@@ -1232,7 +1269,7 @@ function InputFn<
12321269
case Keys.Escape:
12331270
if (data.comboboxState !== ComboboxState.Open) return
12341271
event.preventDefault()
1235-
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
1272+
if (data.optionsElement && !data.optionsPropsRef.current.static) {
12361273
event.stopPropagation()
12371274
}
12381275

@@ -1286,10 +1323,10 @@ function InputFn<
12861323
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
12871324

12881325
// Focus is moved into the list, we don't want to close yet.
1289-
if (data.optionsRef.current?.contains(relatedTarget)) return
1326+
if (data.optionsElement?.contains(relatedTarget)) return
12901327

12911328
// Focus is moved to the button, we don't want to close yet.
1292-
if (data.buttonRef.current?.contains(relatedTarget)) return
1329+
if (data.buttonElement?.contains(relatedTarget)) return
12931330

12941331
// Focus is moved, but the combobox is not open. This can mean two things:
12951332
//
@@ -1316,8 +1353,8 @@ function InputFn<
13161353
let handleFocus = useEvent((event: ReactFocusEvent) => {
13171354
let relatedTarget =
13181355
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
1319-
if (data.buttonRef.current?.contains(relatedTarget)) return
1320-
if (data.optionsRef.current?.contains(relatedTarget)) return
1356+
if (data.buttonElement?.contains(relatedTarget)) return
1357+
if (data.optionsElement?.contains(relatedTarget)) return
13211358
if (data.disabled) return
13221359

13231360
if (!data.immediate) return
@@ -1378,7 +1415,7 @@ function InputFn<
13781415
id,
13791416
role: 'combobox',
13801417
type,
1381-
'aria-controls': data.optionsRef.current?.id,
1418+
'aria-controls': data.optionsElement?.id,
13821419
'aria-expanded': data.comboboxState === ComboboxState.Open,
13831420
'aria-activedescendant':
13841421
data.activeOptionIndex === null
@@ -1457,7 +1494,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14571494
) {
14581495
let data = useData('Combobox.Button')
14591496
let actions = useActions('Combobox.Button')
1460-
let buttonRef = useSyncRefs(data.buttonRef, ref)
1497+
let buttonRef = useSyncRefs(ref, actions.setButtonElement)
1498+
14611499
let internalId = useId()
14621500
let {
14631501
id = `headlessui-combobox-button-${internalId}`,
@@ -1466,7 +1504,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14661504
...theirProps
14671505
} = props
14681506

1469-
let refocusInput = useRefocusableInput(data.inputRef)
1507+
let refocusInput = useRefocusableInput(data.inputElement)
14701508

14711509
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
14721510
switch (event.key) {
@@ -1505,7 +1543,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
15051543
case Keys.Escape:
15061544
if (data.comboboxState !== ComboboxState.Open) return
15071545
event.preventDefault()
1508-
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
1546+
if (data.optionsElement && !data.optionsPropsRef.current.static) {
15091547
event.stopPropagation()
15101548
}
15111549
flushSync(() => actions.closeCombobox())
@@ -1561,10 +1599,10 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
15611599
{
15621600
ref: buttonRef,
15631601
id,
1564-
type: useResolveButtonType(props, data.buttonRef),
1602+
type: useResolveButtonType(props, data.buttonElement),
15651603
tabIndex: -1,
15661604
'aria-haspopup': 'listbox',
1567-
'aria-controls': data.optionsRef.current?.id,
1605+
'aria-controls': data.optionsElement?.id,
15681606
'aria-expanded': data.comboboxState === ComboboxState.Open,
15691607
'aria-labelledby': labelledBy,
15701608
disabled: disabled || undefined,
@@ -1635,20 +1673,20 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16351673

16361674
let [floatingRef, style] = useFloatingPanel(anchor)
16371675
let getFloatingPanelProps = useFloatingPanelProps()
1638-
let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null)
1639-
let ownerDocument = useOwnerDocument(data.optionsRef)
1676+
let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement)
1677+
let ownerDocument = useOwnerDocument(data.optionsElement)
16401678

16411679
let usesOpenClosedState = useOpenClosed()
16421680
let [visible, transitionData] = useTransition(
16431681
transition,
1644-
data.optionsRef,
1682+
data.optionsElement,
16451683
usesOpenClosedState !== null
16461684
? (usesOpenClosedState & State.Open) === State.Open
16471685
: data.comboboxState === ComboboxState.Open
16481686
)
16491687

16501688
// Ensure we close the combobox as soon as the input becomes hidden
1651-
useOnDisappear(visible, data.inputRef, actions.closeCombobox)
1689+
useOnDisappear(visible, data.inputElement, actions.closeCombobox)
16521690

16531691
// Enable scroll locking when the combobox is visible, and `modal` is enabled
16541692
let scrollLockEnabled = data.__demoMode
@@ -1661,11 +1699,10 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16611699
? false
16621700
: modal && data.comboboxState === ComboboxState.Open
16631701
useInertOthers(inertOthersEnabled, {
1664-
allowed: useEvent(() => [
1665-
data.inputRef.current,
1666-
data.buttonRef.current,
1667-
data.optionsRef.current,
1668-
]),
1702+
allowed: useCallback(
1703+
() => [data.inputElement, data.buttonElement, data.optionsElement],
1704+
[data.inputElement, data.buttonElement, data.optionsElement]
1705+
),
16691706
})
16701707

16711708
useIsoMorphicEffect(() => {
@@ -1676,7 +1713,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16761713
}, [data.optionsPropsRef, hold])
16771714

16781715
useTreeWalker(data.comboboxState === ComboboxState.Open, {
1679-
container: data.optionsRef.current,
1716+
container: data.optionsElement,
16801717
accept(node) {
16811718
if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT
16821719
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
@@ -1687,7 +1724,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16871724
},
16881725
})
16891726

1690-
let labelledBy = useLabelledBy([data.buttonRef.current?.id])
1727+
let labelledBy = useLabelledBy([data.buttonElement?.id])
16911728

16921729
let slot = useMemo(() => {
16931730
return {
@@ -1728,8 +1765,8 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
17281765
style: {
17291766
...theirProps.style,
17301767
...style,
1731-
'--input-width': useElementSize(data.inputRef, true).width,
1732-
'--button-width': useElementSize(data.buttonRef, true).width,
1768+
'--input-width': useElementSize(data.inputElement, true).width,
1769+
'--button-width': useElementSize(data.buttonElement, true).width,
17331770
} as CSSProperties,
17341771
onWheel: data.activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel,
17351772
onMouseDown: handleMouseDown,
@@ -1840,7 +1877,7 @@ function OptionFn<
18401877
...theirProps
18411878
} = props
18421879

1843-
let refocusInput = useRefocusableInput(data.inputRef)
1880+
let refocusInput = useRefocusableInput(data.inputElement)
18441881

18451882
let active = data.virtual
18461883
? data.activeOptionIndex === data.calculateIndex(value)

0 commit comments

Comments
 (0)