@momo-kits/foundation 0.167.1-duo.13 → 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
 
@@ -316,7 +316,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
316
316
  <DuoTabBar
317
317
  {...props}
318
318
  floatingButton={floatingButton}
319
- inactiveTintColor={theme.colors.text.default}
319
+ inactiveTintColor={theme.colors.text.secondary}
320
320
  activeTintColor={theme.colors.primary}
321
321
  />
322
322
  ) : (
package/Layout/utils.ts CHANGED
@@ -80,6 +80,11 @@ const HeaderMetrics = {
80
80
  * - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
81
81
  * clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
82
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)
83
88
  */
84
89
  const DuoMetrics = {
85
90
  maxAspectRatio: 1.6,
@@ -93,6 +98,9 @@ const DuoMetrics = {
93
98
  edgeInset: 24,
94
99
  innerStatusBarCenterY: 47,
95
100
  innerStatusBarWidth: 120,
101
+ floatingTabBarHeight: 62,
102
+ floatingTabBarMargin: 20,
103
+ floatingTabBarInsetOverlap: 13,
96
104
  };
97
105
 
98
106
  const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duo.13",
3
+ "version": "0.167.1-duo.14",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},