diff --git a/packages/react-native-gesture-handler/src/ActionType.ts b/packages/react-native-gesture-handler/src/ActionType.ts index 07071aa2dd..ea4d5e38ec 100644 --- a/packages/react-native-gesture-handler/src/ActionType.ts +++ b/packages/react-native-gesture-handler/src/ActionType.ts @@ -1,4 +1,5 @@ export const ActionType = { + NONE: 0, REANIMATED_WORKLET: 1, NATIVE_ANIMATED_EVENT: 2, JS_FUNCTION_OLD_API: 3, diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 6140bd2b68..cf1031569f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -2,13 +2,21 @@ import * as React from 'react'; import type { ColorValue, NativeSyntheticEvent, ViewProps } from 'react-native'; import { View } from 'react-native'; +import { ActionType } from '../ActionType'; +import RNGestureHandlerModule from '../RNGestureHandlerModule.web'; +import type { ButtonEvent } from '../specs/RNGestureHandlerButtonNativeComponent'; +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; +import type { PropsRef } from '../web/interfaces'; import { NativeGestureRole } from '../web/interfaces'; +import { ButtonEventName } from '../web/tools/ButtonEvents'; import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; const prefersReducedMotion = (): boolean => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; +const noopGestureEvent = () => undefined; + type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; @@ -28,6 +36,34 @@ type ButtonProps = ViewProps & { defaultScale?: number; defaultUnderlayOpacity?: number; underlayColor?: ColorValue; + hasLongPressHandler?: boolean; + moduleId?: number; + handlerTag?: number; + cancelOnLeave?: boolean; + gestureTestID?: string; + gestureHitSlop?: + | { + top?: number; + left?: number; + bottom?: number; + right?: number; + } + | undefined; + onButtonPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonPressIn?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonPressOut?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonLongPress?: + | ((event: NativeSyntheticEvent) => void) + | undefined; + onButtonInteractionFinished?: + | ((event: NativeSyntheticEvent) => void) + | undefined; }; export const ButtonComponent = ({ @@ -49,6 +85,17 @@ export const ButtonComponent = ({ defaultScale = 1, defaultUnderlayOpacity = 0, underlayColor, + hasLongPressHandler = false, + moduleId: _moduleId, + handlerTag, + cancelOnLeave = true, + gestureTestID, + gestureHitSlop, + onButtonPress, + onButtonPressIn, + onButtonPressOut, + onButtonLongPress, + onButtonInteractionFinished, style, children, ...rest @@ -69,6 +116,37 @@ export const ButtonComponent = ({ ); const gestureEnabledRef = React.useRef(true); const viewRef = React.useRef(null); + const gesturePropsRef = React.useRef({ + // Managed button handlers dispatch their events through DOM CustomEvents, + // so ActionType.NONE prevents these callbacks from being used. + // They remain present because the web module shares its attachment API + // with regular JS-driven gesture handlers. + onGestureHandlerEvent: noopGestureEvent, + onGestureHandlerStateChange: noopGestureEvent, + onGestureHandlerTouchEvent: noopGestureEvent, + }); + const managedGestureConfigRef = React.useRef({ + enabled, + shouldCancelWhenOutside: cancelOnLeave, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + testID: gestureTestID, + hitSlop: gestureHitSlop, + hasLongPressHandler, + longPressDuration, + }); + managedGestureConfigRef.current = { + enabled, + shouldCancelWhenOutside: cancelOnLeave, + shouldActivateOnStart: false, + disallowInterruption: true, + yieldsToContinuousGestures: true, + testID: gestureTestID, + hitSlop: gestureHitSlop, + hasLongPressHandler, + longPressDuration, + }; const setRef = React.useCallback( (node: React.ComponentRef | null) => { @@ -82,7 +160,7 @@ export const ButtonComponent = ({ [externalRef] ); - React.useEffect(() => { + useIsomorphicLayoutEffect(() => { const node = viewRef.current; const handleGestureBegan = () => { @@ -120,12 +198,22 @@ export const ButtonComponent = ({ }, []); const pressIn = React.useCallback( - (event: NativeSyntheticEvent) => { - if (!enabled || !gestureEnabledRef.current) { + (event?: NativeSyntheticEvent) => { + const isManagedButtonEvent = event === undefined; + + if (!enabled || (!isManagedButtonEvent && !gestureEnabledRef.current)) { return; } - event.stopPropagation(); + // Managed button events are emitted before the Began lifecycle event. + // Unmanaged buttons are still driven by their wrapping native gesture, + // so they must keep honoring its cancellation state. + if (isManagedButtonEvent) { + gestureEnabledRef.current = true; + } + + event?.stopPropagation(); + if (pressOutTimer.current != null) { clearTimeout(pressOutTimer.current); pressOutTimer.current = null; @@ -138,7 +226,7 @@ export const ButtonComponent = ({ ); const pressOut = React.useCallback( - (event: NativeSyntheticEvent) => { + (event?: NativeSyntheticEvent) => { // Only release if a press-in was actually recorded — guards against // stray pointer events and lets us complete the release cycle even if // `enabled` flipped to false between press-in and press-out. @@ -146,7 +234,8 @@ export const ButtonComponent = ({ return; } - event.stopPropagation(); + event?.stopPropagation(); + if (pressOutTimer.current != null) { clearTimeout(pressOutTimer.current); pressOutTimer.current = null; @@ -187,6 +276,108 @@ export const ButtonComponent = ({ ] ); + useIsomorphicLayoutEffect(() => { + const node = viewRef.current; + if (handlerTag === undefined || node == null) { + return; + } + + const wrapEvent = (event: Event): NativeSyntheticEvent => + ({ + nativeEvent: (event as CustomEvent).detail, + }) as NativeSyntheticEvent; + + const handlePress = (event: Event) => { + onButtonPress?.(wrapEvent(event)); + }; + const handlePressIn = (event: Event) => { + pressIn(); + onButtonPressIn?.(wrapEvent(event)); + }; + const handlePressOut = (event: Event) => { + pressOut(); + onButtonPressOut?.(wrapEvent(event)); + }; + const handleLongPress = (event: Event) => { + onButtonLongPress?.(wrapEvent(event)); + }; + const handleInteractionFinished = (event: Event) => { + onButtonInteractionFinished?.(wrapEvent(event)); + }; + + node.addEventListener(ButtonEventName.Press, handlePress); + node.addEventListener(ButtonEventName.PressIn, handlePressIn); + node.addEventListener(ButtonEventName.PressOut, handlePressOut); + node.addEventListener(ButtonEventName.LongPress, handleLongPress); + node.addEventListener( + ButtonEventName.InteractionFinished, + handleInteractionFinished + ); + + return () => { + node.removeEventListener(ButtonEventName.Press, handlePress); + node.removeEventListener(ButtonEventName.PressIn, handlePressIn); + node.removeEventListener(ButtonEventName.PressOut, handlePressOut); + node.removeEventListener(ButtonEventName.LongPress, handleLongPress); + node.removeEventListener( + ButtonEventName.InteractionFinished, + handleInteractionFinished + ); + }; + }, [ + handlerTag, + onButtonInteractionFinished, + onButtonLongPress, + onButtonPress, + onButtonPressIn, + onButtonPressOut, + pressIn, + pressOut, + ]); + + useIsomorphicLayoutEffect(() => { + const node = viewRef.current; + if (handlerTag === undefined || node === null) { + return; + } + + RNGestureHandlerModule.createGestureHandler( + 'NativeViewGestureHandler', + handlerTag, + managedGestureConfigRef.current + ); + RNGestureHandlerModule.attachGestureHandler( + handlerTag, + node, + ActionType.NONE, + gesturePropsRef + ); + + return () => { + RNGestureHandlerModule.detachGestureHandler(handlerTag); + RNGestureHandlerModule.dropGestureHandler(handlerTag); + }; + }, [handlerTag]); + + useIsomorphicLayoutEffect(() => { + if (handlerTag === undefined) { + return; + } + + RNGestureHandlerModule.setGestureHandlerConfig( + handlerTag, + managedGestureConfigRef.current + ); + }, [ + cancelOnLeave, + enabled, + gestureHitSlop, + gestureTestID, + handlerTag, + hasLongPressHandler, + longPressDuration, + ]); + const handlePointerEnter = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { if (!enabled || event.nativeEvent.pointerType === 'touch') { @@ -203,7 +394,9 @@ export const ButtonComponent = ({ const handlePointerLeave = React.useCallback( (event: NativeSyntheticEvent<{ pointerType?: string }>) => { - pressOut(event); + if (handlerTag === undefined) { + pressOut(event); + } if (event.nativeEvent.pointerType === 'touch') { return; } @@ -212,7 +405,7 @@ export const ButtonComponent = ({ } setHovered(false); }, - [pressOut, pressed, hoverAnimationOutDuration] + [handlerTag, hoverAnimationOutDuration, pressOut, pressed] ); // Mask hover at render rather than clearing the state. Avoids a state @@ -268,9 +461,9 @@ export const ButtonComponent = ({ }, ]} onPointerEnter={handlePointerEnter} - onPointerDown={pressIn} - onPointerUp={pressOut} - onPointerCancel={pressOut} + onPointerDown={handlerTag === undefined ? pressIn : undefined} + onPointerUp={handlerTag === undefined ? pressOut : undefined} + onPointerCancel={handlerTag === undefined ? pressOut : undefined} onPointerLeave={handlePointerLeave}> {hasUnderlay && ( = 0 ? value : 0; -} - -function resolveAnimationDuration(value: AnimationDuration | undefined) { - if (value === undefined) { - return { - tapAnimationInDuration: DEFAULT_IN_DURATION_MS, - tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - hoverAnimationInDuration: DEFAULT_IN_DURATION_MS, - hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS, - }; - } - - if (typeof value === 'number') { - const sanitized = sanitizeDuration(value); - return { - tapAnimationInDuration: sanitized, - tapAnimationOutDuration: sanitized, - longPressAnimationOutDuration: sanitized, - hoverAnimationInDuration: sanitized, - hoverAnimationOutDuration: sanitized, - }; - } - - // The union guarantees variant 2 supplies top-level `in`/`out`, variant 3 - // supplies both category objects — so per-category fallback to base is - // always defined for well-typed input; the 0 fallbacks here are unreachable. - const baseIn = 'in' in value ? value.in : 0; - const baseOut = 'out' in value ? value.out : 0; - const tapOut = value.tap?.out ?? baseOut; - - return { - tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn), - tapAnimationOutDuration: sanitizeDuration(tapOut), - longPressAnimationOutDuration: sanitizeDuration( - value.longPress?.out ?? tapOut - ), - hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn), - hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut), - }; -} - -export const Touchable = (props: TouchableProps) => { - const { - underlayColor = 'transparent', - defaultUnderlayOpacity = 0, - activeUnderlayOpacity = 0.105, - defaultOpacity = 1, - animationDuration, - androidRipple, - delayLongPress = 600, - onLongPress, - onPress, - onPressIn, - onPressOut, - children, - disabled = false, - cancelOnLeave = true, - ref, - ...rest - } = props; - - const resolvedDurations = resolveAnimationDuration(animationDuration); - const resolvedDelayLongPress = sanitizeDuration(delayLongPress); - - const shouldUseNativeRipple = isAndroid && androidRipple !== undefined; - - const pointerState = useRef(PointerState.UNKNOWN); - const longPressDetected = useRef(false); - const longPressTimeout = useRef | undefined>( - undefined - ); - // The last event observed by the gesture, used as the payload of the - // long-press callback since it fires from a timer rather than an event. - const lastEvent = useRef(null); - - // Swallow the tap that dismisses the keyboard in - // keyboardShouldPersistTaps="never", matching RN's touchables. - const jsResponderContext = use(JSResponderContext); - const dropKeyboardTapRef = useRef(null); - - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); - - const resetKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current = null; - }, []); - - const wrappedLongPress = useCallback(() => { - longPressDetected.current = true; - if (lastEvent.current) { - onLongPress?.(lastEvent.current); - } - }, [onLongPress]); - - const startLongPressTimer = useCallback(() => { - longPressDetected.current = false; - - if (onLongPress && !longPressTimeout.current) { - longPressTimeout.current = setTimeout( - wrappedLongPress, - resolvedDelayLongPress - ); - } - }, [onLongPress, resolvedDelayLongPress, wrappedLongPress]); - - const onBegin = useCallback( - (e: CallbackEventType) => { - captureKeyboardDismiss(); - lastEvent.current = e; - - if (!e.pointerInside || dropKeyboardTapRef.current) { - pointerState.current = PointerState.OUTSIDE; - return; - } - - onPressIn?.(e); - startLongPressTimer(); - - pointerState.current = PointerState.INSIDE; - }, - [captureKeyboardDismiss, startLongPressTimer, onPressIn] - ); - - const onActivate = useCallback((e: CallbackEventType) => { - if (!e.pointerInside && longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - }, []); - - const onFinalize = useCallback( - (e: EndCallbackEventType) => { - if ( - !dropKeyboardTapRef.current && - pointerState.current === PointerState.INSIDE - ) { - onPressOut?.(e); - } - - if ( - !dropKeyboardTapRef.current && - !e.canceled && - !longPressDetected.current && - e.pointerInside - ) { - onPress?.(e); - } - - pointerState.current = PointerState.UNKNOWN; - lastEvent.current = null; - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - - resetKeyboardDismiss(); - }, - [resetKeyboardDismiss, onPressOut, onPress] - ); - - const onUpdate = useCallback( - (e: CallbackEventType) => { - lastEvent.current = e; - - if (dropKeyboardTapRef.current) { - return; - } - - if (pointerState.current === PointerState.UNKNOWN) { - return; - } - - if (e.pointerInside) { - if (pointerState.current === PointerState.OUTSIDE) { - onPressIn?.(e); - } - pointerState.current = PointerState.INSIDE; - } else { - if (pointerState.current === PointerState.INSIDE) { - onPressOut?.(e); - - if (longPressTimeout.current !== undefined) { - clearTimeout(longPressTimeout.current); - longPressTimeout.current = undefined; - } - } - pointerState.current = PointerState.OUTSIDE; - } - }, - [onPressIn, onPressOut] - ); - - const nativeGesture = useNativeGesture({ - onBegin, - onActivate, - onFinalize, - onUpdate, - hitSlop: props.hitSlop, - testID: props.testID, - enabled: !disabled, - shouldCancelWhenOutside: cancelOnLeave, - disableReanimated: true, - shouldActivateOnStart: false, - disallowInterruption: true, - yieldsToContinuousGestures: true, - }); - - const rippleProps = shouldUseNativeRipple - ? { - rippleColor: androidRipple?.color, - rippleRadius: androidRipple?.radius, - borderless: androidRipple?.borderless, - foreground: androidRipple?.foreground, - } - : TRANSPARENT_RIPPLE; - - const tvProps = getTVProps(rest); - - return ( - - - {children} - - - ); -}; diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index b7888aee9a..50ed36b95f 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -144,7 +144,12 @@ export default abstract class GestureHandler implements IGestureHandler { manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); manager.setOnWheel(this.onWheel.bind(this)); - manager.registerListeners(); + // The initial config is applied before the handler is attached. Honor an + // initially disabled handler here because the delegate's enabled-change + // callback cannot update event managers until attachment has finished. + if (this.enabled) { + manager.registerListeners(); + } } // @@ -445,6 +450,10 @@ export default abstract class GestureHandler implements IGestureHandler { // public sendEvent = (newState: State, oldState: State): void => { + if (this.actionType === ActionType.NONE) { + return; + } + const { onGestureHandlerEvent, onGestureHandlerStateChange, diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 92738c14c8..9cd638d4e0 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -1,6 +1,7 @@ import { Platform } from 'react-native'; -import { type ActionType } from '../../ActionType'; +import { ActionType } from '../../ActionType'; +import type { ButtonEvent } from '../../specs/RNGestureHandlerButtonNativeComponent'; import { State } from '../../State'; import type { NativeHandlerData } from '../../v3/hooks/gestures/native/NativeTypes'; import type { HandlerData } from '../../v3/types'; @@ -16,6 +17,11 @@ import type { PropsRef, } from '../interfaces'; import { NativeGestureRole } from '../interfaces'; +import { + ButtonEventName, + type ButtonEventTypeName, + dispatchButtonEvent, +} from '../tools/ButtonEvents'; import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; import { dispatchGestureLifecycleEvent, @@ -39,6 +45,12 @@ export default class NativeViewGestureHandler extends GestureHandler { private lastActiveHandlerData: HandlerData | null = null; + private hasLongPressHandler = false; + private longPressDuration = -1; + private longPressDetected = false; + private lastEventWasInside = false; + private pendingLongPress: ReturnType | null = null; + public constructor( delegate: GestureHandlerDelegate ) { @@ -90,6 +102,12 @@ export default class NativeViewGestureHandler extends GestureHandler { if (config.yieldsToContinuousGestures !== undefined) { this.yieldsToContinuousGestures = config.yieldsToContinuousGestures; } + if (config.hasLongPressHandler !== undefined) { + this.hasLongPressHandler = config.hasLongPressHandler; + } + if (config.longPressDuration !== undefined) { + this.longPressDuration = config.longPressDuration; + } const view = this.delegate.view as HTMLElement; this.restoreViewStyles(view); @@ -234,6 +252,7 @@ export default class NativeViewGestureHandler extends GestureHandler { } public override detach(): void { + this.clearLongPressTimer(); super.detach(); this.role = null; } @@ -257,6 +276,10 @@ export default class NativeViewGestureHandler extends GestureHandler { return this.role === NativeGestureRole.Button; } + private isManagedButton(): boolean { + return this.isButton() && this.actionType === ActionType.NONE; + } + public override shouldBeginWithRecordedHandlers( recorded: IGestureHandler[] ): boolean { @@ -282,6 +305,88 @@ export default class NativeViewGestureHandler extends GestureHandler { ); } + protected override onStateChange(newState: State): void { + if (!this.isManagedButton()) { + return; + } + + if (newState === State.BEGAN) { + if (!this.getButtonEventData().pointerInside) { + return; + } + + this.dispatchButtonEvent(ButtonEventName.PressIn); + this.longPressDetected = false; + + if (this.hasLongPressHandler && this.longPressDuration >= 0) { + this.pendingLongPress = setTimeout(() => { + this.pendingLongPress = null; + this.longPressDetected = true; + this.dispatchButtonEvent(ButtonEventName.LongPress); + }, this.longPressDuration); + } + return; + } + + if ( + newState !== State.END && + newState !== State.FAILED && + newState !== State.CANCELLED + ) { + return; + } + + const endedInside = this.lastEventWasInside; + + if (endedInside) { + this.dispatchButtonEvent(ButtonEventName.PressOut); + } + this.clearLongPressTimer(); + + if (newState === State.END && !this.longPressDetected && endedInside) { + this.dispatchButtonEvent(ButtonEventName.Press); + } + + this.dispatchButtonEvent(ButtonEventName.InteractionFinished); + this.longPressDetected = false; + } + + private dispatchButtonEvent(name: ButtonEventTypeName): void { + if (name === ButtonEventName.PressIn) { + this.lastEventWasInside = true; + } else if (name === ButtonEventName.PressOut) { + this.lastEventWasInside = false; + } + + dispatchButtonEvent( + this.delegate.view as HTMLElement | null, + name, + this.getButtonEventData() + ); + } + + private getButtonEventData(): ButtonEvent { + const absolute = this.tracker.getAbsoluteCoordsAverage(); + const relative = this.tracker.getRelativeCoordsAverage(); + + return { + pointerInside: this.delegate.isPointerInBounds(absolute), + x: relative.x, + y: relative.y, + absoluteX: absolute.x, + absoluteY: absolute.y, + numberOfPointers: this.tracker.trackedPointersCount, + pointerType: this.pointerType, + }; + } + + private clearLongPressTimer(): void { + if (this.pendingLongPress !== null) { + clearTimeout(this.pendingLongPress); + this.pendingLongPress = null; + } + } + protected override transformNativeEvent(): Record { const absolute = this.tracker.getAbsoluteCoordsAverage(); const relative = this.tracker.getRelativeCoordsAverage(); @@ -317,7 +422,15 @@ export default class NativeViewGestureHandler extends GestureHandler { } public override reset(): void { + this.clearLongPressTimer(); super.reset(); this.lastActiveHandlerData = null; + this.lastEventWasInside = false; + this.longPressDetected = false; + } + + public override onDestroy(): void { + this.clearLongPressTimer(); + super.onDestroy(); } } diff --git a/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts new file mode 100644 index 0000000000..1824c16523 --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts @@ -0,0 +1,44 @@ +import EventManager from '../../tools/EventManager'; +import type { GestureHandlerDelegate } from '../../tools/GestureHandlerDelegate'; +import GestureHandler from '../GestureHandler'; +import type IGestureHandler from '../IGestureHandler'; + +class TestGestureHandler extends GestureHandler {} + +class TestEventManager extends EventManager { + public registerListeners = jest.fn(); + public unregisterListeners = jest.fn(); + + protected mapEvent(): never { + throw new Error('Test event manager does not map events'); + } +} + +function createHandler(enabled: boolean) { + const delegate = { + onEnabledChange: jest.fn(), + } as unknown as GestureHandlerDelegate; + const handler = new TestGestureHandler(delegate); + + handler.setGestureConfig({ enabled }); + + return handler; +} + +describe('GestureHandler web event manager attachment', () => { + test('registers listeners for an initially enabled handler', () => { + const manager = new TestEventManager({}); + + createHandler(true).attachEventManager(manager); + + expect(manager.registerListeners).toHaveBeenCalledTimes(1); + }); + + test('does not register listeners for an initially disabled handler', () => { + const manager = new TestEventManager({}); + + createHandler(false).attachEventManager(manager); + + expect(manager.registerListeners).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-gesture-handler/src/web/interfaces.ts b/packages/react-native-gesture-handler/src/web/interfaces.ts index 069ba0b1f1..626a2add2c 100644 --- a/packages/react-native-gesture-handler/src/web/interfaces.ts +++ b/packages/react-native-gesture-handler/src/web/interfaces.ts @@ -91,6 +91,8 @@ export interface Config extends Record { shouldActivateOnStart?: boolean; disallowInterruption?: boolean; yieldsToContinuousGestures?: boolean; + hasLongPressHandler?: boolean; + longPressDuration?: number; direction?: Directions; enableTrackpadTwoFingerGesture?: boolean; } diff --git a/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts new file mode 100644 index 0000000000..6512c841df --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/tools/ButtonEvents.ts @@ -0,0 +1,20 @@ +import type { ButtonEvent } from '../../specs/RNGestureHandlerButtonNativeComponent'; + +export const ButtonEventName = { + Press: 'gh:buttonPress', + PressIn: 'gh:buttonPressIn', + PressOut: 'gh:buttonPressOut', + LongPress: 'gh:buttonLongPress', + InteractionFinished: 'gh:buttonInteractionFinished', +} as const; + +export type ButtonEventTypeName = + (typeof ButtonEventName)[keyof typeof ButtonEventName]; + +export function dispatchButtonEvent( + view: HTMLElement | null | undefined, + name: ButtonEventTypeName, + event: ButtonEvent +): void { + view?.dispatchEvent(new CustomEvent(name, { detail: event })); +}