From 8ca5d639e6cd2498a23775b7089791a372887482 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 11:26:30 -0700 Subject: [PATCH 1/7] add tests/docs/stories --- packages/@react-spectrum/s2/src/Menu.tsx | 8 +- .../s2/stories/Menu.stories.tsx | 46 ++++ .../dev/s2-docs/pages/react-aria/Menu.mdx | 53 ++++ packages/dev/s2-docs/pages/s2/Menu.mdx | 51 ++++ packages/react-aria-components/src/Menu.tsx | 3 +- .../stories/Menu.stories.tsx | 41 ++++ .../react-aria-components/test/Menu.test.tsx | 53 ++++ .../src/interactions/useContextMenu.ts | 122 ++++++++++ .../react-aria/src/interactions/usePress.ts | 5 + .../react-aria/src/menu/useMenuTrigger.ts | 24 +- .../react-aria/src/overlays/usePopover.ts | 5 +- .../test/interactions/useContextMenu.test.tsx | 226 ++++++++++++++++++ .../src/menu/useMenuTriggerState.ts | 2 +- .../src/overlays/useOverlayTriggerState.ts | 16 +- 14 files changed, 646 insertions(+), 9 deletions(-) create mode 100644 packages/react-aria/src/interactions/useContextMenu.ts create mode 100644 packages/react-aria/test/interactions/useContextMenu.test.tsx diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 0925c4bea93..956808641cb 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -759,7 +759,13 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode { shouldFlip: props.shouldFlip }}> + value={{ + hideArrow: true, + offset: props.trigger === 'contextMenu' ? 0 : 8, + crossOffset: 0, + placement, + shouldFlip + }}> diff --git a/packages/@react-spectrum/s2/stories/Menu.stories.tsx b/packages/@react-spectrum/s2/stories/Menu.stories.tsx index d80f78d5d8d..73e930bb138 100644 --- a/packages/@react-spectrum/s2/stories/Menu.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Menu.stories.tsx @@ -29,6 +29,7 @@ import CropRotate from '../s2wf-icons/S2_Icon_CropRotate_20_N.svg'; import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg'; import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg'; import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg'; +import {focusRing, style} from '../style' with {type: 'macro'}; import {Image} from '../src/Image'; import ImgIcon from '../s2wf-icons/S2_Icon_Image_20_N.svg'; import Italic from '../s2wf-icons/S2_Icon_TextItalic_20_N.svg'; @@ -46,6 +47,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import More from '../s2wf-icons/S2_Icon_More_20_N.svg'; import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg'; import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; +import {Button as RACButton} from 'react-aria-components'; import {ReactElement, useState} from 'react'; import {Selection} from '@react-types/shared'; import StampClone from '../s2wf-icons/S2_Icon_StampClone_20_N.svg'; @@ -402,6 +404,50 @@ export const UnavailableMenuItem: Story = { } }; +export const ContextMenu: Story = { + render: args => ( + + + Right click here + + + Open + + Open with + + Preview + Photoshop + Safari + + + Get Info + Rename + Duplicate + Move to Trash + + + ) +}; + export const HoldAffordance: Story = { render: args => (
``` +### Context menu + +Use `trigger="contextMenu"` to open the menu when right clicking with a mouse, long pressing on touch, or via OS and screen reader specific keyboard shortcuts. The menu is positioned at the point the user clicked. + +```tsx render hideImports +"use client"; +import {MenuTrigger, Menu, MenuItem, SubmenuTrigger, Separator} from 'vanilla-starter/Menu'; +import {Button} from 'react-aria-components/Button'; + + + + + Open + + Open with + + Preview + Photoshop + Safari + + + + Get Info + Rename + Duplicate + Move to Trash + + +``` + +```css render hidden +.context-menu-trigger { + width: 250px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--gray-400); + border-radius: 10px; + background: transparent; + font: inherit; + color: inherit; + outline: none; + + &[data-focus-visible] { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } +} +``` + ## Examples diff --git a/packages/dev/s2-docs/pages/s2/Menu.mdx b/packages/dev/s2-docs/pages/s2/Menu.mdx index 5489f78ae51..0aaca4221e9 100644 --- a/packages/dev/s2-docs/pages/s2/Menu.mdx +++ b/packages/dev/s2-docs/pages/s2/Menu.mdx @@ -521,6 +521,57 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; ``` +### Context menu + +Use `trigger="contextMenu"` to open the menu when right clicking with a mouse, long pressing on touch, or via OS and screen reader specific keyboard shortcuts. The menu is positioned at the point the user clicked. + +```tsx render hideImports +"use client"; +import {Menu, MenuTrigger, MenuItem, SubmenuTrigger} from '@react-spectrum/s2/Menu'; +import {Button} from 'react-aria-components'; +import {style, focusRing} from '@react-spectrum/s2/style' with {type: 'macro'}; + +/*- begin collapse -*/ +const styles = style({ + ...focusRing(), + width: 256, + height: 144, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderStyle: 'dashed', + borderColor: 'gray-400', + borderRadius: 'lg', + backgroundColor: 'transparent', + font: 'ui', + color: 'neutral', + cursor: 'default' +}); +/*- end collapse -*/ + +/*- begin highlight -*/ + + {/*- end highlight -*/} + + + Open + + Open with + + Preview + Photoshop + Safari + + + Get Info + Rename + Duplicate + Move to Trash + + +``` + ## API ```tsx links={{MenuTrigger: '#menutrigger', Button: 'Button', Menu: '#menu', MenuItem: '#menuitem', MenuSection: '#menusection', SubmenuTrigger: '#submenutrigger', UnavailableMenuItemTrigger: '#unavailablemenuitemtrigger', Popover: 'Popover', Icon: 'icons', Image: 'Image', Text: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/span', Keyboard: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/kbd', Header: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/header', Heading: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements'}} diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 639d50da1ac..a8e540becb8 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -151,7 +151,8 @@ export function MenuTrigger(props: MenuTriggerProps): JSX.Element | null { triggerRef: ref, scrollRef, placement: 'bottom start', - 'aria-labelledby': menuProps['aria-labelledby'] + 'aria-labelledby': menuProps['aria-labelledby'], + offset: props.trigger === 'contextMenu' ? 0 : undefined } ] ]}> diff --git a/packages/react-aria-components/stories/Menu.stories.tsx b/packages/react-aria-components/stories/Menu.stories.tsx index 954eb696b92..8c87e41a30c 100644 --- a/packages/react-aria-components/stories/Menu.stories.tsx +++ b/packages/react-aria-components/stories/Menu.stories.tsx @@ -528,6 +528,47 @@ export const VirtualizedExample: MenuStory = () => { ); }; +export const ContextMenuExample: MenuStory = () => ( + + + + + Open + + Open with + + + Preview + Photoshop + Safari + + + + + Get Info + Rename + Duplicate + Move to Trash + + + +); + let UnavailableContext = createContext(false); function UnavailableMenuItemTrigger(props: {isUnavailable?: boolean; children: ReactElement[]}) { diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index fe0111e6fbd..f7121c7815b 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -646,6 +646,59 @@ describe('Menu', () => { expect(onAction).toHaveBeenLastCalledWith('rename', undefined); }); + it('should support a context menu trigger', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole, queryByRole} = render( + + + + + Open + Rename… + Duplicate + Share… + Delete… + + + + ); + + let button = getByRole('button'); + expect(queryByRole('menu')).not.toBeInTheDocument(); + + // A regular press should not open a context menu trigger. + await user.click(button); + expect(queryByRole('menu')).not.toBeInTheDocument(); + + // Right click opens the menu at the pointer position. + let event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 20 + }); + let preventDefault = jest.spyOn(event, 'preventDefault'); + fireEvent(button, event); + act(() => { + jest.runAllTimers(); + }); + expect(preventDefault).toHaveBeenCalled(); + + let menu = getByRole('menu'); + expect(getAllByRole('menuitem')).toHaveLength(5); + + let popover = menu.closest('.react-aria-Popover'); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute('data-trigger', 'MenuTrigger'); + + await user.click(getAllByRole('menuitem')[1]); + expect(onAction).toHaveBeenLastCalledWith('rename', undefined); + act(() => { + jest.runAllTimers(); + }); + expect(queryByRole('menu')).not.toBeInTheDocument(); + }); + it('should support onScroll', () => { let onScroll = jest.fn(); let {getByRole} = renderMenu({onScroll}); diff --git a/packages/react-aria/src/interactions/useContextMenu.ts b/packages/react-aria/src/interactions/useContextMenu.ts new file mode 100644 index 00000000000..ce3f7a8a6d8 --- /dev/null +++ b/packages/react-aria/src/interactions/useContextMenu.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {HTMLAttributes, useRef} from 'react'; +import {isIOS, isMac} from '../utils/platform'; +import {mergeProps} from '../utils/mergeProps'; +import {useLongPress} from './useLongPress'; + +export interface ContextMenuEvent { + /** The target element on which the event was triggered. */ + target: Element; + /** X position relative to the target. */ + x: number; + /** Y position relative to the target. */ + y: number; +} + +export interface ContextMenuProps { + /** Event that is called when a context menu is triggered. */ + onContextMenu?: (e: ContextMenuEvent) => void; +} + +export interface ContextMenuAria { + /** Props to spread on the target element. */ + contextMenuProps: HTMLAttributes; +} + +/** + * Handles context menu events across mouse, touch, keyboard, and screen reader interactions. + */ +export function useContextMenu(props: ContextMenuProps): ContextMenuAria { + // How to trigger context menu events on various platforms: + // - macOS + // - Mouse right click + // - Control + click + // - Control + Enter (does not fire the contextmenu event in certain WebKit / Chrome versions - https://bugs.webkit.org/show_bug.cgi?id=302049, https://issues.chromium.org/issues/369897039) + // - Control + Option + Shift + M with VoiceOver + // - Windows / Linux + // - Mouse right click + // - Shift + F10 + // - Long press on a touch screen + // - iOS + // - Long press (does not fire contextmenu event - https://bugs.webkit.org/show_bug.cgi?id=213953) + // - Android + // - Long press + + let {onContextMenu} = props; + let firedContextMenuEvent = useRef(false); + + // iOS does not fire the contextmenu event, so use long press. + let {longPressProps} = useLongPress({ + onLongPressStart() { + firedContextMenuEvent.current = false; + }, + onLongPress(e) { + if (!firedContextMenuEvent.current) { + onContextMenu?.({target: e.target, x: e.x, y: e.y}); + } else { + firedContextMenuEvent.current = false; + } + } + }); + + if (!onContextMenu) { + return { + contextMenuProps: {} + }; + } + + return { + contextMenuProps: mergeProps(isIOS() ? longPressProps : {}, { + onContextMenu(e) { + e.stopPropagation(); + e.preventDefault(); + firedContextMenuEvent.current = true; + + let rect = e.currentTarget.getBoundingClientRect(); + onContextMenu({ + target: e.currentTarget, + x: e.clientX - rect.x, + y: e.clientY - rect.y + }); + }, + onKeyDown(e) { + // macOS has a default keyboard shortcut to show the contextmenu: Ctrl + Enter. + // However, some versions of Safari and Chrome do not trigger the contextmenu event. + // Fixed in https://github.com/WebKit/WebKit/pull/62278 (currently in WekKit nightly) and + // https://github.com/chromium/chromium/commit/268c876c191cd4712c2d1043aab9760fb71d9be5 (Chrome 147). + // Remove this workaround once those are broadly available. + // An additional bug still occurs when the target has a border-radius: https://bugs.webkit.org/show_bug.cgi?id=317496 + if (isMac()) { + if (e.ctrlKey && e.key === 'Enter') { + firedContextMenuEvent.current = false; + let target = e.currentTarget; + e.stopPropagation(); + setTimeout(() => { + if (!firedContextMenuEvent.current) { + let rect = target.getBoundingClientRect(); + onContextMenu({ + target, + x: rect.width / 2, + y: rect.height / 2 + }); + } else { + firedContextMenuEvent.current = false; + } + }, 10); + } + } + } + } satisfies HTMLAttributes) + }; +} diff --git a/packages/react-aria/src/interactions/usePress.ts b/packages/react-aria/src/interactions/usePress.ts index 2c79632c076..6f6a5bc8080 100644 --- a/packages/react-aria/src/interactions/usePress.ts +++ b/packages/react-aria/src/interactions/usePress.ts @@ -1183,6 +1183,11 @@ function shouldPreventDefaultUp(target: Element) { } function shouldPreventDefaultKeyboard(target: Element, key: string) { + // Don't prevent the contextmenu shortcut on mac. + if (isMac() && key === 'Enter') { + return false; + } + if (target instanceof HTMLInputElement) { return !isValidInputKey(target, key); } diff --git a/packages/react-aria/src/menu/useMenuTrigger.ts b/packages/react-aria/src/menu/useMenuTrigger.ts index 2cda3915319..75b172e0246 100644 --- a/packages/react-aria/src/menu/useMenuTrigger.ts +++ b/packages/react-aria/src/menu/useMenuTrigger.ts @@ -17,6 +17,7 @@ import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import intlMessages from '../../intl/menu/*.json'; import {MenuTriggerState, MenuTriggerType} from 'react-stately/useMenuTriggerState'; import {PressProps} from '../interactions/usePress'; +import {useContextMenu} from '../interactions/useContextMenu'; import {useId} from '../utils/useId'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useLongPress} from '../interactions/useLongPress'; @@ -139,13 +140,30 @@ export function useMenuTrigger( // omit onPress from triggerProps since we override it above. delete triggerProps.onPress; + let {contextMenuProps} = useContextMenu({ + onContextMenu(e) { + // eslint-disable-next-line rsp-rules/safe-event-target + let rect = e.target.getBoundingClientRect(); + state.setPoint({x: rect.x + e.x, y: rect.y + e.y}); + state.open(); + } + }); + + let interactionProps; + if (trigger === 'press') { + interactionProps = {...pressProps, onKeyDown}; + } else if (trigger === 'longPress') { + interactionProps = {...longPressProps, onKeyDown}; + } else if (trigger === 'contextMenu') { + interactionProps = contextMenuProps; + } + return { // @ts-ignore - TODO we pass out both DOMAttributes AND AriaButtonProps, but useButton will discard the longPress event handlers, it's only through PressResponder magic that this works for RSP and RAC. it does not work in aria examples menuTriggerProps: { ...triggerProps, - ...(trigger === 'press' ? pressProps : longPressProps), - id: menuTriggerId, - onKeyDown + ...interactionProps, + id: menuTriggerId }, menuProps: { ...overlayProps, diff --git a/packages/react-aria/src/overlays/usePopover.ts b/packages/react-aria/src/overlays/usePopover.ts index 8fad9505553..5fc7b67a914 100644 --- a/packages/react-aria/src/overlays/usePopover.ts +++ b/packages/react-aria/src/overlays/usePopover.ts @@ -118,7 +118,10 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState): targetRef: triggerRef, overlayRef: popoverRef, isOpen: state.isOpen, - onClose: isNonModal && !isSubmenu ? state.close : null + onClose: isNonModal && !isSubmenu ? state.close : null, + getTargetRect: + otherProps.getTargetRect ?? + (state.point ? () => new DOMRect(state.point!.x, state.point!.y, 0, 0) : undefined) }); usePreventScroll({ diff --git a/packages/react-aria/test/interactions/useContextMenu.test.tsx b/packages/react-aria/test/interactions/useContextMenu.test.tsx new file mode 100644 index 00000000000..f78c8229100 --- /dev/null +++ b/packages/react-aria/test/interactions/useContextMenu.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, fireEvent, installPointerEvent, render} from '@react-spectrum/test-utils-internal'; +import React from 'react'; +import {useContextMenu} from '../../src/interactions/useContextMenu'; + +function Example(props) { + let {contextMenuProps} = useContextMenu(props); + return ( +
+ test +
+ ); +} + +describe('useContextMenu', function () { + let onContextMenu = jest.fn(); + + afterEach(() => { + onContextMenu.mockClear(); + }); + + describe('mouse / right-click (contextmenu event)', function () { + it('calls onContextMenu on right click', function () { + let {getByText} = render(); + let el = getByText('test'); + jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + x: 10, + y: 20, + width: 100, + height: 50, + top: 20, + left: 10, + right: 110, + bottom: 70 + } as DOMRect); + + fireEvent.contextMenu(el, {clientX: 30, clientY: 40}); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith({ + target: el, + x: 20, // clientX - rect.x + y: 20 // clientY - rect.y + }); + }); + + it('prevents default and stops propagation on contextmenu event', function () { + let {getByText} = render(); + let el = getByText('test'); + let event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 0, + clientY: 0 + }); + let stopPropagation = jest.spyOn(event, 'stopPropagation'); + let preventDefault = jest.spyOn(event, 'preventDefault'); + + el.dispatchEvent(event); + + expect(stopPropagation).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('does not call onContextMenu when prop is not provided', function () { + let {getByText} = render(); + let el = getByText('test'); + // Should not throw + fireEvent.contextMenu(el, {clientX: 30, clientY: 40}); + expect(onContextMenu).not.toHaveBeenCalled(); + }); + }); + + describe('macOS keyboard shortcut (Ctrl + Enter)', function () { + let platformGetter; + + beforeAll(() => { + jest.useFakeTimers(); + platformGetter = jest.spyOn(window.navigator, 'platform', 'get'); + }); + + afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + beforeEach(() => { + platformGetter.mockReturnValue('MacIntel'); + }); + + it('triggers onContextMenu via Ctrl+Enter on macOS when no contextmenu event fires', function () { + let {getByText} = render(); + let el = getByText('test'); + jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 50, + top: 0, + left: 0, + right: 100, + bottom: 50 + } as DOMRect); + + fireEvent.keyDown(el, {key: 'Enter', ctrlKey: true}); + + // Fires after a short timeout when no contextmenu event follows + expect(onContextMenu).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(10)); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith({ + target: el, + x: 50, // rect.width / 2 + y: 25 // rect.height / 2 + }); + }); + + it('does not double-fire when Ctrl+Enter also triggers a contextmenu event', function () { + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.keyDown(el, {key: 'Enter', ctrlKey: true}); + // Browser also fires contextmenu event + fireEvent.contextMenu(el, {clientX: 0, clientY: 0}); + + act(() => jest.advanceTimersByTime(10)); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + }); + + it('does not trigger on Ctrl+Enter on non-macOS', function () { + platformGetter.mockReturnValue('Win32'); + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.keyDown(el, {key: 'Enter', ctrlKey: true}); + act(() => jest.advanceTimersByTime(10)); + + expect(onContextMenu).not.toHaveBeenCalled(); + }); + + it('does not trigger on Enter without Ctrl on macOS', function () { + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.keyDown(el, {key: 'Enter', ctrlKey: false}); + act(() => jest.advanceTimersByTime(10)); + + expect(onContextMenu).not.toHaveBeenCalled(); + }); + }); + + describe('iOS long press', function () { + installPointerEvent(); + + let platformGetter; + + beforeAll(() => { + jest.useFakeTimers(); + platformGetter = jest.spyOn(window.navigator, 'platform', 'get'); + }); + + afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + beforeEach(() => { + platformGetter.mockReturnValue('iPhone'); + }); + + it('triggers onContextMenu via long press on iOS', function () { + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', clientX: 10, clientY: 20}); + act(() => jest.advanceTimersByTime(500)); + fireEvent.pointerUp(el, {pointerType: 'touch', clientX: 10, clientY: 20}); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ + target: el + }) + ); + }); + + it('does not trigger if the press is cancelled before the long press threshold', function () { + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', clientX: 10, clientY: 20}); + act(() => jest.advanceTimersByTime(200)); + fireEvent.pointerCancel(el, {pointerType: 'touch'}); + act(() => jest.advanceTimersByTime(400)); + + expect(onContextMenu).not.toHaveBeenCalled(); + }); + + it('does not double-fire when long press and contextmenu event both occur (Android)', function () { + platformGetter.mockReturnValue('Android'); + let {getByText} = render(); + let el = getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', clientX: 10, clientY: 20}); + act(() => jest.advanceTimersByTime(500)); + // Browser fires contextmenu event during long press + fireEvent.contextMenu(el, {clientX: 10, clientY: 20}); + fireEvent.pointerUp(el, {pointerType: 'touch', clientX: 10, clientY: 20}); + + expect(onContextMenu).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react-stately/src/menu/useMenuTriggerState.ts b/packages/react-stately/src/menu/useMenuTriggerState.ts index bc7cf9dd62f..1ace7c6ab84 100644 --- a/packages/react-stately/src/menu/useMenuTriggerState.ts +++ b/packages/react-stately/src/menu/useMenuTriggerState.ts @@ -18,7 +18,7 @@ import { } from '../overlays/useOverlayTriggerState'; import {useState} from 'react'; -export type MenuTriggerType = 'press' | 'longPress'; +export type MenuTriggerType = 'press' | 'longPress' | 'contextMenu'; export interface MenuTriggerProps extends OverlayTriggerProps { /** diff --git a/packages/react-stately/src/overlays/useOverlayTriggerState.ts b/packages/react-stately/src/overlays/useOverlayTriggerState.ts index db90db8388a..5c1378cda80 100644 --- a/packages/react-stately/src/overlays/useOverlayTriggerState.ts +++ b/packages/react-stately/src/overlays/useOverlayTriggerState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {useCallback} from 'react'; +import {useCallback, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; export interface OverlayTriggerProps { @@ -22,6 +22,11 @@ export interface OverlayTriggerProps { onOpenChange?: (isOpen: boolean) => void; } +interface Point { + x: number; + y: number; +} + export interface OverlayTriggerState { /** Whether the overlay is currently open. */ readonly isOpen: boolean; @@ -33,6 +38,10 @@ export interface OverlayTriggerState { close(): void; /** Toggles the overlay's visibility. */ toggle(): void; + /** The cursor position when the overlay was triggered, relative to the window viewport. */ + readonly point: Point | null; + /** Sets the cursor position relative to the window viewport. */ + setPoint(point: Point): void; } /** @@ -45,6 +54,7 @@ export function useOverlayTriggerState(props: OverlayTriggerProps): OverlayTrigg props.defaultOpen || false, props.onOpenChange ); + let [point, setPoint] = useState(null); const open = useCallback(() => { setOpen(true); @@ -63,6 +73,8 @@ export function useOverlayTriggerState(props: OverlayTriggerProps): OverlayTrigg setOpen, open, close, - toggle + toggle, + point, + setPoint }; } From 22f8a298748312f39ab4eecaa46e9cc67eb4573d Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 11:31:13 -0700 Subject: [PATCH 2/7] fix lint --- packages/react-aria/test/interactions/useContextMenu.test.tsx | 1 + packages/react-stately/src/menu/useSubmenuTriggerState.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-aria/test/interactions/useContextMenu.test.tsx b/packages/react-aria/test/interactions/useContextMenu.test.tsx index f78c8229100..b8388b44622 100644 --- a/packages/react-aria/test/interactions/useContextMenu.test.tsx +++ b/packages/react-aria/test/interactions/useContextMenu.test.tsx @@ -17,6 +17,7 @@ import {useContextMenu} from '../../src/interactions/useContextMenu'; function Example(props) { let {contextMenuProps} = useContextMenu(props); return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
test
diff --git a/packages/react-stately/src/menu/useSubmenuTriggerState.ts b/packages/react-stately/src/menu/useSubmenuTriggerState.ts index d79ca4035ed..657b5b7ce20 100644 --- a/packages/react-stately/src/menu/useSubmenuTriggerState.ts +++ b/packages/react-stately/src/menu/useSubmenuTriggerState.ts @@ -92,7 +92,9 @@ export function useSubmenuTriggerState( // TODO: Placeholders that aren't used but give us parity with OverlayTriggerState so we can use this in Popover. Refactor if we update Popover via // https://github.com/adobe/react-spectrum/pull/4976#discussion_r1336472863 setOpen: () => {}, - toggle + toggle, + point: null, + setPoint: () => {} }), [isOpen, open, close, closeAll, focusStrategy, toggle, submenuLevel] ); From 72762ef9957613ceea7332624a4e682997d4b2c9 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 11:48:21 -0700 Subject: [PATCH 3/7] Export useContextMenu from react-aria --- packages/react-aria/exports/index.ts | 2 ++ packages/react-aria/exports/useContextMenu.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/react-aria/exports/useContextMenu.ts diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 37c8c65bfa1..756fa228655 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -84,6 +84,7 @@ export {useKeyboard} from '../src/interactions/useKeyboard'; export {useMove} from '../src/interactions/useMove'; export {usePress} from '../src/interactions/usePress'; export {useLongPress} from '../src/interactions/useLongPress'; +export {useContextMenu} from '../src/interactions/useContextMenu'; export {useFocusable, Focusable} from '../src/interactions/useFocusable'; export {Pressable} from '../src/interactions/Pressable'; export {useField} from '../src/label/useField'; @@ -327,6 +328,7 @@ export type {HoverProps, HoverResult} from '../src/interactions/useHover'; export type {InteractOutsideProps} from '../src/interactions/useInteractOutside'; export type {KeyboardProps, KeyboardResult} from '../src/interactions/useKeyboard'; export type {LongPressProps, LongPressResult} from '../src/interactions/useLongPress'; +export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from '../src/interactions/useContextMenu'; export type { MoveEvents, PressEvent, diff --git a/packages/react-aria/exports/useContextMenu.ts b/packages/react-aria/exports/useContextMenu.ts new file mode 100644 index 00000000000..7c6b8f7f68a --- /dev/null +++ b/packages/react-aria/exports/useContextMenu.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export {useContextMenu} from '../src/interactions/useContextMenu'; +export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from '../src/interactions/useContextMenu'; From 3c1c9e4782f9e66e68d075eb411a90ed77f04986 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 11:55:39 -0700 Subject: [PATCH 4/7] Add docs for useContextMenu --- .../@react-aria/interactions/src/index.ts | 2 + .../pages/react-aria/useContextMenu.mdx | 104 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx diff --git a/packages/@react-aria/interactions/src/index.ts b/packages/@react-aria/interactions/src/index.ts index 2639760c836..6ca55e6215b 100644 --- a/packages/@react-aria/interactions/src/index.ts +++ b/packages/@react-aria/interactions/src/index.ts @@ -32,6 +32,7 @@ export {useMove} from 'react-aria/useMove'; export {usePress} from 'react-aria/usePress'; export {useScrollWheel} from 'react-aria/private/interactions/useScrollWheel'; export {useLongPress} from 'react-aria/useLongPress'; +export {useContextMenu} from 'react-aria/useContextMenu'; export {FocusableProvider, FocusableContext} from 'react-aria/private/interactions/useFocusable'; export {useFocusable} from 'react-aria/useFocusable'; export {Focusable} from 'react-aria/Focusable'; @@ -46,6 +47,7 @@ export type {KeyboardProps, KeyboardResult} from 'react-aria/useKeyboard'; export type {PressProps, PressHookProps, PressResult} from 'react-aria/usePress'; export type {MoveResult} from 'react-aria/useMove'; export type {LongPressProps, LongPressResult} from 'react-aria/useLongPress'; +export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from 'react-aria/useContextMenu'; export type {ScrollWheelProps} from 'react-aria/private/interactions/useScrollWheel'; export type {FocusableProviderProps} from 'react-aria/private/interactions/useFocusable'; export type {FocusableAria, FocusableOptions} from 'react-aria/useFocusable'; diff --git a/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx b/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx new file mode 100644 index 00000000000..f10bb83881c --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx @@ -0,0 +1,104 @@ +{/* Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '../../src/Layout'; +export default Layout; +import {FunctionAPI} from '../../src/FunctionAPI'; +import docs from 'docs:@react-aria/interactions'; +import {InterfaceType} from '../../src/types'; + +export const section = 'Interactions'; +export const description = 'Handles context menu interactions across mouse, touch, keyboard, and screen reader.'; + +# useContextMenu + +{docs.exports.useContextMenu.description} + +```tsx render +"use client"; +import React from 'react'; +import {useContextMenu} from 'react-aria/useContextMenu'; + +function Example() { + let [events, setEvents] = React.useState([]); + + /*- begin focus -*/ + let {contextMenuProps} = useContextMenu({ + onContextMenu: e => setEvents( + events => [`context menu at (${e.x}, ${e.y})`, ...events] + ) + }); + /*- end focus -*/ + + return ( + <> +
+ Right click here +
+
    + {events.map((e, i) =>
  • {e}
  • )} +
+ + ); +} +``` + +## Features + +There is no standard way to trigger a context menu consistently across platforms, input devices, and assistive technologies. `useContextMenu` normalizes these differences into a single `onContextMenu` event. + +* Handles mouse right click and Control + click on macOS +* Handles long press on touch devices, including iOS where the `contextmenu` event does not fire +* Handles keyboard shortcuts such as Shift + F10 on Windows and Linux, and Control + Enter on macOS +* Handles screen reader specific gestures such as VoiceOver's context menu command +* Prevents the browser and OS context menus from appearing +* Reports the position the menu should be displayed relative to the target element + +## Anatomy + +`useContextMenu` returns props that you spread onto the element that should respond to context menu interactions. The `onContextMenu` handler is called with a [ContextMenuEvent](#contextmenuevent) that includes the target element and the `x` and `y` position where the menu should appear, relative to the target. + +```tsx +import {useContextMenu} from 'react-aria/useContextMenu'; + +let {contextMenuProps} = useContextMenu(props); +``` + +## API + + + +### ContextMenuProps + + + +### ContextMenuAria + + + +### ContextMenuEvent + +The `onContextMenu` handler is fired with a `ContextMenuEvent`, which exposes the target element and the position the menu should be displayed relative to it. + + From 1d25b74063c42f45cc8625a8db7dd14dcf38d4b8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 12:13:27 -0700 Subject: [PATCH 5/7] format --- packages/react-aria/exports/index.ts | 6 +++++- packages/react-aria/exports/useContextMenu.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 756fa228655..0398da52ad7 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -328,7 +328,11 @@ export type {HoverProps, HoverResult} from '../src/interactions/useHover'; export type {InteractOutsideProps} from '../src/interactions/useInteractOutside'; export type {KeyboardProps, KeyboardResult} from '../src/interactions/useKeyboard'; export type {LongPressProps, LongPressResult} from '../src/interactions/useLongPress'; -export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from '../src/interactions/useContextMenu'; +export type { + ContextMenuProps, + ContextMenuAria, + ContextMenuEvent +} from '../src/interactions/useContextMenu'; export type { MoveEvents, PressEvent, diff --git a/packages/react-aria/exports/useContextMenu.ts b/packages/react-aria/exports/useContextMenu.ts index 7c6b8f7f68a..dbb4f91c6d3 100644 --- a/packages/react-aria/exports/useContextMenu.ts +++ b/packages/react-aria/exports/useContextMenu.ts @@ -11,4 +11,8 @@ */ export {useContextMenu} from '../src/interactions/useContextMenu'; -export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from '../src/interactions/useContextMenu'; +export type { + ContextMenuProps, + ContextMenuAria, + ContextMenuEvent +} from '../src/interactions/useContextMenu'; From 801bf4ea3b86bf85d69d4659d3689bf1bf73a5b8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 12:41:16 -0700 Subject: [PATCH 6/7] revert change to @react-aria/interactions --- packages/@react-aria/interactions/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@react-aria/interactions/src/index.ts b/packages/@react-aria/interactions/src/index.ts index 6ca55e6215b..2639760c836 100644 --- a/packages/@react-aria/interactions/src/index.ts +++ b/packages/@react-aria/interactions/src/index.ts @@ -32,7 +32,6 @@ export {useMove} from 'react-aria/useMove'; export {usePress} from 'react-aria/usePress'; export {useScrollWheel} from 'react-aria/private/interactions/useScrollWheel'; export {useLongPress} from 'react-aria/useLongPress'; -export {useContextMenu} from 'react-aria/useContextMenu'; export {FocusableProvider, FocusableContext} from 'react-aria/private/interactions/useFocusable'; export {useFocusable} from 'react-aria/useFocusable'; export {Focusable} from 'react-aria/Focusable'; @@ -47,7 +46,6 @@ export type {KeyboardProps, KeyboardResult} from 'react-aria/useKeyboard'; export type {PressProps, PressHookProps, PressResult} from 'react-aria/usePress'; export type {MoveResult} from 'react-aria/useMove'; export type {LongPressProps, LongPressResult} from 'react-aria/useLongPress'; -export type {ContextMenuProps, ContextMenuAria, ContextMenuEvent} from 'react-aria/useContextMenu'; export type {ScrollWheelProps} from 'react-aria/private/interactions/useScrollWheel'; export type {FocusableProviderProps} from 'react-aria/private/interactions/useFocusable'; export type {FocusableAria, FocusableOptions} from 'react-aria/useFocusable'; From 0c375f3ec1c48cbab1f3acafc69956b5ed195ee8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 19 Jun 2026 14:40:18 -0700 Subject: [PATCH 7/7] Fix import --- packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx b/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx index f10bb83881c..5b7d6e4abdd 100644 --- a/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx +++ b/packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx @@ -10,7 +10,7 @@ governing permissions and limitations under the License. */} import {Layout} from '../../src/Layout'; export default Layout; import {FunctionAPI} from '../../src/FunctionAPI'; -import docs from 'docs:@react-aria/interactions'; +import docs from 'docs:react-aria/useContextMenu'; import {InterfaceType} from '../../src/types'; export const section = 'Interactions';