@momo-kits/foundation 0.167.1-beta.13 → 0.167.1-beta.14

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 (56) hide show
  1. package/Application/BottomSheet.tsx +246 -42
  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 -8
  14. package/Application/Components/SearchHeader.tsx +20 -15
  15. package/Application/Components/SideBarButton.tsx +134 -0
  16. package/Application/ModalScreen.tsx +20 -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 +52 -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 +123 -195
  29. package/Input/InputOTP.tsx +9 -12
  30. package/Input/InputPhoneNumber.tsx +3 -7
  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/styles.ts +9 -0
  36. package/Input/utils.ts +2 -3
  37. package/Layout/Card.tsx +8 -26
  38. package/Layout/FloatingButton.tsx +3 -4
  39. package/Layout/Item.tsx +1 -10
  40. package/Layout/ReservedRegions.tsx +78 -0
  41. package/Layout/Screen.tsx +242 -48
  42. package/Layout/Section.tsx +5 -18
  43. package/Layout/index.ts +4 -4
  44. package/Layout/styles.ts +5 -0
  45. package/Layout/utils.ts +478 -5
  46. package/Pagination/Dot.tsx +3 -6
  47. package/Popup/PopupNotify.tsx +49 -28
  48. package/Popup/PopupPromotion.tsx +21 -8
  49. package/Popup/utils.ts +26 -0
  50. package/Radio/index.tsx +2 -3
  51. package/Text/index.tsx +4 -0
  52. package/Text/utils.ts +2 -3
  53. package/Title/index.tsx +8 -11
  54. package/Title/styles.ts +0 -1
  55. package/package.json +10 -7
  56. package/Layout/GridSystem.tsx +0 -137
@@ -1,14 +1,25 @@
1
- import React, { useCallback, useContext, useEffect, useRef } from 'react';
1
+ import React, {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import {
3
9
  BackHandler,
4
10
  Dimensions,
11
+ Keyboard,
5
12
  KeyboardAvoidingView,
13
+ KeyboardEvent,
14
+ LayoutAnimation,
6
15
  Modal,
7
16
  PanResponder,
8
17
  Platform,
9
18
  Pressable,
19
+ StatusBar,
10
20
  StyleSheet,
11
21
  TouchableOpacity,
22
+ useWindowDimensions,
12
23
  View,
13
24
  } from 'react-native';
14
25
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -22,22 +33,58 @@ import Animated, {
22
33
  Easing,
23
34
  Extrapolation,
24
35
  interpolate,
25
- runOnJS,
26
36
  useAnimatedStyle,
27
37
  useSharedValue,
28
38
  withSpring,
29
39
  withTiming,
30
40
  } from 'react-native-reanimated';
41
+ import { scheduleOnRN } from 'react-native-worklets';
31
42
  import layoutStyles from '../Layout/styles';
43
+ import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
44
+ import { getDuoTopInset, isIPhoneDuo } from './device';
45
+ import { SideBarButton } from './Components/SideBarButton';
46
+
47
+ // iPhone Duo inner display: center the sheet instead of spanning the width
48
+ const DUO_REGULAR_MIN_WIDTH = 600;
49
+
50
+ const INDICATOR_HEIGHT = 16;
51
+ const HEADER_HEIGHT = 56;
52
+ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
53
+ const SIDE_COLUMN_OFFSET =
54
+ INDICATOR_HEIGHT + (HEADER_HEIGHT - DuoMetrics.controlSize) / 2;
32
55
 
33
56
  const BottomSheet: React.FC<BottomSheetParams> = props => {
34
57
  const { theme, navigator } = useContext(ApplicationContext);
35
58
  const context = useContext<any>(MiniAppContext);
36
- const heightDevice = Dimensions.get('screen').height;
59
+ const windowSize = useWindowDimensions();
60
+ const isDuo = isIPhoneDuo();
61
+ const isDuoCentered =
62
+ isDuo &&
63
+ Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
64
+ const isDuoFloating = isDuo && !isDuoCentered;
65
+ const { sideBar } = useScreenLayout();
66
+ const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
67
+ const sideColumn =
68
+ isDuoFloating &&
69
+ sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
70
+ sideBar
71
+ ? sideBar
72
+ : undefined;
73
+ const hasSideColumn = !!sideColumn;
74
+ // 'screen' is the outer display on iPhone Duo, even when unfolded
75
+ const screenHeight = Dimensions.get('screen').height;
76
+ const heightDevice = isDuo ? windowSize.height : screenHeight;
37
77
  const action = useRef<undefined | string>(undefined);
38
78
  const insets = useSafeAreaInsets();
39
79
  const heightHeader = useHeaderHeight();
40
- const bottomInset = Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
80
+ const topGap = Math.max(
81
+ sideColumn
82
+ ? insets.top
83
+ : getDuoTopInset(insets, windowSize.width, windowSize.height),
84
+ Spacing.XL,
85
+ );
86
+ const isIOS = Platform.OS === 'ios';
87
+ const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
41
88
  const keyboardOffset = heightHeader - bottomInset;
42
89
 
43
90
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -58,6 +105,46 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
58
105
  }: BottomSheetParams = props.route.params;
59
106
 
60
107
  const translateY = useSharedValue(heightDevice);
108
+ const onDismissRef = useRef(props.route.params?.onDismiss);
109
+ onDismissRef.current = props.route.params?.onDismiss;
110
+ const [keyboardLift, setKeyboardLift] = useState(0);
111
+ const [sheetTop, setSheetTop] = useState(windowSize.height);
112
+
113
+ // KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
114
+ // so lift it here and shrink maxHeight by the same amount instead
115
+ useEffect(() => {
116
+ if (!isDuo || !useKeyboardAvoidingView) {
117
+ return;
118
+ }
119
+ const onKeyboardChange = (e: KeyboardEvent) => {
120
+ const overlap = windowSize.height - e.endCoordinates.screenY;
121
+ const lift = Math.max(
122
+ overlap - bottomInset - (keyboardVerticalOffset ?? 0),
123
+ 0,
124
+ );
125
+ if (e.duration) {
126
+ LayoutAnimation.configureNext({
127
+ duration: e.duration,
128
+ update: {
129
+ duration: e.duration,
130
+ type: LayoutAnimation.Types[e.easing] || 'keyboard',
131
+ },
132
+ });
133
+ }
134
+ setKeyboardLift(lift);
135
+ };
136
+ const subscriptions = [
137
+ Keyboard.addListener('keyboardWillChangeFrame', onKeyboardChange),
138
+ Keyboard.addListener('keyboardWillHide', onKeyboardChange),
139
+ ];
140
+ return () => subscriptions.forEach(sub => sub.remove());
141
+ }, [
142
+ bottomInset,
143
+ isDuo,
144
+ keyboardVerticalOffset,
145
+ useKeyboardAvoidingView,
146
+ windowSize.height,
147
+ ]);
61
148
 
62
149
  const openAnimation = useCallback(() => {
63
150
  translateY.value = withTiming(0, {
@@ -74,7 +161,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
74
161
  duration: 200,
75
162
  easing: Easing.bezier(0.3, 0.0, 0.8, 0.15),
76
163
  },
77
- () => runOnJS(callback)(),
164
+ () => scheduleOnRN(callback),
78
165
  );
79
166
  },
80
167
  [heightDevice, translateY],
@@ -117,15 +204,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
117
204
  }
118
205
 
119
206
  /**
120
- * emit dismiss event
207
+ * emit dismiss event, once: folding or rotating iPhone Duo changes the
208
+ * window height while the sheet stays open
121
209
  */
122
210
  useEffect(() => {
123
- translateY.value = heightDevice;
124
211
  openAnimation();
125
212
  return () => {
126
- props.route.params?.onDismiss?.(action.current);
213
+ onDismissRef.current?.(action.current);
127
214
  };
128
- }, [heightDevice, openAnimation, props.route.params, translateY]);
215
+ }, [openAnimation]);
129
216
 
130
217
  /**
131
218
  * handler dismiss
@@ -137,9 +224,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
137
224
  }
138
225
  closeAnimation(() => {
139
226
  navigator?.pop();
140
- runOnJS(() => {
227
+ scheduleOnRN(() => {
141
228
  callback?.();
142
- })();
229
+ });
143
230
  });
144
231
  },
145
232
  [barrierDismissible, closeAnimation, navigator],
@@ -166,13 +253,42 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
166
253
  return () => backHandler.remove();
167
254
  }, [barrierDismissible, onDismiss]);
168
255
 
256
+ const onPressBack = useCallback(() => {
257
+ onDismiss(true, leftOptions?.onPressIconLeft);
258
+ }, [leftOptions, onDismiss]);
259
+
260
+ const onPressClose = useCallback(() => {
261
+ action.current = 'icon_close';
262
+ onDismiss(true);
263
+ }, [onDismiss]);
264
+
265
+ const showBack = !!leftOptions?.onPressIconLeft;
266
+ const iconBack = leftOptions?.iconLeft ?? 'ic_back';
267
+ const sideColumnCount = showBack ? 2 : 1;
268
+ const sideColumnHeight =
269
+ sideColumnCount * DuoMetrics.controlSize +
270
+ (sideColumnCount - 1) * Spacing.M;
271
+ const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
272
+
273
+ useEffect(() => {
274
+ if (!coversStatusBar) {
275
+ return;
276
+ }
277
+ const entry = StatusBar.pushStackEntry({
278
+ barStyle: theme.dark ? 'light-content' : 'dark-content',
279
+ animated: true,
280
+ });
281
+ return () => StatusBar.popStackEntry(entry);
282
+ }, [coversStatusBar, theme.dark]);
283
+
169
284
  /**
170
285
  * render header
171
286
  */
172
287
  const renderHeader = useCallback(() => {
288
+ const showButtons = !hasSideColumn;
173
289
  return (
174
290
  <View {...panResponder.panHandlers}>
175
- <View style={{ height: 16 }}>
291
+ <View style={{ height: INDICATOR_HEIGHT }}>
176
292
  <View style={styles.indicator} />
177
293
  </View>
178
294
  <View
@@ -184,14 +300,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
184
300
  },
185
301
  ]}
186
302
  >
187
- {leftOptions?.onPressIconLeft ? (
188
- <TouchableOpacity
189
- style={styles.iconButton}
190
- onPress={() => {
191
- onDismiss(true, leftOptions.onPressIconLeft);
192
- }}
193
- >
194
- <Icon source={leftOptions.iconLeft ?? 'ic_back'} />
303
+ {showButtons && showBack ? (
304
+ <TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
305
+ <Icon source={iconBack} />
195
306
  </TouchableOpacity>
196
307
  ) : null}
197
308
 
@@ -199,9 +310,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
199
310
  <View style={Styles.flex}>{options.header}</View>
200
311
  ) : (
201
312
  <>
202
- {!leftOptions ? <View style={styles.iconButton} /> : null}
313
+ {showButtons && !leftOptions ? (
314
+ <View style={styles.iconButton} />
315
+ ) : null}
203
316
  <Text
204
- style={[Styles.flex, { textAlign: 'center' }]}
317
+ style={[
318
+ Styles.flex,
319
+ styles.title,
320
+ !showButtons && styles.titleInset,
321
+ ]}
205
322
  numberOfLines={1}
206
323
  typography="header_default_bold"
207
324
  >
@@ -209,28 +326,63 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
209
326
  </Text>
210
327
  </>
211
328
  )}
212
- <TouchableOpacity
213
- style={styles.iconButton}
214
- onPress={() => {
215
- action.current = 'icon_close';
216
- onDismiss(true);
217
- }}
218
- >
219
- <Icon source="navigation_close" />
220
- </TouchableOpacity>
329
+ {showButtons ? (
330
+ <TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
331
+ <Icon source="navigation_close" />
332
+ </TouchableOpacity>
333
+ ) : null}
221
334
  </View>
222
335
  </View>
223
336
  );
224
337
  }, [
225
- onDismiss,
338
+ hasSideColumn,
339
+ iconBack,
226
340
  leftOptions,
341
+ onPressBack,
342
+ onPressClose,
227
343
  options.header,
228
344
  options.title,
229
345
  panResponder.panHandlers,
346
+ showBack,
230
347
  theme.colors.border.default,
231
348
  useDivider,
232
349
  ]);
233
350
 
351
+ /**
352
+ * iPhone Duo folded: back and close move to the side bar column, drawn on
353
+ * the part of the sheet under the side safe area. They line up with the
354
+ * header row, stay below the status bar cluster on a tall sheet and above a
355
+ * camera at the bottom end of the strip.
356
+ */
357
+ const renderSideColumn = () => {
358
+ if (!sideColumn) {
359
+ return null;
360
+ }
361
+ const lowestTop =
362
+ windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
363
+ const top = Math.min(
364
+ Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
365
+ lowestTop,
366
+ );
367
+
368
+ return (
369
+ <View
370
+ pointerEvents={'box-none'}
371
+ style={[
372
+ styles.sideColumn,
373
+ sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
374
+ InnerEdgeStyles[sideColumn.edge],
375
+ { top, width: sideColumnWidth },
376
+ ]}
377
+ >
378
+ {showBack ? (
379
+ <SideBarButton icon={iconBack} onPress={onPressBack} />
380
+ ) : null}
381
+ <SideBarButton icon="navigation_close" onPress={onPressClose} />
382
+ </View>
383
+ );
384
+ };
385
+
234
386
  const animatedStyle = useAnimatedStyle(() => {
235
387
  return {
236
388
  transform: [{ translateY: translateY.value }],
@@ -248,6 +400,19 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
248
400
  };
249
401
  });
250
402
 
403
+ const sideInsetStyle = isDuoFloating && {
404
+ paddingLeft: Math.max(insets.left - Spacing.M, 0),
405
+ paddingRight: Math.max(insets.right - Spacing.M, 0),
406
+ };
407
+
408
+ const screen = (
409
+ <Screen
410
+ {...props}
411
+ {...props.route.params}
412
+ onRequestClose={onRequestClose}
413
+ />
414
+ );
415
+
251
416
  return (
252
417
  <Container
253
418
  transparent
@@ -257,6 +422,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
257
422
  }}
258
423
  style={StyleSheet.absoluteFillObject}
259
424
  isModalKit={true}
425
+ supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
260
426
  >
261
427
  <KeyboardAvoidingView
262
428
  behavior={Platform.select({
@@ -264,7 +430,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
264
430
  android: undefined,
265
431
  })}
266
432
  keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
267
- enabled={useKeyboardAvoidingView}
433
+ enabled={useKeyboardAvoidingView && !isDuo}
268
434
  contentContainerStyle={styles.container}
269
435
  style={styles.container}
270
436
  >
@@ -279,23 +445,50 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
279
445
  </Pressable>
280
446
 
281
447
  <Animated.View
448
+ onLayout={
449
+ isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
450
+ }
282
451
  style={[
283
452
  animatedStyle,
284
453
  {
285
454
  backgroundColor,
286
- borderTopLeftRadius: Radius.M,
287
- borderTopRightRadius: Radius.M,
455
+ borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
456
+ borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
288
457
  overflow: 'hidden',
289
458
  },
459
+ isDuo && {
460
+ maxHeight: windowSize.height - topGap - keyboardLift,
461
+ marginBottom: keyboardLift,
462
+ },
463
+ isDuoFloating && { marginHorizontal: Spacing.M },
464
+ sideColumn && {
465
+ minHeight:
466
+ SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
467
+ },
468
+ isDuoCentered && {
469
+ // keeps the portrait width in landscape, like system sheets
470
+ width: Math.min(
471
+ Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
472
+ windowSize.width -
473
+ Math.max(insets.left, insets.right, Spacing.L) * 2,
474
+ ),
475
+ alignSelf: 'center',
476
+ },
290
477
  showBaseLineDebug && styles.debugBaseLine,
291
478
  ]}
292
479
  >
293
- {renderHeader()}
294
- <Screen
295
- {...props}
296
- {...props.route.params}
297
- onRequestClose={onRequestClose}
298
- />
480
+ {isDuo ? (
481
+ // content shrinks so a tall sheet keeps its header and footer
482
+ <View style={[styles.shrink, sideInsetStyle]}>
483
+ {renderHeader()}
484
+ <View style={styles.shrink}>{screen}</View>
485
+ </View>
486
+ ) : (
487
+ <>
488
+ {renderHeader()}
489
+ {screen}
490
+ </>
491
+ )}
299
492
  {footerComponent && (
300
493
  <View
301
494
  style={[
@@ -304,6 +497,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
304
497
  paddingBottom: Spacing.S,
305
498
  backgroundColor: theme.colors.background.surface,
306
499
  },
500
+ sideInsetStyle,
307
501
  ]}
308
502
  >
309
503
  {footerComponent}
@@ -317,6 +511,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
317
511
  }}
318
512
  />
319
513
  )}
514
+ {renderSideColumn()}
320
515
  </Animated.View>
321
516
  </KeyboardAvoidingView>
322
517
  </Container>
@@ -342,10 +537,12 @@ const styles = StyleSheet.create({
342
537
  top: Spacing.S,
343
538
  },
344
539
  header: {
345
- height: 56,
540
+ height: HEADER_HEIGHT,
346
541
  flexDirection: 'row',
347
542
  alignItems: 'center',
348
543
  },
544
+ title: { textAlign: 'center' },
545
+ titleInset: { paddingHorizontal: Spacing.M },
349
546
  iconButton: {
350
547
  width: 24,
351
548
  height: 24,
@@ -353,6 +550,13 @@ const styles = StyleSheet.create({
353
550
  justifyContent: 'center',
354
551
  marginHorizontal: Spacing.M,
355
552
  },
553
+ shrink: { flexShrink: 1 },
554
+ sideColumn: {
555
+ position: 'absolute',
556
+ gap: Spacing.M,
557
+ },
558
+ alignRight: { right: 0 },
559
+ alignLeft: { left: 0 },
356
560
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
357
561
  });
358
562
 
@@ -58,11 +58,13 @@ export default function BottomTabBar({
58
58
  const { theme } = useContext(ApplicationContext);
59
59
  const { routes } = state;
60
60
  const widthDevice = useWindowDimensions()?.width;
61
+ const insets = useSafeAreaInsets();
61
62
  const useFloating = floatingButton && state.routes.length % 2 === 0;
62
- const itemWidth = widthDevice / (state.routes.length + (useFloating ? 1 : 0));
63
+ const itemWidth =
64
+ (widthDevice - insets.left - insets.right) /
65
+ (state.routes.length + (useFloating ? 1 : 0));
63
66
  // const buildLink = useLinkBuilder();
64
67
  const indicatorAnimated = useSharedValue(0);
65
- const insets = useSafeAreaInsets();
66
68
 
67
69
  const indicatorStyle = useAnimatedStyle(() => ({
68
70
  width: itemWidth,
@@ -246,7 +248,8 @@ export default function BottomTabBar({
246
248
  },
247
249
  {
248
250
  paddingBottom: paddingBottom,
249
- paddingHorizontal: Math.max(insets.left, insets.right),
251
+ paddingLeft: insets.left,
252
+ paddingRight: insets.right,
250
253
  },
251
254
  style,
252
255
  ]}
@@ -116,17 +116,15 @@ const CustomBottomTabItem: React.FC<CustomBottomTabItemProps> = ({
116
116
  });
117
117
  };
118
118
 
119
+ const background = focused ? activeBackgroundColor : inactiveBackgroundColor;
120
+
119
121
  return (
120
122
  <TouchableOpacity
121
123
  onPress={onPress}
122
124
  onLongPress={onLongPress}
123
125
  style={[
124
126
  styles.container,
125
- {
126
- backgroundColor: focused
127
- ? activeBackgroundColor
128
- : inactiveBackgroundColor,
129
- },
127
+ { backgroundColor: background },
130
128
  style,
131
129
  showBaseLineDebug && styles.debugBaseLine,
132
130
  ]}
@@ -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
+
38
+ const NO_COMPRESSION: DuoBarCompression = {
39
+ toolbarCompressed: false,
40
+ tabBarCompressed: false,
41
+ };
42
+
43
+ const DuoBarCompressionContext =
44
+ createContext<DuoBarCompression>(NO_COMPRESSION);
45
+
46
+ /**
47
+ * height of the vertical tab rail, plus the QR button under it
48
+ */
49
+ const getRailHeight = (tabCount: number, hasQr: boolean) =>
50
+ 2 * RAIL_PADDING +
51
+ tabCount * DuoMetrics.controlSize +
52
+ Math.max(tabCount - 1, 0) * RAIL_GAP +
53
+ (hasQr ? Spacing.M + DuoMetrics.controlSize : 0);
54
+
55
+ /**
56
+ * keeps the side bar's toolbar and tab bar clear of each other (HIG, Designing for
57
+ * iPhone Duo: Vertical controls):
58
+ * - both fit: both stay whole
59
+ * - one fits whole beside the other compressed: one is compressed, tapping it swaps them
60
+ * - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
61
+ * again once a tab is picked)
62
+ */
63
+ const DuoBarCompressionProvider: React.FC<{
64
+ behavior?: DuoBarCompressionBehavior;
65
+ tabCount: number;
66
+ hasQr: boolean;
67
+ children: ReactNode;
68
+ }> = ({ behavior = 'toolbar', tabCount, hasQr, children }) => {
69
+ const { height, sideBar } = useScreenLayout();
70
+ const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_TOOLBAR_HEIGHT);
71
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
72
+ const [expanded, setExpanded] = useState<'toolbar' | 'tabBar'>(
73
+ behavior === 'toolbar' ? 'tabBar' : 'toolbar',
74
+ );
75
+ // bar opened for a moment while both rest compressed
76
+ const [opened, setOpened] = useState<'toolbar' | 'tabBar'>();
77
+
78
+ const value = useMemo(() => {
79
+ if (!sideBar) {
80
+ return NO_COMPRESSION;
81
+ }
82
+ const room = height - sideBar.bottom - sideBar.top - GROUP_GAP;
83
+ const tabBar = getRailHeight(tabCount, hasQr);
84
+ const compressedTabBar = getRailHeight(1, hasQr) - 2 * RAIL_PADDING;
85
+ const fits = (toolbar: number, tabs: number) => toolbar + tabs <= room;
86
+
87
+ if (fits(toolbarHeight, tabBar)) {
88
+ return { ...NO_COMPRESSION, setToolbarHeight };
89
+ }
90
+ const canSwap =
91
+ fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) &&
92
+ fits(toolbarHeight, compressedTabBar);
93
+ if (canSwap) {
94
+ return {
95
+ toolbarCompressed: expanded === 'tabBar',
96
+ tabBarCompressed: expanded === 'toolbar',
97
+ expandToolbar: () => setExpanded('toolbar'),
98
+ expandTabBar: () => setExpanded('tabBar'),
99
+ setToolbarHeight,
100
+ };
101
+ }
102
+
103
+ const open = {
104
+ expandToolbar: () => setOpened('toolbar'),
105
+ expandTabBar: () => setOpened('tabBar'),
106
+ setToolbarHeight,
107
+ };
108
+ // an opened rail always shows, even when only the gap between the groups is short:
109
+ // it closes again once a tab is picked
110
+ if (opened === '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 };