@momo-kits/foundation 0.167.1-duo.5 → 0.167.1-duotest.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.
@@ -0,0 +1,135 @@
1
+ import React, {
2
+ createContext,
3
+ ReactNode,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ } from 'react';
8
+ import { Spacing } from '../../Consts';
9
+ import { useScreenLayout } from '../../Layout';
10
+ import { DuoMetrics } from '../../Layout/utils';
11
+
12
+ export type DuoBarCompressionBehavior = 'toolbar' | 'tabBar';
13
+
14
+ type DuoBarCompression = {
15
+ toolbarCompressed: boolean;
16
+ tabBarCompressed: boolean;
17
+ // the open rail needs the toolbar button slot too: only Back stays
18
+ toolbarHidden?: boolean;
19
+ // tapping the compressed bar expands it and compresses the other one
20
+ expandToolbar?: () => void;
21
+ expandTabBar?: () => void;
22
+ // a rail opened from the compressed state closes once a tab is picked
23
+ onTabSelected?: () => void;
24
+ // header buttons report their full height so the tab bar knows what is left
25
+ setToolbarHeight?: (height: number) => void;
26
+ };
27
+
28
+ const RAIL_PADDING = 6;
29
+ const RAIL_GAP = Spacing.XXS;
30
+ // space kept between the top and the bottom groups (HIG Vertical controls)
31
+ const GROUP_GAP = Spacing.M;
32
+ // Back, then the right toolkit: help + home capsule
33
+ const DEFAULT_TOOLBAR_HEIGHT =
34
+ DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
35
+ // Back, then the single button the toolbar folds into
36
+ const COMPRESSED_TOOLBAR_HEIGHT = 2 * DuoMetrics.controlSize + Spacing.M;
37
+ // Back alone, while a rail opened from the compressed state takes the toolbar slot
38
+ const BACK_ONLY_HEIGHT = DuoMetrics.controlSize;
39
+
40
+ const NO_COMPRESSION: DuoBarCompression = {
41
+ toolbarCompressed: false,
42
+ tabBarCompressed: false,
43
+ };
44
+
45
+ const DuoBarCompressionContext =
46
+ createContext<DuoBarCompression>(NO_COMPRESSION);
47
+
48
+ /**
49
+ * height of the vertical tab rail, plus the QR button under it
50
+ */
51
+ const getRailHeight = (tabCount: number, hasQr: boolean) =>
52
+ 2 * RAIL_PADDING +
53
+ tabCount * DuoMetrics.controlSize +
54
+ Math.max(tabCount - 1, 0) * RAIL_GAP +
55
+ (hasQr ? Spacing.M + DuoMetrics.controlSize : 0);
56
+
57
+ /**
58
+ * keeps the side bar's toolbar and tab bar clear of each other (HIG, Designing for
59
+ * iPhone Duo: Vertical controls):
60
+ * - both fit: both stay whole
61
+ * - one fits whole beside the other compressed: one is compressed, tapping it swaps them
62
+ * - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
63
+ * again once a tab is picked)
64
+ */
65
+ const DuoBarCompressionProvider: React.FC<{
66
+ behavior?: DuoBarCompressionBehavior;
67
+ tabCount: number;
68
+ hasQr: boolean;
69
+ children: ReactNode;
70
+ }> = ({ behavior = 'toolbar', tabCount, hasQr, children }) => {
71
+ const { height, sideBar } = useScreenLayout();
72
+ const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_TOOLBAR_HEIGHT);
73
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
74
+ const [expanded, setExpanded] = useState<'toolbar' | 'tabBar'>(
75
+ behavior === 'toolbar' ? 'tabBar' : 'toolbar',
76
+ );
77
+ // bar opened for a moment while both rest compressed
78
+ const [opened, setOpened] = useState<'toolbar' | 'tabBar'>();
79
+
80
+ const value = useMemo(() => {
81
+ if (!sideBar) {
82
+ return NO_COMPRESSION;
83
+ }
84
+ const room = height - sideBar.bottom - sideBar.top - GROUP_GAP;
85
+ const tabBar = getRailHeight(tabCount, hasQr);
86
+ const compressedTabBar = getRailHeight(1, hasQr) - 2 * RAIL_PADDING;
87
+ const fits = (toolbar: number, tabs: number) => toolbar + tabs <= room;
88
+
89
+ if (fits(toolbarHeight, tabBar)) {
90
+ return { ...NO_COMPRESSION, setToolbarHeight };
91
+ }
92
+ const canSwap =
93
+ fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) &&
94
+ fits(toolbarHeight, compressedTabBar);
95
+ if (canSwap) {
96
+ return {
97
+ toolbarCompressed: expanded === 'tabBar',
98
+ tabBarCompressed: expanded === 'toolbar',
99
+ expandToolbar: () => setExpanded('toolbar'),
100
+ expandTabBar: () => setExpanded('tabBar'),
101
+ setToolbarHeight,
102
+ };
103
+ }
104
+
105
+ const open = {
106
+ expandToolbar: () => setOpened('toolbar'),
107
+ expandTabBar: () => setOpened('tabBar'),
108
+ setToolbarHeight,
109
+ };
110
+ if (opened === 'tabBar' && fits(BACK_ONLY_HEIGHT, tabBar)) {
111
+ return {
112
+ ...open,
113
+ toolbarCompressed: true,
114
+ toolbarHidden: true,
115
+ tabBarCompressed: false,
116
+ onTabSelected: () => setOpened(undefined),
117
+ };
118
+ }
119
+ return {
120
+ ...open,
121
+ toolbarCompressed: opened !== 'toolbar',
122
+ tabBarCompressed: true,
123
+ };
124
+ }, [expanded, opened, hasQr, height, sideBar, tabCount, toolbarHeight]);
125
+
126
+ return (
127
+ <DuoBarCompressionContext.Provider value={value}>
128
+ {children}
129
+ </DuoBarCompressionContext.Provider>
130
+ );
131
+ };
132
+
133
+ const useDuoBarCompression = () => useContext(DuoBarCompressionContext);
134
+
135
+ export { DuoBarCompressionProvider, useDuoBarCompression };
@@ -18,6 +18,7 @@ import { Icon } from '../../Icon';
18
18
  import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
19
19
  import { Text } from '../../Text';
20
20
  import { GlassView } from '../Components/OptionalGlassView';
21
+ import { useDuoBarCompression } from './DuoBarCompression';
21
22
  import { FloatingButtonProps } from '../types';
22
23
  import { TabBarIcon } from './TabBarIcon';
23
24
 
@@ -146,76 +147,121 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
146
147
  return <>{icon}</>;
147
148
  };
148
149
 
150
+ type TabItem = ReturnType<typeof getTabItems>[number];
151
+
149
152
  /**
150
- * vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E)
153
+ * glass column of tab items with the sliding selection bubble
151
154
  */
152
- const DuoRailTabBar: React.FC<Props> = props => {
155
+ const Rail: React.FC<{
156
+ items: TabItem[];
157
+ index: number;
158
+ onItemPress?: () => void;
159
+ }> = ({ items, index, onItemPress }) => {
153
160
  const { theme } = useContext(ApplicationContext);
154
- const { sideBar } = useScreenLayout();
155
- const { state, floatingButton } = props;
156
- const items = getTabItems(props);
157
- const bubbleOffset = useSharedValue(state.index * (ITEM_SIZE + RAIL_GAP));
161
+ const bubbleOffset = useSharedValue(index * (ITEM_SIZE + RAIL_GAP));
158
162
 
159
163
  useEffect(() => {
160
- bubbleOffset.value = withSpring(
161
- state.index * (ITEM_SIZE + RAIL_GAP),
162
- SPRING,
163
- );
164
- }, [state.index, bubbleOffset]);
164
+ bubbleOffset.value = withSpring(index * (ITEM_SIZE + RAIL_GAP), SPRING);
165
+ }, [index, bubbleOffset]);
165
166
 
166
167
  const bubbleStyle = useAnimatedStyle(() => ({
167
168
  transform: [{ translateY: bubbleOffset.value }],
168
169
  }));
169
170
 
171
+ return (
172
+ <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
173
+ <BarMaterial radius={Radius.XL} />
174
+ <Animated.View
175
+ pointerEvents={'none'}
176
+ style={[
177
+ styles.railBubble,
178
+ { backgroundColor: theme.colors.background.selected },
179
+ bubbleStyle,
180
+ ]}
181
+ />
182
+ {items.map(item => (
183
+ <TouchableOpacity
184
+ key={item.route.key}
185
+ accessibilityRole={'tab'}
186
+ accessibilityLabel={item.label}
187
+ accessibilityState={{ selected: item.focused }}
188
+ onPress={() => {
189
+ item.onPress();
190
+ onItemPress?.();
191
+ }}
192
+ onLongPress={item.onLongPress}
193
+ style={styles.railItem}
194
+ >
195
+ {item.icon}
196
+ </TouchableOpacity>
197
+ ))}
198
+ </View>
199
+ );
200
+ };
201
+
202
+ /**
203
+ * vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E).
204
+ * When the side bar is short, it can shrink to one button showing the current tab
205
+ * (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar
206
+ */
207
+ const DuoRailTabBar: React.FC<Props> = props => {
208
+ const { theme } = useContext(ApplicationContext);
209
+ const { sideBar } = useScreenLayout();
210
+ const { tabBarCompressed, expandTabBar, onTabSelected } =
211
+ useDuoBarCompression();
212
+ const { state, floatingButton } = props;
213
+ const items = getTabItems(props);
214
+ const current = items[state.index];
215
+
216
+ const columnStyle = sideBar && [
217
+ styles.railColumn,
218
+ {
219
+ [sideBar.edge]: 0,
220
+ width: sideBar.width,
221
+ bottom: sideBar.bottom,
222
+ },
223
+ sideBar.edge === 'right'
224
+ ? { alignItems: 'flex-start' as const, paddingLeft: Spacing.M }
225
+ : { alignItems: 'flex-end' as const, paddingRight: Spacing.M },
226
+ ];
227
+
228
+ const qr = floatingButton && (
229
+ <TouchableOpacity
230
+ accessibilityRole={'button'}
231
+ accessibilityLabel={floatingButton.label}
232
+ onPress={floatingButton.onPress}
233
+ style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
234
+ >
235
+ <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
236
+ </TouchableOpacity>
237
+ );
238
+
170
239
  if (!sideBar) {
171
240
  return null;
172
241
  }
173
242
 
243
+ if (!tabBarCompressed) {
244
+ return (
245
+ <View pointerEvents={'box-none'} style={columnStyle}>
246
+ <Rail items={items} index={state.index} onItemPress={onTabSelected} />
247
+ {qr}
248
+ </View>
249
+ );
250
+ }
251
+
174
252
  return (
175
- <View
176
- pointerEvents={'box-none'}
177
- style={[
178
- styles.railColumn,
179
- { [sideBar.edge]: 0, width: sideBar.width },
180
- sideBar.edge === 'right'
181
- ? { alignItems: 'flex-start', paddingLeft: Spacing.M }
182
- : { alignItems: 'flex-end', paddingRight: Spacing.M },
183
- ]}
184
- >
185
- <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
253
+ <View pointerEvents={'box-none'} style={columnStyle}>
254
+ <TouchableOpacity
255
+ accessibilityRole={'button'}
256
+ accessibilityLabel={current?.label}
257
+ accessibilityHint={'bottom_tab_bar'}
258
+ onPress={expandTabBar}
259
+ style={styles.railCompact}
260
+ >
186
261
  <BarMaterial radius={Radius.XL} />
187
- <Animated.View
188
- pointerEvents={'none'}
189
- style={[
190
- styles.railBubble,
191
- { backgroundColor: theme.colors.background.selected },
192
- bubbleStyle,
193
- ]}
194
- />
195
- {items.map(item => (
196
- <TouchableOpacity
197
- key={item.route.key}
198
- accessibilityRole={'tab'}
199
- accessibilityLabel={item.label}
200
- accessibilityState={{ selected: item.focused }}
201
- onPress={item.onPress}
202
- onLongPress={item.onLongPress}
203
- style={styles.railItem}
204
- >
205
- {item.icon}
206
- </TouchableOpacity>
207
- ))}
208
- </View>
209
- {floatingButton && (
210
- <TouchableOpacity
211
- accessibilityRole={'button'}
212
- accessibilityLabel={floatingButton.label}
213
- onPress={floatingButton.onPress}
214
- style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
215
- >
216
- <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
217
- </TouchableOpacity>
218
- )}
262
+ {current?.icon}
263
+ </TouchableOpacity>
264
+ {qr}
219
265
  </View>
220
266
  );
221
267
  };
@@ -347,9 +393,15 @@ const styles = StyleSheet.create({
347
393
  },
348
394
  railColumn: {
349
395
  position: 'absolute',
350
- bottom: Spacing.XL,
351
396
  gap: Spacing.M,
352
397
  },
398
+ railCompact: {
399
+ width: ITEM_SIZE,
400
+ height: ITEM_SIZE,
401
+ borderRadius: ITEM_SIZE / 2,
402
+ alignItems: 'center',
403
+ justifyContent: 'center',
404
+ },
353
405
  rail: {
354
406
  width: ITEM_SIZE,
355
407
  paddingVertical: RAIL_PADDING,
@@ -28,8 +28,10 @@ import { BottomTabProps } from '../types';
28
28
  import { getOptions, getStackOptions, useAppState } from '../utils';
29
29
  import BottomTabBar from './BottomTabBar';
30
30
  import { DuoTabBar } from './DuoTabBar';
31
+ import { DuoBarCompressionProvider } from './DuoBarCompression';
31
32
  import { ApplicationContext, MiniAppContext } from '../../Context';
32
33
  import { useScreenLayout } from '../../Layout';
34
+ import { getHeaderRowOptions } from '../../Layout/utils';
33
35
 
34
36
  const Tab = createBottomTabNavigator();
35
37
  const Stack = createStackNavigator();
@@ -38,13 +40,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
38
40
  const { navigator } = useContext(ApplicationContext);
39
41
  const context = useContext<any>(MiniAppContext);
40
42
  const { nested, options, screen, initialParams } = route?.params;
41
- const { headerHeight, headerStatusBarHeight } = useScreenLayout();
42
- const headerStyleOptions = {
43
- headerStyle: {
44
- height: headerHeight,
45
- },
46
- headerStatusBarHeight,
47
- };
43
+ const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
48
44
 
49
45
  const opacityValue = 0.3;
50
46
  const scaleValue = 0.97;
@@ -205,6 +201,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
205
201
  navigation,
206
202
  initialRouteName,
207
203
  floatingButton,
204
+ compressionBehavior,
208
205
  }) => {
209
206
  const { theme, navigator } = useContext(ApplicationContext);
210
207
  const { isIPhoneDuo } = useScreenLayout();
@@ -281,147 +278,156 @@ const BottomTab: React.FC<BottomTabProps> = ({
281
278
  };
282
279
 
283
280
  return (
284
- <Tab.Navigator
285
- screenOptions={{
286
- headerShown: false,
287
- headerTransparent: true,
288
- headerBackground: undefined,
289
- tabBarLabelPosition: 'below-icon',
290
- tabBarBackground: () => (
291
- <View
292
- style={[
293
- styles.container,
294
- {
295
- backgroundColor: theme.colors.background.surface,
296
- },
297
- ]}
298
- >
299
- <Animated.View style={[styles.indicatorContainer, indicatorStyle]}>
300
- <View
301
- style={[
302
- styles.indicator,
303
- { backgroundColor: theme.colors.primary },
304
- ]}
305
- />
306
- </Animated.View>
307
- </View>
308
- ),
309
- }}
310
- tabBar={props =>
311
- isIPhoneDuo ? (
312
- <DuoTabBar
313
- {...props}
314
- floatingButton={floatingButton}
315
- inactiveTintColor={theme.colors.text.default}
316
- activeTintColor={theme.colors.primary}
317
- />
318
- ) : (
319
- <BottomTabBar
320
- {...props}
321
- floatingButton={floatingButton}
322
- inactiveTintColor={theme.colors.text.hint}
323
- activeTintColor={theme.colors.primary}
324
- style={[
325
- {
326
- height:
327
- 64 +
328
- (Platform.OS === 'ios'
329
- ? Math.min(insets.bottom, 21)
330
- : insets.bottom),
331
- },
332
- ]}
333
- />
334
- )
335
- }
336
- initialRouteName={initialRouteName}
281
+ <DuoBarCompressionProvider
282
+ behavior={compressionBehavior}
283
+ tabCount={tabs.length}
284
+ hasQr={!!floatingButton}
337
285
  >
338
- {tabs.map((item, index) => {
339
- const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
340
- const isDot = typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
341
- const isNumZero = isNum && Number(item.badgeLabel) === 0;
342
-
343
- let badgeLabel = item?.badgeLabel;
344
- if (isNumZero) {
345
- badgeLabel = undefined;
286
+ <Tab.Navigator
287
+ screenOptions={{
288
+ headerShown: false,
289
+ headerTransparent: true,
290
+ headerBackground: undefined,
291
+ tabBarLabelPosition: 'below-icon',
292
+ tabBarBackground: () => (
293
+ <View
294
+ style={[
295
+ styles.container,
296
+ {
297
+ backgroundColor: theme.colors.background.surface,
298
+ },
299
+ ]}
300
+ >
301
+ <Animated.View
302
+ style={[styles.indicatorContainer, indicatorStyle]}
303
+ >
304
+ <View
305
+ style={[
306
+ styles.indicator,
307
+ { backgroundColor: theme.colors.primary },
308
+ ]}
309
+ />
310
+ </Animated.View>
311
+ </View>
312
+ ),
313
+ }}
314
+ tabBar={props =>
315
+ isIPhoneDuo ? (
316
+ <DuoTabBar
317
+ {...props}
318
+ floatingButton={floatingButton}
319
+ inactiveTintColor={theme.colors.text.default}
320
+ activeTintColor={theme.colors.primary}
321
+ />
322
+ ) : (
323
+ <BottomTabBar
324
+ {...props}
325
+ floatingButton={floatingButton}
326
+ inactiveTintColor={theme.colors.text.hint}
327
+ activeTintColor={theme.colors.primary}
328
+ style={[
329
+ {
330
+ height:
331
+ 64 +
332
+ (Platform.OS === 'ios'
333
+ ? Math.min(insets.bottom, 21)
334
+ : insets.bottom),
335
+ },
336
+ ]}
337
+ />
338
+ )
346
339
  }
340
+ initialRouteName={initialRouteName}
341
+ >
342
+ {tabs.map((item, index) => {
343
+ const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
344
+ const isDot =
345
+ typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
346
+ const isNumZero = isNum && Number(item.badgeLabel) === 0;
347
347
 
348
- let badgeStyle: {
349
- marginRight: number;
350
- top: number;
351
- backgroundColor: string;
352
- width?: number;
353
- } = {
354
- marginRight: -8,
355
- top: -4,
356
- backgroundColor: Colors.orange_03,
357
- };
348
+ let badgeLabel = item?.badgeLabel;
349
+ if (isNumZero) {
350
+ badgeLabel = undefined;
351
+ }
358
352
 
359
- if (isNum) {
360
- badgeStyle = {
361
- marginRight: -4,
353
+ let badgeStyle: {
354
+ marginRight: number;
355
+ top: number;
356
+ backgroundColor: string;
357
+ width?: number;
358
+ } = {
359
+ marginRight: -8,
362
360
  top: -4,
363
- backgroundColor: Colors.red_03,
364
- };
365
- }
366
- if (isDot) {
367
- badgeStyle = {
368
- marginRight: -2,
369
- top: -2,
370
361
  backgroundColor: Colors.orange_03,
371
- width: 12,
372
362
  };
373
- }
374
363
 
375
- return (
376
- <Tab.Screen
377
- key={`BottomTab-${item.name}`}
378
- name={item.name}
379
- component={TabScreen}
380
- initialParams={{ ...item, nested, index }}
381
- listeners={handler}
382
- options={{
383
- tabBarLabel: ({ focused, color }) => {
384
- return (
385
- <Text
386
- numberOfLines={1}
387
- typography={
388
- focused ? 'label_xs_medium' : 'description_xs_regular'
389
- }
390
- color={color}
391
- >
392
- {item.label}
393
- </Text>
394
- );
395
- },
396
- title: item.label,
397
- tabBarStyle: [
398
- {
399
- height: 64,
400
- },
401
- styles.tabStyle,
402
- ],
403
- tabBarBadge: badgeLabel,
404
- tabBarBadgeStyle: [
405
- {
406
- borderColor: 'white',
407
- borderWidth: 2,
408
- fontSize: 10,
409
- lineHeight: 14,
410
- fontWeight: 'bold',
411
- alignSelf: 'center',
412
- fontFamily: exportFontFamily('bold'),
364
+ if (isNum) {
365
+ badgeStyle = {
366
+ marginRight: -4,
367
+ top: -4,
368
+ backgroundColor: Colors.red_03,
369
+ };
370
+ }
371
+ if (isDot) {
372
+ badgeStyle = {
373
+ marginRight: -2,
374
+ top: -2,
375
+ backgroundColor: Colors.orange_03,
376
+ width: 12,
377
+ };
378
+ }
379
+
380
+ return (
381
+ <Tab.Screen
382
+ key={`BottomTab-${item.name}`}
383
+ name={item.name}
384
+ component={TabScreen}
385
+ initialParams={{ ...item, nested, index }}
386
+ listeners={handler}
387
+ options={{
388
+ tabBarLabel: ({ focused, color }) => {
389
+ return (
390
+ <Text
391
+ numberOfLines={1}
392
+ typography={
393
+ focused ? 'label_xs_medium' : 'description_xs_regular'
394
+ }
395
+ color={color}
396
+ >
397
+ {item.label}
398
+ </Text>
399
+ );
413
400
  },
414
- badgeStyle,
415
- ],
416
- tabBarIcon: ({ color }) => (
417
- <Icon source={item.icon} color={color} size={28} />
418
- ),
419
- ...item,
420
- }}
421
- />
422
- );
423
- })}
424
- </Tab.Navigator>
401
+ title: item.label,
402
+ tabBarStyle: [
403
+ {
404
+ height: 64,
405
+ },
406
+ styles.tabStyle,
407
+ ],
408
+ tabBarBadge: badgeLabel,
409
+ tabBarBadgeStyle: [
410
+ {
411
+ borderColor: 'white',
412
+ borderWidth: 2,
413
+ fontSize: 10,
414
+ lineHeight: 14,
415
+ fontWeight: 'bold',
416
+ alignSelf: 'center',
417
+ fontFamily: exportFontFamily('bold'),
418
+ },
419
+ badgeStyle,
420
+ ],
421
+ tabBarIcon: ({ color }) => (
422
+ <Icon source={item.icon} color={color} size={28} />
423
+ ),
424
+ ...item,
425
+ }}
426
+ />
427
+ );
428
+ })}
429
+ </Tab.Navigator>
430
+ </DuoBarCompressionProvider>
425
431
  );
426
432
  };
427
433