@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 =
|
|
39
|
+
const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
|
|
40
40
|
const FLOATING_PADDING = Spacing.XS;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 +
|
|
63
|
+
? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
|
|
54
64
|
: 0;
|
|
55
65
|
};
|
|
56
66
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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 =
|
|
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
|
-
}, [
|
|
329
|
+
}, [state.index, cellWidth, bubbleOffset]);
|
|
295
330
|
|
|
296
331
|
const bubbleStyle = useAnimatedStyle(() => ({
|
|
297
|
-
transform: [
|
|
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
|
-
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
455
|
-
height:
|
|
456
|
-
borderRadius:
|
|
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.
|
|
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 };
|