Skip to content
Open
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
8 changes: 7 additions & 1 deletion packages/@react-spectrum/s2/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,13 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode {
shouldFlip: props.shouldFlip
}}>
<PopoverContext.Provider
value={{hideArrow: true, offset: 8, crossOffset: 0, placement, shouldFlip}}>
value={{
hideArrow: true,
offset: props.trigger === 'contextMenu' ? 0 : 8,
crossOffset: 0,
placement,
shouldFlip
}}>
<InPopoverContext.Provider value={false}>
<AriaMenuTrigger {...props}>
<PressResponder onPressStart={onPressStart} isPressed={isPressed}>
Expand Down
46 changes: 46 additions & 0 deletions packages/@react-spectrum/s2/stories/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -402,6 +404,50 @@ export const UnavailableMenuItem: Story = {
}
};

export const ContextMenu: Story = {
render: args => (
<MenuTrigger trigger="contextMenu" {...args}>
<RACButton
className={style({
...focusRing(),
width: 256,
height: 144,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderStyle: 'dashed',
borderColor: {
default: 'gray-400',
forcedColors: 'ButtonBorder'
},
borderRadius: 'lg',
backgroundColor: 'transparent',
font: 'ui',
color: 'neutral',
cursor: 'default'
})}>
Right click here
</RACButton>
<Menu {...args}>
<MenuItem>Open</MenuItem>
<SubmenuTrigger>
<MenuItem>Open with</MenuItem>
<Menu>
<MenuItem>Preview</MenuItem>
<MenuItem>Photoshop</MenuItem>
<MenuItem>Safari</MenuItem>
</Menu>
</SubmenuTrigger>
<MenuItem>Get Info</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Move to Trash</MenuItem>
</Menu>
</MenuTrigger>
)
};

export const HoldAffordance: Story = {
render: args => (
<div
Expand Down
53 changes: 53 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/Menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,59 @@ import {ChevronDown} from 'lucide-react';
</MenuTrigger>
```

### 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';

<MenuTrigger trigger="contextMenu">
<Button className="context-menu-trigger">
Right click here
</Button>
<Menu>
<MenuItem>Open</MenuItem>
<SubmenuTrigger>
<MenuItem>Open with</MenuItem>
<Menu>
<MenuItem>Preview</MenuItem>
<MenuItem>Photoshop</MenuItem>
<MenuItem>Safari</MenuItem>
</Menu>
</SubmenuTrigger>
<Separator />
<MenuItem>Get Info</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Move to Trash</MenuItem>
</Menu>
</MenuTrigger>
```

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

<ExampleList tag="menu" pages={props.pages} />
Expand Down
104 changes: 104 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/useContextMenu.mdx
Original file line number Diff line number Diff line change
@@ -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/useContextMenu';
import {InterfaceType} from '../../src/types';

export const section = 'Interactions';
export const description = 'Handles context menu interactions across mouse, touch, keyboard, and screen reader.';

# useContextMenu

<PageDescription>{docs.exports.useContextMenu.description}</PageDescription>

```tsx render
"use client";
import React from 'react';
import {useContextMenu} from 'react-aria/useContextMenu';

function Example() {
let [events, setEvents] = React.useState<string[]>([]);

/*- begin focus -*/
let {contextMenuProps} = useContextMenu({
onContextMenu: e => setEvents(
events => [`context menu at (${e.x}, ${e.y})`, ...events]
)
});
/*- end focus -*/

return (
<>
<div
{...contextMenuProps}
tabIndex={0}
style={{
width: 250,
height: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed gray',
borderRadius: 10
}}>
Right click here
</div>
<ul
style={{
maxHeight: '200px',
overflow: 'auto'
}}>
{events.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</>
);
}
```

## 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 <Keyboard>Control</Keyboard> + click on macOS
* Handles long press on touch devices, including iOS where the `contextmenu` event does not fire
* Handles keyboard shortcuts such as <Keyboard>Shift</Keyboard> + <Keyboard>F10</Keyboard> on Windows and Linux, and <Keyboard>Control</Keyboard> + <Keyboard>Enter</Keyboard> 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

<FunctionAPI function={docs.exports.useContextMenu} links={docs.links} />

### ContextMenuProps

<InterfaceType {...docs.exports.ContextMenuProps} />

### ContextMenuAria

<InterfaceType {...docs.exports.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.

<InterfaceType {...docs.exports.ContextMenuEvent} />
51 changes: 51 additions & 0 deletions packages/dev/s2-docs/pages/s2/Menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,57 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton';
</MenuTrigger>
```

### 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 -*/
<MenuTrigger trigger="contextMenu">
{/*- end highlight -*/}
<Button className={styles}>Right click here</Button>
<Menu>
<MenuItem>Open</MenuItem>
<SubmenuTrigger>
<MenuItem>Open with</MenuItem>
<Menu>
<MenuItem>Preview</MenuItem>
<MenuItem>Photoshop</MenuItem>
<MenuItem>Safari</MenuItem>
</Menu>
</SubmenuTrigger>
<MenuItem>Get Info</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Move to Trash</MenuItem>
</Menu>
</MenuTrigger>
```

## 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'}}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
]}>
Expand Down
41 changes: 41 additions & 0 deletions packages/react-aria-components/stories/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,47 @@ export const VirtualizedExample: MenuStory = () => {
);
};

export const ContextMenuExample: MenuStory = () => (
<MenuTrigger trigger="contextMenu">
<Button
style={{
width: 250,
height: 150,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px dashed gray',
borderRadius: 10,
background: 'transparent',
font: 'inherit',
color: 'inherit',
cursor: 'default'
}}>
Right click here
</Button>
<Popover>
<Menu className={styles.menu} onAction={action('onAction')}>
<MyMenuItem>Open</MyMenuItem>
<SubmenuTrigger>
<MyMenuItem>Open with</MyMenuItem>
<Popover className={styles.popover}>
<Menu className={styles.menu} onAction={action('onAction')}>
<MyMenuItem>Preview</MyMenuItem>
<MyMenuItem>Photoshop</MyMenuItem>
<MyMenuItem>Safari</MyMenuItem>
</Menu>
</Popover>
</SubmenuTrigger>
<Separator style={{borderTop: '1px solid gray', margin: '2px 5px'}} />
<MyMenuItem>Get Info</MyMenuItem>
<MyMenuItem>Rename</MyMenuItem>
<MyMenuItem>Duplicate</MyMenuItem>
<MyMenuItem>Move to Trash</MyMenuItem>
</Menu>
</Popover>
</MenuTrigger>
);

let UnavailableContext = createContext(false);

function UnavailableMenuItemTrigger(props: {isUnavailable?: boolean; children: ReactElement[]}) {
Expand Down
Loading