@momo-kits/foundation 0.167.1-duo.13 → 0.167.1-duo.15

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.
@@ -34,8 +34,6 @@ const DEFAULT_TOOLBAR_HEIGHT =
34
34
  DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
35
35
  // Back, then the single button the toolbar folds into
36
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
37
 
40
38
  const NO_COMPRESSION: DuoBarCompression = {
41
39
  toolbarCompressed: false,
@@ -107,7 +105,9 @@ const DuoBarCompressionProvider: React.FC<{
107
105
  expandTabBar: () => setOpened('tabBar'),
108
106
  setToolbarHeight,
109
107
  };
110
- if (opened === 'tabBar' && fits(BACK_ONLY_HEIGHT, tabBar)) {
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
111
  return {
112
112
  ...open,
113
113
  toolbarCompressed: true,
@@ -13,7 +13,7 @@ import Animated, {
13
13
  withSpring,
14
14
  } from 'react-native-reanimated';
15
15
  import { Colors, Radius, Spacing } from '../../Consts';
16
- import { ApplicationContext } from '../../Context';
16
+ import { ApplicationContext, MiniAppContext } from '../../Context';
17
17
  import { Icon } from '../../Icon';
18
18
  import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
19
19
  import { Text } from '../../Text';
@@ -33,40 +33,49 @@ const ICON_SIZE = 28;
33
33
  const QR_ICON_SIZE = 24;
34
34
  const RAIL_PADDING = 6;
35
35
  const RAIL_GAP = Spacing.XXS;
36
+ const RAIL_PITCH = ITEM_SIZE + RAIL_GAP;
36
37
  // selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
37
38
  const RAIL_BUBBLE_WIDTH = 44;
38
39
  const RAIL_BUBBLE_HEIGHT = 58;
39
- const FLOATING_HEIGHT = 62;
40
+ const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
40
41
  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;
42
+ // the system bar sizes its pill to the tabs instead of stretching it across the display
43
+ const FLOATING_ITEM_WIDTH = 80;
44
+ // while pressed the selection bubble becomes the iOS 26 glass lens, growing past the
45
+ // pill's edges, wider and taller (80x54 to about 96x70)
46
+ const LENS_SCALE_X = 1.2;
47
+ const LENS_SCALE_Y = 1.3;
48
+ const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
45
49
  const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
46
50
 
51
+ /**
52
+ * distance from the screen bottom to the floating tab bar, overlapping the home
53
+ * indicator inset like the system tab bar
54
+ */
55
+ const getFloatingTabBarBottom = (bottomInset: number) =>
56
+ Math.max(bottomInset - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S);
57
+
47
58
  /**
48
59
  * space tab content leaves under the Duo floating tab bar, 0 elsewhere
49
60
  */
50
61
  const useDuoTabBarInset = () => {
51
62
  const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
52
63
  return isIPhoneDuo && !sideBar
53
- ? FLOATING_HEIGHT + Spacing.M + insets.bottom
64
+ ? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
54
65
  : 0;
55
66
  };
56
67
 
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 }) => {
68
+ const BarMaterial: React.FC<{ radius: number; interactive?: boolean }> = ({
69
+ radius,
70
+ interactive,
71
+ }) => {
64
72
  const { theme } = useContext(ApplicationContext);
65
73
  if (GlassView) {
66
74
  return (
67
75
  <GlassView
68
76
  pointerEvents={'none'}
69
77
  effect={'glass'}
78
+ interactive={interactive}
70
79
  cornerRadius={radius}
71
80
  useShadow
72
81
  style={StyleSheet.absoluteFill}
@@ -89,6 +98,67 @@ const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
89
98
  );
90
99
  };
91
100
 
101
+ // Figma Home-2026 tab bar "Selection": brand pink at 8% over glass
102
+ const SELECTION_TINT_ALPHA = '14';
103
+
104
+ /**
105
+ * translucent glass capsule under the selected floating tab with the Figma pink tint on
106
+ * top; the tint is a separate layer since a glass tint color renders opaque
107
+ */
108
+ const SelectionMaterial: React.FC<{ radius: number }> = ({ radius }) => {
109
+ const { theme } = useContext(ApplicationContext);
110
+ return (
111
+ <>
112
+ {GlassView && (
113
+ <GlassView
114
+ pointerEvents={'none'}
115
+ effect={'glass'}
116
+ cornerRadius={radius}
117
+ style={StyleSheet.absoluteFill}
118
+ />
119
+ )}
120
+ <View
121
+ pointerEvents={'none'}
122
+ style={[
123
+ StyleSheet.absoluteFill,
124
+ {
125
+ borderRadius: radius,
126
+ backgroundColor: theme.colors.primary + SELECTION_TINT_ALPHA,
127
+ },
128
+ ]}
129
+ />
130
+ </>
131
+ );
132
+ };
133
+
134
+ /**
135
+ * tab label at its design size: like the system tab bar it doesn't grow with the wide
136
+ * Duo displays, which Text otherwise scales up to 5pt
137
+ */
138
+ const TabLabel: React.FC<{
139
+ label: string;
140
+ color: string;
141
+ typography: 'label_xs_medium';
142
+ }> = ({ label, color, typography }) => {
143
+ const context = useContext<any>(MiniAppContext);
144
+ return (
145
+ <MiniAppContext.Provider
146
+ value={{
147
+ ...context,
148
+ fontScale: { useOSScaleRate: false, userScaleRate: 1 },
149
+ }}
150
+ >
151
+ <Text
152
+ numberOfLines={1}
153
+ typography={typography}
154
+ color={color}
155
+ >
156
+ {label}
157
+ </Text>
158
+ </MiniAppContext.Provider>
159
+ );
160
+ };
161
+
92
162
  const getTabItems = ({
93
163
  state,
94
164
  descriptors,
@@ -150,41 +220,52 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
150
220
  type TabItem = ReturnType<typeof getTabItems>[number];
151
221
 
152
222
  /**
153
- * glass column of tab items with the sliding selection bubble
223
+ * glass column of icon-only tab items with the sliding selection bubble
154
224
  */
155
225
  const Rail: React.FC<{
156
226
  items: TabItem[];
157
227
  index: number;
158
228
  onItemPress?: () => void;
159
229
  }> = ({ items, index, onItemPress }) => {
160
- const { theme } = useContext(ApplicationContext);
161
- const bubbleOffset = useSharedValue(index * (ITEM_SIZE + RAIL_GAP));
230
+ const bubbleOffset = useSharedValue(index * RAIL_PITCH);
231
+ const lens = useSharedValue(0);
162
232
 
163
233
  useEffect(() => {
164
- bubbleOffset.value = withSpring(index * (ITEM_SIZE + RAIL_GAP), SPRING);
234
+ bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
165
235
  }, [index, bubbleOffset]);
166
236
 
167
237
  const bubbleStyle = useAnimatedStyle(() => ({
168
- transform: [{ translateY: bubbleOffset.value }],
238
+ transform: [
239
+ { translateY: bubbleOffset.value },
240
+ { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
241
+ { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
242
+ ],
169
243
  }));
170
244
 
171
245
  return (
172
246
  <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
173
- <BarMaterial radius={Radius.XL} />
247
+ <BarMaterial radius={ITEM_SIZE / 2} interactive />
174
248
  <Animated.View
175
249
  pointerEvents={'none'}
176
- style={[
177
- styles.railBubble,
178
- { backgroundColor: theme.colors.background.selected },
179
- bubbleStyle,
180
- ]}
181
- />
182
- {items.map(item => (
250
+ style={[styles.railBubble, bubbleStyle]}
251
+ >
252
+ <SelectionMaterial radius={RAIL_BUBBLE_WIDTH / 2} />
253
+ </Animated.View>
254
+ {items.map((item, itemIndex) => (
183
255
  <TouchableOpacity
184
256
  key={item.route.key}
185
257
  accessibilityRole={'tab'}
186
258
  accessibilityLabel={item.label}
187
259
  accessibilityState={{ selected: item.focused }}
260
+ activeOpacity={1}
261
+ onPressIn={() => {
262
+ bubbleOffset.value = withSpring(itemIndex * RAIL_PITCH, SPRING);
263
+ lens.value = withSpring(1, SPRING);
264
+ }}
265
+ onPressOut={() => {
266
+ bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
267
+ lens.value = withSpring(0, SPRING);
268
+ }}
188
269
  onPress={() => {
189
270
  item.onPress();
190
271
  onItemPress?.();
@@ -267,65 +348,103 @@ const DuoRailTabBar: React.FC<Props> = props => {
267
348
  };
268
349
 
269
350
  /**
270
- * floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M)
351
+ * floating labelled glass bar for the unfolded portrait display, QR pinned in its own
352
+ * platter on the trailing side, like the host app's iOS 26 system tab bar
271
353
  */
272
354
  const DuoFloatingTabBar: React.FC<Props> = props => {
273
355
  const { theme } = useContext(ApplicationContext);
274
- const { width, insets } = useScreenLayout();
356
+ const { insets } = useScreenLayout();
275
357
  const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
276
358
  const items = getTabItems(props);
277
359
  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;
360
+ const cellWidth = rowWidth / state.routes.length;
285
361
 
286
362
  const bubbleOffset = useSharedValue(0);
363
+ const lens = useSharedValue(0);
287
364
  const measured = useRef(false);
288
365
 
289
366
  useEffect(() => {
290
- const target = visualIndex * cellWidth;
367
+ const target = state.index * cellWidth;
291
368
  // snap on first layout so the bubble doesn't slide in from the left edge
292
369
  bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
293
370
  measured.current = cellWidth > 0;
294
- }, [visualIndex, cellWidth, bubbleOffset]);
371
+ }, [state.index, cellWidth, bubbleOffset]);
295
372
 
296
373
  const bubbleStyle = useAnimatedStyle(() => ({
297
- transform: [{ translateX: bubbleOffset.value }],
374
+ transform: [
375
+ { translateX: bubbleOffset.value },
376
+ { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
377
+ { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
378
+ ],
298
379
  }));
299
380
 
300
381
  const onRowLayout = (e: LayoutChangeEvent) =>
301
382
  setRowWidth(e.nativeEvent.layout.width);
302
383
 
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}
384
+ return (
385
+ <View
386
+ pointerEvents={'box-none'}
387
+ style={[
388
+ styles.floating,
389
+ {
390
+ left: insets.left + DuoMetrics.floatingTabBarMargin,
391
+ right: insets.right + DuoMetrics.floatingTabBarMargin,
392
+ bottom: getFloatingTabBarBottom(insets.bottom),
393
+ },
394
+ ]}
312
395
  >
313
- {item.icon}
314
- <Text
315
- numberOfLines={1}
316
- typography={'label_xs_medium'}
317
- color={item.focused ? activeTintColor : inactiveTintColor}
396
+ <View
397
+ style={[
398
+ styles.floatingBar,
399
+ {
400
+ width:
401
+ state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
402
+ },
403
+ ]}
404
+ accessibilityLabel={'bottom_tab_bar'}
318
405
  >
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}>
406
+ <BarMaterial radius={FLOATING_HEIGHT / 2} interactive />
407
+ <View style={styles.floatingRow} onLayout={onRowLayout}>
408
+ {rowWidth > 0 && (
409
+ <Animated.View
410
+ pointerEvents={'none'}
411
+ style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
412
+ >
413
+ <SelectionMaterial radius={FLOATING_BUBBLE_RADIUS} />
414
+ </Animated.View>
415
+ )}
416
+ {items.map((item, index) => (
417
+ <TouchableOpacity
418
+ key={item.route.key}
419
+ accessibilityRole={'tab'}
420
+ accessibilityLabel={item.label}
421
+ accessibilityState={{ selected: item.focused }}
422
+ activeOpacity={1}
423
+ onPressIn={() => {
424
+ // the lens jumps to the pressed tab, not only the selected one
425
+ bubbleOffset.value = withSpring(index * cellWidth, SPRING);
426
+ lens.value = withSpring(1, SPRING);
427
+ }}
428
+ onPressOut={() => {
429
+ // onPress follows synchronously, a tab switch re-targets the bubble
430
+ bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
431
+ lens.value = withSpring(0, SPRING);
432
+ }}
433
+ onPress={item.onPress}
434
+ onLongPress={item.onLongPress}
435
+ style={styles.floatingItem}
436
+ >
437
+ {item.icon}
438
+ <TabLabel
439
+ label={item.label}
440
+ typography={'label_xs_medium'}
441
+ color={item.focused ? activeTintColor : inactiveTintColor}
442
+ />
443
+ </TouchableOpacity>
444
+ ))}
445
+ </View>
446
+ </View>
447
+ {floatingButton && (
329
448
  <TouchableOpacity
330
449
  accessibilityRole={'button'}
331
450
  accessibilityLabel={floatingButton.label}
@@ -334,39 +453,7 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
334
453
  >
335
454
  <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
336
455
  </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>
456
+ )}
370
457
  </View>
371
458
  );
372
459
  };
@@ -413,7 +500,6 @@ const styles = StyleSheet.create({
413
500
  left: (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
414
501
  width: RAIL_BUBBLE_WIDTH,
415
502
  height: RAIL_BUBBLE_HEIGHT,
416
- borderRadius: RAIL_BUBBLE_WIDTH / 2,
417
503
  },
418
504
  railItem: {
419
505
  width: ITEM_SIZE,
@@ -430,6 +516,12 @@ const styles = StyleSheet.create({
430
516
  },
431
517
  floating: {
432
518
  position: 'absolute',
519
+ flexDirection: 'row',
520
+ justifyContent: 'center',
521
+ gap: Spacing.S,
522
+ },
523
+ floatingBar: {
524
+ flexShrink: 1,
433
525
  height: FLOATING_HEIGHT,
434
526
  padding: FLOATING_PADDING,
435
527
  },
@@ -442,7 +534,6 @@ const styles = StyleSheet.create({
442
534
  top: 0,
443
535
  bottom: 0,
444
536
  left: 0,
445
- borderRadius: (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2,
446
537
  },
447
538
  floatingItem: {
448
539
  flex: 1,
@@ -450,12 +541,17 @@ const styles = StyleSheet.create({
450
541
  justifyContent: 'center',
451
542
  gap: Spacing.XXS,
452
543
  },
544
+ // pink fills the whole pinned platter like the host app, shadow matches the glass bar
453
545
  floatingQr: {
454
- width: FLOATING_QR_SIZE,
455
- height: FLOATING_QR_SIZE,
456
- borderRadius: FLOATING_QR_SIZE / 2,
546
+ width: FLOATING_HEIGHT,
547
+ height: FLOATING_HEIGHT,
548
+ borderRadius: FLOATING_HEIGHT / 2,
457
549
  alignItems: 'center',
458
550
  justifyContent: 'center',
551
+ shadowColor: Colors.black_20,
552
+ shadowOffset: { width: 0, height: 6 },
553
+ shadowOpacity: 0.16,
554
+ shadowRadius: 16,
459
555
  },
460
556
  });
461
557
 
@@ -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.hint}
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.15",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},