@momo-kits/foundation 0.167.2-beta.1 → 0.168.1-beta.1

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.
Files changed (53) hide show
  1. package/Application/BottomSheet.tsx +113 -14
  2. package/Application/BottomTab/BottomTabBar.tsx +6 -3
  3. package/Application/BottomTab/CustomBottomTabItem.tsx +3 -5
  4. package/Application/BottomTab/DuoBarCompression.tsx +135 -0
  5. package/Application/BottomTab/DuoTabBar.tsx +537 -0
  6. package/Application/BottomTab/index.tsx +145 -134
  7. package/Application/Components/DuoGlassBackground.tsx +158 -0
  8. package/Application/Components/DuoQuickReturnHeader.tsx +42 -0
  9. package/Application/Components/HeaderAnimated.tsx +9 -3
  10. package/Application/Components/HeaderExtendHeader.tsx +70 -28
  11. package/Application/Components/HeaderLeft.tsx +38 -0
  12. package/Application/Components/HeaderRight.tsx +144 -9
  13. package/Application/Components/HeaderTitle.tsx +43 -7
  14. package/Application/Components/SearchHeader.tsx +20 -15
  15. package/Application/Components/SideBarButton.tsx +134 -0
  16. package/Application/ModalScreen.tsx +16 -2
  17. package/Application/NavigationContainer.tsx +22 -38
  18. package/Application/Navigator.ts +1 -3
  19. package/Application/StackScreen.tsx +2 -5
  20. package/Application/device.ts +29 -0
  21. package/Application/types.ts +10 -1
  22. package/Application/utils.tsx +10 -7
  23. package/Badge/Badge.tsx +2 -3
  24. package/Badge/BadgeDotAnimation.tsx +3 -3
  25. package/Button/index.tsx +11 -11
  26. package/Consts/theme.ts +3 -4
  27. package/Input/Input.tsx +3 -4
  28. package/Input/InputMoney.tsx +8 -12
  29. package/Input/InputOTP.tsx +9 -12
  30. package/Input/InputPhoneNumber.tsx +3 -4
  31. package/Input/InputSearch.tsx +17 -14
  32. package/Input/InputTextArea.tsx +3 -4
  33. package/Input/SystemTextInput.tsx +2 -3
  34. package/Input/TextTyping.tsx +11 -6
  35. package/Input/utils.ts +2 -3
  36. package/Layout/Card.tsx +8 -26
  37. package/Layout/FloatingButton.tsx +3 -3
  38. package/Layout/Item.tsx +1 -10
  39. package/Layout/ReservedRegions.tsx +78 -0
  40. package/Layout/Screen.tsx +242 -48
  41. package/Layout/Section.tsx +5 -18
  42. package/Layout/index.ts +4 -4
  43. package/Layout/styles.ts +5 -0
  44. package/Layout/utils.ts +477 -5
  45. package/Pagination/Dot.tsx +3 -6
  46. package/Popup/PopupNotify.tsx +33 -22
  47. package/Popup/PopupPromotion.tsx +21 -8
  48. package/Popup/utils.ts +32 -0
  49. package/Radio/index.tsx +2 -3
  50. package/Text/utils.ts +1 -2
  51. package/Title/index.tsx +8 -11
  52. package/package.json +5 -2
  53. package/Layout/GridSystem.tsx +0 -137
@@ -0,0 +1,537 @@
1
+ import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
2
+ import { CommonActions, Route } from '@react-navigation/native';
3
+ import React, { useContext, useEffect, useRef, useState } from 'react';
4
+ import {
5
+ LayoutChangeEvent,
6
+ StyleSheet,
7
+ TouchableOpacity,
8
+ View,
9
+ } from 'react-native';
10
+ import Animated, {
11
+ useAnimatedStyle,
12
+ useSharedValue,
13
+ withSpring,
14
+ } from 'react-native-reanimated';
15
+ import { Colors, Radius, Spacing } from '../../Consts';
16
+ import { ApplicationContext, MiniAppContext } from '../../Context';
17
+ import { Icon } from '../../Icon';
18
+ import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
19
+ import { Text } from '../../Text';
20
+ import { useDuoBarCompression } from './DuoBarCompression';
21
+ import { FloatingButtonProps } from '../types';
22
+ import { TabBarIcon } from './TabBarIcon';
23
+
24
+ type Props = BottomTabBarProps & {
25
+ activeTintColor: string;
26
+ inactiveTintColor: string;
27
+ floatingButton?: FloatingButtonProps;
28
+ };
29
+
30
+ const ITEM_SIZE = DuoMetrics.controlSize;
31
+ const ICON_SIZE = 28;
32
+ const QR_ICON_SIZE = 24;
33
+ const RAIL_PADDING = 6;
34
+ const RAIL_GAP = Spacing.XXS;
35
+ const RAIL_PITCH = ITEM_SIZE + RAIL_GAP;
36
+ // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
37
+ const RAIL_BUBBLE_WIDTH = 44;
38
+ const RAIL_BUBBLE_HEIGHT = 58;
39
+ const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
40
+ const FLOATING_PADDING = Spacing.XS;
41
+ // the system bar sizes its pill to the tabs instead of stretching it across the display
42
+ const FLOATING_ITEM_WIDTH = 80;
43
+ // while pressed the selection bubble grows into a lens past the pill's edges, wider and taller (80x54 to about 96x70)
44
+ const LENS_SCALE_X = 1.2;
45
+ const LENS_SCALE_Y = 1.3;
46
+ const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
47
+ const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
48
+
49
+ /**
50
+ * distance from the screen bottom to the floating tab bar, overlapping the home
51
+ * indicator inset like the system tab bar
52
+ */
53
+ const getFloatingTabBarBottom = (bottomInset: number) =>
54
+ Math.max(bottomInset - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S);
55
+
56
+ /**
57
+ * space tab content leaves under the Duo floating tab bar, 0 elsewhere
58
+ */
59
+ const useDuoTabBarInset = () => {
60
+ const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
61
+ if (!isIPhoneDuo || sideBar) {
62
+ return 0;
63
+ }
64
+ return FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom);
65
+ };
66
+
67
+ const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
68
+ const { theme } = useContext(ApplicationContext);
69
+ return (
70
+ <View
71
+ pointerEvents={'none'}
72
+ style={[
73
+ StyleSheet.absoluteFill,
74
+ styles.barMaterial,
75
+ {
76
+ borderRadius: radius,
77
+ backgroundColor: theme.colors.background.surface,
78
+ borderColor: theme.colors.border.default,
79
+ },
80
+ ]}
81
+ />
82
+ );
83
+ };
84
+
85
+ // Figma Home-2026 tab bar "Selection": brand pink at 8%
86
+ const SELECTION_TINT_ALPHA = '14';
87
+
88
+ /**
89
+ * pink tinted capsule under the selected tab
90
+ */
91
+ const SelectionMaterial: React.FC<{ radius: number }> = ({ radius }) => {
92
+ const { theme } = useContext(ApplicationContext);
93
+ return (
94
+ <View
95
+ pointerEvents={'none'}
96
+ style={[
97
+ StyleSheet.absoluteFill,
98
+ {
99
+ borderRadius: radius,
100
+ backgroundColor: theme.colors.primary + SELECTION_TINT_ALPHA,
101
+ },
102
+ ]}
103
+ />
104
+ );
105
+ };
106
+
107
+ /**
108
+ * tab label at its design size: like the system tab bar it doesn't grow with the wide
109
+ * Duo displays, which Text otherwise scales up to 5pt
110
+ */
111
+ const TabLabel: React.FC<{
112
+ label: string;
113
+ color: string;
114
+ typography: 'label_xs_medium';
115
+ }> = ({ label, color, typography }) => {
116
+ const context = useContext<any>(MiniAppContext);
117
+ return (
118
+ <MiniAppContext.Provider
119
+ value={{
120
+ ...context,
121
+ fontScale: { useOSScaleRate: false, userScaleRate: 1 },
122
+ }}
123
+ >
124
+ <Text
125
+ numberOfLines={1}
126
+ typography={typography}
127
+ color={color}
128
+ >
129
+ {label}
130
+ </Text>
131
+ </MiniAppContext.Provider>
132
+ );
133
+ };
134
+
135
+ const getTabItems = ({
136
+ state,
137
+ descriptors,
138
+ navigation,
139
+ activeTintColor,
140
+ inactiveTintColor,
141
+ }: Props) => {
142
+ return state.routes.map((route: Route<string>, index: number) => {
143
+ const { options } = descriptors[route.key];
144
+ const focused = index === state.index;
145
+ const label = options.title ?? route.name;
146
+
147
+ const onPress = () => {
148
+ const event = navigation.emit({
149
+ type: 'tabPress',
150
+ target: route.key,
151
+ canPreventDefault: true,
152
+ });
153
+ if (!focused && !event.defaultPrevented) {
154
+ navigation.dispatch({
155
+ ...CommonActions.navigate(route.name),
156
+ target: state.key,
157
+ });
158
+ }
159
+ };
160
+
161
+ const onLongPress = () => {
162
+ navigation.emit({ type: 'tabLongPress', target: route.key });
163
+ };
164
+
165
+ const icon = options.tabBarIcon && (
166
+ <TabBarIcon
167
+ route={route}
168
+ badge={options.tabBarBadge}
169
+ badgeStyle={options.tabBarBadgeStyle}
170
+ activeOpacity={focused ? 1 : 0}
171
+ inactiveOpacity={focused ? 0 : 1}
172
+ activeTintColor={activeTintColor}
173
+ inactiveTintColor={inactiveTintColor}
174
+ renderIcon={options.tabBarIcon}
175
+ style={styles.icon}
176
+ />
177
+ );
178
+
179
+ return { route, focused, label, onPress, onLongPress, icon };
180
+ });
181
+ };
182
+
183
+ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
184
+ icon,
185
+ size,
186
+ }) => {
187
+ if (typeof icon === 'string') {
188
+ return <Icon source={icon} size={size} color={Colors.black_01} />;
189
+ }
190
+ return <>{icon}</>;
191
+ };
192
+
193
+ type TabItem = ReturnType<typeof getTabItems>[number];
194
+
195
+ /**
196
+ * column of icon-only tab items with the sliding selection bubble
197
+ */
198
+ const Rail: React.FC<{
199
+ items: TabItem[];
200
+ index: number;
201
+ onItemPress?: () => void;
202
+ }> = ({ items, index, onItemPress }) => {
203
+ const bubbleOffset = useSharedValue(index * RAIL_PITCH);
204
+ const lens = useSharedValue(0);
205
+
206
+ useEffect(() => {
207
+ bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
208
+ }, [index, bubbleOffset]);
209
+
210
+ const bubbleStyle = useAnimatedStyle(() => ({
211
+ transform: [
212
+ { translateY: bubbleOffset.value },
213
+ { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
214
+ { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
215
+ ],
216
+ }));
217
+
218
+ return (
219
+ <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
220
+ <BarMaterial radius={ITEM_SIZE / 2} />
221
+ <Animated.View
222
+ pointerEvents={'none'}
223
+ style={[styles.railBubble, bubbleStyle]}
224
+ >
225
+ <SelectionMaterial radius={RAIL_BUBBLE_WIDTH / 2} />
226
+ </Animated.View>
227
+ {items.map((item, itemIndex) => (
228
+ <TouchableOpacity
229
+ key={item.route.key}
230
+ accessibilityRole={'tab'}
231
+ accessibilityLabel={item.label}
232
+ accessibilityState={{ selected: item.focused }}
233
+ activeOpacity={1}
234
+ onPressIn={() => {
235
+ bubbleOffset.value = withSpring(itemIndex * RAIL_PITCH, SPRING);
236
+ lens.value = withSpring(1, SPRING);
237
+ }}
238
+ onPressOut={() => {
239
+ bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
240
+ lens.value = withSpring(0, SPRING);
241
+ }}
242
+ onPress={() => {
243
+ item.onPress();
244
+ onItemPress?.();
245
+ }}
246
+ onLongPress={item.onLongPress}
247
+ style={styles.railItem}
248
+ >
249
+ {item.icon}
250
+ </TouchableOpacity>
251
+ ))}
252
+ </View>
253
+ );
254
+ };
255
+
256
+ /**
257
+ * vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E).
258
+ * When the side bar is short, it can shrink to one button showing the current tab
259
+ * (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar
260
+ */
261
+ const DuoRailTabBar: React.FC<Props> = props => {
262
+ const { theme } = useContext(ApplicationContext);
263
+ const { sideBar } = useScreenLayout();
264
+ const { tabBarCompressed, expandTabBar, onTabSelected } =
265
+ useDuoBarCompression();
266
+ const { state, floatingButton } = props;
267
+ const items = getTabItems(props);
268
+ const current = items[state.index];
269
+
270
+ const columnStyle = sideBar && [
271
+ styles.railColumn,
272
+ {
273
+ [sideBar.edge]: 0,
274
+ width: sideBar.width,
275
+ bottom: sideBar.bottom,
276
+ },
277
+ sideBar.edge === 'right' ? styles.railColumnRight : styles.railColumnLeft,
278
+ ];
279
+
280
+ const qr = floatingButton && (
281
+ <TouchableOpacity
282
+ accessibilityRole={'button'}
283
+ accessibilityLabel={floatingButton.label}
284
+ onPress={floatingButton.onPress}
285
+ style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
286
+ >
287
+ <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
288
+ </TouchableOpacity>
289
+ );
290
+
291
+ if (!sideBar) {
292
+ return null;
293
+ }
294
+
295
+ if (!tabBarCompressed) {
296
+ return (
297
+ <View pointerEvents={'box-none'} style={columnStyle}>
298
+ <Rail items={items} index={state.index} onItemPress={onTabSelected} />
299
+ {qr}
300
+ </View>
301
+ );
302
+ }
303
+
304
+ return (
305
+ <View pointerEvents={'box-none'} style={columnStyle}>
306
+ <TouchableOpacity
307
+ accessibilityRole={'button'}
308
+ accessibilityLabel={current?.label}
309
+ accessibilityHint={'bottom_tab_bar'}
310
+ onPress={expandTabBar}
311
+ style={styles.railCompact}
312
+ >
313
+ <BarMaterial radius={Radius.XL} />
314
+ {current?.icon}
315
+ </TouchableOpacity>
316
+ {qr}
317
+ </View>
318
+ );
319
+ };
320
+
321
+ /**
322
+ * floating labelled bar for the unfolded portrait display, QR pinned in its own
323
+ * platter on the trailing side
324
+ */
325
+ const DuoFloatingTabBar: React.FC<Props> = props => {
326
+ const { theme } = useContext(ApplicationContext);
327
+ const { insets } = useScreenLayout();
328
+ const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
329
+ const items = getTabItems(props);
330
+ const [rowWidth, setRowWidth] = useState(0);
331
+ const cellWidth = rowWidth / state.routes.length;
332
+
333
+ const bubbleOffset = useSharedValue(0);
334
+ const lens = useSharedValue(0);
335
+ const measured = useRef(false);
336
+
337
+ useEffect(() => {
338
+ const target = state.index * cellWidth;
339
+ // snap on first layout so the bubble doesn't slide in from the left edge
340
+ bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
341
+ measured.current = cellWidth > 0;
342
+ }, [state.index, cellWidth, bubbleOffset]);
343
+
344
+ const bubbleStyle = useAnimatedStyle(() => ({
345
+ transform: [
346
+ { translateX: bubbleOffset.value },
347
+ { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
348
+ { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
349
+ ],
350
+ }));
351
+
352
+ const onRowLayout = (e: LayoutChangeEvent) =>
353
+ setRowWidth(e.nativeEvent.layout.width);
354
+
355
+ return (
356
+ <View
357
+ pointerEvents={'box-none'}
358
+ style={[
359
+ styles.floating,
360
+ {
361
+ left: insets.left + DuoMetrics.floatingTabBarMargin,
362
+ right: insets.right + DuoMetrics.floatingTabBarMargin,
363
+ bottom: getFloatingTabBarBottom(insets.bottom),
364
+ },
365
+ ]}
366
+ >
367
+ <View
368
+ style={[
369
+ styles.floatingBar,
370
+ {
371
+ width:
372
+ state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
373
+ },
374
+ ]}
375
+ accessibilityLabel={'bottom_tab_bar'}
376
+ >
377
+ <BarMaterial radius={FLOATING_HEIGHT / 2} />
378
+ <View style={styles.floatingRow} onLayout={onRowLayout}>
379
+ {rowWidth > 0 && (
380
+ <Animated.View
381
+ pointerEvents={'none'}
382
+ style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
383
+ >
384
+ <SelectionMaterial radius={FLOATING_BUBBLE_RADIUS} />
385
+ </Animated.View>
386
+ )}
387
+ {items.map((item, index) => (
388
+ <TouchableOpacity
389
+ key={item.route.key}
390
+ accessibilityRole={'tab'}
391
+ accessibilityLabel={item.label}
392
+ accessibilityState={{ selected: item.focused }}
393
+ activeOpacity={1}
394
+ onPressIn={() => {
395
+ // the lens jumps to the pressed tab, not only the selected one
396
+ bubbleOffset.value = withSpring(index * cellWidth, SPRING);
397
+ lens.value = withSpring(1, SPRING);
398
+ }}
399
+ onPressOut={() => {
400
+ // onPress follows synchronously, a tab switch re-targets the bubble
401
+ bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
402
+ lens.value = withSpring(0, SPRING);
403
+ }}
404
+ onPress={item.onPress}
405
+ onLongPress={item.onLongPress}
406
+ style={styles.floatingItem}
407
+ >
408
+ {item.icon}
409
+ <TabLabel
410
+ label={item.label}
411
+ typography={'label_xs_medium'}
412
+ color={item.focused ? activeTintColor : inactiveTintColor}
413
+ />
414
+ </TouchableOpacity>
415
+ ))}
416
+ </View>
417
+ </View>
418
+ {floatingButton && (
419
+ <TouchableOpacity
420
+ accessibilityRole={'button'}
421
+ accessibilityLabel={floatingButton.label}
422
+ onPress={floatingButton.onPress}
423
+ style={[styles.floatingQr, { backgroundColor: theme.colors.primary }]}
424
+ >
425
+ <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
426
+ </TouchableOpacity>
427
+ )}
428
+ </View>
429
+ );
430
+ };
431
+
432
+ /**
433
+ * iPhone Duo tab bar: rail in the side bar, or a floating bar when unfolded portrait
434
+ */
435
+ const DuoTabBar: React.FC<Props> = props => {
436
+ const { sideBar } = useScreenLayout();
437
+ return sideBar ? (
438
+ <DuoRailTabBar {...props} />
439
+ ) : (
440
+ <DuoFloatingTabBar {...props} />
441
+ );
442
+ };
443
+
444
+ const styles = StyleSheet.create({
445
+ barMaterial: {
446
+ borderWidth: 1,
447
+ },
448
+ icon: {
449
+ width: ICON_SIZE,
450
+ height: ICON_SIZE,
451
+ },
452
+ railColumn: {
453
+ position: 'absolute',
454
+ gap: Spacing.M,
455
+ },
456
+ railColumnRight: {
457
+ alignItems: 'flex-start',
458
+ paddingLeft: Spacing.M,
459
+ },
460
+ railColumnLeft: {
461
+ alignItems: 'flex-end',
462
+ paddingRight: Spacing.M,
463
+ },
464
+ railCompact: {
465
+ width: ITEM_SIZE,
466
+ height: ITEM_SIZE,
467
+ borderRadius: ITEM_SIZE / 2,
468
+ alignItems: 'center',
469
+ justifyContent: 'center',
470
+ },
471
+ rail: {
472
+ width: ITEM_SIZE,
473
+ paddingVertical: RAIL_PADDING,
474
+ gap: RAIL_GAP,
475
+ },
476
+ railBubble: {
477
+ position: 'absolute',
478
+ top: RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2,
479
+ left: (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
480
+ width: RAIL_BUBBLE_WIDTH,
481
+ height: RAIL_BUBBLE_HEIGHT,
482
+ },
483
+ railItem: {
484
+ width: ITEM_SIZE,
485
+ height: ITEM_SIZE,
486
+ alignItems: 'center',
487
+ justifyContent: 'center',
488
+ },
489
+ railQr: {
490
+ width: ITEM_SIZE,
491
+ height: ITEM_SIZE,
492
+ borderRadius: ITEM_SIZE / 2,
493
+ alignItems: 'center',
494
+ justifyContent: 'center',
495
+ },
496
+ floating: {
497
+ position: 'absolute',
498
+ flexDirection: 'row',
499
+ justifyContent: 'center',
500
+ gap: Spacing.S,
501
+ },
502
+ floatingBar: {
503
+ flexShrink: 1,
504
+ height: FLOATING_HEIGHT,
505
+ padding: FLOATING_PADDING,
506
+ },
507
+ floatingRow: {
508
+ flex: 1,
509
+ flexDirection: 'row',
510
+ },
511
+ floatingBubble: {
512
+ position: 'absolute',
513
+ top: 0,
514
+ bottom: 0,
515
+ left: 0,
516
+ },
517
+ floatingItem: {
518
+ flex: 1,
519
+ alignItems: 'center',
520
+ justifyContent: 'center',
521
+ gap: Spacing.XXS,
522
+ },
523
+ // pink fills the whole pinned platter
524
+ floatingQr: {
525
+ width: FLOATING_HEIGHT,
526
+ height: FLOATING_HEIGHT,
527
+ borderRadius: FLOATING_HEIGHT / 2,
528
+ alignItems: 'center',
529
+ justifyContent: 'center',
530
+ shadowColor: Colors.black_20,
531
+ shadowOffset: { width: 0, height: 6 },
532
+ shadowOpacity: 0.16,
533
+ shadowRadius: 16,
534
+ },
535
+ });
536
+
537
+ export { DuoTabBar, useDuoTabBarInset };