Skip to content

feat: Add trigger="contextMenu" to MenuTrigger#10237

Open
devongovett wants to merge 7 commits into
mainfrom
context-menu
Open

feat: Add trigger="contextMenu" to MenuTrigger#10237
devongovett wants to merge 7 commits into
mainfrom
context-menu

Conversation

@devongovett

@devongovett devongovett commented Jun 19, 2026

Copy link
Copy Markdown
Member

Closes #5020, closes #6117, closes #5387, closes #2876

Now that we have getTargetRect support on Popover, it's pretty simple to add support for context menus. This PR resurrects an old branch I had with an implementation.

  • useContextMenu – a new interaction hook. It uses the native contextmenu event where possible, with fallbacks to handle keyboard and long press interactions for certain operating systems.
  • trigger="contextMenu" prop for MenuTrigger. Stores the event's coordinates in useOverlayTriggerState, which is used in the popover's getTargetRect.

The contextmenu event is now triggered by most browsers for keyboard events as well as mouse events. On macOS, that's Control + Enter, on Windows it's Shift + F10. However, there are some browser bugs that prevent this in some cases on macOS so we have fallback code to handle it manually. On iOS the event is not fired at all so we manually handle long press.

@rspbot

rspbot commented Jun 19, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 19, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Jun 19, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:ComboBoxState

 ComboBoxState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commit: () => void
   commitValidation: () => void
   defaultInputValue: string
   defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   inputValue: string
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null, MenuTriggerAction) => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   revert: () => void
   selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setInputValue: (string) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setValue: (Key | readonly Array<Key> | null) => void
   toggle: (FocusStrategy | null, MenuTriggerAction) => void
   updateValidation: (ValidationResult) => void
   value: ValueType<SelectionMode>

/react-aria-components:DatePickerState

 DatePickerState {
   close: () => void
   commitValidation: () => void
   dateValue: DateValue | null
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (string, FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   hasTime: boolean
   isInvalid: boolean
   isOpen: boolean
   open: () => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   setDateValue: (DateValue) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setTimeValue: (TimeValue) => void
   setValue: (DateValue | null) => void
   timeValue: TimeValue | null
   toggle: () => void
   value: DateValue | null
 }

/react-aria-components:DateRangePickerState

 DateRangePickerState {
   close: () => void
   commitValidation: () => void
   dateRange: RangeValue<DateValue | null> | null
   defaultValue: DateRange | null
   displayValidation: ValidationResult
   formatValue: (string, FieldOptions) => {
     start: string
   end: string
 } | null
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   hasTime: boolean
   isInvalid: boolean
   isOpen: boolean
   open: () => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   setDate: ('start' | 'end', DateValue | null) => void
   setDateRange: (DateRange) => void
   setDateTime: ('start' | 'end', DateValue | null) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setTime: ('start' | 'end', TimeValue | null) => void
   setTimeRange: (TimeRange) => void
   setValue: (DateRange | null) => void
   timeRange: RangeValue<TimeValue | null> | null
   updateValidation: (ValidationResult) => void
   value: RangeValue<DateValue | null>
 }

/react-aria-components:OverlayTriggerState

 OverlayTriggerState {
   close: () => void
   isOpen: boolean
   open: () => void
+  point: Point | null
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   toggle: () => void
 }

/react-aria-components:RootMenuTriggerState

 RootMenuTriggerState {
   close: () => void
   closeSubmenu: (Key, number) => void
   expandedKeysStack: Array<Key>
   focusStrategy: FocusStrategy | null
   isOpen: boolean
   open: (FocusStrategy | null) => void
   openSubmenu: (Key, number) => void
+  point: Point | null
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   toggle: (FocusStrategy | null) => void
 }

/react-aria-components:SelectState

 SelectState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commitValidation: () => void
   defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null) => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setValue: (Key | readonly Array<Key> | null) => void
   toggle: (FocusStrategy | null) => void
   updateValidation: (ValidationResult) => void
   value: ValueType<SelectionMode>

@react-stately/combobox

/@react-stately/combobox:ComboBoxState

 ComboBoxState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commit: () => void
   commitValidation: () => void
   defaultInputValue: string
   defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   inputValue: string
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null, MenuTriggerAction) => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   revert: () => void
   selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setInputValue: (string) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setValue: (Key | readonly Array<Key> | null) => void
   toggle: (FocusStrategy | null, MenuTriggerAction) => void
   updateValidation: (ValidationResult) => void
   value: ValueType<SelectionMode>

@react-stately/datepicker

/@react-stately/datepicker:DatePickerState

 DatePickerState {
   close: () => void
   commitValidation: () => void
   dateValue: DateValue | null
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (string, FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   hasTime: boolean
   isInvalid: boolean
   isOpen: boolean
   open: () => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   setDateValue: (DateValue) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setTimeValue: (TimeValue) => void
   setValue: (DateValue | null) => void
   timeValue: TimeValue | null
   toggle: () => void
   value: DateValue | null
 }

/@react-stately/datepicker:DateRangePickerState

 DateRangePickerState {
   close: () => void
   commitValidation: () => void
   dateRange: RangeValue<DateValue | null> | null
   defaultValue: DateRange | null
   displayValidation: ValidationResult
   formatValue: (string, FieldOptions) => {
     start: string
   end: string
 } | null
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   hasTime: boolean
   isInvalid: boolean
   isOpen: boolean
   open: () => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   setDate: ('start' | 'end', DateValue | null) => void
   setDateRange: (DateRange) => void
   setDateTime: ('start' | 'end', DateValue | null) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setTime: ('start' | 'end', TimeValue | null) => void
   setTimeRange: (TimeRange) => void
   setValue: (DateRange | null) => void
   timeRange: RangeValue<TimeValue | null> | null
   updateValidation: (ValidationResult) => void
   value: RangeValue<DateValue | null>
 }

@react-stately/menu

/@react-stately/menu:MenuTriggerState

 MenuTriggerState {
   close: () => void
   focusStrategy: FocusStrategy | null
   isOpen: boolean
   open: (FocusStrategy | null) => void
+  point: Point | null
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   toggle: (FocusStrategy | null) => void
 }

/@react-stately/menu:RootMenuTriggerState

 RootMenuTriggerState {
   close: () => void
   closeSubmenu: (Key, number) => void
   expandedKeysStack: Array<Key>
   focusStrategy: FocusStrategy | null
   isOpen: boolean
   open: (FocusStrategy | null) => void
   openSubmenu: (Key, number) => void
+  point: Point | null
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   toggle: (FocusStrategy | null) => void
 }

/@react-stately/menu:SubmenuTriggerState

 SubmenuTriggerState {
   close: () => void
   closeAll: () => void
   focusStrategy: FocusStrategy | null
   isOpen: boolean
   open: (FocusStrategy | null) => void
+  point: Point | null
+  setPoint: (Point) => void
   submenuLevel: number
   toggle: (FocusStrategy | null) => void
 }

@react-stately/overlays

/@react-stately/overlays:OverlayTriggerState

 OverlayTriggerState {
   close: () => void
   isOpen: boolean
   open: () => void
+  point: Point | null
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   toggle: () => void
 }

@react-stately/select

/@react-stately/select:SelectState

 SelectState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commitValidation: () => void
   defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null) => void
+  point: Point | null
   realtimeValidation: ValidationResult
   resetValidation: () => void
   selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setOpen: (boolean) => void
+  setPoint: (Point) => void
   setValue: (Key | readonly Array<Key> | null) => void
   toggle: (FocusStrategy | null) => void
   updateValidation: (ValidationResult) => void
   value: ValueType<SelectionMode>

@rspbot

rspbot commented Jun 19, 2026

Copy link
Copy Markdown

Agent Skills Changes

Added (2)
Modified (8)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/0c375f3ec1c48cbab1f3acafc69956b5ed195ee8/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/0c375f3ec1c48cbab1f3acafc69956b5ed195ee8/

let {contextMenuProps} = useContextMenu({
onContextMenu(e) {
// eslint-disable-next-line rsp-rules/safe-event-target
let rect = e.target.getBoundingClientRect();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to call the safe get target?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the linter is wrong. this is not a dom event. same thing happens with usePress for example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Creating context menus with useMenu

3 participants