@momo-kits/foundation 0.167.1-duo.12 → 0.167.1-duo.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.
@@ -36,37 +36,45 @@ const RAIL_GAP = Spacing.XXS;
36
36
  // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
37
37
  const RAIL_BUBBLE_WIDTH = 44;
38
38
  const RAIL_BUBBLE_HEIGHT = 58;
39
- const FLOATING_HEIGHT = 62;
39
+ const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
40
40
  const FLOATING_PADDING = Spacing.XS;
41
- const FLOATING_QR_SIZE = 44;
42
- // floating bar spans 6 of the 8 portrait grid columns, like the content
43
- const GRID_COLUMNS = 8;
44
- const GRID_SPAN = 6;
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 becomes the iOS 26 glass lens, growing past the
44
+ // pill's edges, wider and taller (80x54 to about 96x70)
45
+ const LENS_SCALE_X = 1.2;
46
+ const LENS_SCALE_Y = 1.3;
47
+ const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
45
48
  const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
46
49
 
50
+ /**
51
+ * distance from the screen bottom to the floating tab bar, overlapping the home
52
+ * indicator inset like the system tab bar
53
+ */
54
+ const getFloatingTabBarBottom = (bottomInset: number) =>
55
+ Math.max(bottomInset - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S);
56
+
47
57
  /**
48
58
  * space tab content leaves under the Duo floating tab bar, 0 elsewhere
49
59
  */
50
60
  const useDuoTabBarInset = () => {
51
61
  const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
52
62
  return isIPhoneDuo && !sideBar
53
- ? FLOATING_HEIGHT + Spacing.M + insets.bottom
63
+ ? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
54
64
  : 0;
55
65
  };
56
66
 
57
- const gridSpanWidth = (width: number) => {
58
- const columnWidth =
59
- (width - 2 * Spacing.M - (GRID_COLUMNS - 1) * Spacing.M) / GRID_COLUMNS;
60
- return columnWidth * GRID_SPAN + (GRID_SPAN - 1) * Spacing.M;
61
- };
62
-
63
- const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
67
+ const BarMaterial: React.FC<{ radius: number; interactive?: boolean }> = ({
68
+ radius,
69
+ interactive,
70
+ }) => {
64
71
  const { theme } = useContext(ApplicationContext);
65
72
  if (GlassView) {
66
73
  return (
67
74
  <GlassView
68
75
  pointerEvents={'none'}
69
76
  effect={'glass'}
77
+ interactive={interactive}
70
78
  cornerRadius={radius}
71
79
  useShadow
72
80
  style={StyleSheet.absoluteFill}
@@ -89,6 +97,37 @@ const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
89
97
  );
90
98
  };
91
99
 
100
+ // Figma Home-2026 tab bar "Selection": brand pink at 8% over glass
101
+ const SELECTION_TINT_ALPHA = '14';
102
+
103
+ /**
104
+ * translucent glass capsule under the selected floating tab with the Figma pink tint on
105
+ * top; the tint is a separate layer since a glass tint color renders opaque
106
+ */
107
+ const SelectionMaterial: React.FC = () => {
108
+ const { theme } = useContext(ApplicationContext);
109
+ return (
110
+ <>
111
+ {GlassView && (
112
+ <GlassView
113
+ pointerEvents={'none'}
114
+ effect={'glass'}
115
+ cornerRadius={FLOATING_BUBBLE_RADIUS}
116
+ style={StyleSheet.absoluteFill}
117
+ />
118
+ )}
119
+ <View
120
+ pointerEvents={'none'}
121
+ style={[
122
+ StyleSheet.absoluteFill,
123
+ styles.floatingBubbleFill,
124
+ { backgroundColor: theme.colors.primary + SELECTION_TINT_ALPHA },
125
+ ]}
126
+ />
127
+ </>
128
+ );
129
+ };
130
+
92
131
  const getTabItems = ({
93
132
  state,
94
133
  descriptors,
@@ -267,65 +306,106 @@ const DuoRailTabBar: React.FC<Props> = props => {
267
306
  };
268
307
 
269
308
  /**
270
- * floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M)
309
+ * floating labelled glass bar for the unfolded portrait display, QR pinned in its own
310
+ * platter on the trailing side, like the host app's iOS 26 system tab bar
271
311
  */
272
312
  const DuoFloatingTabBar: React.FC<Props> = props => {
273
313
  const { theme } = useContext(ApplicationContext);
274
- const { width, insets } = useScreenLayout();
314
+ const { insets } = useScreenLayout();
275
315
  const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
276
316
  const items = getTabItems(props);
277
317
  const [rowWidth, setRowWidth] = useState(0);
278
- const barWidth = gridSpanWidth(width);
279
-
280
- const useQr = !!floatingButton && state.routes.length % 2 === 0;
281
- const middle = state.routes.length / 2;
282
- const cellWidth = rowWidth / (state.routes.length + (useQr ? 1 : 0));
283
- const visualIndex =
284
- useQr && state.index >= middle ? state.index + 1 : state.index;
318
+ const cellWidth = rowWidth / state.routes.length;
285
319
 
286
320
  const bubbleOffset = useSharedValue(0);
321
+ const lens = useSharedValue(0);
287
322
  const measured = useRef(false);
288
323
 
289
324
  useEffect(() => {
290
- const target = visualIndex * cellWidth;
325
+ const target = state.index * cellWidth;
291
326
  // snap on first layout so the bubble doesn't slide in from the left edge
292
327
  bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
293
328
  measured.current = cellWidth > 0;
294
- }, [visualIndex, cellWidth, bubbleOffset]);
329
+ }, [state.index, cellWidth, bubbleOffset]);
295
330
 
296
331
  const bubbleStyle = useAnimatedStyle(() => ({
297
- transform: [{ translateX: bubbleOffset.value }],
332
+ transform: [
333
+ { translateX: bubbleOffset.value },
334
+ { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
335
+ { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
336
+ ],
298
337
  }));
299
338
 
300
339
  const onRowLayout = (e: LayoutChangeEvent) =>
301
340
  setRowWidth(e.nativeEvent.layout.width);
302
341
 
303
- const cells = items.map(item => (
304
- <TouchableOpacity
305
- key={item.route.key}
306
- accessibilityRole={'tab'}
307
- accessibilityLabel={item.label}
308
- accessibilityState={{ selected: item.focused }}
309
- onPress={item.onPress}
310
- onLongPress={item.onLongPress}
311
- style={styles.floatingItem}
342
+ return (
343
+ <View
344
+ pointerEvents={'box-none'}
345
+ style={[
346
+ styles.floating,
347
+ {
348
+ left: insets.left + DuoMetrics.floatingTabBarMargin,
349
+ right: insets.right + DuoMetrics.floatingTabBarMargin,
350
+ bottom: getFloatingTabBarBottom(insets.bottom),
351
+ },
352
+ ]}
312
353
  >
313
- {item.icon}
314
- <Text
315
- numberOfLines={1}
316
- typography={'label_xs_medium'}
317
- color={item.focused ? activeTintColor : inactiveTintColor}
354
+ <View
355
+ style={[
356
+ styles.floatingBar,
357
+ {
358
+ width:
359
+ state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
360
+ },
361
+ ]}
362
+ accessibilityLabel={'bottom_tab_bar'}
318
363
  >
319
- {item.label}
320
- </Text>
321
- </TouchableOpacity>
322
- ));
323
-
324
- if (useQr && floatingButton) {
325
- cells.splice(
326
- middle,
327
- 0,
328
- <View key={'duo_tab_qr'} style={styles.floatingItem}>
364
+ <BarMaterial radius={FLOATING_HEIGHT / 2} interactive />
365
+ <View style={styles.floatingRow} onLayout={onRowLayout}>
366
+ {rowWidth > 0 && (
367
+ <Animated.View
368
+ pointerEvents={'none'}
369
+ style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
370
+ >
371
+ <SelectionMaterial />
372
+ </Animated.View>
373
+ )}
374
+ {items.map((item, index) => (
375
+ <TouchableOpacity
376
+ key={item.route.key}
377
+ accessibilityRole={'tab'}
378
+ accessibilityLabel={item.label}
379
+ accessibilityState={{ selected: item.focused }}
380
+ activeOpacity={1}
381
+ onPressIn={() => {
382
+ // the lens jumps to the pressed tab, not only the selected one
383
+ bubbleOffset.value = withSpring(index * cellWidth, SPRING);
384
+ lens.value = withSpring(1, SPRING);
385
+ }}
386
+ onPressOut={() => {
387
+ // onPress follows synchronously, a tab switch re-targets the bubble
388
+ bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
389
+ lens.value = withSpring(0, SPRING);
390
+ }}
391
+ onPress={item.onPress}
392
+ onLongPress={item.onLongPress}
393
+ style={styles.floatingItem}
394
+ >
395
+ {item.icon}
396
+ {/* Text scales with the 669pt wide display, the smaller token lands near the system bar's label */}
397
+ <Text
398
+ numberOfLines={1}
399
+ typography={'label_xxs'}
400
+ color={item.focused ? activeTintColor : inactiveTintColor}
401
+ >
402
+ {item.label}
403
+ </Text>
404
+ </TouchableOpacity>
405
+ ))}
406
+ </View>
407
+ </View>
408
+ {floatingButton && (
329
409
  <TouchableOpacity
330
410
  accessibilityRole={'button'}
331
411
  accessibilityLabel={floatingButton.label}
@@ -334,39 +414,7 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
334
414
  >
335
415
  <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
336
416
  </TouchableOpacity>
337
- </View>,
338
- );
339
- }
340
-
341
- return (
342
- <View
343
- style={[
344
- styles.floating,
345
- {
346
- width: barWidth,
347
- left: (width - barWidth) / 2,
348
- bottom: insets.bottom + Spacing.M,
349
- },
350
- ]}
351
- accessibilityLabel={'bottom_tab_bar'}
352
- >
353
- <BarMaterial radius={FLOATING_HEIGHT / 2} />
354
- <View style={styles.floatingRow} onLayout={onRowLayout}>
355
- {rowWidth > 0 && (
356
- <Animated.View
357
- pointerEvents={'none'}
358
- style={[
359
- styles.floatingBubble,
360
- {
361
- width: cellWidth,
362
- backgroundColor: theme.colors.background.selected,
363
- },
364
- bubbleStyle,
365
- ]}
366
- />
367
- )}
368
- {cells}
369
- </View>
417
+ )}
370
418
  </View>
371
419
  );
372
420
  };
@@ -430,6 +478,12 @@ const styles = StyleSheet.create({
430
478
  },
431
479
  floating: {
432
480
  position: 'absolute',
481
+ flexDirection: 'row',
482
+ justifyContent: 'center',
483
+ gap: Spacing.S,
484
+ },
485
+ floatingBar: {
486
+ flexShrink: 1,
433
487
  height: FLOATING_HEIGHT,
434
488
  padding: FLOATING_PADDING,
435
489
  },
@@ -442,7 +496,9 @@ const styles = StyleSheet.create({
442
496
  top: 0,
443
497
  bottom: 0,
444
498
  left: 0,
445
- borderRadius: (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2,
499
+ },
500
+ floatingBubbleFill: {
501
+ borderRadius: FLOATING_BUBBLE_RADIUS,
446
502
  },
447
503
  floatingItem: {
448
504
  flex: 1,
@@ -450,12 +506,17 @@ const styles = StyleSheet.create({
450
506
  justifyContent: 'center',
451
507
  gap: Spacing.XXS,
452
508
  },
509
+ // pink fills the whole pinned platter like the host app, shadow matches the glass bar
453
510
  floatingQr: {
454
- width: FLOATING_QR_SIZE,
455
- height: FLOATING_QR_SIZE,
456
- borderRadius: FLOATING_QR_SIZE / 2,
511
+ width: FLOATING_HEIGHT,
512
+ height: FLOATING_HEIGHT,
513
+ borderRadius: FLOATING_HEIGHT / 2,
457
514
  alignItems: 'center',
458
515
  justifyContent: 'center',
516
+ shadowColor: Colors.black_20,
517
+ shadowOffset: { width: 0, height: 6 },
518
+ shadowOpacity: 0.16,
519
+ shadowRadius: 16,
459
520
  },
460
521
  });
461
522
 
@@ -31,6 +31,7 @@ import { DuoTabBar } from './DuoTabBar';
31
31
  import { DuoBarCompressionProvider } from './DuoBarCompression';
32
32
  import { ApplicationContext, MiniAppContext } from '../../Context';
33
33
  import { useScreenLayout } from '../../Layout';
34
+ import { getHeaderRowOptions } from '../../Layout/utils';
34
35
 
35
36
  const Tab = createBottomTabNavigator();
36
37
  const Stack = createStackNavigator();
@@ -39,13 +40,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
39
40
  const { navigator } = useContext(ApplicationContext);
40
41
  const context = useContext<any>(MiniAppContext);
41
42
  const { nested, options, screen, initialParams } = route?.params;
42
- const { headerHeight, headerStatusBarHeight } = useScreenLayout();
43
- const headerStyleOptions = {
44
- headerStyle: {
45
- height: headerHeight,
46
- },
47
- headerStatusBarHeight,
48
- };
43
+ const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
49
44
 
50
45
  const opacityValue = 0.3;
51
46
  const scaleValue = 0.97;
@@ -321,7 +316,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
321
316
  <DuoTabBar
322
317
  {...props}
323
318
  floatingButton={floatingButton}
324
- inactiveTintColor={theme.colors.text.default}
319
+ inactiveTintColor={theme.colors.text.secondary}
325
320
  activeTintColor={theme.colors.primary}
326
321
  />
327
322
  ) : (
@@ -16,7 +16,7 @@ import { SearchHeaderProps } from '../types';
16
16
  import BackgroundImageView from './BackgroundImageView';
17
17
  import { AnimatedCompatValue, useNormalizedSharedValue } from '../utils';
18
18
  import { useScreenLayout } from '../../Layout';
19
- import { HeaderMetrics } from '../../Layout/utils';
19
+ import { DuoMetrics, HeaderMetrics } from '../../Layout/utils';
20
20
 
21
21
  const SCREEN_PADDING = 12;
22
22
  const BACK_WIDTH = 28;
@@ -51,17 +51,27 @@ const HeaderExtendHeader: React.FC<{
51
51
  const headerBackground = customBackground ?? theme.assets?.headerBackground;
52
52
  const layout = useScreenLayout();
53
53
 
54
+ // the iPhone Duo status bar row keeps side bar sized back and toolkit
55
+ const backWidth = layout.statusBarClearance
56
+ ? DuoMetrics.controlSize
57
+ : BACK_WIDTH;
58
+ const toolkitWidth = layout.statusBarClearance
59
+ ? DuoMetrics.itemPitch * 2 + SCREEN_PADDING
60
+ : TOOLKIT_WIDTH;
54
61
  const leftPosition =
55
62
  inputSearchProps?.leftPosition ||
56
- (layout.sideBar ? SCREEN_PADDING : BACK_WIDTH + 20);
63
+ (layout.sideBar ? SCREEN_PADDING : backWidth + 20);
57
64
  const headerRightWidth =
58
- customHeaderRightWidth ?? (layout.sideBar ? 0 : TOOLKIT_WIDTH);
65
+ customHeaderRightWidth ?? (layout.sideBar ? 0 : toolkitWidth);
59
66
  const contentLeft = layout.insetLeft + SCREEN_PADDING;
60
67
  const contentRight = layout.insetLeft + layout.contentWidth;
61
- const collapsedLeft = layout.insetLeft + leftPosition;
68
+ // collapsed on the status bar row, the search stays between its clusters
69
+ const collapsedLeft =
70
+ layout.insetLeft + leftPosition + (layout.statusBarClearance?.left ?? 0);
71
+ const collapsedRight = contentRight - (layout.statusBarClearance?.right ?? 0);
62
72
  const searchWidth = layout.contentWidth - SCREEN_PADDING * 2;
63
73
  const collapsedSearchWidth =
64
- contentRight - collapsedLeft - SCREEN_PADDING - headerRightWidth;
74
+ collapsedRight - collapsedLeft - SCREEN_PADDING - headerRightWidth;
65
75
  // a row taller than barHeight (iPhone Duo) centers the collapsed search on the title
66
76
  const collapsedRaise =
67
77
  (layout.headerHeight -
@@ -6,7 +6,7 @@ import { PopupNotify } from '../../Popup';
6
6
  import { NavigationButton } from './NavigationButton';
7
7
  import { SideBarButton } from './SideBarButton';
8
8
  import Animated from 'react-native-reanimated';
9
- import { useSideBarItemStyle } from '../../Layout/utils';
9
+ import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
10
10
  import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
11
11
 
12
12
  /**
@@ -23,6 +23,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
23
23
  const { navigator } = useContext(ApplicationContext);
24
24
  const sideBarStyle = useSideBarItemStyle('left', 0);
25
25
  const counterStyle = useSideBarCounterStyle();
26
+ const { statusBarClearance } = useScreenLayout();
26
27
 
27
28
  const goBack = useCallback(() => {
28
29
  const canGoBack = navigator?.ref?.current?.canGoBack?.();
@@ -95,6 +96,20 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
95
96
  );
96
97
  }
97
98
 
99
+ // on the status bar row the controls keep the side bar size
100
+ if (statusBarClearance) {
101
+ return (
102
+ <View style={styles.headerLeft}>
103
+ <SideBarButton
104
+ accessibilityLabel="btn_navigation_back"
105
+ icon="arrow-back"
106
+ tintColor={tintColor}
107
+ onPress={goBackSafe}
108
+ />
109
+ </View>
110
+ );
111
+ }
112
+
98
113
  return (
99
114
  <View style={styles.headerLeft}>
100
115
  <NavigationButton
@@ -14,7 +14,7 @@ import { Tool, ToolGroup } from '../types';
14
14
  import { Icon } from '../../Icon';
15
15
  import { Text } from '../../Text';
16
16
  import Animated from 'react-native-reanimated';
17
- import { useSideBarItemStyle } from '../../Layout/utils';
17
+ import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
18
18
  import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
19
19
  import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
20
20
  import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
@@ -85,6 +85,7 @@ const HeaderToolkitAction: React.FC<any> = ({
85
85
  const context = useContext<any>(MiniAppContext);
86
86
  const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
87
87
  useDuoBarCompression();
88
+ const { statusBarClearance } = useScreenLayout();
88
89
 
89
90
  const [isFavorite, setIsFavorite] = useState<boolean>(false);
90
91
  const [isLoading, setIsLoading] = useState(false);
@@ -358,6 +359,39 @@ const HeaderToolkitAction: React.FC<any> = ({
358
359
  );
359
360
  }
360
361
 
362
+ // on the status bar row the controls keep the side bar size
363
+ if (statusBarClearance) {
364
+ return (
365
+ <View style={styles.sideBarRow}>
366
+ {useShortcut && (
367
+ <SideBarButton
368
+ disabled={isLoading}
369
+ icon={iconShortcut}
370
+ onPress={shortcutOnPress}
371
+ tintColor={tintColor}
372
+ showBadge={showBadge}
373
+ />
374
+ )}
375
+ <SideBarGroup tintColor={tintColor} horizontal>
376
+ {useHelpCenter && (
377
+ <SideBarGroupItem
378
+ accessibilityLabel={'btn_navigation_help_center'}
379
+ icon={'help_center'}
380
+ tintColor={tintColor}
381
+ onPress={onPressHelpCenter}
382
+ />
383
+ )}
384
+ <SideBarGroupItem
385
+ accessibilityLabel={'btn_navigation_close'}
386
+ icon={closeIcon}
387
+ tintColor={tintColor}
388
+ onPress={onClose}
389
+ />
390
+ </SideBarGroup>
391
+ </View>
392
+ );
393
+ }
394
+
361
395
  return (
362
396
  <View style={Styles.row}>
363
397
  {useShortcut && (
@@ -471,6 +505,10 @@ const styles = StyleSheet.create({
471
505
  sideBarColumn: {
472
506
  gap: Spacing.M,
473
507
  },
508
+ sideBarRow: {
509
+ flexDirection: 'row',
510
+ gap: Spacing.M,
511
+ },
474
512
  headerLeft: {
475
513
  marginLeft: 12,
476
514
  },
@@ -33,15 +33,29 @@ const useHeaderFontFamily = (): string => {
33
33
  */
34
34
  const HEADER_ACTIONS_WIDTH = 172;
35
35
 
36
+ /**
37
+ * extra width the 48pt back and toolkit take on the iPhone Duo status bar row
38
+ */
39
+ const STATUS_BAR_ROW_ACTIONS_EXTRA = 55;
40
+
36
41
  /**
37
42
  * max title width, the iPhone Duo side bar takes back and toolkit out of the bar
38
43
  */
39
44
  const useTitleMaxWidth = () => {
40
- const { width, contentWidth, sideBar } = useScreenLayout();
45
+ const { width, contentWidth, sideBar, statusBarClearance } =
46
+ useScreenLayout();
47
+ const clearance = statusBarClearance
48
+ ? statusBarClearance.left + statusBarClearance.right
49
+ : 0;
41
50
  if (sideBar) {
42
- return contentWidth - Spacing.M * 2;
51
+ return contentWidth - Spacing.M * 2 - clearance;
43
52
  }
44
- return width - HEADER_ACTIONS_WIDTH;
53
+ return (
54
+ width -
55
+ HEADER_ACTIONS_WIDTH -
56
+ (statusBarClearance ? STATUS_BAR_ROW_ACTIONS_EXTRA : 0) -
57
+ clearance
58
+ );
45
59
  };
46
60
 
47
61
  type HeaderTitleInterpolate = {
@@ -8,6 +8,7 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
8
8
  import { Text } from '../../Text';
9
9
  import { useNormalizedSharedValue } from '../utils';
10
10
  import { useScreenLayout } from '../../Layout';
11
+ import { DuoMetrics } from '../../Layout/utils';
11
12
 
12
13
  const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
13
14
  (
@@ -22,8 +23,9 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
22
23
  ) => {
23
24
  const { theme, navigator } = useContext(ApplicationContext);
24
25
  const context = useContext<any>(MiniAppContext);
25
- const BACK_WIDTH = 28;
26
26
  const layout = useScreenLayout();
27
+ // the iPhone Duo status bar row keeps a side bar sized back button
28
+ const BACK_WIDTH = layout.statusBarClearance ? DuoMetrics.controlSize : 28;
27
29
 
28
30
  const sharedValue = useNormalizedSharedValue(animatedValue);
29
31
  const leftPosition = props?.leftPosition ?? BACK_WIDTH + 20;
@@ -34,6 +36,10 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
34
36
  if (layout.sideBar) {
35
37
  searchMaxWidth = layout.contentWidth - Spacing.M * 2 - headerRightWidth;
36
38
  }
39
+ if (layout.statusBarClearance) {
40
+ searchMaxWidth -=
41
+ layout.statusBarClearance.left + layout.statusBarClearance.right;
42
+ }
37
43
 
38
44
  const backgroundStyle = useAnimatedStyle(() => {
39
45
  const t = Math.min(Math.max(sharedValue.value / 100, 0), 1);
@@ -54,19 +54,30 @@ const SideBarButton: React.FC<NavigationButtonProps> = ({
54
54
  };
55
55
 
56
56
  /**
57
- * 48pt wide glass capsule grouping related controls, one item per 50pt
57
+ * 48pt glass capsule grouping related controls, one item per 50pt, laid out vertically
58
+ * in the side bar or horizontally on a header row
58
59
  */
59
60
  const SideBarGroup: React.FC<{
60
61
  tintColor?: string;
62
+ horizontal?: boolean;
61
63
  style?: StyleProp<ViewStyle>;
62
64
  children: ReactNode;
63
- }> = ({ tintColor, style, children }) => {
65
+ }> = ({ tintColor, horizontal = false, style, children }) => {
64
66
  const count = React.Children.toArray(children).length;
65
- const height =
67
+ const length =
66
68
  count > 1 ? count * DuoMetrics.itemPitch : DuoMetrics.controlSize;
67
69
 
68
70
  return (
69
- <View style={[styles.control, getGlassStyle(tintColor), { height }, style]}>
71
+ <View
72
+ style={[
73
+ styles.control,
74
+ getGlassStyle(tintColor),
75
+ horizontal
76
+ ? { width: length, flexDirection: 'row' }
77
+ : { height: length },
78
+ style,
79
+ ]}
80
+ >
70
81
  {children}
71
82
  </View>
72
83
  );
@@ -31,6 +31,7 @@ import { HeaderTitle } from './Components/HeaderTitle';
31
31
  import { HeaderBackground } from './Components/HeaderBackground';
32
32
  import { type EdgeInsets } from 'react-native-safe-area-context';
33
33
  import { useScreenLayout } from '../Layout';
34
+ import { getHeaderRowOptions } from '../Layout/utils';
34
35
  import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
35
36
 
36
37
  const Stack = createStackNavigator();
@@ -107,7 +108,7 @@ const Navigation: React.FC<any> = ({
107
108
  const navigationRef = useRef<any>(null);
108
109
  const isReady = useRef(false);
109
110
  const navigator = useRef(new Navigator(navigationRef, isReady));
110
- const { headerHeight, headerStatusBarHeight } = useScreenLayout();
111
+ const headerRowOptions = getHeaderRowOptions(useScreenLayout());
111
112
  const syncCanGoBack = useNativeCanGoBack();
112
113
  const isWhiteList = isWhiteListDesignSystem(context);
113
114
  const fontFamily = isWhiteList ? 'MomoTrustSans' : 'SFProText';
@@ -204,10 +205,7 @@ const Navigation: React.FC<any> = ({
204
205
  gestureEnabled: false,
205
206
  headerTitleAlign: 'center',
206
207
  headerTintColor: theme.colors.text.default,
207
- headerStyle: {
208
- height: headerHeight,
209
- },
210
- headerStatusBarHeight,
208
+ ...headerRowOptions,
211
209
  }}
212
210
  >
213
211
  <Stack.Screen
package/Layout/Screen.tsx CHANGED
@@ -525,6 +525,10 @@ const Screen = forwardRef(
525
525
  searchWidth = layout.contentWidth - Spacing.M * 2;
526
526
  searchLeft = Spacing.M - getHeaderMargin(layout.width);
527
527
  }
528
+ if (layout.statusBarClearance) {
529
+ searchWidth -=
530
+ layout.statusBarClearance.left + layout.statusBarClearance.right;
531
+ }
528
532
  const options: StackNavigationOptions = {
529
533
  headerRight: undefined,
530
534
  headerTitleContainerStyle: {
package/Layout/utils.ts CHANGED
@@ -77,6 +77,14 @@ const HeaderMetrics = {
77
77
  * - edgeInset: on the outer display in landscape the strip holds no status bar, so its
78
78
  * controls start this far from the top and stay this far above the bottom, unless a
79
79
  * reserved region (the camera at either end) asks for more
80
+ * - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
81
+ * clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
82
+ * digits centered on y 47); its reserved region, when reported, is taller than the glyphs
83
+ * - floatingTabBarHeight / floatingTabBarMargin / floatingTabBarInsetOverlap: the host
84
+ * app's iOS 26 system tab bar on the inner display in portrait, a 62pt Liquid Glass pill
85
+ * sized to its tabs with the QR as a pinned 62pt platter beside it, centered and at
86
+ * least 20pt from the side edges; it overlaps the home indicator inset by 13pt (21pt
87
+ * above the screen bottom with a 34pt inset)
80
88
  */
81
89
  const DuoMetrics = {
82
90
  maxAspectRatio: 1.6,
@@ -88,6 +96,11 @@ const DuoMetrics = {
88
96
  outerHeaderHeight: 76,
89
97
  outerCameraCenterY: 45,
90
98
  edgeInset: 24,
99
+ innerStatusBarCenterY: 47,
100
+ innerStatusBarWidth: 120,
101
+ floatingTabBarHeight: 62,
102
+ floatingTabBarMargin: 20,
103
+ floatingTabBarInsetOverlap: 13,
91
104
  };
92
105
 
93
106
  const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -124,6 +137,39 @@ const getStripOcclusion = (
124
137
  { top: 0, topCenter: 0, bottom: 0 },
125
138
  );
126
139
 
140
+ /**
141
+ * how far the clock and battery clusters of a status bar on the top edge reach in from
142
+ * each side. `undefined` without reserved regions there
143
+ */
144
+ const getStatusBarClusters = (
145
+ regions: ReservedRegion[],
146
+ windowWidth: number,
147
+ topInset: number,
148
+ ) => {
149
+ const midX = windowWidth / 2;
150
+ const clusters = regions.filter(
151
+ region =>
152
+ region.y < topInset &&
153
+ region.y + region.height <= topInset &&
154
+ (region.x + region.width <= midX || region.x >= midX),
155
+ );
156
+ if (!clusters.length) {
157
+ return undefined;
158
+ }
159
+
160
+ return clusters.reduce(
161
+ (result, region) => {
162
+ if (region.x < midX) {
163
+ result.left = Math.max(result.left, region.x + region.width);
164
+ } else {
165
+ result.right = Math.max(result.right, windowWidth - region.x);
166
+ }
167
+ return result;
168
+ },
169
+ { left: 0, right: 0 },
170
+ );
171
+ };
172
+
127
173
  const isIPhone = () =>
128
174
  Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
129
175
 
@@ -232,6 +278,17 @@ export type ScreenLayout = {
232
278
  * bars to the side. `undefined` while the bars stay horizontal.
233
279
  */
234
280
  sideBar?: SideBarLayout;
281
+
282
+ /**
283
+ * Room the header row keeps from each side for the clock and battery clusters, when
284
+ * iPhone Duo puts the row on the status bar (inner display in portrait).
285
+ */
286
+ statusBarClearance?: StatusBarClearance;
287
+ };
288
+
289
+ export type StatusBarClearance = {
290
+ left: number;
291
+ right: number;
235
292
  };
236
293
 
237
294
  /**
@@ -280,6 +337,23 @@ const useScreenLayout = (): ScreenLayout => {
280
337
  };
281
338
  }
282
339
 
340
+ let statusBarClearance: StatusBarClearance | undefined;
341
+ const isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight;
342
+ if (isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset) {
343
+ // the row shares the status bar band, between its clusters
344
+ const clusters = getStatusBarClusters(
345
+ reservedRegions,
346
+ width,
347
+ insets.top,
348
+ ) ?? { left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S };
349
+ // center on the clock digits, the region center sits above them
350
+ headerLayout = {
351
+ headerHeight: DuoMetrics.innerStatusBarCenterY * 2,
352
+ headerStatusBarHeight: 0,
353
+ };
354
+ statusBarClearance = clusters;
355
+ }
356
+
283
357
  const sideInset = Math.max(insets.left, insets.right);
284
358
  let sideBar: SideBarLayout | undefined;
285
359
  // narrower strips cannot host a control 12pt from the inner edge
@@ -295,7 +369,9 @@ const useScreenLayout = (): ScreenLayout => {
295
369
  height,
296
370
  );
297
371
 
298
- let top = insets.top + HeaderMetrics.barHeight + Spacing.M;
372
+ let top = statusBarClearance
373
+ ? headerLayout.headerHeight + Spacing.M
374
+ : insets.top + HeaderMetrics.barHeight + Spacing.M;
299
375
  if (insets.top < HeaderMetrics.statusBarMinInset) {
300
376
  // status bar lives in the strip: start under its cluster, as the native bar does
301
377
  const isLandscape = width > height;
@@ -342,9 +418,30 @@ const useScreenLayout = (): ScreenLayout => {
342
418
  contentWidth: width - insets.left - insets.right,
343
419
  ...headerLayout,
344
420
  sideBar,
421
+ statusBarClearance,
345
422
  };
346
423
  };
347
424
 
425
+ /**
426
+ * Header row options for react-navigation: its height, the room for the status bar, and
427
+ * on iPhone Duo inner portrait the buttons kept clear of the clock and battery clusters
428
+ */
429
+ const getHeaderRowOptions = ({
430
+ headerHeight,
431
+ headerStatusBarHeight,
432
+ statusBarClearance,
433
+ sideBar,
434
+ }: ScreenLayout) => ({
435
+ headerStyle: { height: headerHeight },
436
+ headerStatusBarHeight,
437
+ // side bar controls are absolute within the containers, padding would not move them
438
+ ...(statusBarClearance &&
439
+ !sideBar && {
440
+ headerLeftContainerStyle: { paddingLeft: statusBarClearance.left },
441
+ headerRightContainerStyle: { paddingRight: statusBarClearance.right },
442
+ }),
443
+ });
444
+
348
445
  /**
349
446
  * Side margin @react-navigation/elements Header adds to its content row
350
447
  */
@@ -410,6 +507,7 @@ export {
410
507
  DuoMetrics,
411
508
  HeaderMetrics,
412
509
  getHeaderMargin,
510
+ getHeaderRowOptions,
413
511
  useGridSystem,
414
512
  useScreenLayout,
415
513
  useSideBarItemStyle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duo.12",
3
+ "version": "0.167.1-duo.14",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},