@momo-kits/foundation 0.168.1-beta.24 → 0.168.1-beta.25

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,90 @@
1
+ import React, { useContext, useEffect, useRef } from 'react';
2
+ import { StyleProp, StyleSheet, ViewStyle } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withTiming,
7
+ } from 'react-native-reanimated';
8
+ import { ApplicationContext } from '../../Context';
9
+ import { getIndicatorShadow, TabBarMotion } from './metrics';
10
+
11
+ type Props = {
12
+ axis: 'x' | 'y';
13
+ // position of the selected item along the axis, undefined until it is measured
14
+ offset?: number;
15
+ // horizontal bar: width of the selected item
16
+ extent?: number;
17
+ radius: number;
18
+ style: StyleProp<ViewStyle>;
19
+ };
20
+
21
+ /**
22
+ * one active indicator per bar (Background/tonal with its inner shadows) sliding to the
23
+ * selected item; it lands in place on the first measure and slides from then on
24
+ */
25
+ const ActiveIndicator: React.FC<Props> = ({
26
+ axis,
27
+ offset,
28
+ extent,
29
+ radius,
30
+ style,
31
+ }) => {
32
+ const { theme } = useContext(ApplicationContext);
33
+ const position = useSharedValue(offset ?? 0);
34
+ const size = useSharedValue(extent ?? 0);
35
+ const visible = useSharedValue(offset === undefined ? 0 : 1);
36
+ const placed = useRef(offset !== undefined);
37
+
38
+ useEffect(() => {
39
+ if (offset === undefined) {
40
+ return;
41
+ }
42
+ size.value = extent ?? 0;
43
+ if (!placed.current) {
44
+ placed.current = true;
45
+ position.value = offset;
46
+ visible.value = 1;
47
+ return;
48
+ }
49
+ position.value = withTiming(offset, TabBarMotion.indicator);
50
+ }, [offset, extent, position, size, visible]);
51
+
52
+ const animatedStyle = useAnimatedStyle(() =>
53
+ axis === 'x'
54
+ ? {
55
+ opacity: visible.value,
56
+ width: size.value,
57
+ transform: [{ translateX: position.value }],
58
+ }
59
+ : {
60
+ opacity: visible.value,
61
+ transform: [{ translateY: position.value }],
62
+ },
63
+ );
64
+
65
+ return (
66
+ <Animated.View
67
+ pointerEvents={'none'}
68
+ style={[
69
+ styles.indicator,
70
+ {
71
+ borderRadius: radius,
72
+ backgroundColor: theme.colors.background.tonal,
73
+ boxShadow: getIndicatorShadow(theme.colors.primary),
74
+ },
75
+ style,
76
+ animatedStyle,
77
+ ]}
78
+ />
79
+ );
80
+ };
81
+
82
+ const styles = StyleSheet.create({
83
+ indicator: {
84
+ position: 'absolute',
85
+ left: 0,
86
+ top: 0,
87
+ },
88
+ });
89
+
90
+ export { ActiveIndicator };
@@ -1,10 +1,9 @@
1
1
  import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
2
- import { CommonActions, Route } from '@react-navigation/native';
3
- import React, { useCallback, useContext } from 'react';
2
+ import React, { useContext, useState } from 'react';
4
3
  import {
5
4
  Platform,
5
+ Pressable,
6
6
  StyleSheet,
7
- TouchableOpacity,
8
7
  useWindowDimensions,
9
8
  View,
10
9
  } from 'react-native';
@@ -13,311 +12,333 @@ import Animated, {
13
12
  useSharedValue,
14
13
  withTiming,
15
14
  } from 'react-native-reanimated';
16
- import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
17
- import { Colors, Radius, Styles } from '../../Consts';
15
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
16
+ import { Colors, Radius, Spacing } from '../../Consts';
18
17
  import { Icon } from '../../Icon';
19
18
  import { Text } from '../../Text';
20
19
  import { FloatingButtonProps } from '../types';
21
20
  import CustomBottomTabItem from './CustomBottomTabItem';
22
21
  import { ApplicationContext, MiniAppContext } from '../../Context';
22
+ import { CurvedContainer } from './CurvedContainer';
23
+ import { ActiveIndicator } from './ActiveIndicator';
24
+ import {
25
+ CTA_SHADOW,
26
+ getHorizontalBarWidth,
27
+ TabBarMetrics,
28
+ TabBarMotion,
29
+ } from './metrics';
30
+ import { getTabItems, TabItem } from './tabItems';
31
+ import { FixedScaleText } from './TabBarIcon';
23
32
 
24
33
  type Props = BottomTabBarProps & {
25
- activeTintColor?: string;
26
- inactiveTintColor?: string;
34
+ activeTintColor: string;
35
+ inactiveTintColor: string;
27
36
  floatingButton?: FloatingButtonProps;
28
- activeBackgroundColor?: string;
29
- allowFontScaling?: boolean;
30
- inactiveBackgroundColor?: string;
31
- labelStyle?: any;
32
- iconStyle?: any;
33
- showLabel?: boolean;
34
- style?: any;
35
- tabStyle?: any;
36
- badgeStyle?: any;
37
37
  };
38
38
 
39
- const getPaddingBottom = (insets: EdgeInsets) => {
40
- return Math.max(insets.bottom - Platform.select({ ios: 4, default: 0 }), 0);
41
- };
39
+ /**
40
+ * the center action needs a tab on each side: Special type takes 2 or 4 tabs
41
+ */
42
+ const hasCenterAction = (floatingButton: unknown, tabCount: number) =>
43
+ !!floatingButton && tabCount % 2 === 0;
42
44
 
43
- export default function BottomTabBar({
44
- state,
45
- navigation,
46
- descriptors,
47
- activeBackgroundColor,
48
- activeTintColor,
49
- allowFontScaling,
50
- inactiveBackgroundColor,
51
- inactiveTintColor,
52
- labelStyle,
53
- iconStyle,
54
- showLabel,
55
- style,
56
- floatingButton,
57
- }: Props) {
58
- const { theme } = useContext(ApplicationContext);
59
- const { routes } = state;
60
- const widthDevice = useWindowDimensions()?.width;
61
- const insets = useSafeAreaInsets();
62
- const useFloating = floatingButton && state.routes.length % 2 === 0;
63
- const itemWidth =
64
- (widthDevice - insets.left - insets.right) /
65
- (state.routes.length + (useFloating ? 1 : 0));
66
- // const buildLink = useLinkBuilder();
67
- const indicatorAnimated = useSharedValue(0);
45
+ /**
46
+ * gap under the container: the home indicator / gesture bar area
47
+ */
48
+ const getSystemIndicatorHeight = (bottomInset: number) =>
49
+ Math.max(
50
+ Platform.OS === 'ios'
51
+ ? Math.min(bottomInset, TabBarMetrics.systemIndicatorHeight)
52
+ : bottomInset,
53
+ Spacing.S,
54
+ );
68
55
 
69
- const indicatorStyle = useAnimatedStyle(() => ({
70
- width: itemWidth,
71
- transform: [{ translateX: indicatorAnimated.value }],
56
+ /**
57
+ * center action press: the button shrinks while held and its label turns active
58
+ * (label in a Background/primary pill, white text)
59
+ */
60
+ const useCenterActionPress = () => {
61
+ const [pressed, setPressed] = useState(false);
62
+ const scale = useSharedValue(1);
63
+ const buttonStyle = useAnimatedStyle(() => ({
64
+ transform: [{ scale: scale.value }],
72
65
  }));
73
- const paddingBottom = getPaddingBottom(insets);
74
- const focusedTab = state?.routes[state.index]?.name || routes[0]?.params;
66
+ const onPressIn = () => {
67
+ setPressed(true);
68
+ scale.value = withTiming(
69
+ TabBarMotion.ctaPressedScale,
70
+ TabBarMotion.ctaPress,
71
+ );
72
+ };
73
+ const onPressOut = () => {
74
+ setPressed(false);
75
+ scale.value = withTiming(1, TabBarMotion.ctaPress);
76
+ };
77
+ return { pressed, buttonStyle, onPressIn, onPressOut };
78
+ };
75
79
 
80
+ /**
81
+ * center action (.Nav/CTA): button rising out of the notch, label under it
82
+ */
83
+ const CenterAction: React.FC<{
84
+ floatingButton: FloatingButtonProps;
85
+ fixedFontScale?: boolean;
86
+ }> = ({ floatingButton, fixedFontScale }) => {
87
+ const { theme } = useContext(ApplicationContext);
76
88
  const context = useContext<any>(MiniAppContext);
77
89
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
78
-
79
- /**
80
- * render tab item
81
- */
82
- const renderTabItem = useCallback(
83
- (data: Route<string, any>[]) => {
84
- return data?.map(route => {
85
- const focused = route?.params?.name === focusedTab;
86
- const { options } = descriptors[route.key];
87
-
88
- const onPress = () => {
89
- const event = navigation.emit({
90
- type: 'tabPress',
91
- target: route.key,
92
- canPreventDefault: true,
93
- });
94
-
95
- if (!focused && !event.defaultPrevented) {
96
- navigation.dispatch({
97
- ...CommonActions.navigate(route.name),
98
- target: state.key,
99
- });
100
- }
101
- };
102
-
103
- const onLongPress = () => {
104
- navigation.emit({
105
- type: 'tabLongPress',
106
- target: route.key,
107
- });
108
- };
109
-
110
- return (
111
- <CustomBottomTabItem
112
- key={route.key}
113
- route={route}
114
- label={options.tabBarLabel || options.title || route.name}
115
- focused={focused}
116
- onPress={onPress}
117
- onLongPress={onLongPress}
118
- activeTintColor={activeTintColor ?? theme.colors.primary}
119
- inactiveTintColor={inactiveTintColor ?? theme.colors.text.default}
120
- activeBackgroundColor={activeBackgroundColor}
121
- inactiveBackgroundColor={inactiveBackgroundColor}
122
- icon={options.tabBarIcon}
123
- badge={options.tabBarBadge}
124
- showLabel={showLabel}
125
- labelStyle={labelStyle}
126
- iconStyle={iconStyle}
127
- style={options.tabBarStyle}
128
- badgeStyle={options.tabBarBadgeStyle}
129
- allowFontScaling={allowFontScaling}
130
- />
131
- );
132
- });
133
- },
134
- [
135
- focusedTab,
136
- descriptors,
137
- activeTintColor,
138
- theme.colors.primary,
139
- theme.colors.text.default,
140
- inactiveTintColor,
141
- activeBackgroundColor,
142
- inactiveBackgroundColor,
143
- showLabel,
144
- labelStyle,
145
- iconStyle,
146
- allowFontScaling,
147
- navigation,
148
- state.key,
149
- ],
90
+ const { icon, label, onPress } = floatingButton;
91
+ const { pressed, buttonStyle, onPressIn, onPressOut } =
92
+ useCenterActionPress();
93
+ const labelText = (
94
+ <Text
95
+ numberOfLines={1}
96
+ typography={'label_s_medium'}
97
+ color={pressed ? Colors.black_01 : theme.colors.text.secondary}
98
+ >
99
+ {label}
100
+ </Text>
150
101
  );
151
102
 
152
- /**
153
- * render floating button
154
- */
155
- const renderFloatingButton = useCallback(() => {
156
- const renderFloatingIcon = () => {
157
- if (typeof floatingButton?.icon === 'string') {
158
- return <Icon source={floatingButton?.icon} size={32} color={'white'} />;
159
- }
160
- return floatingButton?.icon;
161
- };
162
-
163
- return (
164
- <View style={[Styles.flex, showBaseLineDebug && styles.debugBaseLine]}>
165
- <TouchableOpacity
166
- onPress={floatingButton?.onPress}
167
- style={styles.floatingContainer}
103
+ return (
104
+ <View style={[styles.ctaSlot, showBaseLineDebug && styles.debugBaseLine]}>
105
+ <Pressable
106
+ accessibilityRole={'button'}
107
+ accessibilityLabel={label}
108
+ onPress={onPress}
109
+ onPressIn={onPressIn}
110
+ onPressOut={onPressOut}
111
+ style={styles.cta}
112
+ >
113
+ <Animated.View
114
+ style={[
115
+ styles.ctaButton,
116
+ { backgroundColor: theme.colors.primary },
117
+ buttonStyle,
118
+ ]}
168
119
  >
169
- <View
170
- style={{
171
- ...styles.floatingContent,
172
- backgroundColor: activeTintColor ?? theme.colors.primary,
173
- }}
174
- >
175
- {renderFloatingIcon()}
176
- </View>
177
- <View
178
- style={{
179
- top: -4,
180
- backgroundColor: activeTintColor ?? theme.colors.primary,
181
- borderRadius: Radius.XL,
182
- paddingHorizontal: 6,
183
- }}
184
- >
185
- <Text
186
- numberOfLines={1}
187
- typography={'description_xs_regular'}
188
- color={'white'}
189
- >
190
- {floatingButton?.label}
191
- </Text>
192
- </View>
193
- </TouchableOpacity>
194
- </View>
195
- );
196
- }, [
197
- activeTintColor,
198
- floatingButton?.icon,
199
- floatingButton?.label,
200
- floatingButton?.onPress,
201
- showBaseLineDebug,
202
- theme.colors.primary,
203
- ]);
120
+ {typeof icon === 'string' ? (
121
+ <Icon
122
+ source={icon}
123
+ size={TabBarMetrics.ctaIconSize}
124
+ color={Colors.black_01}
125
+ />
126
+ ) : (
127
+ icon
128
+ )}
129
+ </Animated.View>
130
+ <View
131
+ style={[
132
+ styles.ctaLabel,
133
+ pressed && { backgroundColor: theme.colors.primary },
134
+ ]}
135
+ >
136
+ {fixedFontScale ? (
137
+ <FixedScaleText>{labelText}</FixedScaleText>
138
+ ) : (
139
+ labelText
140
+ )}
141
+ </View>
142
+ </Pressable>
143
+ </View>
144
+ );
145
+ };
204
146
 
205
- /**
206
- * animated indicator
207
- */
208
- React.useEffect(() => {
209
- let index = state.index;
210
- if (useFloating && index > (state.routes.length - 1) / 2) {
211
- index += 1;
212
- }
213
- indicatorAnimated.value = withTiming(itemWidth * index, { duration: 200 });
214
- }, [
215
- state.index,
216
- itemWidth,
217
- indicatorAnimated,
218
- useFloating,
219
- state.routes.length,
220
- ]);
147
+ /**
148
+ * horizontal navigation bar: floating container, items split evenly, Special type puts
149
+ * the center action in a notch in the middle
150
+ */
151
+ const HorizontalTabBar: React.FC<{
152
+ items: TabItem[];
153
+ width: number;
154
+ activeTintColor: string;
155
+ inactiveTintColor: string;
156
+ floatingButton?: FloatingButtonProps;
157
+ fixedFontScale?: boolean;
158
+ }> = ({
159
+ items,
160
+ width,
161
+ activeTintColor,
162
+ inactiveTintColor,
163
+ floatingButton,
164
+ fixedFontScale,
165
+ }) => {
166
+ const withCta = hasCenterAction(floatingButton, items.length);
167
+ const [layouts, setLayouts] = useState<
168
+ Record<string, { x: number; y: number; width: number }>
169
+ >({});
170
+ const selected = layouts[items.find(item => item.focused)?.route.key ?? ''];
221
171
 
222
- /**
223
- * render tab bar
224
- */
225
- const renderTabBar = () => {
226
- if (useFloating) {
227
- const middleIndex = routes.length / 2;
228
- const leftArr = routes.slice(0, middleIndex);
229
- const rightArr = routes.slice(middleIndex);
172
+ const renderItems = (data: TabItem[]) =>
173
+ data.map(item => (
174
+ <CustomBottomTabItem
175
+ key={item.route.key}
176
+ onLayout={e => {
177
+ const { x, y, width: itemWidth } = e.nativeEvent.layout;
178
+ setLayouts(prev => ({
179
+ ...prev,
180
+ [item.route.key]: { x, y, width: itemWidth },
181
+ }));
182
+ }}
183
+ label={item.label}
184
+ focused={item.focused}
185
+ icon={item.icon}
186
+ badge={item.badge}
187
+ onPress={item.onPress}
188
+ onLongPress={item.onLongPress}
189
+ activeTintColor={activeTintColor}
190
+ inactiveTintColor={inactiveTintColor}
191
+ fixedFontScale={fixedFontScale}
192
+ />
193
+ ));
194
+
195
+ const renderContent = () => {
196
+ if (withCta && floatingButton) {
197
+ const middle = items.length / 2;
230
198
  return (
231
199
  <>
232
- {renderTabItem(leftArr)}
233
- {renderFloatingButton()}
234
- {renderTabItem(rightArr)}
200
+ {renderItems(items.slice(0, middle))}
201
+ <CenterAction
202
+ floatingButton={floatingButton}
203
+ fixedFontScale={fixedFontScale}
204
+ />
205
+ {renderItems(items.slice(middle))}
235
206
  </>
236
207
  );
237
208
  }
238
- return renderTabItem(routes);
209
+ return renderItems(items);
239
210
  };
240
211
 
212
+ const customContainer = withCta && floatingButton?.container;
213
+
241
214
  return (
242
215
  <View
216
+ style={[styles.container, { width }]}
217
+ accessibilityLabel={'bottom_tab_bar'}
218
+ >
219
+ {customContainer || (
220
+ <CurvedContainer
221
+ width={width}
222
+ height={TabBarMetrics.height}
223
+ radius={TabBarMetrics.height / 2}
224
+ notchCenterX={withCta ? width / 2 : undefined}
225
+ />
226
+ )}
227
+ <View style={styles.content}>
228
+ <ActiveIndicator
229
+ axis={'x'}
230
+ offset={selected?.x}
231
+ extent={selected?.width}
232
+ radius={TabBarMetrics.indicatorRadius}
233
+ style={[
234
+ styles.indicator,
235
+ { top: selected?.y ?? TabBarMetrics.paddingVertical },
236
+ ]}
237
+ />
238
+ {renderContent()}
239
+ </View>
240
+ </View>
241
+ );
242
+ };
243
+
244
+ /**
245
+ * phone tab bar: the horizontal navigation bar floating over the tab content above the home
246
+ * indicator, so the content shows around it and through the notch. The transparent strip
247
+ * above it keeps the rising center action touchable
248
+ */
249
+ export default function BottomTabBar(props: Props) {
250
+ const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
251
+ const window = useWindowDimensions();
252
+ const insets = useSafeAreaInsets();
253
+ const withCta = hasCenterAction(floatingButton, state.routes.length);
254
+ const width = getHorizontalBarWidth(
255
+ window.width,
256
+ window.height,
257
+ insets.left,
258
+ insets.right,
259
+ );
260
+
261
+ return (
262
+ <View
263
+ pointerEvents={'box-none'}
243
264
  style={[
244
- floatingButton ? styles.tabBarFloatingButton : styles.tabBar,
265
+ styles.bar,
245
266
  {
246
- backgroundColor: theme.colors.background.surface,
247
- borderTopColor: theme.colors.border.default,
248
- },
249
- {
250
- paddingBottom: paddingBottom,
267
+ paddingTop: withCta ? TabBarMetrics.ctaLift : 0,
268
+ paddingBottom: getSystemIndicatorHeight(insets.bottom),
269
+ // centered between the side safe areas
251
270
  paddingLeft: insets.left,
252
271
  paddingRight: insets.right,
253
272
  },
254
- style,
255
273
  ]}
256
- pointerEvents={'auto'}
257
274
  >
258
- <View style={styles.content} accessibilityLabel={'bottom_tab_bar'}>
259
- {useFloating && floatingButton?.container}
260
- <Animated.View
261
- style={[{ position: 'absolute', top: 0 }, indicatorStyle]}
262
- >
263
- <View
264
- style={[
265
- styles.indicator,
266
- { backgroundColor: activeTintColor ?? theme.colors.primary },
267
- ]}
268
- />
269
- </Animated.View>
270
- {renderTabBar()}
271
- </View>
275
+ <HorizontalTabBar
276
+ items={getTabItems(props)}
277
+ width={width}
278
+ floatingButton={floatingButton}
279
+ activeTintColor={activeTintColor}
280
+ inactiveTintColor={inactiveTintColor}
281
+ />
272
282
  </View>
273
283
  );
274
284
  }
275
285
 
276
286
  const styles = StyleSheet.create({
287
+ bar: {
288
+ position: 'absolute',
289
+ left: 0,
290
+ right: 0,
291
+ bottom: 0,
292
+ alignItems: 'center',
293
+ },
294
+ container: {
295
+ height: TabBarMetrics.height,
296
+ },
277
297
  content: {
278
298
  flex: 1,
279
299
  flexDirection: 'row',
300
+ alignItems: 'center',
301
+ gap: TabBarMetrics.itemGap,
302
+ paddingVertical: TabBarMetrics.paddingVertical,
303
+ paddingHorizontal: TabBarMetrics.paddingHorizontal,
280
304
  },
281
- tabBar: {
282
- left: 0,
283
- right: 0,
284
- bottom: 0,
285
- borderTopWidth: StyleSheet.hairlineWidth,
286
- elevation: 8,
305
+ indicator: {
306
+ height: TabBarMetrics.indicatorHeight,
287
307
  },
288
- tabBarFloatingButton: {
289
- left: 0,
290
- right: 0,
291
- bottom: 0,
292
- zIndex: 1,
293
- shadowColor: Colors.black_20,
294
- shadowOffset: { width: 0, height: -2 },
295
- shadowOpacity: 0.05,
296
- shadowRadius: 3,
297
- elevation: 8,
308
+ ctaSlot: {
309
+ width: TabBarMetrics.ctaSlotWidth,
310
+ alignSelf: 'stretch',
298
311
  },
299
- floatingContainer: {
312
+ // the button rises ctaLift above the container top, the label ends on the item baseline
313
+ cta: {
300
314
  position: 'absolute',
301
- alignItems: 'center',
302
315
  left: 0,
303
316
  right: 0,
304
- top: -2,
305
- bottom: Platform.OS === 'ios' ? -6 : 4
317
+ top: -TabBarMetrics.ctaLift - TabBarMetrics.paddingVertical,
318
+ alignItems: 'center',
319
+ gap: TabBarMetrics.ctaLabelGap,
306
320
  },
307
- floatingContent: {
321
+ ctaButton: {
322
+ boxShadow: CTA_SHADOW,
323
+ width: TabBarMetrics.ctaSize,
324
+ height: TabBarMetrics.ctaSize,
325
+ borderRadius: TabBarMetrics.ctaSize / 2,
308
326
  alignItems: 'center',
309
327
  justifyContent: 'center',
310
- height: 48,
311
- width: 48,
312
- borderRadius: 24,
313
- top: -10,
314
328
  },
315
- indicator: {
316
- height: 2,
317
- width: 44,
318
- alignSelf: 'center',
319
- borderBottomLeftRadius: 1,
320
- borderBottomRightRadius: 1,
329
+ ctaLabel: {
330
+ maxWidth: TabBarMetrics.ctaSlotWidth,
331
+ minHeight: TabBarMetrics.ctaLabelHeight,
332
+ paddingHorizontal: Spacing.XXS,
333
+ borderRadius: Radius.XL,
334
+ justifyContent: 'center',
321
335
  },
322
336
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
323
337
  });
338
+
339
+ export {
340
+ HorizontalTabBar,
341
+ getSystemIndicatorHeight,
342
+ hasCenterAction,
343
+ useCenterActionPress,
344
+ };