Skip to content

Commit 80402e7

Browse files
authored
Fix various event bugs (#211)
* add right click option to the interactions * add tests to ensure right click behaves as expected Fixes: #142 Fixes: #167 * fallback to mouse events if pointer events are not supported When the pointer events are not supported, then this is essentially a no-op. When they *are* supported, then both the pointer *and* mouse events will fire. To mitigate potential issues, we make sure that state changes (and potential re-renders) are idempotent (we bail out on potential state updates when we are already ina certain state). Fixes: #173 Fixes: #167
1 parent 9b0d9e1 commit 80402e7

File tree

12 files changed

+301
-53
lines changed

12 files changed

+301
-53
lines changed

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type,
1414
word,
1515
Keys,
16+
MouseButton,
1617
} from '../../test-utils/interactions'
1718
import {
1819
assertActiveElement,
@@ -2754,6 +2755,32 @@ describe('Mouse interactions', () => {
27542755
})
27552756
)
27562757

2758+
it(
2759+
'should not focus the Listbox.Button when we right click the Listbox.Label',
2760+
suppressConsoleLogs(async () => {
2761+
render(
2762+
<Listbox value={undefined} onChange={console.log}>
2763+
<Listbox.Label>Label</Listbox.Label>
2764+
<Listbox.Button>Trigger</Listbox.Button>
2765+
<Listbox.Options>
2766+
<Listbox.Option value="a">Option A</Listbox.Option>
2767+
<Listbox.Option value="b">Option B</Listbox.Option>
2768+
<Listbox.Option value="c">Option C</Listbox.Option>
2769+
</Listbox.Options>
2770+
</Listbox>
2771+
)
2772+
2773+
// Ensure the button is not focused yet
2774+
assertActiveElement(document.body)
2775+
2776+
// Focus the label
2777+
await click(getListboxLabel(), MouseButton.Right)
2778+
2779+
// Ensure that the body is still active
2780+
assertActiveElement(document.body)
2781+
})
2782+
)
2783+
27572784
it(
27582785
'should be possible to open the listbox on click',
27592786
suppressConsoleLogs(async () => {
@@ -2793,6 +2820,34 @@ describe('Mouse interactions', () => {
27932820
})
27942821
)
27952822

2823+
it(
2824+
'should not be possible to open the listbox on right click',
2825+
suppressConsoleLogs(async () => {
2826+
render(
2827+
<Listbox value={undefined} onChange={console.log}>
2828+
<Listbox.Button>Trigger</Listbox.Button>
2829+
<Listbox.Options>
2830+
<Listbox.Option value="a">Item A</Listbox.Option>
2831+
<Listbox.Option value="b">Item B</Listbox.Option>
2832+
<Listbox.Option value="c">Item C</Listbox.Option>
2833+
</Listbox.Options>
2834+
</Listbox>
2835+
)
2836+
2837+
assertListboxButton({
2838+
state: ListboxState.InvisibleUnmounted,
2839+
attributes: { id: 'headlessui-listbox-button-1' },
2840+
})
2841+
assertListbox({ state: ListboxState.InvisibleUnmounted })
2842+
2843+
// Try to open the menu
2844+
await click(getListboxButton(), MouseButton.Right)
2845+
2846+
// Verify it is still closed
2847+
assertListboxButton({ state: ListboxState.InvisibleUnmounted })
2848+
})
2849+
)
2850+
27962851
it(
27972852
'should not be possible to open the listbox on click when the button is disabled',
27982853
suppressConsoleLogs(async () => {

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { disposables } from '../../utils/disposables'
1212
import { Keys } from '../keyboard'
1313
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
1414
import { resolvePropValue } from '../../utils/resolve-prop-value'
15+
import { isDisabledReactIssue7711 } from '../../utils/bugs'
1516

1617
enum ListboxStates {
1718
Open,
@@ -213,7 +214,7 @@ type ButtonPropsWeControl =
213214
| 'aria-expanded'
214215
| 'aria-labelledby'
215216
| 'onKeyDown'
216-
| 'onPointerUp'
217+
| 'onClick'
217218

218219
const Button = forwardRefWithAs(function Button<
219220
TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG
@@ -258,8 +259,9 @@ const Button = forwardRefWithAs(function Button<
258259
[dispatch, state, d]
259260
)
260261

261-
const handlePointerUp = React.useCallback(
262-
(event: MouseEvent) => {
262+
const handleClick = React.useCallback(
263+
(event: React.MouseEvent) => {
264+
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
263265
if (props.disabled) return
264266
if (state.listboxState === ListboxStates.Open) {
265267
dispatch({ type: ActionTypes.CloseListbox })
@@ -292,7 +294,7 @@ const Button = forwardRefWithAs(function Button<
292294
'aria-expanded': state.listboxState === ListboxStates.Open ? true : undefined,
293295
'aria-labelledby': labelledby,
294296
onKeyDown: handleKeyDown,
295-
onPointerUp: handlePointerUp,
297+
onClick: handleClick,
296298
}
297299

298300
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG)
@@ -301,7 +303,7 @@ const Button = forwardRefWithAs(function Button<
301303
// ---
302304

303305
const DEFAULT_LABEL_TAG = 'label'
304-
type LabelPropsWeControl = 'id' | 'ref' | 'onPointerUp'
306+
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
305307
type LabelRenderPropArg = { open: boolean }
306308

307309
function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>(
@@ -310,7 +312,7 @@ function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>(
310312
const [state] = useListboxContext([Listbox.name, Label.name].join('.'))
311313
const id = `headlessui-listbox-label-${useId()}`
312314

313-
const handlePointerUp = React.useCallback(
315+
const handleClick = React.useCallback(
314316
() => state.buttonRef.current?.focus({ preventScroll: true }),
315317
[state.buttonRef]
316318
)
@@ -319,7 +321,7 @@ function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>(
319321
() => ({ open: state.listboxState === ListboxStates.Open }),
320322
[state]
321323
)
322-
const propsWeControl = { ref: state.labelRef, id, onPointerUp: handlePointerUp }
324+
const propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
323325
return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG)
324326
}
325327

@@ -453,6 +455,9 @@ type ListboxOptionPropsWeControl =
453455
| 'aria-disabled'
454456
| 'aria-selected'
455457
| 'onPointerLeave'
458+
| 'onMouseLeave'
459+
| 'onPointerMove'
460+
| 'onMouseMove'
456461
| 'onFocus'
457462

458463
function Option<
@@ -528,13 +533,13 @@ function Option<
528533
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
529534
}, [disabled, id, dispatch])
530535

531-
const handlePointerMove = React.useCallback(() => {
536+
const handleMove = React.useCallback(() => {
532537
if (disabled) return
533538
if (active) return
534539
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
535540
}, [disabled, active, id, dispatch])
536541

537-
const handlePointerLeave = React.useCallback(() => {
542+
const handleLeave = React.useCallback(() => {
538543
if (disabled) return
539544
if (!active) return
540545
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
@@ -554,8 +559,10 @@ function Option<
554559
'aria-selected': selected === true ? true : undefined,
555560
onClick: handleClick,
556561
onFocus: handleFocus,
557-
onPointerMove: handlePointerMove,
558-
onPointerLeave: handlePointerLeave,
562+
onPointerMove: handleMove,
563+
onMouseMove: handleMove,
564+
onPointerLeave: handleLeave,
565+
onMouseLeave: handleLeave,
559566
}
560567

561568
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_OPTION_TAG)

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type,
2929
word,
3030
Keys,
31+
MouseButton,
3132
} from '../../test-utils/interactions'
3233

3334
jest.mock('../../hooks/use-id')
@@ -2403,6 +2404,34 @@ describe('Mouse interactions', () => {
24032404
})
24042405
)
24052406

2407+
it(
2408+
'should not be possible to open a menu on right click',
2409+
suppressConsoleLogs(async () => {
2410+
render(
2411+
<Menu>
2412+
<Menu.Button>Trigger</Menu.Button>
2413+
<Menu.Items>
2414+
<Menu.Item as="a">Item A</Menu.Item>
2415+
<Menu.Item as="a">Item B</Menu.Item>
2416+
<Menu.Item as="a">Item C</Menu.Item>
2417+
</Menu.Items>
2418+
</Menu>
2419+
)
2420+
2421+
assertMenuButton({
2422+
state: MenuState.InvisibleUnmounted,
2423+
attributes: { id: 'headlessui-menu-button-1' },
2424+
})
2425+
assertMenu({ state: MenuState.InvisibleUnmounted })
2426+
2427+
// Try to open the menu
2428+
await click(getMenuButton(), MouseButton.Right)
2429+
2430+
// Verify it is still closed
2431+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
2432+
})
2433+
)
2434+
24062435
it(
24072436
'should not be possible to open a menu on click when the button is disabled',
24082437
suppressConsoleLogs(async () => {

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ type ButtonPropsWeControl =
186186
| 'aria-controls'
187187
| 'aria-expanded'
188188
| 'onKeyDown'
189-
| 'onPointerUp'
189+
| 'onClick'
190190

191191
const Button = forwardRefWithAs(function Button<
192192
TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG
@@ -229,8 +229,9 @@ const Button = forwardRefWithAs(function Button<
229229
[dispatch, state, d]
230230
)
231231

232-
const handlePointerUp = React.useCallback(
233-
(event: MouseEvent) => {
232+
const handleClick = React.useCallback(
233+
(event: React.MouseEvent) => {
234+
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
234235
if (props.disabled) return
235236
if (state.menuState === MenuStates.Open) {
236237
dispatch({ type: ActionTypes.CloseMenu })
@@ -254,7 +255,7 @@ const Button = forwardRefWithAs(function Button<
254255
'aria-controls': state.itemsRef.current?.id,
255256
'aria-expanded': state.menuState === MenuStates.Open ? true : undefined,
256257
onKeyDown: handleKeyDown,
257-
onPointerUp: handlePointerUp,
258+
onClick: handleClick,
258259
}
259260

260261
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG)
@@ -381,6 +382,9 @@ type MenuItemPropsWeControl =
381382
| 'tabIndex'
382383
| 'aria-disabled'
383384
| 'onPointerLeave'
385+
| 'onPointerMove'
386+
| 'onMouseLeave'
387+
| 'onMouseMove'
384388
| 'onFocus'
385389

386390
function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
@@ -415,7 +419,6 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
415419

416420
const handleClick = React.useCallback(
417421
(event: React.MouseEvent) => {
418-
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
419422
if (disabled) return event.preventDefault()
420423
dispatch({ type: ActionTypes.CloseMenu })
421424
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
@@ -429,13 +432,13 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
429432
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
430433
}, [disabled, id, dispatch])
431434

432-
const handlePointerMove = React.useCallback(() => {
435+
const handleMove = React.useCallback(() => {
433436
if (disabled) return
434437
if (active) return
435438
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
436439
}, [disabled, active, id, dispatch])
437440

438-
const handlePointerLeave = React.useCallback(() => {
441+
const handleLeave = React.useCallback(() => {
439442
if (disabled) return
440443
if (!active) return
441444
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
@@ -450,8 +453,10 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
450453
'aria-disabled': disabled === true ? true : undefined,
451454
onClick: handleClick,
452455
onFocus: handleFocus,
453-
onPointerMove: handlePointerMove,
454-
onPointerLeave: handlePointerLeave,
456+
onPointerMove: handleMove,
457+
onMouseMove: handleMove,
458+
onPointerLeave: handleLeave,
459+
onMouseLeave: handleLeave,
455460
}
456461

457462
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_ITEM_TAG)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,21 @@ export function Switch<TTag extends React.ElementType = typeof DEFAULT_SWITCH_TA
130130

131131
const DEFAULT_LABEL_TAG = 'label'
132132
type LabelRenderPropArg = {}
133-
type LabelPropsWeControl = 'id' | 'ref' | 'onPointerUp'
133+
type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
134134

135135
function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>(
136136
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
137137
) {
138138
const state = useGroupContext([Switch.name, Label.name].join('.'))
139139
const id = `headlessui-switch-label-${useId()}`
140140

141-
const handlePointerUp = React.useCallback(() => {
141+
const handleClick = React.useCallback(() => {
142142
if (!state.switch) return
143143
state.switch.click()
144144
state.switch.focus({ preventScroll: true })
145145
}, [state.switch])
146146

147-
const propsWeControl = { ref: state.setLabel, id, onPointerUp: handlePointerUp }
147+
const propsWeControl = { ref: state.setLabel, id, onClick: handleClick }
148148
return render({ ...props, ...propsWeControl }, {}, DEFAULT_LABEL_TAG)
149149
}
150150

packages/@headlessui-react/src/test-utils/interactions.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,44 @@ export async function press(event: Partial<KeyboardEvent>) {
170170
return type([event])
171171
}
172172

173-
export async function click(element: Document | Element | Window | Node | null) {
173+
export enum MouseButton {
174+
Left = 0,
175+
Right = 2,
176+
}
177+
178+
export async function click(
179+
element: Document | Element | Window | Node | null,
180+
button = MouseButton.Left
181+
) {
174182
try {
175183
if (element === null) return expect(element).not.toBe(null)
176184

177-
fireEvent.pointerDown(element)
178-
fireEvent.mouseDown(element)
179-
fireEvent.pointerUp(element)
180-
fireEvent.mouseUp(element)
181-
fireEvent.click(element)
185+
let options = { button }
186+
187+
if (button === MouseButton.Left) {
188+
// Cancel in pointerDown cancels mouseDown, mouseUp
189+
let cancelled = !fireEvent.pointerDown(element, options)
190+
if (!cancelled) {
191+
fireEvent.mouseDown(element, options)
192+
}
193+
fireEvent.pointerUp(element, options)
194+
if (!cancelled) {
195+
fireEvent.mouseUp(element, options)
196+
}
197+
fireEvent.click(element, options)
198+
} else if (button === MouseButton.Right) {
199+
// Cancel in pointerDown cancels mouseDown, mouseUp
200+
let cancelled = !fireEvent.pointerDown(element, options)
201+
if (!cancelled) {
202+
fireEvent.mouseDown(element, options)
203+
}
204+
205+
// Only in Firefox:
206+
fireEvent.pointerUp(element, options)
207+
if (!cancelled) {
208+
fireEvent.mouseUp(element, options)
209+
}
210+
}
182211

183212
await new Promise(nextFrame)
184213
} catch (err) {

0 commit comments

Comments
 (0)