react-native-molecules 0.5.0-beta.3 → 0.5.0-beta.5
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 +39 -51
- package/components/Chip/utils.ts +3 -7
- package/components/IconButton/IconButton.tsx +42 -57
- package/components/IconButton/utils.ts +4 -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 +20 -7
- package/components/Surface/Surface.ios.tsx +22 -29
- package/components/Surface/Surface.tsx +14 -4
- package/components/Surface/utils.ts +44 -6
- package/components/Switch/Switch.tsx +8 -2
- package/components/TextInput/TextInput.tsx +4 -4
- package/components/Tooltip/TooltipTrigger.tsx +25 -16
- package/components/TouchableRipple/TouchableRipple.native.tsx +35 -13
- package/components/TouchableRipple/TouchableRipple.tsx +119 -46
- package/hooks/useControlledValue.tsx +20 -4
- package/hooks/useWhatHasUpdated.tsx +48 -0
- package/package.json +1 -10
- package/shortcuts-manager/ShortcutsManager/ShortcutsManager.tsx +5 -2
- package/styles/shadow.ts +2 -1
- package/utils/lodash.ts +77 -5
|
@@ -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,11 @@ export type Props = ComponentPropsWithRef<typeof View> & {
|
|
|
27
30
|
* TestID used for testing purposes
|
|
28
31
|
*/
|
|
29
32
|
testID?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Change the component to the HTML tag or custom component use the passed child.
|
|
35
|
+
* This will merge the props of the Surface with the props of the child element.
|
|
36
|
+
*/
|
|
37
|
+
asChild?: boolean;
|
|
30
38
|
};
|
|
31
39
|
|
|
32
40
|
/**
|
|
@@ -71,53 +79,38 @@ export type Props = ComponentPropsWithRef<typeof View> & {
|
|
|
71
79
|
* });
|
|
72
80
|
* ```
|
|
73
81
|
*/
|
|
74
|
-
const Surface = (
|
|
82
|
+
const Surface = (
|
|
83
|
+
{ elevation = 1, style, children, testID, asChild = false, ...props }: Props,
|
|
84
|
+
ref: any,
|
|
85
|
+
) => {
|
|
75
86
|
const { theme } = useUnistyles();
|
|
76
87
|
const backgroundColor = (() => {
|
|
77
88
|
// @ts-ignore
|
|
78
89
|
return theme.colors.elevation?.[`level${elevation}`];
|
|
79
90
|
})();
|
|
80
91
|
|
|
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
|
-
|
|
92
|
+
const { surfaceBackground, combinedStyle } = useMemo(() => {
|
|
89
93
|
return {
|
|
90
94
|
surfaceBackground: extractPropertiesFromStyles(
|
|
91
95
|
[defaultStyles.root as ViewStyle, style],
|
|
92
96
|
['backgroundColor'],
|
|
93
97
|
).backgroundColor,
|
|
94
|
-
|
|
95
|
-
{ backgroundColor
|
|
98
|
+
combinedStyle: [
|
|
99
|
+
{ backgroundColor },
|
|
100
|
+
getCombinedShadowStyle(elevation),
|
|
96
101
|
defaultStyles.root,
|
|
97
102
|
style,
|
|
98
|
-
{
|
|
99
|
-
position: undefined,
|
|
100
|
-
alignSelf: undefined,
|
|
101
|
-
top: undefined,
|
|
102
|
-
left: undefined,
|
|
103
|
-
right: undefined,
|
|
104
|
-
bottom: undefined,
|
|
105
|
-
},
|
|
106
103
|
],
|
|
107
|
-
layer0Style: [getStyleForShadowLayer(0, elevation), absoluteStyle, { borderRadius }],
|
|
108
|
-
layer1Style: [getStyleForShadowLayer(1, elevation), { borderRadius }],
|
|
109
104
|
};
|
|
110
105
|
}, [backgroundColor, elevation, style]);
|
|
111
106
|
|
|
107
|
+
const Component = asChild ? Slot : AnimatedView;
|
|
108
|
+
|
|
112
109
|
return (
|
|
113
110
|
<BackgroundContextWrapper backgroundColor={surfaceBackground}>
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{children}
|
|
118
|
-
</View>
|
|
119
|
-
</View>
|
|
120
|
-
</View>
|
|
111
|
+
<Component ref={ref} {...props} testID={testID} style={combinedStyle}>
|
|
112
|
+
{children}
|
|
113
|
+
</Component>
|
|
121
114
|
</BackgroundContextWrapper>
|
|
122
115
|
);
|
|
123
116
|
};
|
|
@@ -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,16 @@ export type Props = ViewProps & {
|
|
|
18
21
|
* TestID used for testing purposes
|
|
19
22
|
*/
|
|
20
23
|
testID?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Change the component to the HTML tag or custom component use the passed child.
|
|
26
|
+
* This will merge the props of the Surface with the props of the child element.
|
|
27
|
+
*/
|
|
28
|
+
asChild?: boolean;
|
|
21
29
|
};
|
|
22
30
|
|
|
23
31
|
// for Web
|
|
24
32
|
const Surface = (
|
|
25
|
-
{ elevation = 1, style, children, testID, backgroundColor, ...props }: Props,
|
|
33
|
+
{ elevation = 1, style, children, testID, backgroundColor, asChild = false, ...props }: Props,
|
|
26
34
|
ref: any,
|
|
27
35
|
) => {
|
|
28
36
|
const { surfaceStyle } = useMemo(() => {
|
|
@@ -36,11 +44,13 @@ const Surface = (
|
|
|
36
44
|
};
|
|
37
45
|
}, [backgroundColor, elevation, style]);
|
|
38
46
|
|
|
47
|
+
const Component = asChild ? Slot : AnimatedView;
|
|
48
|
+
|
|
39
49
|
return (
|
|
40
50
|
<BackgroundContextWrapper backgroundColor={backgroundColor!}>
|
|
41
|
-
<
|
|
51
|
+
<Component ref={ref} {...props} testID={testID} style={surfaceStyle}>
|
|
42
52
|
{children}
|
|
43
|
-
</
|
|
53
|
+
</Component>
|
|
44
54
|
</BackgroundContextWrapper>
|
|
45
55
|
);
|
|
46
56
|
};
|
|
@@ -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,
|
|
@@ -3,7 +3,7 @@ import React, {
|
|
|
3
3
|
memo,
|
|
4
4
|
type PropsWithoutRef,
|
|
5
5
|
type ReactNode,
|
|
6
|
-
type
|
|
6
|
+
type Ref,
|
|
7
7
|
useCallback,
|
|
8
8
|
useContext,
|
|
9
9
|
useEffect,
|
|
@@ -50,7 +50,7 @@ type Element = ReactNode | ((props: ElementProps) => ReactNode);
|
|
|
50
50
|
|
|
51
51
|
export type Props = Omit<TextInputProps, 'ref'> &
|
|
52
52
|
WithElements<Element> & {
|
|
53
|
-
ref?:
|
|
53
|
+
ref?: Ref<NativeTextInput | null>;
|
|
54
54
|
/**
|
|
55
55
|
* Variant of the TextInput.
|
|
56
56
|
* - `flat` - flat input with an underline.
|
|
@@ -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,
|
|
@@ -30,27 +30,36 @@ const TooltipTrigger = memo(({ children }: { children: ReactElement }) => {
|
|
|
30
30
|
() => triggerRef?.current,
|
|
31
31
|
);
|
|
32
32
|
|
|
33
|
-
const onPress = useCallback(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const onPress = useCallback(
|
|
34
|
+
(e: unknown) => {
|
|
35
|
+
// @ts-ignore
|
|
36
|
+
children?.props?.onPress?.(e);
|
|
37
|
+
},
|
|
38
|
+
[children?.props],
|
|
39
|
+
);
|
|
37
40
|
|
|
38
|
-
const onLongPress = useCallback(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
const onLongPress = useCallback(
|
|
42
|
+
(e: unknown) => {
|
|
43
|
+
// @ts-ignore
|
|
44
|
+
children?.props?.onLongPress?.(e);
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
if (isWeb) return;
|
|
47
|
+
onOpen();
|
|
48
|
+
},
|
|
49
|
+
[children?.props, isWeb, onOpen],
|
|
50
|
+
);
|
|
45
51
|
|
|
46
|
-
const onPressOut = useCallback(
|
|
47
|
-
|
|
52
|
+
const onPressOut = useCallback(
|
|
53
|
+
(e: unknown) => {
|
|
54
|
+
// @ts-ignore
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
children?.props?.onPressOut?.(e);
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
if (isWeb) return;
|
|
59
|
+
onClose();
|
|
60
|
+
},
|
|
61
|
+
[children?.props, isWeb, onClose],
|
|
62
|
+
);
|
|
54
63
|
|
|
55
64
|
const onHoverIn = useCallback(() => {
|
|
56
65
|
// @ts-ignore
|
|
@@ -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,30 @@ 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
|
+
* Change the component to the HTML tag or custom component use the passed child.
|
|
31
|
+
* This will merge the props of the TouchableRipple with the props of the child element.
|
|
32
|
+
*/
|
|
33
|
+
asChild?: boolean;
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
const TouchableRipple = (
|
|
@@ -37,6 +42,7 @@ const TouchableRipple = (
|
|
|
37
42
|
rippleColor: rippleColorProp,
|
|
38
43
|
underlayColor: underlayColorProp,
|
|
39
44
|
children,
|
|
45
|
+
asChild = false,
|
|
40
46
|
...rest
|
|
41
47
|
}: Props,
|
|
42
48
|
ref: any,
|
|
@@ -46,8 +52,12 @@ const TouchableRipple = (
|
|
|
46
52
|
const componentStyles = touchableRippleStyles;
|
|
47
53
|
|
|
48
54
|
const { rippleColor, underlayColor, containerStyle } = useMemo(() => {
|
|
55
|
+
const { rippleColor: _rippleColor } = extractPropertiesFromStyles(
|
|
56
|
+
[componentStyles.root, style],
|
|
57
|
+
['rippleColor'],
|
|
58
|
+
);
|
|
49
59
|
return {
|
|
50
|
-
rippleColor: rippleColorProp,
|
|
60
|
+
rippleColor: rippleColorProp || _rippleColor,
|
|
51
61
|
underlayColor: underlayColorProp || rippleColorProp,
|
|
52
62
|
containerStyle: [borderless && styles.borderless, componentStyles.root, style],
|
|
53
63
|
};
|
|
@@ -58,6 +68,21 @@ const TouchableRipple = (
|
|
|
58
68
|
const useForeground =
|
|
59
69
|
Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_PIE && borderless;
|
|
60
70
|
|
|
71
|
+
if (asChild) {
|
|
72
|
+
// When asChild is true, use Slot to merge props with the child
|
|
73
|
+
// Note: TouchableNativeFeedback ripple won't work with asChild since it requires a View wrapper
|
|
74
|
+
return (
|
|
75
|
+
<Slot
|
|
76
|
+
{...rest}
|
|
77
|
+
style={containerStyle}
|
|
78
|
+
ref={ref}
|
|
79
|
+
onPress={rest.onPress}
|
|
80
|
+
disabled={disabled}>
|
|
81
|
+
{children}
|
|
82
|
+
</Slot>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
if (TouchableRipple.supported) {
|
|
62
87
|
return (
|
|
63
88
|
<TouchableNativeFeedback
|
|
@@ -65,12 +90,13 @@ const TouchableRipple = (
|
|
|
65
90
|
ref={ref}
|
|
66
91
|
disabled={disabled}
|
|
67
92
|
useForeground={useForeground}
|
|
93
|
+
style={containerStyle}
|
|
68
94
|
background={
|
|
69
95
|
background != null
|
|
70
96
|
? background
|
|
71
97
|
: TouchableNativeFeedback.Ripple(rippleColor!, borderless)
|
|
72
98
|
}>
|
|
73
|
-
|
|
99
|
+
<>{children}</>
|
|
74
100
|
</TouchableNativeFeedback>
|
|
75
101
|
);
|
|
76
102
|
}
|
|
@@ -84,7 +110,7 @@ const TouchableRipple = (
|
|
|
84
110
|
containerStyle,
|
|
85
111
|
pressed && { backgroundColor: underlayColor },
|
|
86
112
|
]}>
|
|
87
|
-
{
|
|
113
|
+
{children}
|
|
88
114
|
</Pressable>
|
|
89
115
|
);
|
|
90
116
|
};
|
|
@@ -98,8 +124,4 @@ const styles = StyleSheet.create({
|
|
|
98
124
|
TouchableRipple.supported =
|
|
99
125
|
Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
|
|
100
126
|
|
|
101
|
-
export default memo(
|
|
102
|
-
withUnistyles(forwardRef(TouchableRipple), theme => ({
|
|
103
|
-
rippleColor: theme.colors.onSurfaceRipple,
|
|
104
|
-
})),
|
|
105
|
-
);
|
|
127
|
+
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,11 @@ export type Props = PressableProps & {
|
|
|
50
51
|
*/
|
|
51
52
|
children: ReactNode;
|
|
52
53
|
style?: StyleProp<ViewStyle>;
|
|
54
|
+
/**
|
|
55
|
+
* Change the component to the HTML tag or custom component use the passed child.
|
|
56
|
+
* This will merge the props of the TouchableRipple with the props of the child element.
|
|
57
|
+
*/
|
|
58
|
+
asChild?: boolean;
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
/**
|
|
@@ -96,6 +102,7 @@ const TouchableRipple = (
|
|
|
96
102
|
onPressIn: onPressInProp,
|
|
97
103
|
onPressOut: onPressOutProp,
|
|
98
104
|
centered,
|
|
105
|
+
asChild = false,
|
|
99
106
|
...rest
|
|
100
107
|
}: Props,
|
|
101
108
|
ref: any,
|
|
@@ -121,29 +128,52 @@ const TouchableRipple = (
|
|
|
121
128
|
};
|
|
122
129
|
}, [borderless, componentStyles.root, rippleColorProp, style]);
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
// Track whether pointer is currently down for handling pointer leave
|
|
132
|
+
const isPointerDownRef = useRef(false);
|
|
133
|
+
// Store current target element to clean up ripples on pointer up/leave
|
|
134
|
+
const currentTargetRef = useRef<HTMLElement | null>(null);
|
|
135
|
+
|
|
136
|
+
// Using 'any' for event types to support both React DOM PointerEvent and React Native events
|
|
137
|
+
// This is a web-only file, so we primarily handle DOM pointer events
|
|
138
|
+
const handlePointerDown = useCallback(
|
|
125
139
|
(e: any) => {
|
|
126
|
-
onPressInProp?.(e);
|
|
140
|
+
onPressInProp?.(e as GestureResponderEvent);
|
|
127
141
|
|
|
128
142
|
if (disabled) return;
|
|
129
143
|
|
|
130
|
-
|
|
144
|
+
isPointerDownRef.current = true;
|
|
145
|
+
|
|
146
|
+
const button = e.currentTarget as HTMLElement;
|
|
147
|
+
currentTargetRef.current = button;
|
|
131
148
|
const computedStyle = window.getComputedStyle(button);
|
|
132
149
|
const dimensions = button.getBoundingClientRect();
|
|
133
150
|
|
|
134
|
-
let touchX;
|
|
135
|
-
let touchY;
|
|
136
|
-
|
|
137
|
-
const { changedTouches, touches } = e.nativeEvent;
|
|
138
|
-
const touch = touches?.[0] ?? changedTouches?.[0];
|
|
151
|
+
let touchX: number;
|
|
152
|
+
let touchY: number;
|
|
139
153
|
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
if (centered) {
|
|
155
|
+
// If centered, always position ripple at center
|
|
142
156
|
touchX = dimensions.width / 2;
|
|
143
157
|
touchY = dimensions.height / 2;
|
|
158
|
+
} else if ('clientX' in e && 'clientY' in e) {
|
|
159
|
+
// Web pointer event - calculate position relative to element
|
|
160
|
+
touchX = e.clientX - dimensions.left;
|
|
161
|
+
touchY = e.clientY - dimensions.top;
|
|
162
|
+
} else if (e.nativeEvent) {
|
|
163
|
+
// React Native gesture event
|
|
164
|
+
const { changedTouches, touches } = e.nativeEvent;
|
|
165
|
+
const touch = touches?.[0] ?? changedTouches?.[0];
|
|
166
|
+
if (touch) {
|
|
167
|
+
touchX = touch.locationX ?? dimensions.width / 2;
|
|
168
|
+
touchY = touch.locationY ?? dimensions.height / 2;
|
|
169
|
+
} else {
|
|
170
|
+
touchX = dimensions.width / 2;
|
|
171
|
+
touchY = dimensions.height / 2;
|
|
172
|
+
}
|
|
144
173
|
} else {
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
// Fallback to center (keyboard activation)
|
|
175
|
+
touchX = dimensions.width / 2;
|
|
176
|
+
touchY = dimensions.height / 2;
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
// Get the size of the button to determine how big the ripple should be
|
|
@@ -156,7 +186,7 @@ const TouchableRipple = (
|
|
|
156
186
|
// Create a container for our ripple effect so we don't need to change the parent's style
|
|
157
187
|
const container = document.createElement('span');
|
|
158
188
|
|
|
159
|
-
container.setAttribute('data-
|
|
189
|
+
container.setAttribute('data-molecules-ripple', '');
|
|
160
190
|
|
|
161
191
|
Object.assign(container.style, {
|
|
162
192
|
position: 'absolute',
|
|
@@ -217,42 +247,86 @@ const TouchableRipple = (
|
|
|
217
247
|
[onPressInProp, disabled, centered, rippleColor],
|
|
218
248
|
);
|
|
219
249
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (disabled) return;
|
|
225
|
-
|
|
226
|
-
const containers = e.currentTarget.querySelectorAll(
|
|
227
|
-
'[data-paper-ripple]',
|
|
228
|
-
) as HTMLElement[];
|
|
250
|
+
const fadeOutRipples = useCallback((target: HTMLElement) => {
|
|
251
|
+
const containers = target.querySelectorAll(
|
|
252
|
+
'[data-molecules-ripple]',
|
|
253
|
+
) as NodeListOf<HTMLElement>;
|
|
229
254
|
|
|
255
|
+
requestAnimationFrame(() => {
|
|
230
256
|
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);
|
|
257
|
+
containers.forEach(container => {
|
|
258
|
+
const ripple = container.firstChild as HTMLSpanElement;
|
|
259
|
+
|
|
260
|
+
Object.assign(ripple.style, {
|
|
261
|
+
transitionDuration: '250ms',
|
|
262
|
+
opacity: 0,
|
|
248
263
|
});
|
|
264
|
+
|
|
265
|
+
// Finally remove the span after the transition
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
const { parentNode } = container;
|
|
268
|
+
|
|
269
|
+
if (parentNode) {
|
|
270
|
+
parentNode.removeChild(container);
|
|
271
|
+
}
|
|
272
|
+
}, 500);
|
|
249
273
|
});
|
|
250
274
|
});
|
|
275
|
+
});
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
const handlePointerUp = useCallback(
|
|
279
|
+
(e: any) => {
|
|
280
|
+
onPressOutProp?.(e as GestureResponderEvent);
|
|
281
|
+
|
|
282
|
+
if (disabled || !isPointerDownRef.current) return;
|
|
283
|
+
|
|
284
|
+
isPointerDownRef.current = false;
|
|
285
|
+
currentTargetRef.current = null;
|
|
286
|
+
|
|
287
|
+
const target = e.currentTarget as HTMLElement;
|
|
288
|
+
fadeOutRipples(target);
|
|
251
289
|
},
|
|
252
|
-
[onPressOutProp, disabled],
|
|
290
|
+
[onPressOutProp, disabled, fadeOutRipples],
|
|
253
291
|
);
|
|
254
292
|
|
|
255
|
-
const
|
|
293
|
+
const handlePointerLeave = useCallback(
|
|
294
|
+
(e: any) => {
|
|
295
|
+
// Only fade out if pointer was down (dragging out of element)
|
|
296
|
+
if (disabled || !isPointerDownRef.current) return;
|
|
297
|
+
|
|
298
|
+
isPointerDownRef.current = false;
|
|
299
|
+
currentTargetRef.current = null;
|
|
300
|
+
|
|
301
|
+
const target = e.currentTarget as HTMLElement;
|
|
302
|
+
fadeOutRipples(target);
|
|
303
|
+
},
|
|
304
|
+
[disabled, fadeOutRipples],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handlePointerCancel = useCallback(
|
|
308
|
+
(e: any) => {
|
|
309
|
+
if (disabled || !isPointerDownRef.current) return;
|
|
310
|
+
|
|
311
|
+
isPointerDownRef.current = false;
|
|
312
|
+
currentTargetRef.current = null;
|
|
313
|
+
|
|
314
|
+
const target = e.currentTarget as HTMLElement;
|
|
315
|
+
fadeOutRipples(target);
|
|
316
|
+
},
|
|
317
|
+
[disabled, fadeOutRipples],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const Component = asChild ? Slot : onPress ? Pressable : View;
|
|
321
|
+
|
|
322
|
+
// Use pointer events for universal compatibility (works on any HTML element)
|
|
323
|
+
// These events work with mouse, touch, and stylus inputs
|
|
324
|
+
const pointerEventProps = {
|
|
325
|
+
onPointerDown: handlePointerDown,
|
|
326
|
+
onPointerUp: handlePointerUp,
|
|
327
|
+
onPointerLeave: handlePointerLeave,
|
|
328
|
+
onPointerCancel: handlePointerCancel,
|
|
329
|
+
};
|
|
256
330
|
|
|
257
331
|
return (
|
|
258
332
|
<Component
|
|
@@ -261,10 +335,9 @@ const TouchableRipple = (
|
|
|
261
335
|
style={containerStyle}
|
|
262
336
|
ref={ref}
|
|
263
337
|
onPress={onPress}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
{Children.only(children)}
|
|
338
|
+
disabled={disabled}
|
|
339
|
+
{...pointerEventProps}>
|
|
340
|
+
{children}
|
|
268
341
|
</Component>
|
|
269
342
|
);
|
|
270
343
|
};
|