import React, { forwardRef } from 'react' import { TypeGuards } from '@codeleap/types' import { onMount, useComponentTestId } from '@codeleap/hooks' import { Pressable, StyleSheet, View as RNView, Insets, Platform } from 'react-native' import { View } from '../View' import { TouchableFeedbackConfig, usePressableFeedback } from '../../utils' import { Keyboard } from 'react-native' import { PressableRipple } from '../../modules/PressableRipple' import { AnyRecord, IJSX, StyledComponentProps, StyledComponentWithProps } from '@codeleap/styles' import { TouchableProps } from './types' import { MobileStyleRegistry } from '../../Registry' import { useStylesFor } from '../../hooks' import { logger } from '@codeleap/logger' export * from './styles' export * from './types' export const Touchable = forwardRef((touchableProps, ref) => { const { children, onPress, style, debugName, debugComponent, debounce, leadingDebounce, noFeedback, setPressed, rippleDisabled, analyticsEnabled, analyticsName, analyticsData = {}, dismissKeyboard, ...props } = { ...Touchable.defaultProps, ...touchableProps, } // Start locked when leadingDebounce is true so the very first render is immune to ghost taps // (e.g. a list item that mounts directly under the user's finger on Android). const pressed = React.useRef(!!leadingDebounce) onMount(() => { if (!!leadingDebounce && !!debounce) { setTimeout(() => { pressed.current = false }, debounce) } }) const styles = useStylesFor(Touchable.styleRegistryName, style) const testId = useComponentTestId(Touchable, touchableProps, ['style', 'children', 'debounce']) const press = () => { if (!onPress) return const _onPress = () => { logger.log( `<${debugComponent || 'Touchable'}/> pressed:`, debugName, ) if (dismissKeyboard) { Keyboard.dismiss() } // if (analyticsEnabled) { // const name = analyticsName || debugName // if (!!name?.trim?.()) { // logger.analytics?.interaction(name, analyticsData) // } // } onPress() } if (TypeGuards.isNumber(debounce)) { if (pressed.current) { return } setPressed?.(true) pressed.current = true _onPress() setTimeout(() => { setPressed?.(false) pressed.current = false }, debounce) } else { _onPress() } } const _styles = StyleSheet.flatten([styles?.wrapper, props?.disabled && styles?.['wrapper:disabled']]) const disableFeedback = !onPress || noFeedback const { rippleConfig, getFeedbackStyle } = usePressableFeedback(_styles, { hightlightPropertyIn: 'backgroundColor', hightlightPropertyOut: 'backgroundColor', disabled: disableFeedback, feedbackConfig: styles?.feedback as TouchableFeedbackConfig, }) const Wrapper = View // Style properties are split between the outer View (wrapperStyle) and the inner Pressable/Ripple // (pressableStyle) so that margin/position are owned by the layout parent while the pressable // touch area matches the visual surface. Radius is copied to both so clipping stays consistent. const { radiusStyle, wrapperStyle, pressableStyle } = React.useMemo(() => { const wrapperkeys = [ 'margin', 'alignSelf', 'top!', 'left!', 'right!', 'bottom!', 'position!', 'transform!', ] const radiusKey = [ 'Radius#', ] const sharedKeys = [ 'width!', 'height!', 'flex!', 'backgroundColor!', ] const wrapperStyle = {} as any const pressableStyle = {} as any const radiusStyle = {} as any const match = (k, key) => { if (k.endsWith('#')) { return key.includes(k.substring(0, k.length - 1)) } else if (k.endsWith('!')) { return key === k.substring(0, k.length - 1) } else { return key.startsWith(k) } } Object.entries(_styles).forEach(([key, value]) => { if (radiusKey.some(k => match(k, key))) { wrapperStyle[key] = value pressableStyle[key] = value radiusStyle[key] = value return } if (wrapperkeys.some(k => match(k, key))) { wrapperStyle[key] = value } else if (sharedKeys.some(k => match(k, key))) { wrapperStyle[key] = value pressableStyle[key] = value } else { pressableStyle[key] = value } }) if (wrapperStyle.position === 'absolute') { pressableStyle.width = '100%' pressableStyle.height = '100%' } wrapperStyle.overflow = 'visible' return { wrapperStyle, pressableStyle, radiusStyle, } }, [JSON.stringify(_styles)]) const hitSlop = TypeGuards.isNumber(props.hitSlop) ? { top: props.hitSlop, left: props.hitSlop, right: props.hitSlop, bottom: props.hitSlop, } as Insets : props.hitSlop // Ripple is Android-only; iOS uses Pressable's built-in opacity/color feedback via getFeedbackStyle. const disableRipple = disableFeedback || rippleDisabled || Platform.OS !== 'android' return ( {!disableRipple ? ( {children} ) : ( ([ pressableStyle, getFeedbackStyle(pressed), styles?.pressable, ])} testID={testId} {...props} ref={ref} > {children} )} ) }) as StyledComponentWithProps Touchable.styleRegistryName = 'Touchable' Touchable.elements = ['wrapper', 'feedback', 'pressable'] Touchable.rootElement = 'wrapper' Touchable.withVariantTypes = (styles: S) => { return Touchable as (props: StyledComponentProps) => IJSX } Touchable.defaultProps = { debounce: 500, noFeedback: false, rippleDisabled: false, analyticsEnabled: false, analyticsName: null, dismissKeyboard: true, } as Partial MobileStyleRegistry.registerComponent(Touchable)