react-native-molecules 0.5.0-beta.4 → 0.5.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,11 @@ import { useUnistyles } from 'react-native-unistyles';
4
4
 
5
5
  import type { MD3Elevation } from '../../types/theme';
6
6
  import { extractPropertiesFromStyles } from '../../utils/extractPropertiesFromStyles';
7
+ import { Slot } from '../Slot';
7
8
  import { BackgroundContextWrapper } from './BackgroundContextWrapper';
8
- import { defaultStyles, getStyleForShadowLayer } from './utils';
9
+ import { defaultStyles, getCombinedShadowStyle } from './utils';
10
+
11
+ const AnimatedView = Animated.createAnimatedComponent(View);
9
12
 
10
13
  export type Props = ComponentPropsWithRef<typeof View> & {
11
14
  /**
@@ -27,6 +30,23 @@ export type Props = ComponentPropsWithRef<typeof View> & {
27
30
  * TestID used for testing purposes
28
31
  */
29
32
  testID?: string;
33
+ /**
34
+ * When `true`, the component will not render a wrapper element. Instead, it will
35
+ * merge its props (styles, elevation shadow, ref) onto its immediate child element.
36
+ * This follows the Radix UI "Slot" pattern for flexible component composition.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * // With asChild - merges elevation styles onto the child
41
+ * <Surface asChild elevation={2}>
42
+ * <Card><Text>Content</Text></Card>
43
+ * </Surface>
44
+ * ```
45
+ *
46
+ * @note When `asChild` is `true`, only a single child element is allowed.
47
+ * @default false
48
+ */
49
+ asChild?: boolean;
30
50
  };
31
51
 
32
52
  /**
@@ -71,53 +91,38 @@ export type Props = ComponentPropsWithRef<typeof View> & {
71
91
  * });
72
92
  * ```
73
93
  */
74
- const Surface = ({ elevation = 1, style, children, testID, ...props }: Props, ref: any) => {
94
+ const Surface = (
95
+ { elevation = 1, style, children, testID, asChild = false, ...props }: Props,
96
+ ref: any,
97
+ ) => {
75
98
  const { theme } = useUnistyles();
76
99
  const backgroundColor = (() => {
77
100
  // @ts-ignore
78
101
  return theme.colors.elevation?.[`level${elevation}`];
79
102
  })();
80
103
 
81
- const { surfaceBackground, sharedStyle, layer0Style, layer1Style } = useMemo(() => {
82
- const { position, alignSelf, top, left, right, bottom, borderRadius } =
83
- extractPropertiesFromStyles(
84
- [defaultStyles.root as ViewStyle, style],
85
- ['position', 'alignSelf', 'top', 'left', 'right', 'bottom', 'borderRadius'],
86
- );
87
- const absoluteStyle = { position, alignSelf, top, right, bottom, left };
88
-
104
+ const { surfaceBackground, combinedStyle } = useMemo(() => {
89
105
  return {
90
106
  surfaceBackground: extractPropertiesFromStyles(
91
107
  [defaultStyles.root as ViewStyle, style],
92
108
  ['backgroundColor'],
93
109
  ).backgroundColor,
94
- sharedStyle: [
95
- { backgroundColor, borderRadius },
110
+ combinedStyle: [
111
+ { backgroundColor },
112
+ getCombinedShadowStyle(elevation),
96
113
  defaultStyles.root,
97
114
  style,
98
- {
99
- position: undefined,
100
- alignSelf: undefined,
101
- top: undefined,
102
- left: undefined,
103
- right: undefined,
104
- bottom: undefined,
105
- },
106
115
  ],
107
- layer0Style: [getStyleForShadowLayer(0, elevation), absoluteStyle, { borderRadius }],
108
- layer1Style: [getStyleForShadowLayer(1, elevation), { borderRadius }],
109
116
  };
110
117
  }, [backgroundColor, elevation, style]);
111
118
 
119
+ const Component = asChild ? Slot : AnimatedView;
120
+
112
121
  return (
113
122
  <BackgroundContextWrapper backgroundColor={surfaceBackground}>
114
- <View ref={ref} style={layer0Style}>
115
- <View style={layer1Style}>
116
- <View {...props} testID={testID} style={sharedStyle}>
117
- {children}
118
- </View>
119
- </View>
120
- </View>
123
+ <Component ref={ref} {...props} testID={testID} style={combinedStyle}>
124
+ {children}
125
+ </Component>
121
126
  </BackgroundContextWrapper>
122
127
  );
123
128
  };
@@ -1,11 +1,14 @@
1
1
  import { forwardRef, memo, type ReactNode, useMemo } from 'react';
2
- import { type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native';
2
+ import { Animated, type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native';
3
3
 
4
4
  import shadow from '../../styles/shadow';
5
5
  import type { MD3Elevation } from '../../types/theme';
6
+ import { Slot } from '../Slot';
6
7
  import { BackgroundContextWrapper } from './BackgroundContextWrapper';
7
8
  import { defaultStyles } from './utils';
8
9
 
10
+ const AnimatedView = Animated.createAnimatedComponent(View);
11
+
9
12
  export type Props = ViewProps & {
10
13
  /**
11
14
  * Content of the `Surface`.
@@ -18,11 +21,33 @@ export type Props = ViewProps & {
18
21
  * TestID used for testing purposes
19
22
  */
20
23
  testID?: string;
24
+ /**
25
+ * When `true`, the component will not render a wrapper element. Instead, it will
26
+ * merge its props (styles, elevation shadow, ref) onto its immediate child element.
27
+ * This follows the Radix UI "Slot" pattern for flexible component composition.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * // Without asChild - renders an AnimatedView wrapper
32
+ * <Surface elevation={2}>
33
+ * <Card><Text>Content</Text></Card>
34
+ * </Surface>
35
+ *
36
+ * // With asChild - merges elevation styles onto the child
37
+ * <Surface asChild elevation={2}>
38
+ * <Card><Text>Content</Text></Card>
39
+ * </Surface>
40
+ * ```
41
+ *
42
+ * @note When `asChild` is `true`, only a single child element is allowed.
43
+ * @default false
44
+ */
45
+ asChild?: boolean;
21
46
  };
22
47
 
23
48
  // for Web
24
49
  const Surface = (
25
- { elevation = 1, style, children, testID, backgroundColor, ...props }: Props,
50
+ { elevation = 1, style, children, testID, backgroundColor, asChild = false, ...props }: Props,
26
51
  ref: any,
27
52
  ) => {
28
53
  const { surfaceStyle } = useMemo(() => {
@@ -36,11 +61,13 @@ const Surface = (
36
61
  };
37
62
  }, [backgroundColor, elevation, style]);
38
63
 
64
+ const Component = asChild ? Slot : AnimatedView;
65
+
39
66
  return (
40
67
  <BackgroundContextWrapper backgroundColor={backgroundColor!}>
41
- <View ref={ref} {...props} testID={testID} style={surfaceStyle}>
68
+ <Component ref={ref} {...props} testID={testID} style={surfaceStyle}>
42
69
  {children}
43
- </View>
70
+ </Component>
44
71
  </BackgroundContextWrapper>
45
72
  );
46
73
  };
@@ -72,10 +72,48 @@ export const getStyleForShadowLayer = (
72
72
  };
73
73
  };
74
74
 
75
- export const getElevationAndroid = (
76
- elevation: number,
77
- _inputRange: number[],
78
- elevationLevel: number[],
79
- ) => {
80
- return elevationLevel[elevation];
75
+ /**
76
+ * Combines the two shadow layers into a single shadow style.
77
+ * This approximates the two-layer shadow effect using a single shadow.
78
+ */
79
+ export const getCombinedShadowStyle = (elevation: number, shadowColor = _shadowColor) => {
80
+ if (elevation === 0) {
81
+ return {
82
+ shadowColor,
83
+ shadowOpacity: 0,
84
+ shadowOffset: { width: 0, height: 0 },
85
+ shadowRadius: 0,
86
+ };
87
+ }
88
+
89
+ const layer0 = iOSShadowOutputRanges[0];
90
+ const layer1 = iOSShadowOutputRanges[1];
91
+
92
+ // Use the larger shadow offset (from layer 0)
93
+ const shadowOffsetHeight = layer0.height[elevation];
94
+
95
+ // Use the larger shadow radius (from layer 0)
96
+ const shadowRadius = layer0.shadowRadius[elevation];
97
+
98
+ // Combine opacities (additive, capped at 1.0)
99
+ // This approximates the visual effect of two overlapping shadows
100
+ const shadowOpacity = Math.min(1.0, layer0.shadowOpacity + layer1.shadowOpacity);
101
+
102
+ return {
103
+ shadowColor,
104
+ shadowOpacity,
105
+ shadowOffset: {
106
+ width: 0,
107
+ height: shadowOffsetHeight,
108
+ },
109
+ shadowRadius,
110
+ };
81
111
  };
112
+
113
+ // export const getElevationAndroid = (
114
+ // elevation: number,
115
+ // _inputRange: number[],
116
+ // elevationLevel: number[],
117
+ // ) => {
118
+ // return elevationLevel[elevation];
119
+ // };
@@ -70,6 +70,7 @@ const Switch = (
70
70
  ref,
71
71
  actionsToListen: ['focus', 'hover', 'press'],
72
72
  });
73
+ const isFirstRender = useRef(true);
73
74
 
74
75
  const [value, onChange] = useControlledValue({
75
76
  value: valueProp,
@@ -98,8 +99,8 @@ const Switch = (
98
99
  state,
99
100
  });
100
101
 
101
- const toggleMarginAnimation = useRef(new Animated.Value(value ? 0 : 1)).current;
102
- const toggleSizeAnimation = useRef(new Animated.Value(value ? 0 : 1)).current;
102
+ const toggleMarginAnimation = useRef(new Animated.Value(value ? 1 : 0)).current;
103
+ const toggleSizeAnimation = useRef(new Animated.Value(value ? 1 : 0)).current;
103
104
 
104
105
  const thumbPosition = toggleMarginAnimation.interpolate({
105
106
  inputRange: [0, 1],
@@ -130,6 +131,11 @@ const Switch = (
130
131
  });
131
132
 
132
133
  useEffect(() => {
134
+ if (isFirstRender.current) {
135
+ isFirstRender.current = false;
136
+ return;
137
+ }
138
+
133
139
  Animated.timing(toggleMarginAnimation, {
134
140
  toValue: value ? 1 : 0,
135
141
  duration: 300,
@@ -194,8 +194,8 @@ const DefaultComponent = (props: RenderProps) => <NativeTextInput {...props} />;
194
194
  const TextInput = forwardRef<TextInputHandles, Props>(
195
195
  (
196
196
  {
197
- variant = 'flat',
198
- size = 'md',
197
+ variant = 'outlined',
198
+ size = 'sm',
199
199
  disabled = false,
200
200
  error: errorProp = false,
201
201
  multiline = false,
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, memo, useMemo } from 'react';
1
+ import { type ComponentProps, forwardRef, memo, type ReactNode, useMemo } from 'react';
2
2
  import {
3
3
  type BackgroundPropType,
4
4
  Platform,
@@ -7,25 +7,44 @@ import {
7
7
  StyleSheet,
8
8
  TouchableNativeFeedback,
9
9
  TouchableWithoutFeedback,
10
- View,
11
10
  type ViewStyle,
12
11
  } from 'react-native';
13
- import { withUnistyles } from 'react-native-unistyles';
14
12
 
13
+ import { extractPropertiesFromStyles } from '../../utils/extractPropertiesFromStyles';
14
+ import { Slot } from '../Slot';
15
15
  import { touchableRippleStyles } from './utils';
16
16
 
17
17
  const ANDROID_VERSION_LOLLIPOP = 21;
18
18
  const ANDROID_VERSION_PIE = 28;
19
19
 
20
- type Props = React.ComponentProps<typeof TouchableWithoutFeedback> & {
20
+ type Props = ComponentProps<typeof TouchableWithoutFeedback> & {
21
21
  borderless?: boolean;
22
22
  background?: BackgroundPropType;
23
23
  disabled?: boolean;
24
24
  onPress?: () => void | null;
25
25
  rippleColor?: string;
26
26
  underlayColor?: string;
27
- children: React.ReactNode;
27
+ children: ReactNode;
28
28
  style?: StyleProp<ViewStyle>;
29
+ /**
30
+ * When `true`, the component will not render a wrapper element. Instead, it will
31
+ * merge its props (styles, event handlers, ref) onto its immediate child element.
32
+ * This follows the Radix UI "Slot" pattern for flexible component composition.
33
+ *
34
+ * @note On Android, the native ripple effect will NOT work when `asChild` is `true`
35
+ * because `TouchableNativeFeedback` requires a View wrapper. Only press events will work.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <TouchableRipple asChild onPress={handlePress}>
40
+ * <View><Text>Custom pressable</Text></View>
41
+ * </TouchableRipple>
42
+ * ```
43
+ *
44
+ * @note When `asChild` is `true`, only a single child element is allowed.
45
+ * @default false
46
+ */
47
+ asChild?: boolean;
29
48
  };
30
49
 
31
50
  const TouchableRipple = (
@@ -37,6 +56,7 @@ const TouchableRipple = (
37
56
  rippleColor: rippleColorProp,
38
57
  underlayColor: underlayColorProp,
39
58
  children,
59
+ asChild = false,
40
60
  ...rest
41
61
  }: Props,
42
62
  ref: any,
@@ -46,8 +66,12 @@ const TouchableRipple = (
46
66
  const componentStyles = touchableRippleStyles;
47
67
 
48
68
  const { rippleColor, underlayColor, containerStyle } = useMemo(() => {
69
+ const { rippleColor: _rippleColor } = extractPropertiesFromStyles(
70
+ [componentStyles.root, style],
71
+ ['rippleColor'],
72
+ );
49
73
  return {
50
- rippleColor: rippleColorProp,
74
+ rippleColor: rippleColorProp || _rippleColor,
51
75
  underlayColor: underlayColorProp || rippleColorProp,
52
76
  containerStyle: [borderless && styles.borderless, componentStyles.root, style],
53
77
  };
@@ -58,6 +82,21 @@ const TouchableRipple = (
58
82
  const useForeground =
59
83
  Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_PIE && borderless;
60
84
 
85
+ if (asChild) {
86
+ // When asChild is true, use Slot to merge props with the child
87
+ // Note: TouchableNativeFeedback ripple won't work with asChild since it requires a View wrapper
88
+ return (
89
+ <Slot
90
+ {...rest}
91
+ style={containerStyle}
92
+ ref={ref}
93
+ onPress={rest.onPress}
94
+ disabled={disabled}>
95
+ {children}
96
+ </Slot>
97
+ );
98
+ }
99
+
61
100
  if (TouchableRipple.supported) {
62
101
  return (
63
102
  <TouchableNativeFeedback
@@ -65,12 +104,13 @@ const TouchableRipple = (
65
104
  ref={ref}
66
105
  disabled={disabled}
67
106
  useForeground={useForeground}
107
+ style={containerStyle}
68
108
  background={
69
109
  background != null
70
110
  ? background
71
111
  : TouchableNativeFeedback.Ripple(rippleColor!, borderless)
72
112
  }>
73
- <View style={containerStyle}>{React.Children.only(children)}</View>
113
+ <>{children}</>
74
114
  </TouchableNativeFeedback>
75
115
  );
76
116
  }
@@ -84,7 +124,7 @@ const TouchableRipple = (
84
124
  containerStyle,
85
125
  pressed && { backgroundColor: underlayColor },
86
126
  ]}>
87
- {React.Children.only(children)}
127
+ {children}
88
128
  </Pressable>
89
129
  );
90
130
  };
@@ -98,8 +138,4 @@ const styles = StyleSheet.create({
98
138
  TouchableRipple.supported =
99
139
  Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
100
140
 
101
- export default memo(
102
- withUnistyles(forwardRef(TouchableRipple), theme => ({
103
- rippleColor: theme.colors.onSurfaceRipple,
104
- })),
105
- );
141
+ export default memo(forwardRef(TouchableRipple));
@@ -1,4 +1,4 @@
1
- import { Children, forwardRef, memo, type ReactNode, useCallback, useMemo } from 'react';
1
+ import { forwardRef, memo, type ReactNode, useCallback, useMemo, useRef } from 'react';
2
2
  import {
3
3
  type GestureResponderEvent,
4
4
  Pressable,
@@ -9,6 +9,7 @@ import {
9
9
  } from 'react-native';
10
10
  import { StyleSheet } from 'react-native-unistyles';
11
11
 
12
+ import { Slot } from '../Slot';
12
13
  import { touchableRippleStyles } from './utils';
13
14
 
14
15
  export type Props = PressableProps & {
@@ -50,6 +51,28 @@ export type Props = PressableProps & {
50
51
  */
51
52
  children: ReactNode;
52
53
  style?: StyleProp<ViewStyle>;
54
+ /**
55
+ * When `true`, the component will not render a wrapper element. Instead, it will
56
+ * merge its props (styles, event handlers, ref) onto its immediate child element.
57
+ * This follows the Radix UI "Slot" pattern for flexible component composition.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * // Without asChild - renders a Pressable wrapper
62
+ * <TouchableRipple onPress={handlePress}>
63
+ * <View><Text>Click me</Text></View>
64
+ * </TouchableRipple>
65
+ *
66
+ * // With asChild - merges props onto the child
67
+ * <TouchableRipple asChild onPress={handlePress}>
68
+ * <Link href="/page"><Text>Navigate</Text></Link>
69
+ * </TouchableRipple>
70
+ * ```
71
+ *
72
+ * @note When `asChild` is `true`, only a single child element is allowed.
73
+ * @default false
74
+ */
75
+ asChild?: boolean;
53
76
  };
54
77
 
55
78
  /**
@@ -96,6 +119,7 @@ const TouchableRipple = (
96
119
  onPressIn: onPressInProp,
97
120
  onPressOut: onPressOutProp,
98
121
  centered,
122
+ asChild = false,
99
123
  ...rest
100
124
  }: Props,
101
125
  ref: any,
@@ -121,29 +145,52 @@ const TouchableRipple = (
121
145
  };
122
146
  }, [borderless, componentStyles.root, rippleColorProp, style]);
123
147
 
124
- const handlePressIn = useCallback(
148
+ // Track whether pointer is currently down for handling pointer leave
149
+ const isPointerDownRef = useRef(false);
150
+ // Store current target element to clean up ripples on pointer up/leave
151
+ const currentTargetRef = useRef<HTMLElement | null>(null);
152
+
153
+ // Using 'any' for event types to support both React DOM PointerEvent and React Native events
154
+ // This is a web-only file, so we primarily handle DOM pointer events
155
+ const handlePointerDown = useCallback(
125
156
  (e: any) => {
126
- onPressInProp?.(e);
157
+ onPressInProp?.(e as GestureResponderEvent);
127
158
 
128
159
  if (disabled) return;
129
160
 
130
- const button = e.currentTarget;
161
+ isPointerDownRef.current = true;
162
+
163
+ const button = e.currentTarget as HTMLElement;
164
+ currentTargetRef.current = button;
131
165
  const computedStyle = window.getComputedStyle(button);
132
166
  const dimensions = button.getBoundingClientRect();
133
167
 
134
- let touchX;
135
- let touchY;
136
-
137
- const { changedTouches, touches } = e.nativeEvent;
138
- const touch = touches?.[0] ?? changedTouches?.[0];
168
+ let touchX: number;
169
+ let touchY: number;
139
170
 
140
- // If centered or it was pressed using keyboard - enter or space
141
- if (centered || !touch) {
171
+ if (centered) {
172
+ // If centered, always position ripple at center
142
173
  touchX = dimensions.width / 2;
143
174
  touchY = dimensions.height / 2;
175
+ } else if ('clientX' in e && 'clientY' in e) {
176
+ // Web pointer event - calculate position relative to element
177
+ touchX = e.clientX - dimensions.left;
178
+ touchY = e.clientY - dimensions.top;
179
+ } else if (e.nativeEvent) {
180
+ // React Native gesture event
181
+ const { changedTouches, touches } = e.nativeEvent;
182
+ const touch = touches?.[0] ?? changedTouches?.[0];
183
+ if (touch) {
184
+ touchX = touch.locationX ?? dimensions.width / 2;
185
+ touchY = touch.locationY ?? dimensions.height / 2;
186
+ } else {
187
+ touchX = dimensions.width / 2;
188
+ touchY = dimensions.height / 2;
189
+ }
144
190
  } else {
145
- touchX = touch.locationX ?? e.pageX;
146
- touchY = touch.locationY ?? e.pageY;
191
+ // Fallback to center (keyboard activation)
192
+ touchX = dimensions.width / 2;
193
+ touchY = dimensions.height / 2;
147
194
  }
148
195
 
149
196
  // Get the size of the button to determine how big the ripple should be
@@ -156,7 +203,7 @@ const TouchableRipple = (
156
203
  // Create a container for our ripple effect so we don't need to change the parent's style
157
204
  const container = document.createElement('span');
158
205
 
159
- container.setAttribute('data-paper-ripple', '');
206
+ container.setAttribute('data-molecules-ripple', '');
160
207
 
161
208
  Object.assign(container.style, {
162
209
  position: 'absolute',
@@ -217,42 +264,86 @@ const TouchableRipple = (
217
264
  [onPressInProp, disabled, centered, rippleColor],
218
265
  );
219
266
 
220
- const handlePressOut = useCallback(
221
- (e: any) => {
222
- onPressOutProp?.(e);
223
-
224
- if (disabled) return;
225
-
226
- const containers = e.currentTarget.querySelectorAll(
227
- '[data-paper-ripple]',
228
- ) as HTMLElement[];
267
+ const fadeOutRipples = useCallback((target: HTMLElement) => {
268
+ const containers = target.querySelectorAll(
269
+ '[data-molecules-ripple]',
270
+ ) as NodeListOf<HTMLElement>;
229
271
 
272
+ requestAnimationFrame(() => {
230
273
  requestAnimationFrame(() => {
231
- requestAnimationFrame(() => {
232
- containers.forEach(container => {
233
- const ripple = container.firstChild as HTMLSpanElement;
234
-
235
- Object.assign(ripple.style, {
236
- transitionDuration: '250ms',
237
- opacity: 0,
238
- });
239
-
240
- // Finally remove the span after the transition
241
- setTimeout(() => {
242
- const { parentNode } = container;
243
-
244
- if (parentNode) {
245
- parentNode.removeChild(container);
246
- }
247
- }, 500);
274
+ containers.forEach(container => {
275
+ const ripple = container.firstChild as HTMLSpanElement;
276
+
277
+ Object.assign(ripple.style, {
278
+ transitionDuration: '250ms',
279
+ opacity: 0,
248
280
  });
281
+
282
+ // Finally remove the span after the transition
283
+ setTimeout(() => {
284
+ const { parentNode } = container;
285
+
286
+ if (parentNode) {
287
+ parentNode.removeChild(container);
288
+ }
289
+ }, 500);
249
290
  });
250
291
  });
292
+ });
293
+ }, []);
294
+
295
+ const handlePointerUp = useCallback(
296
+ (e: any) => {
297
+ onPressOutProp?.(e as GestureResponderEvent);
298
+
299
+ if (disabled || !isPointerDownRef.current) return;
300
+
301
+ isPointerDownRef.current = false;
302
+ currentTargetRef.current = null;
303
+
304
+ const target = e.currentTarget as HTMLElement;
305
+ fadeOutRipples(target);
251
306
  },
252
- [onPressOutProp, disabled],
307
+ [onPressOutProp, disabled, fadeOutRipples],
253
308
  );
254
309
 
255
- const Component = onPress ? Pressable : View;
310
+ const handlePointerLeave = useCallback(
311
+ (e: any) => {
312
+ // Only fade out if pointer was down (dragging out of element)
313
+ if (disabled || !isPointerDownRef.current) return;
314
+
315
+ isPointerDownRef.current = false;
316
+ currentTargetRef.current = null;
317
+
318
+ const target = e.currentTarget as HTMLElement;
319
+ fadeOutRipples(target);
320
+ },
321
+ [disabled, fadeOutRipples],
322
+ );
323
+
324
+ const handlePointerCancel = useCallback(
325
+ (e: any) => {
326
+ if (disabled || !isPointerDownRef.current) return;
327
+
328
+ isPointerDownRef.current = false;
329
+ currentTargetRef.current = null;
330
+
331
+ const target = e.currentTarget as HTMLElement;
332
+ fadeOutRipples(target);
333
+ },
334
+ [disabled, fadeOutRipples],
335
+ );
336
+
337
+ const Component = asChild ? Slot : onPress ? Pressable : View;
338
+
339
+ // Use pointer events for universal compatibility (works on any HTML element)
340
+ // These events work with mouse, touch, and stylus inputs
341
+ const pointerEventProps = {
342
+ onPointerDown: handlePointerDown,
343
+ onPointerUp: handlePointerUp,
344
+ onPointerLeave: handlePointerLeave,
345
+ onPointerCancel: handlePointerCancel,
346
+ };
256
347
 
257
348
  return (
258
349
  <Component
@@ -261,10 +352,9 @@ const TouchableRipple = (
261
352
  style={containerStyle}
262
353
  ref={ref}
263
354
  onPress={onPress}
264
- onPressIn={handlePressIn}
265
- onPressOut={handlePressOut}
266
- disabled={disabled}>
267
- {Children.only(children)}
355
+ disabled={disabled}
356
+ {...pointerEventProps}>
357
+ {children}
268
358
  </Component>
269
359
  );
270
360
  };