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

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, 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; the label stays plain text
58
+ */
59
+ const useCenterActionPress = () => {
60
+ const scale = useSharedValue(1);
61
+ const buttonStyle = useAnimatedStyle(() => ({
62
+ transform: [{ scale: scale.value }],
72
63
  }));
73
- const paddingBottom = getPaddingBottom(insets);
74
- const focusedTab = state?.routes[state.index]?.name || routes[0]?.params;
64
+ const onPressIn = () => {
65
+ scale.value = withTiming(
66
+ TabBarMotion.ctaPressedScale,
67
+ TabBarMotion.ctaPress,
68
+ );
69
+ };
70
+ const onPressOut = () => {
71
+ scale.value = withTiming(1, TabBarMotion.ctaPress);
72
+ };
73
+ return { buttonStyle, onPressIn, onPressOut };
74
+ };
75
75
 
76
+ /**
77
+ * center action (.Nav/CTA): button rising out of the notch, label under it
78
+ */
79
+ const CenterAction: React.FC<{
80
+ floatingButton: FloatingButtonProps;
81
+ fixedFontScale?: boolean;
82
+ }> = ({ floatingButton, fixedFontScale }) => {
83
+ const { theme } = useContext(ApplicationContext);
76
84
  const context = useContext<any>(MiniAppContext);
77
85
  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
- ],
86
+ const { icon, label, onPress, labelContent } = floatingButton;
87
+ const { buttonStyle, onPressIn, onPressOut } = useCenterActionPress();
88
+ const labelText = (
89
+ <Text
90
+ numberOfLines={1}
91
+ typography={'label_s_medium'}
92
+ color={theme.colors.text.secondary}
93
+ >
94
+ {label}
95
+ </Text>
150
96
  );
151
97
 
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}
98
+ return (
99
+ <View style={[styles.ctaSlot, showBaseLineDebug && styles.debugBaseLine]}>
100
+ <Pressable
101
+ accessibilityRole={'button'}
102
+ accessibilityLabel={label}
103
+ onPress={onPress}
104
+ onPressIn={onPressIn}
105
+ onPressOut={onPressOut}
106
+ style={styles.cta}
107
+ >
108
+ <Animated.View
109
+ style={[
110
+ styles.ctaButton,
111
+ { backgroundColor: theme.colors.primary },
112
+ buttonStyle,
113
+ ]}
168
114
  >
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
- ]);
115
+ {typeof icon === 'string' ? (
116
+ <Icon
117
+ source={icon}
118
+ size={TabBarMetrics.ctaIconSize}
119
+ color={Colors.black_01}
120
+ />
121
+ ) : (
122
+ icon
123
+ )}
124
+ </Animated.View>
125
+ <View style={styles.ctaLabel}>
126
+ {fixedFontScale ? (
127
+ <FixedScaleText>{labelText}</FixedScaleText>
128
+ ) : (
129
+ labelText
130
+ )}
131
+ {labelContent && (
132
+ <View pointerEvents={'none'} style={styles.ctaLabelOverlay}>
133
+ {labelContent}
134
+ </View>
135
+ )}
136
+ </View>
137
+ </Pressable>
138
+ </View>
139
+ );
140
+ };
204
141
 
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
- ]);
142
+ /**
143
+ * horizontal navigation bar: floating container, items split evenly, Special type puts
144
+ * the center action in a notch in the middle
145
+ */
146
+ const HorizontalTabBar: React.FC<{
147
+ items: TabItem[];
148
+ width: number;
149
+ activeTintColor: string;
150
+ inactiveTintColor: string;
151
+ floatingButton?: FloatingButtonProps;
152
+ fixedFontScale?: boolean;
153
+ }> = ({
154
+ items,
155
+ width,
156
+ activeTintColor,
157
+ inactiveTintColor,
158
+ floatingButton,
159
+ fixedFontScale,
160
+ }) => {
161
+ const withCta = hasCenterAction(floatingButton, items.length);
162
+ const [layouts, setLayouts] = useState<
163
+ Record<string, { x: number; y: number; width: number }>
164
+ >({});
165
+ const selected = layouts[items.find(item => item.focused)?.route.key ?? ''];
166
+
167
+ const renderItems = (data: TabItem[]) =>
168
+ data.map(item => (
169
+ <CustomBottomTabItem
170
+ key={item.route.key}
171
+ onLayout={e => {
172
+ const { x, y, width: itemWidth } = e.nativeEvent.layout;
173
+ setLayouts(prev => ({
174
+ ...prev,
175
+ [item.route.key]: { x, y, width: itemWidth },
176
+ }));
177
+ }}
178
+ label={item.label}
179
+ focused={item.focused}
180
+ icon={item.icon}
181
+ badge={item.badge}
182
+ onPress={item.onPress}
183
+ onLongPress={item.onLongPress}
184
+ activeTintColor={activeTintColor}
185
+ inactiveTintColor={inactiveTintColor}
186
+ fixedFontScale={fixedFontScale}
187
+ />
188
+ ));
221
189
 
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);
190
+ const renderContent = () => {
191
+ if (withCta && floatingButton) {
192
+ const middle = items.length / 2;
230
193
  return (
231
194
  <>
232
- {renderTabItem(leftArr)}
233
- {renderFloatingButton()}
234
- {renderTabItem(rightArr)}
195
+ {renderItems(items.slice(0, middle))}
196
+ <CenterAction
197
+ floatingButton={floatingButton}
198
+ fixedFontScale={fixedFontScale}
199
+ />
200
+ {renderItems(items.slice(middle))}
235
201
  </>
236
202
  );
237
203
  }
238
- return renderTabItem(routes);
204
+ return renderItems(items);
239
205
  };
240
206
 
207
+ const customContainer = withCta && floatingButton?.container;
208
+
241
209
  return (
242
210
  <View
211
+ style={[styles.container, { width }]}
212
+ accessibilityLabel={'bottom_tab_bar'}
213
+ >
214
+ {customContainer || (
215
+ <CurvedContainer
216
+ width={width}
217
+ height={TabBarMetrics.height}
218
+ radius={TabBarMetrics.height / 2}
219
+ notchCenterX={withCta ? width / 2 : undefined}
220
+ />
221
+ )}
222
+ <View style={styles.content}>
223
+ <ActiveIndicator
224
+ axis={'x'}
225
+ offset={selected?.x}
226
+ extent={selected?.width}
227
+ radius={TabBarMetrics.indicatorRadius}
228
+ style={[
229
+ styles.indicator,
230
+ { top: selected?.y ?? TabBarMetrics.paddingVertical },
231
+ ]}
232
+ />
233
+ {renderContent()}
234
+ </View>
235
+ </View>
236
+ );
237
+ };
238
+
239
+ /**
240
+ * phone tab bar: the horizontal navigation bar floating over the tab content above the home
241
+ * indicator, so the content shows around it and through the notch. The transparent strip
242
+ * above it keeps the rising center action touchable
243
+ */
244
+ export default function BottomTabBar(props: Props) {
245
+ const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
246
+ const window = useWindowDimensions();
247
+ const insets = useSafeAreaInsets();
248
+ const withCta = hasCenterAction(floatingButton, state.routes.length);
249
+ const width = getHorizontalBarWidth(
250
+ window.width,
251
+ window.height,
252
+ insets.left,
253
+ insets.right,
254
+ );
255
+
256
+ return (
257
+ <View
258
+ pointerEvents={'box-none'}
243
259
  style={[
244
- floatingButton ? styles.tabBarFloatingButton : styles.tabBar,
245
- {
246
- backgroundColor: theme.colors.background.surface,
247
- borderTopColor: theme.colors.border.default,
248
- },
260
+ styles.bar,
249
261
  {
250
- paddingBottom: paddingBottom,
262
+ paddingTop: withCta ? TabBarMetrics.ctaLift : 0,
263
+ paddingBottom: getSystemIndicatorHeight(insets.bottom),
264
+ // centered between the side safe areas
251
265
  paddingLeft: insets.left,
252
266
  paddingRight: insets.right,
253
267
  },
254
- style,
255
268
  ]}
256
- pointerEvents={'auto'}
257
269
  >
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>
270
+ <HorizontalTabBar
271
+ items={getTabItems(props)}
272
+ width={width}
273
+ floatingButton={floatingButton}
274
+ activeTintColor={activeTintColor}
275
+ inactiveTintColor={inactiveTintColor}
276
+ />
272
277
  </View>
273
278
  );
274
279
  }
275
280
 
276
281
  const styles = StyleSheet.create({
282
+ bar: {
283
+ position: 'absolute',
284
+ left: 0,
285
+ right: 0,
286
+ bottom: 0,
287
+ alignItems: 'center',
288
+ },
289
+ container: {
290
+ height: TabBarMetrics.height,
291
+ },
277
292
  content: {
278
293
  flex: 1,
279
294
  flexDirection: 'row',
295
+ alignItems: 'center',
296
+ gap: TabBarMetrics.itemGap,
297
+ paddingVertical: TabBarMetrics.paddingVertical,
298
+ paddingHorizontal: TabBarMetrics.paddingHorizontal,
280
299
  },
281
- tabBar: {
282
- left: 0,
283
- right: 0,
284
- bottom: 0,
285
- borderTopWidth: StyleSheet.hairlineWidth,
286
- elevation: 8,
300
+ indicator: {
301
+ height: TabBarMetrics.indicatorHeight,
287
302
  },
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,
303
+ ctaSlot: {
304
+ width: TabBarMetrics.ctaSlotWidth,
305
+ alignSelf: 'stretch',
298
306
  },
299
- floatingContainer: {
307
+ // the button rises ctaLift above the container top, the label ends on the item baseline
308
+ cta: {
300
309
  position: 'absolute',
301
- alignItems: 'center',
302
310
  left: 0,
303
311
  right: 0,
304
- top: -2,
305
- bottom: Platform.OS === 'ios' ? -6 : 4
312
+ top: -TabBarMetrics.ctaLift - TabBarMetrics.paddingVertical,
313
+ alignItems: 'center',
314
+ gap: TabBarMetrics.ctaLabelGap,
306
315
  },
307
- floatingContent: {
316
+ ctaButton: {
317
+ boxShadow: CTA_SHADOW,
318
+ width: TabBarMetrics.ctaSize,
319
+ height: TabBarMetrics.ctaSize,
320
+ borderRadius: TabBarMetrics.ctaSize / 2,
308
321
  alignItems: 'center',
309
322
  justifyContent: 'center',
310
- height: 48,
311
- width: 48,
312
- borderRadius: 24,
313
- top: -10,
314
323
  },
315
- indicator: {
316
- height: 2,
317
- width: 44,
318
- alignSelf: 'center',
319
- borderBottomLeftRadius: 1,
320
- borderBottomRightRadius: 1,
324
+ // custom label content over the label text, centered and unclipped
325
+ ctaLabelOverlay: {
326
+ ...StyleSheet.absoluteFillObject,
327
+ alignItems: 'center',
328
+ justifyContent: 'center',
329
+ },
330
+ ctaLabel: {
331
+ maxWidth: TabBarMetrics.ctaSlotWidth,
332
+ minHeight: TabBarMetrics.ctaLabelHeight,
333
+ paddingHorizontal: Spacing.XXS,
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
+ };