Skip to content

Commit af5b0b4

Browse files
authored
Ensure buttonRef.current.click() works (#3768)
This PR fixes an issue where adding a ref to the `<MenuButton ref={btnRef}>` and later calling `btnRef.current.click()` would not open the `Menu`. This is happening because recently we started using `pointerdown` instead of `click` to open the `Menu`. So the only way to open the `Menu` programmatically is to call `btnRef.current.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))` which is a bit of a mouthful. We also recently fixed an issue where the `Listbox` would immediately close after opening the Listbox on touch devices. That solution required us to not only handle the `pointerdown` event but also the `click` event. So if anything, this PR makes the code more consistent between the `Menu` and `Listbox` component behavior and in turn solves this `ref.current.click()` issue. This PR also does some internal refactoring to make the code a bit cleaner. Fixes: #3749
1 parent a12f9f2 commit af5b0b4

File tree

5 files changed

+100
-23
lines changed

5 files changed

+100
-23
lines changed

packages/@headlessui-react/CHANGELOG.md

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

1212
- Fix incorrect double invocation of menu items, listbox options and combobox options ([#3766](https://github.com/tailwindlabs/headlessui/pull/3766))
1313
- Fix memory leak in SSR environment ([#3767](https://github.com/tailwindlabs/headlessui/pull/3767))
14+
- Ensure programmatic `.click()` on `MenuButton` ref works ([#3768](https://github.com/tailwindlabs/headlessui/pull/3768))
1415

1516
## [2.2.6] - 2025-07-24
1617

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { render, waitFor } from '@testing-library/react'
2-
import React, { Fragment, createElement, useEffect, useState } from 'react'
1+
import { act, render, waitFor } from '@testing-library/react'
2+
import React, { Fragment, createElement, createRef, useEffect, useState } from 'react'
33
import {
44
ListboxMode,
55
ListboxState,
@@ -1233,6 +1233,40 @@ describe('Rendering', () => {
12331233
expect(handleChange).toHaveBeenNthCalledWith(2, 'bob')
12341234
})
12351235
})
1236+
1237+
it(
1238+
'should be possible to open a listbox programmatically via .click()',
1239+
suppressConsoleLogs(async () => {
1240+
let btnRef = createRef<HTMLButtonElement>()
1241+
1242+
render(
1243+
<Listbox>
1244+
<ListboxButton ref={btnRef}>Trigger</ListboxButton>
1245+
<ListboxOptions>
1246+
<ListboxOption value="a">Option A</ListboxOption>
1247+
<ListboxOption value="b">Option B</ListboxOption>
1248+
<ListboxOption value="c">Option C</ListboxOption>
1249+
</ListboxOptions>
1250+
</Listbox>
1251+
)
1252+
1253+
assertListboxButton({ state: ListboxState.InvisibleUnmounted })
1254+
assertListbox({ state: ListboxState.InvisibleUnmounted })
1255+
1256+
// Open listbox
1257+
act(() => btnRef.current?.click())
1258+
1259+
// Verify it is open
1260+
assertListboxButton({ state: ListboxState.Visible })
1261+
assertListbox({ state: ListboxState.Visible })
1262+
assertListboxButtonLinkedWithListbox()
1263+
1264+
// Verify we have listbox options
1265+
let options = getListboxOptions()
1266+
expect(options).toHaveLength(3)
1267+
options.forEach((option) => assertListboxOption(option))
1268+
})
1269+
)
12361270
})
12371271

12381272
describe('Rendering composition', () => {

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

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import React, {
1515
type ElementType,
1616
type MutableRefObject,
1717
type KeyboardEvent as ReactKeyboardEvent,
18+
type MouseEvent as ReactMouseEvent,
1819
type PointerEvent as ReactPointerEvent,
1920
type Ref,
2021
} from 'react'
@@ -435,12 +436,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
435436
}
436437
})
437438

438-
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
439-
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
440-
pointerTypeRef.current = event.pointerType
441-
442-
if (event.pointerType !== 'mouse') return
443-
439+
let toggle = useEvent((event: ReactPointerEvent | ReactMouseEvent) => {
444440
if (event.button !== MouseButton.Left) return // Only handle left clicks
445441
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
446442
if (machine.state.listboxState === ListboxStates.Open) {
@@ -452,18 +448,16 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
452448
}
453449
})
454450

455-
let handleClick = useEvent((event: ReactPointerEvent) => {
456-
if (pointerTypeRef.current === 'mouse') return
451+
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
452+
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
453+
pointerTypeRef.current = event.pointerType
454+
if (event.pointerType !== 'mouse') return
455+
toggle(event)
456+
})
457457

458-
if (event.button !== MouseButton.Left) return // Only handle left clicks
459-
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
460-
if (machine.state.listboxState === ListboxStates.Open) {
461-
flushSync(() => machine.actions.closeListbox())
462-
machine.state.buttonElement?.focus({ preventScroll: true })
463-
} else {
464-
event.preventDefault()
465-
machine.actions.openListbox({ focus: Focus.Nothing })
466-
}
458+
let handleClick = useEvent((event: ReactMouseEvent) => {
459+
if (pointerTypeRef.current === 'mouse') return
460+
toggle(event)
467461
})
468462

469463
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.

packages/@headlessui-react/src/components/menu/menu.test.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { render, waitFor } from '@testing-library/react'
2-
import React, { Fragment, createElement, useEffect } from 'react'
1+
import { act, render, waitFor } from '@testing-library/react'
2+
import React, { Fragment, createElement, createRef, useEffect } from 'react'
33
import {
44
MenuState,
55
assertActiveElement,
@@ -542,6 +542,40 @@ describe('Rendering', () => {
542542
// Verify that the third menu item is active
543543
assertMenuLinkedWithMenuItem(items[2])
544544
})
545+
546+
it(
547+
'should be possible to open a menu programmatically via .click()',
548+
suppressConsoleLogs(async () => {
549+
let btnRef = createRef<HTMLButtonElement>()
550+
551+
render(
552+
<Menu>
553+
<MenuButton ref={btnRef}>Trigger</MenuButton>
554+
<MenuItems>
555+
<MenuItem as="a">Item A</MenuItem>
556+
<MenuItem as="a">Item B</MenuItem>
557+
<MenuItem as="a">Item C</MenuItem>
558+
</MenuItems>
559+
</Menu>
560+
)
561+
562+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
563+
assertMenu({ state: MenuState.InvisibleUnmounted })
564+
565+
// Open menu
566+
act(() => btnRef.current?.click())
567+
568+
// Verify it is open
569+
assertMenuButton({ state: MenuState.Visible })
570+
assertMenu({ state: MenuState.Visible })
571+
assertMenuButtonLinkedWithMenu()
572+
573+
// Verify we have menu items
574+
let items = getMenuItems()
575+
expect(items).toHaveLength(3)
576+
items.forEach((item) => assertMenuItem(item))
577+
})
578+
)
545579
})
546580

547581
describe('Rendering composition', () => {

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
import { useDescriptions } from '../description/description'
7373
import { Keys } from '../keyboard'
7474
import { useLabelContext, useLabels } from '../label/label'
75+
import { MouseButton } from '../mouse'
7576
import { Portal } from '../portal/portal'
7677
import { ActionTypes, ActivationTrigger, MenuState, type MenuItemDataRef } from './menu-machine'
7778
import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue'
@@ -265,8 +266,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
265266
select: useCallback((target) => target.click(), []),
266267
})
267268

268-
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
269-
if (event.button !== 0) return // Only handle left clicks
269+
let toggle = useEvent((event: ReactPointerEvent) => {
270+
if (event.button !== MouseButton.Left) return // Only handle left clicks
270271
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
271272
if (disabled) return
272273
if (menuState === MenuState.Open) {
@@ -282,6 +283,18 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
282283
}
283284
})
284285

286+
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
287+
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
288+
pointerTypeRef.current = event.pointerType
289+
if (event.pointerType !== 'mouse') return
290+
toggle(event)
291+
})
292+
293+
let handleClick = useEvent((event: ReactPointerEvent) => {
294+
if (pointerTypeRef.current === 'mouse') return
295+
toggle(event)
296+
})
297+
285298
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
286299
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
287300
let { pressed: active, pressProps } = useActivePress({ disabled })
@@ -311,6 +324,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
311324
onKeyDown: handleKeyDown,
312325
onKeyUp: handleKeyUp,
313326
onPointerDown: handlePointerDown,
327+
onClick: handleClick,
314328
},
315329
focusProps,
316330
hoverProps,

0 commit comments

Comments
 (0)