Skip to content

Commit 8f151ff

Browse files
Saadnajmiclaude
andauthored
feat(0.81): handle right clicks properly (#2900)
## Summary Backport of #2885 to 0.81-stable. - Add `onAuxClick` event for right-click and middle-click handling on macOS - Add `onClick` event for left single-click on plain Views - Fix `onAuxClick` prop registration in iterator-based setProp path - Override `rightMouseDown:` to prevent context menu modal from stealing `rightMouseUp:` - Add `otherMouseDown:`/`otherMouseUp:` for middle-click support - Prevent Pressable visual press feedback on non-primary mouse buttons - Align with upstream facebook#56298 (shared cross-platform changes) ## Test plan - Left-click on View → fires `onClick` with `button=0` - Double-click on View → fires `onDoubleClick` with `button=0` - Right-click on View → fires `onAuxClick` with `button=2` - Middle-click on View → fires `onAuxClick` with `button=1` - Right-click on Pressable → fires `onAuxClick`, does NOT trigger `onPress` or visual press state 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1efe66b commit 8f151ff

File tree

21 files changed

+303
-8
lines changed

21 files changed

+303
-8
lines changed

docsite/api/view-events.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,34 @@ Example:
126126

127127
---
128128

129+
### `onAuxClick`
130+
131+
Fired when the user clicks on the view with a non-primary button (e.g., right-click or middle-click). This follows the [W3C `auxclick` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event) specification.
132+
133+
**Event Data:** Mouse event with the following properties:
134+
- `clientX`: Horizontal position in the target view
135+
- `clientY`: Vertical position in the target view
136+
- `screenX`: Horizontal position in the window
137+
- `screenY`: Vertical position in the window
138+
- `altKey`: Whether Alt/Option key is pressed
139+
- `ctrlKey`: Whether Control key is pressed
140+
- `shiftKey`: Whether Shift key is pressed
141+
- `metaKey`: Whether Command key is pressed
142+
- `button`: The button number that was pressed (2 for right-click)
143+
144+
Example:
145+
```javascript
146+
<View onAuxClick={(event) => {
147+
console.log('Right clicked, button:', event.nativeEvent.button);
148+
}}>
149+
<Text>Right click me</Text>
150+
</View>
151+
```
152+
153+
> **Note:** Right-clicking a `Pressable` will fire `onAuxClick` but will **not** trigger `onPress`. Only primary (left) button clicks trigger `onPress`.
154+
155+
---
156+
129157
### `onDoubleClick`
130158

131159
Fired when the user double-clicks on the view.
@@ -139,6 +167,7 @@ Fired when the user double-clicks on the view.
139167
- `ctrlKey`: Whether Control key is pressed
140168
- `shiftKey`: Whether Shift key is pressed
141169
- `metaKey`: Whether Command key is pressed
170+
- `button`: The button number that was pressed (0 for left-click)
142171

143172
Example:
144173
```javascript

packages/react-native/Libraries/Components/View/ReactNativeViewAttributes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const UIView = {
4444
mouseDownCanMoveWindow: true,
4545
enableFocusRing: true,
4646
focusable: true,
47+
onAuxClick: true,
4748
onMouseEnter: true,
4849
onMouseLeave: true,
4950
onDoubleClick: true,

packages/react-native/Libraries/Components/View/ViewPropTypes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ type MouseEventProps = $ReadOnly<{
135135

136136
// Experimental/Work in Progress Pointer Event Callbacks (not yet ready for use)
137137
type PointerEventProps = $ReadOnly<{
138+
onAuxClick?: ?(event: PointerEvent) => void,
139+
onAuxClickCapture?: ?(event: PointerEvent) => void,
138140
onClick?: ?(event: PointerEvent) => void,
139141
onClickCapture?: ?(event: PointerEvent) => void,
140142
onPointerEnter?: ?(event: PointerEvent) => void,

packages/react-native/Libraries/NativeComponent/BaseViewConfig.ios.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ const bubblingEventTypes = {
8989
},
9090

9191
// Experimental/Work in Progress Pointer Events (not yet ready for use)
92+
topAuxClick: {
93+
phasedRegistrationNames: {
94+
captured: 'onAuxClickCapture',
95+
bubbled: 'onAuxClick',
96+
},
97+
},
9298
topClick: {
9399
phasedRegistrationNames: {
94100
captured: 'onClickCapture',
@@ -394,6 +400,8 @@ const validAttributesForEventProps = ConditionallyIgnoredEventHandlers({
394400
onTouchCancel: true,
395401

396402
// Pointer events
403+
onAuxClick: true,
404+
onAuxClickCapture: true,
397405
onClick: true,
398406
onClickCapture: true,
399407
onPointerUp: true,

packages/react-native/Libraries/Pressability/Pressability.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,16 @@ export default class Pressability {
410410
_touchActivateTime: ?number;
411411
_touchState: TouchState = 'NOT_RESPONDER';
412412

413+
// [macOS
414+
/**
415+
* Returns true if the button from a press event is the default (primary/left)
416+
* button. A button value of 0 or undefined is considered the default button.
417+
*/
418+
_isDefaultPressButton(button: ?number): boolean {
419+
return !button;
420+
}
421+
// macOS]
422+
413423
constructor(config: PressabilityConfig) {
414424
this.configure(config);
415425
}
@@ -554,6 +564,13 @@ export default class Pressability {
554564
return;
555565
}
556566

567+
// [macOS Only fire onPress for primary (left) mouse button clicks.
568+
// Non-primary buttons (right, middle) should not trigger onPress.
569+
if (!this._isDefaultPressButton(event?.nativeEvent?.button)) {
570+
return;
571+
}
572+
// macOS]
573+
557574
// for non-pointer click events (e.g. accessibility clicks), we should only dispatch when we're the "real" target
558575
// in particular, we shouldn't respond to clicks from nested pressables
559576
if (event?.currentTarget !== event?.target) {
@@ -791,28 +808,45 @@ export default class Pressability {
791808

792809
if (isPressInSignal(prevState) && signal === 'LONG_PRESS_DETECTED') {
793810
const {onLongPress} = this._config;
794-
if (onLongPress != null) {
811+
if (
812+
onLongPress != null &&
813+
this._isDefaultPressButton(
814+
getTouchFromPressEvent(event).button,
815+
) /* [macOS] */
816+
) {
795817
onLongPress(event);
796818
}
797819
}
798820

799821
const isPrevActive = isActiveSignal(prevState);
800822
const isNextActive = isActiveSignal(nextState);
801823

802-
if (!isPrevActive && isNextActive) {
824+
// [macOS Don't activate press visual feedback for non-primary mouse buttons
825+
// (e.g. right-click, middle-click). They should fire onAuxClick, not onPress.
826+
const isPrimaryButton = this._isDefaultPressButton(
827+
getTouchFromPressEvent(event).button,
828+
);
829+
// macOS]
830+
831+
if (!isPrevActive && isNextActive && isPrimaryButton /* [macOS] */) {
803832
this._activate(event);
804833
} else if (isPrevActive && !isNextActive) {
805834
this._deactivate(event);
806835
}
807836

808837
if (isPressInSignal(prevState) && signal === 'RESPONDER_RELEASE') {
809838
// If we never activated (due to delays), activate and deactivate now.
810-
if (!isNextActive && !isPrevActive) {
839+
if (!isNextActive && !isPrevActive && isPrimaryButton /* [macOS] */) {
811840
this._activate(event);
812841
this._deactivate(event);
813842
}
814843
const {onLongPress, onPress, android_disableSound} = this._config;
815-
if (onPress != null) {
844+
if (
845+
onPress != null &&
846+
this._isDefaultPressButton(
847+
getTouchFromPressEvent(event).button,
848+
) /* [macOS] */
849+
) {
816850
const isPressCanceledByLongPress =
817851
onLongPress != null && prevState === 'RESPONDER_ACTIVE_LONG_PRESS_IN';
818852
if (!isPressCanceledByLongPress) {

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,10 +2031,12 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
20312031
enum MouseEventType {
20322032
MouseEnter,
20332033
MouseLeave,
2034+
Click,
20342035
DoubleClick,
2036+
AuxClick,
20352037
};
20362038

2037-
- (void)emitMouseEvent:(MouseEventType)eventType
2039+
- (void)emitMouseEvent:(MouseEventType)eventType button:(int)button
20382040
{
20392041
if (!_eventEmitter) {
20402042
return;
@@ -2054,6 +2056,7 @@ - (void)emitMouseEvent:(MouseEventType)eventType
20542056
.ctrlKey = static_cast<bool>(modifierFlags & NSEventModifierFlagControl),
20552057
.shiftKey = static_cast<bool>(modifierFlags & NSEventModifierFlagShift),
20562058
.metaKey = static_cast<bool>(modifierFlags & NSEventModifierFlagCommand),
2059+
.button = button,
20572060
};
20582061

20592062
switch (eventType) {
@@ -2065,12 +2068,25 @@ - (void)emitMouseEvent:(MouseEventType)eventType
20652068
_eventEmitter->onMouseLeave(mouseEvent);
20662069
break;
20672070

2071+
case Click:
2072+
_eventEmitter->onClick(mouseEvent);
2073+
break;
2074+
20682075
case DoubleClick:
20692076
_eventEmitter->onDoubleClick(mouseEvent);
20702077
break;
2078+
2079+
case AuxClick:
2080+
_eventEmitter->onAuxClick(mouseEvent);
2081+
break;
20712082
}
20722083
}
20732084

2085+
- (void)emitMouseEvent:(MouseEventType)eventType
2086+
{
2087+
[self emitMouseEvent:eventType button:0];
2088+
}
2089+
20742090
- (void)updateMouseOverIfNeeded
20752091
{
20762092
// When an enclosing scrollview is scrolled using the scrollWheel or trackpad,
@@ -2185,12 +2201,55 @@ - (void)mouseExited:(NSEvent *)event
21852201
- (void)mouseUp:(NSEvent *)event
21862202
{
21872203
BOOL hasDoubleClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::DoubleClick];
2204+
BOOL hasClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::Click];
21882205
if (hasDoubleClickEventHandler && event.clickCount == 2) {
2189-
[self emitMouseEvent :DoubleClick];
2206+
[self emitMouseEvent:DoubleClick];
2207+
} else if (hasClickEventHandler && event.clickCount == 1) {
2208+
[self emitMouseEvent:Click];
21902209
} else {
21912210
[super mouseUp:event];
21922211
}
21932212
}
2213+
2214+
- (void)rightMouseDown:(NSEvent *)event
2215+
{
2216+
// Accept rightMouseDown to prevent the default NSView behavior of passing it
2217+
// up the responder chain (which can trigger a context menu modal loop that
2218+
// consumes rightMouseUp).
2219+
BOOL hasAuxClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::AuxClick];
2220+
if (!hasAuxClickEventHandler) {
2221+
[super rightMouseDown:event];
2222+
}
2223+
}
2224+
2225+
- (void)rightMouseUp:(NSEvent *)event
2226+
{
2227+
BOOL hasAuxClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::AuxClick];
2228+
if (hasAuxClickEventHandler) {
2229+
[self emitMouseEvent:AuxClick button:2];
2230+
} else {
2231+
[super rightMouseUp:event];
2232+
}
2233+
}
2234+
2235+
- (void)otherMouseDown:(NSEvent *)event
2236+
{
2237+
// Accept otherMouseDown so that otherMouseUp is delivered to this view.
2238+
BOOL hasAuxClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::AuxClick];
2239+
if (!hasAuxClickEventHandler) {
2240+
[super otherMouseDown:event];
2241+
}
2242+
}
2243+
2244+
- (void)otherMouseUp:(NSEvent *)event
2245+
{
2246+
BOOL hasAuxClickEventHandler = _props->hostPlatformEvents[HostPlatformViewEvents::Offset::AuxClick];
2247+
if (hasAuxClickEventHandler) {
2248+
[self emitMouseEvent:AuxClick button:1];
2249+
} else {
2250+
[super otherMouseUp:event];
2251+
}
2252+
}
21942253
#endif // macOS]
21952254

21962255
- (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point

packages/react-native/React/Fabric/RCTSurfacePointerHandler.mm

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,12 @@ - (void)_dispatchActivePointers:(std::vector<ActivePointer>)activePointers event
703703
}
704704
case RCTPointerEventTypeEnd: {
705705
eventEmitter->onPointerUp(pointerEvent);
706-
if (pointerEvent.isPrimary && pointerEvent.button == 0 && IsPointerWithinInitialTree(activePointer)) {
707-
eventEmitter->onClick(std::move(pointerEvent));
706+
if (pointerEvent.isPrimary && pointerEvent.button == 0) {
707+
if (IsPointerWithinInitialTree(activePointer)) {
708+
eventEmitter->onClick(std::move(pointerEvent));
709+
}
710+
} else if (IsPointerWithinInitialTree(activePointer)) {
711+
eventEmitter->onAuxClick(std::move(pointerEvent));
708712
}
709713
break;
710714
}

packages/react-native/React/Views/RCTView.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait;
145145
/**
146146
* (Experimental and unused for Paper) Pointer event handlers.
147147
*/
148+
@property (nonatomic, assign) RCTBubblingEventBlock onAuxClick;
148149
@property (nonatomic, assign) RCTBubblingEventBlock onClick;
149150
@property (nonatomic, assign) RCTBubblingEventBlock onPointerCancel;
150151
@property (nonatomic, assign) RCTBubblingEventBlock onPointerDown;

packages/react-native/React/Views/RCTViewManager.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ - (void) updateAccessibilityRole:(RCTView *)view withDefaultView:(RCTView *)defa
882882
RCT_CUSTOM_VIEW_PROPERTY(onTouchCancel, BOOL, RCTView) {}
883883

884884
// Experimental/WIP Pointer Events (not yet ready for use)
885+
RCT_EXPORT_VIEW_PROPERTY(onAuxClick, RCTBubblingEventBlock)
885886
RCT_EXPORT_VIEW_PROPERTY(onClick, RCTBubblingEventBlock)
886887
RCT_EXPORT_VIEW_PROPERTY(onPointerCancel, RCTBubblingEventBlock)
887888
RCT_EXPORT_VIEW_PROPERTY(onPointerDown, RCTBubblingEventBlock)

packages/react-native/ReactCommon/react/renderer/components/view/TouchEventEmitter.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ void TouchEventEmitter::onTouchCancel(TouchEvent event) const {
8686
"touchCancel", std::move(event), RawEvent::Category::ContinuousEnd);
8787
}
8888

89+
void TouchEventEmitter::onAuxClick(PointerEvent event) const {
90+
dispatchPointerEvent("auxClick", std::move(event), RawEvent::Category::Discrete);
91+
}
92+
8993
void TouchEventEmitter::onClick(PointerEvent event) const {
9094
dispatchPointerEvent("click", std::move(event), RawEvent::Category::Discrete);
9195
}

0 commit comments

Comments
 (0)