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.
- package/components/Button/Button.tsx +5 -20
- package/components/Button/utils.ts +0 -1
- package/components/Chip/Chip.tsx +40 -52
- package/components/Chip/utils.ts +3 -7
- package/components/IconButton/IconButton.tsx +42 -57
- package/components/IconButton/utils.ts +3 -5
- package/components/Select/Select.tsx +360 -501
- package/components/Select/index.ts +7 -14
- package/components/Select/types.ts +2 -4
- package/components/Select/utils.ts +215 -0
- package/components/Slot/Slot.tsx +244 -0
- package/components/Slot/compose-refs.tsx +60 -0
- package/components/Slot/index.tsx +8 -0
- package/components/Surface/Surface.android.tsx +32 -7
- package/components/Surface/Surface.ios.tsx +34 -29
- package/components/Surface/Surface.tsx +31 -4
- package/components/Surface/utils.ts +44 -6
- package/components/Switch/Switch.tsx +8 -2
- package/components/TextInput/TextInput.tsx +2 -2
- package/components/TouchableRipple/TouchableRipple.native.tsx +49 -13
- package/components/TouchableRipple/TouchableRipple.tsx +136 -46
- package/hooks/useControlledValue.tsx +20 -4
- package/hooks/useWhatHasUpdated.tsx +48 -0
- package/package.json +2 -1
- package/styles/shadow.ts +2 -1
|
@@ -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,
|
|
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 = (
|
|
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,
|
|
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
|
-
|
|
95
|
-
{ backgroundColor
|
|
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
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
<
|
|
68
|
+
<Component ref={ref} {...props} testID={testID} style={surfaceStyle}>
|
|
42
69
|
{children}
|
|
43
|
-
</
|
|
70
|
+
</Component>
|
|
44
71
|
</BackgroundContextWrapper>
|
|
45
72
|
);
|
|
46
73
|
};
|
|
@@ -72,10 +72,48 @@ export const getStyleForShadowLayer = (
|
|
|
72
72
|
};
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
) => {
|
|
80
|
-
|
|
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 ?
|
|
102
|
-
const toggleSizeAnimation = useRef(new Animated.Value(value ?
|
|
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 = '
|
|
198
|
-
size = '
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
{Children.only(children)}
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
{...pointerEventProps}>
|
|
357
|
+
{children}
|
|
268
358
|
</Component>
|
|
269
359
|
);
|
|
270
360
|
};
|