Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure we are not freezing data when the `static` prop is used ([#3779](https://github.com/tailwindlabs/headlessui/pull/3779))
- Ensure `onChange` types are contravariant instead of bivariant ([#3781](https://github.com/tailwindlabs/headlessui/pull/3781))
- Support `<summary>` as a focusable element inside `<details>` ([#3389](https://github.com/tailwindlabs/headlessui/pull/3389))
- Fix `Maximum update depth exceeded` crash when using `transition` prop ([#3782](https://github.com/tailwindlabs/headlessui/pull/3782))

## [2.2.7] - 2025-07-30

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { Machine } from '../../machine'
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
import type { EnsureArray } from '../../types'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import {
ElementPositionState,
computeVisualPosition,
detectMovement,
} from '../../utils/element-movement'
import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match'

Expand Down Expand Up @@ -74,6 +79,9 @@ export interface State<T> {
buttonElement: HTMLButtonElement | null
optionsElement: HTMLElement | null

// Track input to determine if it moved
inputPositionState: ElementPositionState

__demoMode: boolean
}

Expand All @@ -96,6 +104,8 @@ export enum ActionTypes {
SetInputElement,
SetButtonElement,
SetOptionsElement,

MarkInputAsMoved,
}

function adjustOrderedState<T>(
Expand Down Expand Up @@ -160,13 +170,17 @@ type Actions<T> =
| { type: ActionTypes.SetInputElement; element: HTMLInputElement | null }
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
| { type: ActionTypes.SetOptionsElement; element: HTMLElement | null }
| { type: ActionTypes.MarkInputAsMoved }

let reducers: {
[P in ActionTypes]: <T>(state: State<T>, action: Extract<Actions<T>, { type: P }>) => State<T>
} = {
[ActionTypes.CloseCombobox](state) {
if (state.dataRef.current?.disabled) return state
if (state.comboboxState === ComboboxState.Closed) return state
let inputPositionState = state.inputElement
? ElementPositionState.Tracked(computeVisualPosition(state.inputElement))
: state.inputPositionState

return {
...state,
Expand All @@ -181,6 +195,8 @@ let reducers: {
// for example, not scrolling to the active option in a virtual list
activationTrigger: ActivationTrigger.Other,

inputPositionState,

__demoMode: false,
}
},
Expand All @@ -197,11 +213,17 @@ let reducers: {
activeOptionIndex: idx,
comboboxState: ComboboxState.Open,
__demoMode: false,
inputPositionState: ElementPositionState.Idle,
}
}
}

return { ...state, comboboxState: ComboboxState.Open, __demoMode: false }
return {
...state,
comboboxState: ComboboxState.Open,
inputPositionState: ElementPositionState.Idle,
__demoMode: false,
}
},
[ActionTypes.SetTyping](state, action) {
if (state.isTyping === action.isTyping) return state
Expand Down Expand Up @@ -404,6 +426,14 @@ let reducers: {
if (state.optionsElement === action.element) return state
return { ...state, optionsElement: action.element }
},
[ActionTypes.MarkInputAsMoved](state) {
if (state.inputPositionState.kind !== 'Tracked') return state

return {
...state,
inputPositionState: ElementPositionState.Moved,
}
},
}

export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
Expand Down Expand Up @@ -438,6 +468,7 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
buttonElement: null,
optionsElement: null,
__demoMode,
inputPositionState: ElementPositionState.Idle,
})
}

Expand All @@ -464,6 +495,20 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
this.on(ActionTypes.OpenCombobox, () => stackMachine.actions.push(id))
this.on(ActionTypes.CloseCombobox, () => stackMachine.actions.pop(id))
}

// Track whether the input moved or not
this.disposables.group((d) => {
this.on(ActionTypes.CloseCombobox, (state) => {
if (!state.inputElement) return

d.dispose()
d.add(
detectMovement(state.inputElement, state.inputPositionState, () => {
this.send({ type: ActionTypes.MarkInputAsMoved })
})
)
})
})
}

actions = {
Expand Down Expand Up @@ -640,6 +685,10 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {

return true
},

didInputMove(state: State<T>) {
return state.inputPositionState.kind === 'Moved'
},
}

reduce(state: Readonly<State<T>>, action: Actions<T>): State<T> {
Expand Down
17 changes: 16 additions & 1 deletion packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,21 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
),
})

// We keep track whether the input moved or not, we only check this when the
// combobox state becomes closed. If the input moved, then we want to cancel
// pending transitions to prevent that the attached `ComboboxOptions` is still
// transitioning while the input visually moved away.
//
// If we don't cancel these transitions then there will be a period where the
// `ComboboxOptions` is visible and moving around because it is trying to
// re-position itself based on the new position.
let didInputMove = useSlice(machine, machine.selectors.didInputMove)

// Now that we know that the input 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 = didInputMove ? false : visible

useIsoMorphicEffect(() => {
data.optionsPropsRef.current.static = props.static ?? false
}, [data.optionsPropsRef, props.static])
Expand Down Expand Up @@ -1392,7 +1407,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
visible: panelEnabled,
name: 'Combobox.Options',
})}
</ComboboxDataContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Machine, batch } from '../../machine'
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import {
ElementPositionState,
computeVisualPosition,
detectMovement,
} from '../../utils/element-movement'
import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match'

Expand Down Expand Up @@ -65,6 +70,9 @@ interface State<T> {

pendingShouldSort: boolean
pendingFocus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }

// Track button to determine if it moved
buttonPositionState: ElementPositionState
}

export enum ActionTypes {
Expand All @@ -82,6 +90,8 @@ export enum ActionTypes {
SetOptionsElement,

SortOptions,

MarkButtonAsMoved,
}

function adjustOrderedState<T>(
Expand Down Expand Up @@ -135,19 +145,25 @@ type Actions<T> =
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
| { type: ActionTypes.SetOptionsElement; element: HTMLElement | null }
| { type: ActionTypes.SortOptions }
| { type: ActionTypes.MarkButtonAsMoved }

let reducers: {
[P in ActionTypes]: <T>(state: State<T>, action: Extract<Actions<T>, { type: P }>) => State<T>
} = {
[ActionTypes.CloseListbox](state) {
if (state.dataRef.current.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
let buttonPositionState = state.buttonElement
? ElementPositionState.Tracked(computeVisualPosition(state.buttonElement))
: state.buttonPositionState

return {
...state,
activeOptionIndex: null,
pendingFocus: { focus: Focus.Nothing },
listboxState: ListboxStates.Closed,
__demoMode: false,
buttonPositionState,
}
},
[ActionTypes.OpenListbox](state, action) {
Expand All @@ -169,6 +185,7 @@ let reducers: {
listboxState: ListboxStates.Open,
activeOptionIndex,
__demoMode: false,
buttonPositionState: ElementPositionState.Idle,
}
},
[ActionTypes.GoToOption](state, action) {
Expand Down Expand Up @@ -394,6 +411,14 @@ let reducers: {
pendingShouldSort: false,
}
},
[ActionTypes.MarkButtonAsMoved](state) {
if (state.buttonPositionState.kind !== 'Tracked') return state

return {
...state,
buttonPositionState: ElementPositionState.Moved,
}
},
}

export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
Expand All @@ -412,6 +437,7 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
pendingShouldSort: false,
pendingFocus: { focus: Focus.Nothing },
__demoMode,
buttonPositionState: ElementPositionState.Idle,
})
}

Expand Down Expand Up @@ -447,6 +473,20 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
this.on(ActionTypes.OpenListbox, () => stackMachine.actions.push(id))
this.on(ActionTypes.CloseListbox, () => stackMachine.actions.pop(id))
}

// Track whether the button moved or not
this.disposables.group((d) => {
this.on(ActionTypes.CloseListbox, (state) => {
if (!state.buttonElement) return

d.dispose()
d.add(
detectMovement(state.buttonElement, state.buttonPositionState, () => {
this.send({ type: ActionTypes.MarkButtonAsMoved })
})
)
})
})
}

actions = {
Expand Down Expand Up @@ -566,6 +606,10 @@ export class ListboxMachine<T> extends Machine<State<T>, Actions<T>> {
if (state.activationTrigger === ActivationTrigger.Pointer) return false
return this.isActive(state, id)
},

didButtonMove(state: State<T>) {
return state.buttonPositionState.kind === 'Moved'
},
}

reduce(state: Readonly<State<T>>, action: Actions<T>): State<T> {
Expand Down
26 changes: 12 additions & 14 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import { useActivePress } from '../../hooks/use-active-press'
import { useByComparator, type ByComparator } from '../../hooks/use-by-comparator'
import { useControllable } from '../../hooks/use-controllable'
import { useDefaultValue } from '../../hooks/use-default-value'
import { useDidElementMove } from '../../hooks/use-did-element-move'
import { useDisposables } from '../../hooks/use-disposables'
import { useElementSize } from '../../hooks/use-element-size'
import { useEvent } from '../../hooks/use-event'
Expand Down Expand Up @@ -610,20 +609,19 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
allowed: useCallback(() => [buttonElement, optionsElement], [buttonElement, optionsElement]),
})

// We keep track whether the button moved or not, we only check this when the menu state becomes
// closed. If the button moved, then we want to cancel pending transitions to prevent that the
// attached `MenuItems` is still transitioning while the button moved away.
// We keep track whether the button moved or not, we only check this when the
// listbox state becomes closed. If the button moved, then we want to cancel
// pending transitions to prevent that the attached `ListboxOptions` is
// still transitioning while the button visually moved away.
//
// If we don't cancel these transitions then there will be a period where the `MenuItems` is
// visible and moving around because it is trying to re-position itself based on the new position.
//
// 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 = listboxState !== ListboxStates.Open
let didButtonMove = useDidElementMove(didElementMoveEnabled, 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.
// If we don't cancel these transitions then there will be a period where the
// `ListboxOptions` is visible and moving around because it is trying to
// re-position itself based on the new position.
let didButtonMove = useSlice(machine, machine.selectors.didButtonMove)

// 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

// We should freeze when the listbox is visible but "closed". This means that
Expand Down
Loading