@momo-kits/foundation 0.168.1-beta.33 → 0.168.1-beta.34
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.
- package/Application/BottomSheet.tsx +32 -162
- package/Application/ModalIconButton.tsx +66 -0
- package/Application/ModalScreen.tsx +32 -15
- package/Application/__tests__/modalLayout.test.ts +128 -0
- package/Application/device.ts +1 -32
- package/Application/index.ts +6 -0
- package/Application/modalLayout.ts +180 -0
- package/Badge/BadgeRibbon.tsx +3 -3
- package/Badge/styles.ts +2 -0
- package/Layout/ReservedRegions.tsx +21 -4
- package/Layout/index.ts +2 -0
- package/Popup/PopupNotify.tsx +38 -68
- package/Popup/PopupPromotion.tsx +5 -4
- package/Popup/utils.ts +1 -26
- package/package.json +1 -1
|
@@ -16,9 +16,7 @@ import {
|
|
|
16
16
|
PanResponder,
|
|
17
17
|
Platform,
|
|
18
18
|
Pressable,
|
|
19
|
-
StatusBar,
|
|
20
19
|
StyleSheet,
|
|
21
|
-
TouchableOpacity,
|
|
22
20
|
useWindowDimensions,
|
|
23
21
|
View,
|
|
24
22
|
} from 'react-native';
|
|
@@ -27,7 +25,6 @@ import { ApplicationContext, MiniAppContext } from '../Context';
|
|
|
27
25
|
import { BottomSheetParams } from './types';
|
|
28
26
|
import { Colors, Radius, Spacing, Styles } from '../Consts';
|
|
29
27
|
import { Text } from '../Text';
|
|
30
|
-
import { Icon } from '../Icon';
|
|
31
28
|
import { useHeaderHeight } from '@react-navigation/elements';
|
|
32
29
|
import Animated, {
|
|
33
30
|
Easing,
|
|
@@ -40,54 +37,26 @@ import Animated, {
|
|
|
40
37
|
} from 'react-native-reanimated';
|
|
41
38
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
42
39
|
import layoutStyles from '../Layout/styles';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
48
|
-
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
40
|
+
import { isIPhoneDuo } from './device';
|
|
41
|
+
import { useModalLayout } from './modalLayout';
|
|
42
|
+
import { MODAL_ICON_BUTTON_SIZE, ModalIconButton } from './ModalIconButton';
|
|
49
43
|
|
|
50
44
|
const INDICATOR_HEIGHT = 16;
|
|
51
45
|
const HEADER_HEIGHT = 56;
|
|
52
46
|
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
53
|
-
const SIDE_COLUMN_OFFSET =
|
|
54
|
-
INDICATOR_HEIGHT + (HEADER_HEIGHT - DuoMetrics.controlSize) / 2;
|
|
55
|
-
|
|
56
|
-
const getSideInset = (inset: number) => Math.max(inset - Spacing.M, 0);
|
|
57
47
|
|
|
58
48
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
59
49
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
60
50
|
const context = useContext<any>(MiniAppContext);
|
|
61
51
|
const windowSize = useWindowDimensions();
|
|
62
52
|
const isDuo = isIPhoneDuo();
|
|
63
|
-
const
|
|
64
|
-
isDuo &&
|
|
65
|
-
Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
|
|
66
|
-
const isDuoFloating = isDuo && !isDuoCentered;
|
|
67
|
-
const { sideBar } = useScreenLayout();
|
|
68
|
-
const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
|
|
69
|
-
const sideColumn =
|
|
70
|
-
isDuoFloating &&
|
|
71
|
-
sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
|
|
72
|
-
sideBar
|
|
73
|
-
? sideBar
|
|
74
|
-
: undefined;
|
|
75
|
-
const hasSideColumn = !!sideColumn;
|
|
53
|
+
const { isFoldable, placement, sheet, scrimOpacity } = useModalLayout();
|
|
76
54
|
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
77
55
|
const screenHeight = Dimensions.get('screen').height;
|
|
78
56
|
const heightDevice = isDuo ? windowSize.height : screenHeight;
|
|
79
57
|
const action = useRef<undefined | string>(undefined);
|
|
80
58
|
const insets = useSafeAreaInsets();
|
|
81
59
|
const heightHeader = useHeaderHeight();
|
|
82
|
-
const topGap = Math.max(
|
|
83
|
-
sideColumn
|
|
84
|
-
? insets.top
|
|
85
|
-
: getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
86
|
-
Spacing.XL,
|
|
87
|
-
);
|
|
88
|
-
const sideInsetLeft = isDuoFloating ? getSideInset(insets.left) : 0;
|
|
89
|
-
const sideInsetRight = isDuoFloating ? getSideInset(insets.right) : 0;
|
|
90
|
-
const indicatorShift = (sideInsetRight - sideInsetLeft) / 2;
|
|
91
60
|
const isIOS = Platform.OS === 'ios';
|
|
92
61
|
const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
93
62
|
const keyboardOffset = heightHeader - bottomInset;
|
|
@@ -113,7 +82,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
113
82
|
const onDismissRef = useRef(props.route.params?.onDismiss);
|
|
114
83
|
onDismissRef.current = props.route.params?.onDismiss;
|
|
115
84
|
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
116
|
-
const [sheetTop, setSheetTop] = useState(windowSize.height);
|
|
117
85
|
|
|
118
86
|
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
119
87
|
// so lift it here and shrink maxHeight by the same amount instead
|
|
@@ -269,39 +237,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
269
237
|
|
|
270
238
|
const showBack = !!leftOptions?.onPressIconLeft;
|
|
271
239
|
const iconBack = leftOptions?.iconLeft ?? 'ic_back';
|
|
272
|
-
const sideColumnCount = showBack ? 2 : 1;
|
|
273
|
-
const sideColumnHeight =
|
|
274
|
-
sideColumnCount * DuoMetrics.controlSize +
|
|
275
|
-
(sideColumnCount - 1) * Spacing.M;
|
|
276
|
-
const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
|
|
277
|
-
|
|
278
|
-
useEffect(() => {
|
|
279
|
-
if (!coversStatusBar) {
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const entry = StatusBar.pushStackEntry({
|
|
283
|
-
barStyle: theme.dark ? 'light-content' : 'dark-content',
|
|
284
|
-
animated: true,
|
|
285
|
-
});
|
|
286
|
-
return () => StatusBar.popStackEntry(entry);
|
|
287
|
-
}, [coversStatusBar, theme.dark]);
|
|
288
240
|
|
|
289
241
|
/**
|
|
290
242
|
* render header
|
|
291
243
|
*/
|
|
292
244
|
const renderHeader = useCallback(() => {
|
|
293
|
-
const showButtons = !hasSideColumn;
|
|
294
245
|
return (
|
|
295
246
|
<View {...panResponder.panHandlers}>
|
|
296
247
|
<View style={{ height: INDICATOR_HEIGHT }}>
|
|
297
|
-
<View
|
|
298
|
-
style={[
|
|
299
|
-
styles.indicator,
|
|
300
|
-
indicatorShift !== 0 && {
|
|
301
|
-
transform: [{ translateX: indicatorShift }],
|
|
302
|
-
},
|
|
303
|
-
]}
|
|
304
|
-
/>
|
|
248
|
+
<View style={styles.indicator} />
|
|
305
249
|
</View>
|
|
306
250
|
<View
|
|
307
251
|
style={[
|
|
@@ -312,25 +256,17 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
312
256
|
},
|
|
313
257
|
]}
|
|
314
258
|
>
|
|
315
|
-
{
|
|
316
|
-
<
|
|
317
|
-
<Icon source={iconBack} />
|
|
318
|
-
</TouchableOpacity>
|
|
259
|
+
{showBack ? (
|
|
260
|
+
<ModalIconButton icon={iconBack} onPress={onPressBack} />
|
|
319
261
|
) : null}
|
|
320
262
|
|
|
321
263
|
{options.header ? (
|
|
322
264
|
<View style={Styles.flex}>{options.header}</View>
|
|
323
265
|
) : (
|
|
324
266
|
<>
|
|
325
|
-
{
|
|
326
|
-
<View style={styles.iconButton} />
|
|
327
|
-
) : null}
|
|
267
|
+
{!leftOptions ? <View style={styles.iconButtonSpace} /> : null}
|
|
328
268
|
<Text
|
|
329
|
-
style={[
|
|
330
|
-
Styles.flex,
|
|
331
|
-
styles.title,
|
|
332
|
-
!showButtons && styles.titleInset,
|
|
333
|
-
]}
|
|
269
|
+
style={[Styles.flex, styles.title]}
|
|
334
270
|
numberOfLines={1}
|
|
335
271
|
typography="header_default_bold"
|
|
336
272
|
>
|
|
@@ -338,18 +274,12 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
338
274
|
</Text>
|
|
339
275
|
</>
|
|
340
276
|
)}
|
|
341
|
-
{
|
|
342
|
-
<TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
|
|
343
|
-
<Icon source="navigation_close" />
|
|
344
|
-
</TouchableOpacity>
|
|
345
|
-
) : null}
|
|
277
|
+
<ModalIconButton icon="navigation_close" onPress={onPressClose} />
|
|
346
278
|
</View>
|
|
347
279
|
</View>
|
|
348
280
|
);
|
|
349
281
|
}, [
|
|
350
|
-
hasSideColumn,
|
|
351
282
|
iconBack,
|
|
352
|
-
indicatorShift,
|
|
353
283
|
leftOptions,
|
|
354
284
|
onPressBack,
|
|
355
285
|
onPressClose,
|
|
@@ -361,41 +291,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
361
291
|
useDivider,
|
|
362
292
|
]);
|
|
363
293
|
|
|
364
|
-
/**
|
|
365
|
-
* iPhone Duo folded: back and close move to the side bar column, drawn on
|
|
366
|
-
* the part of the sheet under the side safe area. They line up with the
|
|
367
|
-
* header row, stay below the status bar cluster on a tall sheet and above a
|
|
368
|
-
* camera at the bottom end of the strip.
|
|
369
|
-
*/
|
|
370
|
-
const renderSideColumn = () => {
|
|
371
|
-
if (!sideColumn) {
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
|
-
const lowestTop =
|
|
375
|
-
windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
|
|
376
|
-
const top = Math.min(
|
|
377
|
-
Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
|
|
378
|
-
lowestTop,
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
return (
|
|
382
|
-
<View
|
|
383
|
-
pointerEvents={'box-none'}
|
|
384
|
-
style={[
|
|
385
|
-
styles.sideColumn,
|
|
386
|
-
sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
|
|
387
|
-
InnerEdgeStyles[sideColumn.edge],
|
|
388
|
-
{ top, width: sideColumnWidth },
|
|
389
|
-
]}
|
|
390
|
-
>
|
|
391
|
-
{showBack ? (
|
|
392
|
-
<SideBarButton icon={iconBack} onPress={onPressBack} />
|
|
393
|
-
) : null}
|
|
394
|
-
<SideBarButton icon="navigation_close" onPress={onPressClose} />
|
|
395
|
-
</View>
|
|
396
|
-
);
|
|
397
|
-
};
|
|
398
|
-
|
|
399
294
|
const animatedStyle = useAnimatedStyle(() => {
|
|
400
295
|
return {
|
|
401
296
|
transform: [{ translateY: translateY.value }],
|
|
@@ -407,17 +302,12 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
407
302
|
opacity: interpolate(
|
|
408
303
|
translateY.value,
|
|
409
304
|
[0, heightDevice],
|
|
410
|
-
[
|
|
305
|
+
[scrimOpacity, 0],
|
|
411
306
|
Extrapolation.CLAMP,
|
|
412
307
|
),
|
|
413
308
|
};
|
|
414
309
|
});
|
|
415
310
|
|
|
416
|
-
const sideInsetStyle = isDuoFloating && {
|
|
417
|
-
paddingLeft: sideInsetLeft,
|
|
418
|
-
paddingRight: sideInsetRight,
|
|
419
|
-
};
|
|
420
|
-
|
|
421
311
|
const screen = (
|
|
422
312
|
<Screen
|
|
423
313
|
{...props}
|
|
@@ -458,41 +348,31 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
458
348
|
</Pressable>
|
|
459
349
|
|
|
460
350
|
<Animated.View
|
|
461
|
-
onLayout={
|
|
462
|
-
isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
|
|
463
|
-
}
|
|
464
351
|
style={[
|
|
465
352
|
animatedStyle,
|
|
466
353
|
{
|
|
467
354
|
backgroundColor,
|
|
468
|
-
borderTopLeftRadius:
|
|
469
|
-
borderTopRightRadius:
|
|
355
|
+
borderTopLeftRadius: sheet.radius,
|
|
356
|
+
borderTopRightRadius: sheet.radius,
|
|
470
357
|
overflow: 'hidden',
|
|
471
358
|
},
|
|
472
|
-
isDuo && {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
width: Math.min(
|
|
484
|
-
Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
|
|
485
|
-
windowSize.width -
|
|
486
|
-
Math.max(insets.left, insets.right, Spacing.L) * 2,
|
|
487
|
-
),
|
|
488
|
-
alignSelf: 'center',
|
|
489
|
-
},
|
|
359
|
+
isDuo && { marginBottom: keyboardLift },
|
|
360
|
+
isFoldable &&
|
|
361
|
+
sheet.width !== undefined &&
|
|
362
|
+
sheet.maxHeight !== undefined && {
|
|
363
|
+
// centered in the placement area, clear of the iPhone Duo
|
|
364
|
+
// control column and on the right half when half folded
|
|
365
|
+
width: sheet.width,
|
|
366
|
+
marginLeft: placement.x + (placement.width - sheet.width) / 2,
|
|
367
|
+
maxHeight: sheet.maxHeight - keyboardLift,
|
|
368
|
+
minHeight: sheet.minHeight,
|
|
369
|
+
},
|
|
490
370
|
showBaseLineDebug && styles.debugBaseLine,
|
|
491
371
|
]}
|
|
492
372
|
>
|
|
493
|
-
{
|
|
373
|
+
{isFoldable ? (
|
|
494
374
|
// content shrinks so a tall sheet keeps its header and footer
|
|
495
|
-
<View style={
|
|
375
|
+
<View style={styles.shrink}>
|
|
496
376
|
{renderHeader()}
|
|
497
377
|
<View style={styles.shrink}>{screen}</View>
|
|
498
378
|
</View>
|
|
@@ -510,7 +390,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
510
390
|
paddingBottom: Spacing.S,
|
|
511
391
|
backgroundColor: theme.colors.background.surface,
|
|
512
392
|
},
|
|
513
|
-
sideInsetStyle,
|
|
514
393
|
]}
|
|
515
394
|
>
|
|
516
395
|
{footerComponent}
|
|
@@ -524,7 +403,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
524
403
|
}}
|
|
525
404
|
/>
|
|
526
405
|
)}
|
|
527
|
-
{renderSideColumn()}
|
|
528
406
|
</Animated.View>
|
|
529
407
|
</KeyboardAvoidingView>
|
|
530
408
|
</Container>
|
|
@@ -538,7 +416,7 @@ const styles = StyleSheet.create({
|
|
|
538
416
|
},
|
|
539
417
|
overlay: {
|
|
540
418
|
flex: 1,
|
|
541
|
-
backgroundColor:
|
|
419
|
+
backgroundColor: Colors.black_20,
|
|
542
420
|
},
|
|
543
421
|
indicator: {
|
|
544
422
|
width: 40,
|
|
@@ -553,23 +431,15 @@ const styles = StyleSheet.create({
|
|
|
553
431
|
height: HEADER_HEIGHT,
|
|
554
432
|
flexDirection: 'row',
|
|
555
433
|
alignItems: 'center',
|
|
434
|
+
paddingHorizontal: Spacing.M,
|
|
435
|
+
gap: Spacing.S,
|
|
556
436
|
},
|
|
557
437
|
title: { textAlign: 'center' },
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
height: 24,
|
|
562
|
-
alignItems: 'center',
|
|
563
|
-
justifyContent: 'center',
|
|
564
|
-
marginHorizontal: Spacing.M,
|
|
438
|
+
iconButtonSpace: {
|
|
439
|
+
width: MODAL_ICON_BUTTON_SIZE,
|
|
440
|
+
height: MODAL_ICON_BUTTON_SIZE,
|
|
565
441
|
},
|
|
566
442
|
shrink: { flexShrink: 1 },
|
|
567
|
-
sideColumn: {
|
|
568
|
-
position: 'absolute',
|
|
569
|
-
gap: Spacing.M,
|
|
570
|
-
},
|
|
571
|
-
alignRight: { right: 0 },
|
|
572
|
-
alignLeft: { left: 0 },
|
|
573
443
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
574
444
|
});
|
|
575
445
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React, { useContext } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
ViewStyle,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
import { ApplicationContext } from '../Context';
|
|
9
|
+
import { Radius, Spacing } from '../Consts';
|
|
10
|
+
import { Icon } from '../Icon';
|
|
11
|
+
|
|
12
|
+
// modal spec: icon button 32, icon 20, shared with the Compose / SwiftUI kits
|
|
13
|
+
const MODAL_ICON_BUTTON_SIZE = Spacing.XXL;
|
|
14
|
+
const MODAL_ICON_SIZE = 20;
|
|
15
|
+
|
|
16
|
+
type ModalIconButtonProps = {
|
|
17
|
+
icon: string;
|
|
18
|
+
onPress: () => void;
|
|
19
|
+
accessibilityLabel?: string;
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Close / back button of popups and bottom sheet headers
|
|
25
|
+
*/
|
|
26
|
+
const ModalIconButton: React.FC<ModalIconButtonProps> = ({
|
|
27
|
+
icon,
|
|
28
|
+
onPress,
|
|
29
|
+
accessibilityLabel,
|
|
30
|
+
style,
|
|
31
|
+
}) => {
|
|
32
|
+
const { theme } = useContext(ApplicationContext);
|
|
33
|
+
return (
|
|
34
|
+
<TouchableOpacity
|
|
35
|
+
accessibilityLabel={accessibilityLabel}
|
|
36
|
+
onPress={onPress}
|
|
37
|
+
style={[
|
|
38
|
+
styles.button,
|
|
39
|
+
{
|
|
40
|
+
backgroundColor: theme.colors.background.surface,
|
|
41
|
+
borderColor: theme.colors.border.default,
|
|
42
|
+
},
|
|
43
|
+
style,
|
|
44
|
+
]}
|
|
45
|
+
>
|
|
46
|
+
<Icon
|
|
47
|
+
source={icon}
|
|
48
|
+
color={theme.colors.text.default}
|
|
49
|
+
size={MODAL_ICON_SIZE}
|
|
50
|
+
/>
|
|
51
|
+
</TouchableOpacity>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
button: {
|
|
57
|
+
width: MODAL_ICON_BUTTON_SIZE,
|
|
58
|
+
height: MODAL_ICON_BUTTON_SIZE,
|
|
59
|
+
borderRadius: Radius.L,
|
|
60
|
+
borderWidth: 1,
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export { ModalIconButton, MODAL_ICON_BUTTON_SIZE };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
3
9
|
Animated,
|
|
4
10
|
BackHandler,
|
|
@@ -11,14 +17,14 @@ import {
|
|
|
11
17
|
useWindowDimensions,
|
|
12
18
|
View,
|
|
13
19
|
} from 'react-native';
|
|
14
|
-
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
15
20
|
|
|
16
21
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
17
|
-
import { Styles } from '../Consts';
|
|
22
|
+
import { Colors, Styles } from '../Consts';
|
|
18
23
|
import Navigation from './Navigation';
|
|
19
24
|
import { ModalParams } from './types';
|
|
20
25
|
import BottomSheet from './BottomSheet';
|
|
21
|
-
import {
|
|
26
|
+
import { isIPhoneDuo } from './device';
|
|
27
|
+
import { useModalLayout } from './modalLayout';
|
|
22
28
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
23
29
|
|
|
24
30
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -40,9 +46,9 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
40
46
|
title,
|
|
41
47
|
} = props.route.params;
|
|
42
48
|
const Component = useRef(screen).current;
|
|
43
|
-
const
|
|
44
|
-
const windowSize = useWindowDimensions();
|
|
49
|
+
const { width, height } = useWindowDimensions();
|
|
45
50
|
const isDuo = isIPhoneDuo();
|
|
51
|
+
const { isFoldable, placement, popup, scrimOpacity } = useModalLayout();
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -61,6 +67,15 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
61
67
|
|
|
62
68
|
const opacity = useRef(new Animated.Value(0)).current;
|
|
63
69
|
const scale = useRef(new Animated.Value(0.8)).current;
|
|
70
|
+
const scrimStyle = useMemo(
|
|
71
|
+
() => ({
|
|
72
|
+
opacity: opacity.interpolate({
|
|
73
|
+
inputRange: [0, 1],
|
|
74
|
+
outputRange: [0, scrimOpacity],
|
|
75
|
+
}),
|
|
76
|
+
}),
|
|
77
|
+
[opacity, scrimOpacity],
|
|
78
|
+
);
|
|
64
79
|
|
|
65
80
|
const navigation = new Navigation(props.navigation);
|
|
66
81
|
const params = {
|
|
@@ -146,7 +161,7 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
146
161
|
style={StyleSheet.absoluteFillObject}
|
|
147
162
|
onPress={() => onDismiss(undefined, barrierDismissible)}
|
|
148
163
|
>
|
|
149
|
-
<Animated.View style={[styles.overlayContent,
|
|
164
|
+
<Animated.View style={[styles.overlayContent, scrimStyle]} />
|
|
150
165
|
</Pressable>
|
|
151
166
|
<Animated.View
|
|
152
167
|
style={[
|
|
@@ -154,13 +169,15 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
154
169
|
opacity,
|
|
155
170
|
transform: [{ scale }],
|
|
156
171
|
},
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
// centered in the placement area, at most a popup wide
|
|
173
|
+
isFoldable && styles.shrink,
|
|
174
|
+
isFoldable && {
|
|
175
|
+
marginTop: placement.y,
|
|
176
|
+
marginBottom: height - placement.y - placement.height,
|
|
177
|
+
marginLeft: placement.x,
|
|
178
|
+
marginRight: width - placement.x - placement.width,
|
|
179
|
+
maxWidth: popup.width,
|
|
180
|
+
maxHeight: popup.maxHeight,
|
|
164
181
|
},
|
|
165
182
|
modalStyle,
|
|
166
183
|
]}
|
|
@@ -177,7 +194,7 @@ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
|
177
194
|
const styles = StyleSheet.create({
|
|
178
195
|
overlayContent: {
|
|
179
196
|
flex: 1,
|
|
180
|
-
backgroundColor:
|
|
197
|
+
backgroundColor: Colors.black_20,
|
|
181
198
|
},
|
|
182
199
|
shrink: { flexShrink: 1 },
|
|
183
200
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// document/__mocks__ shadows react-native with react-native-web for the doc site
|
|
2
|
+
jest.mock('react-native', () => jest.requireActual('react-native'));
|
|
3
|
+
|
|
4
|
+
import { getModalLayout } from '../modalLayout';
|
|
5
|
+
|
|
6
|
+
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
7
|
+
|
|
8
|
+
describe('getModalLayout', () => {
|
|
9
|
+
it('keeps the phone look', () => {
|
|
10
|
+
const layout = getModalLayout({
|
|
11
|
+
width: 390,
|
|
12
|
+
height: 844,
|
|
13
|
+
insets: { top: 47, right: 0, bottom: 34, left: 0 },
|
|
14
|
+
isDuo: false,
|
|
15
|
+
});
|
|
16
|
+
expect(layout.isFoldable).toBe(false);
|
|
17
|
+
expect(layout.popup.width).toBe(342);
|
|
18
|
+
expect(layout.popup.maxHeight).toBeUndefined();
|
|
19
|
+
expect(layout.popup.radius).toBe(16);
|
|
20
|
+
expect(layout.sheet.width).toBeUndefined();
|
|
21
|
+
expect(layout.sheet.maxHeight).toBeUndefined();
|
|
22
|
+
expect(layout.sheet.radius).toBe(12);
|
|
23
|
+
expect(layout.scrimOpacity).toBe(0.6);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the phone look in landscape', () => {
|
|
27
|
+
const layout = getModalLayout({
|
|
28
|
+
width: 844,
|
|
29
|
+
height: 390,
|
|
30
|
+
insets: { top: 0, right: 47, bottom: 21, left: 47 },
|
|
31
|
+
isDuo: false,
|
|
32
|
+
});
|
|
33
|
+
expect(layout.isFoldable).toBe(false);
|
|
34
|
+
expect(layout.popup.width).toBe(844 - 48);
|
|
35
|
+
expect(layout.popup.maxHeight).toBeUndefined();
|
|
36
|
+
expect(layout.popup.radius).toBe(16);
|
|
37
|
+
expect(layout.sheet.width).toBeUndefined();
|
|
38
|
+
expect(layout.scrimOpacity).toBe(0.6);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('caps popup and sheet on a wide foldable window', () => {
|
|
42
|
+
const layout = getModalLayout({
|
|
43
|
+
width: 884,
|
|
44
|
+
height: 1104,
|
|
45
|
+
insets: { top: 24, right: 0, bottom: 20, left: 0 },
|
|
46
|
+
isDuo: false,
|
|
47
|
+
});
|
|
48
|
+
expect(layout.isFoldable).toBe(true);
|
|
49
|
+
expect(layout.popup).toMatchObject({
|
|
50
|
+
width: 450,
|
|
51
|
+
minWidth: 280,
|
|
52
|
+
maxWidth: 450,
|
|
53
|
+
maxHeight: 600,
|
|
54
|
+
margin: 24,
|
|
55
|
+
radius: 24,
|
|
56
|
+
});
|
|
57
|
+
expect(layout.sheet).toMatchObject({
|
|
58
|
+
width: 653,
|
|
59
|
+
maxHeight: 1104 - 24 - 12,
|
|
60
|
+
minHeight: 230,
|
|
61
|
+
margin: 12,
|
|
62
|
+
radius: 24,
|
|
63
|
+
});
|
|
64
|
+
expect(layout.scrimOpacity).toBe(0.2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('limits popup height to the safe area minus 48', () => {
|
|
68
|
+
const layout = getModalLayout({
|
|
69
|
+
width: 900,
|
|
70
|
+
height: 600,
|
|
71
|
+
insets: { top: 24, right: 0, bottom: 20, left: 0 },
|
|
72
|
+
isDuo: false,
|
|
73
|
+
});
|
|
74
|
+
expect(layout.popup.maxHeight).toBe(600 - 24 - 20 - 48);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('places modals left of the control column on the iPhone Duo outer display', () => {
|
|
78
|
+
const layout = getModalLayout({
|
|
79
|
+
width: 420,
|
|
80
|
+
height: 760,
|
|
81
|
+
insets: { top: 0, right: 64, bottom: 20, left: 0 },
|
|
82
|
+
isDuo: true,
|
|
83
|
+
});
|
|
84
|
+
expect(layout.isFoldable).toBe(true);
|
|
85
|
+
expect(layout.placement).toEqual({ x: 0, y: 0, width: 356, height: 740 });
|
|
86
|
+
expect(layout.popup.margin).toBe(12);
|
|
87
|
+
expect(layout.popup.width).toBe(356 - 24);
|
|
88
|
+
expect(layout.sheet.width).toBe(356 - 24);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('never grows past the margins when the space is under the min width', () => {
|
|
92
|
+
const layout = getModalLayout({
|
|
93
|
+
width: 300,
|
|
94
|
+
height: 600,
|
|
95
|
+
insets: NO_INSETS,
|
|
96
|
+
isDuo: true,
|
|
97
|
+
});
|
|
98
|
+
expect(layout.popup.width).toBe(300 - 24);
|
|
99
|
+
expect(layout.popup.minWidth).toBe(300 - 24);
|
|
100
|
+
expect(layout.sheet.width).toBe(300 - 24);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('moves to the right half when iPhone Duo is half folded', () => {
|
|
104
|
+
const layout = getModalLayout({
|
|
105
|
+
width: 1000,
|
|
106
|
+
height: 800,
|
|
107
|
+
insets: NO_INSETS,
|
|
108
|
+
isDuo: true,
|
|
109
|
+
hinge: { x: 495, y: 0, width: 10, height: 800 },
|
|
110
|
+
});
|
|
111
|
+
expect(layout.placement).toEqual({ x: 505, y: 0, width: 495, height: 800 });
|
|
112
|
+
expect(layout.popup.margin).toBe(12);
|
|
113
|
+
expect(layout.popup.width).toBe(450);
|
|
114
|
+
expect(layout.sheet.width).toBe(495 - 24);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('ignores a division off the iPhone Duo', () => {
|
|
118
|
+
const layout = getModalLayout({
|
|
119
|
+
width: 1000,
|
|
120
|
+
height: 800,
|
|
121
|
+
insets: NO_INSETS,
|
|
122
|
+
isDuo: false,
|
|
123
|
+
hinge: { x: 495, y: 0, width: 10, height: 800 },
|
|
124
|
+
});
|
|
125
|
+
expect(layout.placement.x).toBe(0);
|
|
126
|
+
expect(layout.popup.margin).toBe(24);
|
|
127
|
+
});
|
|
128
|
+
});
|
package/Application/device.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
2
|
-
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
-
import { Spacing } from '../Consts';
|
|
4
2
|
|
|
5
3
|
// widest short side of any single-display iPhone
|
|
6
4
|
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
7
5
|
|
|
8
|
-
const DUO_MODAL_MAX_WIDTH = 440;
|
|
9
|
-
const DUO_MODAL_MAX_HEIGHT = 640;
|
|
10
|
-
|
|
11
6
|
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
12
7
|
// still matches when Split View narrows the window
|
|
13
8
|
const isIPhoneDuo = () => {
|
|
@@ -23,30 +18,4 @@ const isIPhoneDuo = () => {
|
|
|
23
18
|
);
|
|
24
19
|
};
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
|
|
28
|
-
Math.max(
|
|
29
|
-
insets.top,
|
|
30
|
-
height > width ? Math.max(insets.left, insets.right) * 2 : 0,
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Max modal size on iPhone Duo: inside the safe area, so clear of the side
|
|
35
|
-
* toolbar strip, and capped so it stays a dialog on the inner display.
|
|
36
|
-
*/
|
|
37
|
-
const getDuoModalBounds = (
|
|
38
|
-
insets: EdgeInsets,
|
|
39
|
-
width: number,
|
|
40
|
-
height: number,
|
|
41
|
-
) => ({
|
|
42
|
-
maxWidth: Math.min(
|
|
43
|
-
width - insets.left - insets.right - Spacing.XL * 2,
|
|
44
|
-
DUO_MODAL_MAX_WIDTH,
|
|
45
|
-
),
|
|
46
|
-
maxHeight: Math.min(
|
|
47
|
-
height - insets.top - insets.bottom - Spacing.XL * 2,
|
|
48
|
-
DUO_MODAL_MAX_HEIGHT,
|
|
49
|
-
),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export { isIPhoneDuo, getDuoTopInset, getDuoModalBounds };
|
|
21
|
+
export { isIPhoneDuo };
|
package/Application/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import Navigation from './Navigation';
|
|
|
18
18
|
import Navigator from './Navigator';
|
|
19
19
|
import ScaleSizeProvider from './ScaleSizeProvider';
|
|
20
20
|
import { useTooltipPortal } from './TooltipPortal';
|
|
21
|
+
import { getModalLayout, useModalLayout } from './modalLayout';
|
|
22
|
+
import type { ModalLayout, ModalRect } from './modalLayout';
|
|
21
23
|
|
|
22
24
|
export {
|
|
23
25
|
NavigationContainer,
|
|
@@ -31,4 +33,8 @@ export {
|
|
|
31
33
|
exportHeaderTitle,
|
|
32
34
|
ScaleSizeProvider,
|
|
33
35
|
useTooltipPortal,
|
|
36
|
+
getModalLayout,
|
|
37
|
+
useModalLayout,
|
|
34
38
|
};
|
|
39
|
+
|
|
40
|
+
export type { ModalLayout, ModalRect };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useWindowDimensions } from 'react-native';
|
|
2
|
+
import {
|
|
3
|
+
EdgeInsets,
|
|
4
|
+
useSafeAreaInsets,
|
|
5
|
+
} from 'react-native-safe-area-context';
|
|
6
|
+
import { Radius, Spacing } from '../Consts';
|
|
7
|
+
import { ReservedRegion, useHinge } from '../Layout/ReservedRegions';
|
|
8
|
+
import { isIPhoneDuo } from './device';
|
|
9
|
+
|
|
10
|
+
// Foldable modal spec, shared with the Compose / SwiftUI kits
|
|
11
|
+
const POPUP_MAX_WIDTH = 450;
|
|
12
|
+
const POPUP_MIN_WIDTH = 280;
|
|
13
|
+
const POPUP_MAX_HEIGHT = 600;
|
|
14
|
+
const SHEET_MAX_WIDTH = 653;
|
|
15
|
+
const SHEET_MIN_WIDTH = 296;
|
|
16
|
+
const SHEET_MIN_HEIGHT = 230;
|
|
17
|
+
const FOLDABLE_MIN_WIDTH = 600;
|
|
18
|
+
|
|
19
|
+
const SCRIM_OPACITY = 0.6;
|
|
20
|
+
const FOLDABLE_SCRIM_OPACITY = 0.2;
|
|
21
|
+
|
|
22
|
+
export type ModalRect = {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ModalLayout = {
|
|
30
|
+
/**
|
|
31
|
+
* iPhone Duo, or a window whose short side is at least 600 (Galaxy Z Fold / Pixel
|
|
32
|
+
* Fold inner display). Phones, landscape included, keep the regular modal look.
|
|
33
|
+
*/
|
|
34
|
+
isFoldable: boolean;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Area popups and sheets are placed in: the safe area, left of the control column on
|
|
38
|
+
* the iPhone Duo outer display, the right half when iPhone Duo is half folded.
|
|
39
|
+
*/
|
|
40
|
+
placement: ModalRect;
|
|
41
|
+
|
|
42
|
+
popup: {
|
|
43
|
+
width: number;
|
|
44
|
+
minWidth: number;
|
|
45
|
+
maxWidth: number;
|
|
46
|
+
/**
|
|
47
|
+
* `undefined` on phones, the popup grows with its content.
|
|
48
|
+
*/
|
|
49
|
+
maxHeight?: number;
|
|
50
|
+
margin: number;
|
|
51
|
+
radius: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
sheet: {
|
|
55
|
+
/**
|
|
56
|
+
* `undefined` on phones, the sheet spans the window.
|
|
57
|
+
*/
|
|
58
|
+
width?: number;
|
|
59
|
+
maxHeight?: number;
|
|
60
|
+
minHeight?: number;
|
|
61
|
+
margin: number;
|
|
62
|
+
radius: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
scrimOpacity: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ModalLayoutInput = {
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
insets: EdgeInsets;
|
|
72
|
+
isDuo: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* vertical division reported by the system, iPhone Duo half folded
|
|
75
|
+
*/
|
|
76
|
+
hinge?: ReservedRegion;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
80
|
+
Math.min(Math.max(value, min), max);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Modal geometry for a window, pure so it can be tested without a device
|
|
84
|
+
*/
|
|
85
|
+
const getModalLayout = ({
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
insets,
|
|
89
|
+
isDuo,
|
|
90
|
+
hinge,
|
|
91
|
+
}: ModalLayoutInput): ModalLayout => {
|
|
92
|
+
const shortSide = Math.min(width, height);
|
|
93
|
+
// short side, so a phone in landscape stays a phone
|
|
94
|
+
const isFoldable = isDuo || shortSide >= FOLDABLE_MIN_WIDTH;
|
|
95
|
+
const isDuoOuter = isDuo && shortSide < FOLDABLE_MIN_WIDTH;
|
|
96
|
+
const hingeRight = hinge ? hinge.x + hinge.width : 0;
|
|
97
|
+
const isHalfFolded =
|
|
98
|
+
isDuo && !!hinge && hinge.x > 0 && hingeRight < width - insets.right;
|
|
99
|
+
|
|
100
|
+
const safeLeft = isHalfFolded ? hingeRight : insets.left;
|
|
101
|
+
const placement = {
|
|
102
|
+
x: safeLeft,
|
|
103
|
+
y: insets.top,
|
|
104
|
+
width: width - insets.right - safeLeft,
|
|
105
|
+
height: height - insets.top - insets.bottom,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (!isFoldable) {
|
|
109
|
+
const popupWidth = width - Spacing.XL * 2;
|
|
110
|
+
return {
|
|
111
|
+
isFoldable,
|
|
112
|
+
placement,
|
|
113
|
+
popup: {
|
|
114
|
+
width: popupWidth,
|
|
115
|
+
minWidth: popupWidth,
|
|
116
|
+
maxWidth: popupWidth,
|
|
117
|
+
margin: Spacing.XL,
|
|
118
|
+
radius: Radius.L,
|
|
119
|
+
},
|
|
120
|
+
sheet: { margin: 0, radius: Radius.M },
|
|
121
|
+
scrimOpacity: SCRIM_OPACITY,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const popupMargin = isDuoOuter || isHalfFolded ? Spacing.M : Spacing.XL;
|
|
126
|
+
const popupSpace = placement.width - popupMargin * 2;
|
|
127
|
+
const popupMinWidth = Math.min(POPUP_MIN_WIDTH, popupSpace);
|
|
128
|
+
|
|
129
|
+
const sheetMargin = Spacing.M;
|
|
130
|
+
const sheetSpace = placement.width - sheetMargin * 2;
|
|
131
|
+
const sheetMaxHeight = height - insets.top - Spacing.M;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
isFoldable,
|
|
135
|
+
placement,
|
|
136
|
+
popup: {
|
|
137
|
+
width: clamp(popupSpace, popupMinWidth, POPUP_MAX_WIDTH),
|
|
138
|
+
minWidth: popupMinWidth,
|
|
139
|
+
maxWidth: POPUP_MAX_WIDTH,
|
|
140
|
+
maxHeight: Math.min(POPUP_MAX_HEIGHT, placement.height - Spacing.XL * 2),
|
|
141
|
+
margin: popupMargin,
|
|
142
|
+
radius: Radius.XL,
|
|
143
|
+
},
|
|
144
|
+
sheet: {
|
|
145
|
+
width: clamp(
|
|
146
|
+
sheetSpace,
|
|
147
|
+
Math.min(SHEET_MIN_WIDTH, sheetSpace),
|
|
148
|
+
SHEET_MAX_WIDTH,
|
|
149
|
+
),
|
|
150
|
+
maxHeight: sheetMaxHeight,
|
|
151
|
+
minHeight: Math.min(SHEET_MIN_HEIGHT, sheetMaxHeight),
|
|
152
|
+
margin: sheetMargin,
|
|
153
|
+
radius: Radius.XL,
|
|
154
|
+
},
|
|
155
|
+
scrimOpacity: FOLDABLE_SCRIM_OPACITY,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Modal geometry for the current window, follows fold, unfold, rotation and the
|
|
161
|
+
* iPhone Duo hinge
|
|
162
|
+
*/
|
|
163
|
+
const useModalLayout = (): ModalLayout => {
|
|
164
|
+
const { width, height } = useWindowDimensions();
|
|
165
|
+
const insets = useSafeAreaInsets();
|
|
166
|
+
const hinge = useHinge();
|
|
167
|
+
return getModalLayout({ width, height, insets, isDuo: isIPhoneDuo(), hinge });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export {
|
|
171
|
+
getModalLayout,
|
|
172
|
+
useModalLayout,
|
|
173
|
+
POPUP_MAX_WIDTH,
|
|
174
|
+
POPUP_MIN_WIDTH,
|
|
175
|
+
POPUP_MAX_HEIGHT,
|
|
176
|
+
SHEET_MAX_WIDTH,
|
|
177
|
+
SHEET_MIN_WIDTH,
|
|
178
|
+
SHEET_MIN_HEIGHT,
|
|
179
|
+
FOLDABLE_MIN_WIDTH,
|
|
180
|
+
};
|
package/Badge/BadgeRibbon.tsx
CHANGED
|
@@ -46,7 +46,7 @@ const BadgeRibbon: FC<BadgeRibbonProps> = ({
|
|
|
46
46
|
>
|
|
47
47
|
<View
|
|
48
48
|
collapsable={false}
|
|
49
|
-
style={{ transform: textTransform
|
|
49
|
+
style={[styles.ribbonLabelWrapper, { transform: textTransform }]}
|
|
50
50
|
>
|
|
51
51
|
<Text
|
|
52
52
|
style={styles.ribbonLabel}
|
|
@@ -60,7 +60,7 @@ const BadgeRibbon: FC<BadgeRibbonProps> = ({
|
|
|
60
60
|
);
|
|
61
61
|
|
|
62
62
|
const renderSkewContent = () => (
|
|
63
|
-
<View style={{
|
|
63
|
+
<View style={[styles.ribbonSkewRow, { marginTop: skewMargin }]}>
|
|
64
64
|
<View
|
|
65
65
|
collapsable={false}
|
|
66
66
|
style={[
|
|
@@ -70,7 +70,7 @@ const BadgeRibbon: FC<BadgeRibbonProps> = ({
|
|
|
70
70
|
>
|
|
71
71
|
<View
|
|
72
72
|
collapsable={false}
|
|
73
|
-
style={{ transform: textTransform
|
|
73
|
+
style={[styles.ribbonLabelWrapper, { transform: textTransform }]}
|
|
74
74
|
>
|
|
75
75
|
<Text
|
|
76
76
|
style={styles.ribbonLabel}
|
package/Badge/styles.ts
CHANGED
|
@@ -118,6 +118,8 @@ export default StyleSheet.create({
|
|
|
118
118
|
ribbonLabel: {
|
|
119
119
|
color: Colors.black_01,
|
|
120
120
|
},
|
|
121
|
+
ribbonLabelWrapper: {marginVertical: -2},
|
|
122
|
+
ribbonSkewRow: {flexDirection: 'row'},
|
|
121
123
|
skew: {width: 8, height: 16},
|
|
122
124
|
tail: {width: 5, height: 20},
|
|
123
125
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, ReactNode, useMemo } from 'react';
|
|
1
|
+
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
|
2
2
|
import { Platform, StyleSheet } from 'react-native';
|
|
3
3
|
|
|
4
4
|
export type ReservedRegion = {
|
|
@@ -25,7 +25,15 @@ try {
|
|
|
25
25
|
const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* vertical seam splitting the display into left and right halves, e.g. iPhone Duo
|
|
29
|
+
* half folded. `undefined` while the display is flat or the system reports none
|
|
30
|
+
*/
|
|
31
|
+
const HingeContext = createContext<ReservedRegion | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
const useHinge = () => useContext(HingeContext);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* keeps the occlusion regions and the vertical division reported by the native provider
|
|
29
37
|
*/
|
|
30
38
|
const OcclusionRegions: React.FC<{
|
|
31
39
|
useRegions: NativeReservedRegions['useReservedRegions'];
|
|
@@ -39,10 +47,19 @@ const OcclusionRegions: React.FC<{
|
|
|
39
47
|
.map(region => region.frame),
|
|
40
48
|
[regions],
|
|
41
49
|
);
|
|
50
|
+
const hinge = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
regions.find(
|
|
53
|
+
region =>
|
|
54
|
+
region.kind === 'division' &&
|
|
55
|
+
region.frame.height > region.frame.width,
|
|
56
|
+
)?.frame,
|
|
57
|
+
[regions],
|
|
58
|
+
);
|
|
42
59
|
|
|
43
60
|
return (
|
|
44
61
|
<ReservedRegionsContext.Provider value={occlusion}>
|
|
45
|
-
{children}
|
|
62
|
+
<HingeContext.Provider value={hinge}>{children}</HingeContext.Provider>
|
|
46
63
|
</ReservedRegionsContext.Provider>
|
|
47
64
|
);
|
|
48
65
|
};
|
|
@@ -75,4 +92,4 @@ const styles = StyleSheet.create({
|
|
|
75
92
|
},
|
|
76
93
|
});
|
|
77
94
|
|
|
78
|
-
export { ReservedRegionsContext, ReservedRegionsProvider };
|
|
95
|
+
export { ReservedRegionsContext, ReservedRegionsProvider, useHinge };
|
package/Layout/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import ItemSectionList from './ItemSectionList';
|
|
|
10
10
|
import Screen from './Screen';
|
|
11
11
|
import { TrackingScope } from './TrackingScope';
|
|
12
12
|
import { ScreenProps } from './Screen';
|
|
13
|
+
import { useHinge } from './ReservedRegions';
|
|
13
14
|
|
|
14
15
|
const GridContext = createContext<GridContextProps>({
|
|
15
16
|
numberOfColumns: 12,
|
|
@@ -31,4 +32,5 @@ export {
|
|
|
31
32
|
ItemSectionList,
|
|
32
33
|
Screen,
|
|
33
34
|
TrackingScope,
|
|
35
|
+
useHinge,
|
|
34
36
|
};
|
package/Popup/PopupNotify.tsx
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import React, { useContext, useRef, useState } from 'react';
|
|
2
|
-
import { ScrollView, StyleSheet,
|
|
2
|
+
import { ScrollView, StyleSheet, View } from 'react-native';
|
|
3
3
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
4
4
|
import { Button } from '../Button';
|
|
5
|
-
import { Colors,
|
|
6
|
-
import { Icon } from '../Icon';
|
|
5
|
+
import { Colors, Spacing, Styles } from '../Consts';
|
|
7
6
|
import { Image } from '../Image';
|
|
8
7
|
import { Text, useScaleSize } from '../Text';
|
|
9
8
|
import { PopupNotifyProps } from './types';
|
|
10
9
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
usePopupMaxHeight,
|
|
15
|
-
usePopupWidth,
|
|
16
|
-
} from './utils';
|
|
10
|
+
import { useModalLayout } from '../Application/modalLayout';
|
|
11
|
+
import { ModalIconButton } from '../Application/ModalIconButton';
|
|
12
|
+
import { splitHighlightText } from './utils';
|
|
17
13
|
|
|
18
14
|
const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
19
15
|
image,
|
|
@@ -30,16 +26,19 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
30
26
|
const context = useContext<any>(MiniAppContext);
|
|
31
27
|
const { theme, navigator, translate } = useContext(ApplicationContext);
|
|
32
28
|
const [scrollContent, setScrollContent] = useState(false);
|
|
33
|
-
const
|
|
34
|
-
const maxHeight =
|
|
35
|
-
const radius = getPopupRadius();
|
|
29
|
+
const { isFoldable, popup } = useModalLayout();
|
|
30
|
+
const { width: popupWidth, maxHeight, radius } = popup;
|
|
36
31
|
const scaledMaxHeight = useScaleSize(172);
|
|
37
32
|
|
|
38
33
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
39
34
|
const closeAction = useRef('touch_outside');
|
|
40
35
|
let descriptionStyle = {};
|
|
41
36
|
let Description: any = View;
|
|
42
|
-
if (
|
|
37
|
+
if (isFoldable) {
|
|
38
|
+
// foldables cap the popup height, the description takes what is left
|
|
39
|
+
Description = ScrollView;
|
|
40
|
+
descriptionStyle = styles.shrink;
|
|
41
|
+
} else if (scrollContent) {
|
|
43
42
|
Description = ScrollView;
|
|
44
43
|
descriptionStyle = { maxHeight: scaledMaxHeight };
|
|
45
44
|
}
|
|
@@ -165,34 +164,20 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
165
164
|
* build close action
|
|
166
165
|
*/
|
|
167
166
|
const buildCloseIcon = () => {
|
|
168
|
-
if (typeof onIconClose
|
|
169
|
-
return
|
|
170
|
-
<TouchableOpacity
|
|
171
|
-
accessibilityLabel={'ic_popup_close'}
|
|
172
|
-
style={styles.iconCloseContainer}
|
|
173
|
-
onPress={() => {
|
|
174
|
-
closeAction.current = 'icon_close';
|
|
175
|
-
onAction(onIconClose);
|
|
176
|
-
}}
|
|
177
|
-
>
|
|
178
|
-
<View
|
|
179
|
-
style={[
|
|
180
|
-
styles.iconClose,
|
|
181
|
-
{
|
|
182
|
-
backgroundColor: theme.colors.text.default,
|
|
183
|
-
borderColor: theme.colors.background.surface,
|
|
184
|
-
},
|
|
185
|
-
]}
|
|
186
|
-
>
|
|
187
|
-
<Icon
|
|
188
|
-
source="navigation_close"
|
|
189
|
-
color={theme.colors.background.surface}
|
|
190
|
-
size={14}
|
|
191
|
-
/>
|
|
192
|
-
</View>
|
|
193
|
-
</TouchableOpacity>
|
|
194
|
-
);
|
|
167
|
+
if (typeof onIconClose !== 'function') {
|
|
168
|
+
return null;
|
|
195
169
|
}
|
|
170
|
+
return (
|
|
171
|
+
<ModalIconButton
|
|
172
|
+
icon="navigation_close"
|
|
173
|
+
accessibilityLabel={'ic_popup_close'}
|
|
174
|
+
style={styles.iconClose}
|
|
175
|
+
onPress={() => {
|
|
176
|
+
closeAction.current = 'icon_close';
|
|
177
|
+
onAction(onIconClose);
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
196
181
|
};
|
|
197
182
|
|
|
198
183
|
const body = (
|
|
@@ -206,11 +191,17 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
206
191
|
]}
|
|
207
192
|
/>
|
|
208
193
|
)}
|
|
209
|
-
<View style={styles.content}>
|
|
194
|
+
<View style={[styles.content, isFoldable && styles.shrink]}>
|
|
210
195
|
<Text
|
|
211
196
|
typography={'header_default_bold'}
|
|
212
197
|
numberOfLines={3}
|
|
213
198
|
accessibilityLabel={'title_popup_notify'}
|
|
199
|
+
// keeps the title clear of the close button on a text only popup
|
|
200
|
+
style={
|
|
201
|
+
!image &&
|
|
202
|
+
typeof onIconClose === 'function' &&
|
|
203
|
+
styles.titleClearClose
|
|
204
|
+
}
|
|
214
205
|
>
|
|
215
206
|
{title}
|
|
216
207
|
</Text>
|
|
@@ -262,20 +253,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
262
253
|
showBaseLineDebug && styles.debugBaseLine,
|
|
263
254
|
]}
|
|
264
255
|
>
|
|
265
|
-
{
|
|
266
|
-
body
|
|
267
|
-
) : (
|
|
268
|
-
// scrolls when iPhone Duo landscape is too short; actions stay visible
|
|
269
|
-
<ScrollView
|
|
270
|
-
style={[
|
|
271
|
-
styles.body,
|
|
272
|
-
{ borderTopLeftRadius: radius, borderTopRightRadius: radius },
|
|
273
|
-
]}
|
|
274
|
-
bounces={false}
|
|
275
|
-
>
|
|
276
|
-
{body}
|
|
277
|
-
</ScrollView>
|
|
278
|
-
)}
|
|
256
|
+
{body}
|
|
279
257
|
<View style={styles.contentAction}>{buildAction()}</View>
|
|
280
258
|
{buildCloseIcon()}
|
|
281
259
|
</View>
|
|
@@ -288,7 +266,7 @@ const styles = StyleSheet.create({
|
|
|
288
266
|
aspectRatio: 1.7777,
|
|
289
267
|
overflow: 'hidden',
|
|
290
268
|
},
|
|
291
|
-
|
|
269
|
+
shrink: { flexShrink: 1 },
|
|
292
270
|
content: { padding: Spacing.XL },
|
|
293
271
|
description: {
|
|
294
272
|
marginTop: Spacing.S,
|
|
@@ -304,19 +282,11 @@ const styles = StyleSheet.create({
|
|
|
304
282
|
width: Spacing.S,
|
|
305
283
|
height: Spacing.S,
|
|
306
284
|
},
|
|
307
|
-
|
|
308
|
-
position: 'absolute',
|
|
309
|
-
top: -Spacing.M,
|
|
310
|
-
right: -Spacing.M,
|
|
311
|
-
padding: Spacing.XS,
|
|
312
|
-
},
|
|
285
|
+
titleClearClose: { paddingRight: Spacing.XL },
|
|
313
286
|
iconClose: {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
justifyContent: 'center',
|
|
318
|
-
borderRadius: Radius.M,
|
|
319
|
-
borderWidth: 2,
|
|
287
|
+
position: 'absolute',
|
|
288
|
+
top: Spacing.M,
|
|
289
|
+
right: Spacing.M,
|
|
320
290
|
},
|
|
321
291
|
});
|
|
322
292
|
|
package/Popup/PopupPromotion.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { ApplicationContext, MiniAppContext } from '../Context';
|
|
|
5
5
|
import { Image } from '../Image';
|
|
6
6
|
import { Colors, Radius, Spacing } from '../Consts';
|
|
7
7
|
import { Icon } from '../Icon';
|
|
8
|
-
import {
|
|
8
|
+
import { useModalLayout } from '../Application/modalLayout';
|
|
9
9
|
|
|
10
10
|
const ASPECT_RATIO = 0.72;
|
|
11
11
|
const ICON_CLOSE_SIZE = 20;
|
|
@@ -20,9 +20,10 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
20
20
|
}) => {
|
|
21
21
|
const context = useContext<any>(MiniAppContext);
|
|
22
22
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const {
|
|
24
|
+
popup: { width: popupWidth, maxHeight },
|
|
25
|
+
} = useModalLayout();
|
|
26
|
+
// fit the portrait image by height when a foldable leaves too little room
|
|
26
27
|
let imageWidth = popupWidth;
|
|
27
28
|
if (maxHeight !== undefined) {
|
|
28
29
|
const heightFitWidth = (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO;
|
package/Popup/utils.ts
CHANGED
|
@@ -1,30 +1,5 @@
|
|
|
1
|
-
import { Dimensions, useWindowDimensions } from 'react-native';
|
|
2
|
-
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
-
import { Radius, Spacing } from '../Consts';
|
|
4
|
-
import { getDuoModalBounds, isIPhoneDuo } from '../Application/device';
|
|
5
|
-
|
|
6
1
|
type HighlightSegment = { text: string; highlight: boolean };
|
|
7
2
|
|
|
8
|
-
const usePopupWidth = () => {
|
|
9
|
-
const insets = useSafeAreaInsets();
|
|
10
|
-
const { width, height } = useWindowDimensions();
|
|
11
|
-
if (!isIPhoneDuo()) {
|
|
12
|
-
return Dimensions.get('window').width - Spacing.XL * 2;
|
|
13
|
-
}
|
|
14
|
-
return getDuoModalBounds(insets, width, height).maxWidth;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const usePopupMaxHeight = () => {
|
|
18
|
-
const insets = useSafeAreaInsets();
|
|
19
|
-
const { width, height } = useWindowDimensions();
|
|
20
|
-
if (!isIPhoneDuo()) {
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
return getDuoModalBounds(insets, width, height).maxHeight;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const getPopupRadius = () => (isIPhoneDuo() ? Radius.XL : Radius.L);
|
|
27
|
-
|
|
28
3
|
const isWordChar = (char?: string) => {
|
|
29
4
|
if (!char) {
|
|
30
5
|
return false;
|
|
@@ -76,4 +51,4 @@ const splitHighlightText = (
|
|
|
76
51
|
return segments;
|
|
77
52
|
};
|
|
78
53
|
|
|
79
|
-
export {
|
|
54
|
+
export { splitHighlightText };
|