@momo-kits/animated-tooltip 0.153.2

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.
@@ -0,0 +1,67 @@
1
+ import React, { FC } from 'react';
2
+ import { TouchableOpacity } from 'react-native';
3
+ import {
4
+ Button,
5
+ ButtonProps,
6
+ Colors,
7
+ Icon,
8
+ IconButton as FoundationIconButton,
9
+ IconButtonProps,
10
+ Radius,
11
+ Spacing,
12
+ Text,
13
+ } from '@momo-kits/foundation';
14
+
15
+ const PrimaryButton: FC<ButtonProps> = ({ title, onPress }) => {
16
+ return (
17
+ <Button
18
+ full={false}
19
+ title={title}
20
+ onPress={onPress}
21
+ type={'secondary'}
22
+ size={'medium'}
23
+ />
24
+ );
25
+ };
26
+
27
+ const SecondaryButton: FC<ButtonProps> = ({ title, onPress, style }) => {
28
+ return (
29
+ <TouchableOpacity
30
+ style={[
31
+ {
32
+ paddingHorizontal: Spacing.M,
33
+ paddingVertical: Spacing.S,
34
+ },
35
+ style,
36
+ ]}
37
+ onPress={onPress}
38
+ >
39
+ <Text color={Colors.black_01} typography={'action_s_bold'}>
40
+ {title}
41
+ </Text>
42
+ </TouchableOpacity>
43
+ );
44
+ };
45
+
46
+ const IconButton: FC<IconButtonProps> = ({ icon, onPress, style }) => {
47
+ return (
48
+ <TouchableOpacity
49
+ onPress={onPress}
50
+ style={[
51
+ {
52
+ width: 36,
53
+ height: 36,
54
+ borderRadius: Radius.XL,
55
+ backgroundColor: Colors.black_01,
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ },
59
+ style,
60
+ ]}
61
+ >
62
+ <Icon source={icon} />
63
+ </TouchableOpacity>
64
+ );
65
+ };
66
+
67
+ export { PrimaryButton, SecondaryButton, IconButton };
package/index.tsx ADDED
@@ -0,0 +1,410 @@
1
+ import React, {
2
+ forwardRef,
3
+ useContext,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { Animated, Easing, View, ViewStyle } from 'react-native';
11
+ import {
12
+ Colors,
13
+ Icon,
14
+ MiniAppContext,
15
+ ScreenContext,
16
+ Spacing,
17
+ Text,
18
+ } from '@momo-kits/foundation';
19
+ import styles from './styles';
20
+ import { TooltipPlacement, TooltipProps, TooltipRef } from './types';
21
+ import { IconButton, PrimaryButton, SecondaryButton } from './TooltipButtons';
22
+
23
+ const Tooltip = forwardRef<TooltipRef, TooltipProps>(function Tooltip(
24
+ {
25
+ children,
26
+ title,
27
+ description,
28
+ buttons = [],
29
+ placement = 'top',
30
+ align = 'center',
31
+ visible,
32
+ showDelay = 120,
33
+ hideDelay = 120,
34
+ animationDuration = 180,
35
+ offset = 8,
36
+ accessibilityLabel,
37
+ onVisibleChange,
38
+ },
39
+ ref,
40
+ ) {
41
+ const app = useContext<any>(MiniAppContext);
42
+ const screen = useContext<any>(ScreenContext);
43
+ const arrowSize = 6;
44
+ const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 });
45
+
46
+ const componentName = 'Tooltip';
47
+ const componentId = useMemo(() => {
48
+ if (accessibilityLabel) {
49
+ return accessibilityLabel;
50
+ }
51
+
52
+ return `${app.appId}/${app.code}/${screen.screenName}/${componentName}`;
53
+ }, [accessibilityLabel, app, screen, componentName]);
54
+
55
+ const isControlled = typeof visible === 'boolean';
56
+ const [internalVisible, setInternalVisible] = useState(false);
57
+ const animatedValue = useRef(
58
+ new Animated.Value(visible ?? internalVisible ? 1 : 0),
59
+ ).current;
60
+ const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
61
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
62
+ const [anchorSize, setAnchorSize] = useState({ width: 0, height: 0 });
63
+
64
+ const currentVisible = isControlled ? !!visible : internalVisible;
65
+
66
+ const clearTimers = () => {
67
+ if (showTimer.current) {
68
+ clearTimeout(showTimer.current);
69
+ }
70
+ if (hideTimer.current) {
71
+ clearTimeout(hideTimer.current);
72
+ }
73
+ };
74
+
75
+ const setVisibility = (nextVisible: boolean) => {
76
+ if (!isControlled) {
77
+ setInternalVisible(nextVisible);
78
+ }
79
+ onVisibleChange?.(nextVisible);
80
+ };
81
+
82
+ const animate = (nextVisible: boolean) => {
83
+ Animated.timing(animatedValue, {
84
+ toValue: nextVisible ? 1 : 0,
85
+ duration: animationDuration,
86
+ useNativeDriver: true,
87
+ easing: nextVisible ? Easing.out(Easing.quad) : Easing.in(Easing.quad),
88
+ }).start();
89
+ };
90
+
91
+ useEffect(() => {
92
+ animate(currentVisible);
93
+ }, [currentVisible, animationDuration]);
94
+
95
+ useEffect(() => {
96
+ return () => {
97
+ clearTimers();
98
+ animatedValue.stopAnimation();
99
+ };
100
+ }, []);
101
+
102
+ const handleShow = () => {
103
+ clearTimers();
104
+ showTimer.current = setTimeout(() => setVisibility(true), showDelay);
105
+ };
106
+
107
+ const handleHide = () => {
108
+ clearTimers();
109
+ hideTimer.current = setTimeout(() => setVisibility(false), hideDelay);
110
+ };
111
+
112
+ const handleToggle = () => {
113
+ if (currentVisible) {
114
+ handleHide();
115
+ } else {
116
+ handleShow();
117
+ }
118
+ };
119
+
120
+ useImperativeHandle(
121
+ ref,
122
+ () => ({
123
+ show: handleShow,
124
+ hide: handleHide,
125
+ toggle: handleToggle,
126
+ }),
127
+ [currentVisible, showDelay, hideDelay],
128
+ );
129
+
130
+ const translate =
131
+ placement === 'top'
132
+ ? {
133
+ translateY: animatedValue.interpolate({
134
+ inputRange: [0, 1],
135
+ outputRange: [4, 0],
136
+ }),
137
+ }
138
+ : placement === 'bottom'
139
+ ? {
140
+ translateY: animatedValue.interpolate({
141
+ inputRange: [0, 1],
142
+ outputRange: [-4, 0],
143
+ }),
144
+ }
145
+ : placement === 'left'
146
+ ? {
147
+ translateX: animatedValue.interpolate({
148
+ inputRange: [0, 1],
149
+ outputRange: [4, 0],
150
+ }),
151
+ }
152
+ : {
153
+ translateX: animatedValue.interpolate({
154
+ inputRange: [0, 1],
155
+ outputRange: [-4, 0],
156
+ }),
157
+ };
158
+
159
+ const placementStyle: {
160
+ [key: string]: any;
161
+ } =
162
+ placement === 'top'
163
+ ? { bottom: (anchorSize.height || 0) + offset }
164
+ : placement === 'bottom'
165
+ ? { top: (anchorSize.height || 0) + offset }
166
+ : placement === 'left'
167
+ ? { right: (anchorSize.width || 0) + offset }
168
+ : { left: (anchorSize.width || 0) + offset };
169
+
170
+ const centerVerticalOffset =
171
+ (anchorSize.height || 0) / 2 - (tooltipSize.height || 0) / 2;
172
+
173
+ const alignStyle: ViewStyle =
174
+ placement === 'top' || placement === 'bottom'
175
+ ? align === 'start'
176
+ ? { left: 0 }
177
+ : align === 'end'
178
+ ? { right: 0 }
179
+ : { alignSelf: 'center' }
180
+ : align === 'start'
181
+ ? { top: 0 }
182
+ : align === 'end'
183
+ ? { bottom: 0 }
184
+ : { top: centerVerticalOffset };
185
+
186
+ const resolvedBackground = Colors.black_17;
187
+ const resolvedTextColor = Colors.black_01;
188
+
189
+ const renderButtons = () => {
190
+ if (!buttons.length) {
191
+ return null;
192
+ }
193
+
194
+ if (buttons.length === 1) {
195
+ const btn = buttons[0];
196
+ const onPress = btn.onPress ?? (() => {});
197
+ return (
198
+ <View style={styles.buttonsRow}>
199
+ <View
200
+ style={[
201
+ styles.button,
202
+ btn.icon ? styles.iconButton : styles.textButton,
203
+ ]}
204
+ >
205
+ {btn.icon ? (
206
+ <IconButton icon={btn.icon} onPress={onPress} />
207
+ ) : (
208
+ <PrimaryButton title={btn.title || ''} onPress={onPress} />
209
+ )}
210
+ </View>
211
+ </View>
212
+ );
213
+ }
214
+
215
+ if (buttons.length === 2) {
216
+ const [first, second] = buttons;
217
+ const firstPress = first.onPress ?? (() => {});
218
+ const secondPress = second.onPress ?? (() => {});
219
+ const bothIcon = !!first.icon && !!second.icon;
220
+
221
+ return (
222
+ <View style={styles.buttonsRow}>
223
+ {bothIcon ? (
224
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
225
+ <IconButton
226
+ icon={second.icon as string}
227
+ onPress={secondPress}
228
+ style={{ marginRight: Spacing.S }}
229
+ />
230
+ <IconButton icon={first.icon as string} onPress={firstPress} />
231
+ </View>
232
+ ) : (
233
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
234
+ <SecondaryButton
235
+ title={second.title || ''}
236
+ onPress={secondPress}
237
+ style={{ marginRight: Spacing.S }}
238
+ />
239
+ <PrimaryButton title={first.title || ''} onPress={firstPress} />
240
+ </View>
241
+ )}
242
+ </View>
243
+ );
244
+ }
245
+
246
+ return (
247
+ <View style={styles.buttonsRow}>
248
+ {buttons.map((btn, idx) => {
249
+ const isIcon = !!btn.icon;
250
+ const key = btn.title || btn.icon || `${idx}`;
251
+ const onPress = btn.onPress ?? (() => {});
252
+
253
+ return (
254
+ <View
255
+ key={key}
256
+ style={[
257
+ styles.button,
258
+ isIcon ? styles.iconButton : styles.textButton,
259
+ ]}
260
+ >
261
+ {isIcon ? (
262
+ <IconButton icon={btn.icon as string} onPress={onPress} />
263
+ ) : (
264
+ <PrimaryButton title={btn.title || ''} onPress={onPress} />
265
+ )}
266
+ </View>
267
+ );
268
+ })}
269
+ </View>
270
+ );
271
+ };
272
+
273
+ const renderContent = () => {
274
+ return (
275
+ <View style={styles.content}>
276
+ <View style={{ flexDirection: 'row' }}>
277
+ <View style={{ flex: 1 }}>
278
+ {title ? (
279
+ <Text
280
+ typography={'header_s_semibold'}
281
+ color={resolvedTextColor}
282
+ style={styles.title}
283
+ >
284
+ {title}
285
+ </Text>
286
+ ) : null}
287
+ {description ? (
288
+ <Text
289
+ typography={'description_default_regular'}
290
+ color={resolvedTextColor}
291
+ style={styles.description}
292
+ >
293
+ {description}
294
+ </Text>
295
+ ) : null}
296
+ </View>
297
+ <Icon
298
+ source={'navigation_close'}
299
+ size={20}
300
+ color={Colors.black_01}
301
+ style={{ marginLeft: Spacing.S }}
302
+ />
303
+ </View>
304
+ {renderButtons()}
305
+ </View>
306
+ );
307
+ };
308
+
309
+ const getArrowStyle: () => object[] = () => {
310
+ const size = arrowSize;
311
+ const common = [
312
+ styles.arrow,
313
+ {
314
+ width: size * 2,
315
+ height: size * 2,
316
+ backgroundColor: resolvedBackground,
317
+ transform: [{ rotate: '45deg' }],
318
+ borderRadius: size / 2,
319
+ },
320
+ ];
321
+
322
+ switch (placement) {
323
+ case 'top':
324
+ return [
325
+ ...common,
326
+ {
327
+ bottom: -(size - 1),
328
+ ...(align === 'start'
329
+ ? { left: size + Spacing.M }
330
+ : align === 'end'
331
+ ? { right: size + Spacing.M }
332
+ : { alignSelf: 'center' }),
333
+ },
334
+ ];
335
+ case 'bottom':
336
+ return [
337
+ ...common,
338
+ {
339
+ top: -(size - 1),
340
+ ...(align === 'start'
341
+ ? { left: size + Spacing.M }
342
+ : align === 'end'
343
+ ? { right: size + Spacing.M }
344
+ : { alignSelf: 'center' }),
345
+ },
346
+ ];
347
+ case 'left':
348
+ return [
349
+ ...common,
350
+ {
351
+ right: -(size - 1),
352
+ ...(align === 'start'
353
+ ? { top: size + Spacing.M }
354
+ : align === 'end'
355
+ ? { bottom: size + Spacing.M }
356
+ : { top: (tooltipSize.height || 0) / 2 - size }),
357
+ },
358
+ ];
359
+ case 'right':
360
+ default:
361
+ return [
362
+ ...common,
363
+ {
364
+ left: -(size - 1),
365
+ ...(align === 'start'
366
+ ? { top: size + Spacing.M }
367
+ : align === 'end'
368
+ ? { bottom: size + Spacing.M }
369
+ : { top: (tooltipSize.height || 0) / 2 - size }),
370
+ },
371
+ ];
372
+ }
373
+ };
374
+
375
+ const tooltipNode = (
376
+ <Animated.View
377
+ pointerEvents="auto"
378
+ style={[
379
+ styles.tooltip,
380
+ placementStyle,
381
+ alignStyle,
382
+ {
383
+ opacity: animatedValue,
384
+ transform: [translate],
385
+ backgroundColor: resolvedBackground,
386
+ },
387
+ ]}
388
+ onLayout={event => setTooltipSize(event.nativeEvent.layout)}
389
+ >
390
+ {renderContent()}
391
+ <View style={getArrowStyle()} />
392
+ </Animated.View>
393
+ );
394
+
395
+ return (
396
+ <View
397
+ pointerEvents={'box-none'}
398
+ style={styles.container}
399
+ accessibilityLabel={componentId}
400
+ >
401
+ <View onLayout={event => setAnchorSize(event.nativeEvent.layout)}>
402
+ {children}
403
+ </View>
404
+ {tooltipNode}
405
+ </View>
406
+ );
407
+ });
408
+
409
+ export { Tooltip };
410
+ export type { TooltipProps, TooltipPlacement, TooltipRef };
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@momo-kits/animated-tooltip",
3
+ "version": "0.153.2",
4
+ "private": false,
5
+ "main": "index.tsx",
6
+ "dependencies": {},
7
+ "peerDependencies": {
8
+ "@momo-kits/foundation": "latest",
9
+ "react": "*",
10
+ "react-native": "*"
11
+ },
12
+ "license": "MoMo",
13
+ "publishConfig": {
14
+ "registry": "https://registry.npmjs.org/"
15
+ }
16
+ }
17
+
package/publish.sh ADDED
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+
3
+ if [ "$1" == "stable" ]; then
4
+ npm version $(npm view @momo-kits/foundation@stable version)
5
+ npm version patch
6
+ npm publish --tag stable --access=public
7
+ elif [ "$1" == "latest" ]; then
8
+ npm publish --tag latest --access=public
9
+ elif [ "$1" == "beta" ]; then
10
+ npm publish --tag beta --access=public
11
+ else
12
+ npm publish --tag alpha --access=public
13
+ fi
14
+
15
+ PACKAGE_NAME=$(npm pkg get name)
16
+ NEW_PACKAGE_VERSION=$(npm pkg get version)
17
+
18
+
package/styles.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { Colors, Radius, Spacing } from '@momo-kits/foundation';
3
+
4
+ export default StyleSheet.create({
5
+ container: {
6
+ position: 'relative',
7
+ },
8
+ tooltip: {
9
+ position: 'absolute',
10
+ zIndex: 10,
11
+ padding: Spacing.M,
12
+ backgroundColor: Colors.black_17,
13
+ borderRadius: Radius.S,
14
+ width: 256,
15
+ },
16
+ text: {
17
+ color: Colors.black_01,
18
+ },
19
+ arrow: {
20
+ position: 'absolute',
21
+ width: 0,
22
+ height: 0,
23
+ },
24
+ content: {
25
+ flexDirection: 'column',
26
+ },
27
+ title: {
28
+ marginBottom: Spacing.XS / 2,
29
+ },
30
+ description: {
31
+ marginBottom: Spacing.XS / 2,
32
+ },
33
+ buttonsRow: {
34
+ flexDirection: 'row',
35
+ flexWrap: 'wrap',
36
+ justifyContent: 'flex-end',
37
+ marginTop: Spacing.M,
38
+ },
39
+ button: {
40
+ marginRight: Spacing.XS / 2,
41
+ marginBottom: Spacing.XS / 2,
42
+ },
43
+ textButton: {},
44
+ iconButton: {},
45
+ });
package/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import {ReactNode} from 'react';
2
+
3
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
4
+
5
+ export type TooltipRef = {
6
+ show: () => void;
7
+ hide: () => void;
8
+ toggle: () => void;
9
+ };
10
+
11
+ export type TooltipButton = {
12
+ title?: string;
13
+ icon?: string;
14
+ onPress?: () => void;
15
+ };
16
+
17
+ export type TooltipProps = {
18
+ /**
19
+ * Element that the tooltip is anchored to.
20
+ */
21
+ children: ReactNode;
22
+ /**
23
+ * Title displayed at the top of the tooltip.
24
+ */
25
+ title?: string;
26
+ /**
27
+ * Description text shown under the title.
28
+ */
29
+ description?: string;
30
+ /**
31
+ * Action buttons rendered at the bottom of the tooltip.
32
+ */
33
+ buttons?: TooltipButton[];
34
+ /**
35
+ * Tooltip position relative to the anchor element.
36
+ * @default 'top'
37
+ */
38
+ placement?: TooltipPlacement;
39
+ /**
40
+ * Cross-axis alignment (start/center/end).
41
+ * @default 'center'
42
+ */
43
+ align?: 'start' | 'center' | 'end';
44
+ /**
45
+ * Controlled visibility. Internal state is ignored when provided.
46
+ */
47
+ visible?: boolean;
48
+ /**
49
+ * Delay (ms) before showing the tooltip.
50
+ * @default 120
51
+ */
52
+ showDelay?: number;
53
+ /**
54
+ * Delay (ms) before hiding the tooltip.
55
+ * @default 120
56
+ */
57
+ hideDelay?: number;
58
+ /**
59
+ * Duration (ms) of the show/hide animation.
60
+ * @default 180
61
+ */
62
+ animationDuration?: number;
63
+ /**
64
+ * Gap between the tooltip and its anchor.
65
+ * @default 8
66
+ */
67
+ offset?: number;
68
+ /**
69
+ * Accessibility label for the component.
70
+ */
71
+ accessibilityLabel?: string;
72
+ /**
73
+ * Triggered when visibility changes.
74
+ */
75
+ onVisibleChange?: (visible: boolean) => void;
76
+ };
77
+