@momo-kits/foundation 0.168.1-beta.31 → 0.168.1-beta.33
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 +153 -42
- package/Application/ModalScreen.tsx +15 -32
- package/Application/device.ts +32 -1
- package/Application/index.ts +0 -6
- package/Layout/ReservedRegions.tsx +4 -21
- package/Layout/index.ts +0 -2
- package/Popup/PopupNotify.tsx +65 -69
- package/Popup/PopupPromotion.tsx +4 -5
- package/Popup/types.ts +7 -0
- package/Popup/utils.ts +79 -0
- package/package.json +1 -1
- package/Application/__tests__/modalLayout.test.ts +0 -128
- package/Application/modalLayout.ts +0 -180
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
PanResponder,
|
|
17
17
|
Platform,
|
|
18
18
|
Pressable,
|
|
19
|
+
StatusBar,
|
|
19
20
|
StyleSheet,
|
|
20
21
|
TouchableOpacity,
|
|
21
22
|
useWindowDimensions,
|
|
@@ -39,28 +40,54 @@ import Animated, {
|
|
|
39
40
|
} from 'react-native-reanimated';
|
|
40
41
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
41
42
|
import layoutStyles from '../Layout/styles';
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
43
|
+
import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
|
|
44
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
45
|
+
import { SideBarButton } from './Components/SideBarButton';
|
|
46
|
+
|
|
47
|
+
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
48
|
+
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
44
49
|
|
|
45
50
|
const INDICATOR_HEIGHT = 16;
|
|
46
51
|
const HEADER_HEIGHT = 56;
|
|
47
52
|
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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);
|
|
51
57
|
|
|
52
58
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
53
59
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
54
60
|
const context = useContext<any>(MiniAppContext);
|
|
55
61
|
const windowSize = useWindowDimensions();
|
|
56
62
|
const isDuo = isIPhoneDuo();
|
|
57
|
-
const
|
|
63
|
+
const isDuoCentered =
|
|
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;
|
|
58
76
|
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
59
77
|
const screenHeight = Dimensions.get('screen').height;
|
|
60
78
|
const heightDevice = isDuo ? windowSize.height : screenHeight;
|
|
61
79
|
const action = useRef<undefined | string>(undefined);
|
|
62
80
|
const insets = useSafeAreaInsets();
|
|
63
81
|
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;
|
|
64
91
|
const isIOS = Platform.OS === 'ios';
|
|
65
92
|
const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
66
93
|
const keyboardOffset = heightHeader - bottomInset;
|
|
@@ -86,6 +113,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
86
113
|
const onDismissRef = useRef(props.route.params?.onDismiss);
|
|
87
114
|
onDismissRef.current = props.route.params?.onDismiss;
|
|
88
115
|
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
116
|
+
const [sheetTop, setSheetTop] = useState(windowSize.height);
|
|
89
117
|
|
|
90
118
|
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
91
119
|
// so lift it here and shrink maxHeight by the same amount instead
|
|
@@ -241,20 +269,39 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
241
269
|
|
|
242
270
|
const showBack = !!leftOptions?.onPressIconLeft;
|
|
243
271
|
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]);
|
|
244
288
|
|
|
245
289
|
/**
|
|
246
290
|
* render header
|
|
247
291
|
*/
|
|
248
292
|
const renderHeader = useCallback(() => {
|
|
249
|
-
const
|
|
250
|
-
styles.iconButton,
|
|
251
|
-
isFoldable && styles.foldableIconButton,
|
|
252
|
-
];
|
|
253
|
-
const iconSize = isFoldable ? FOLDABLE_ICON_SIZE : undefined;
|
|
293
|
+
const showButtons = !hasSideColumn;
|
|
254
294
|
return (
|
|
255
295
|
<View {...panResponder.panHandlers}>
|
|
256
296
|
<View style={{ height: INDICATOR_HEIGHT }}>
|
|
257
|
-
<View
|
|
297
|
+
<View
|
|
298
|
+
style={[
|
|
299
|
+
styles.indicator,
|
|
300
|
+
indicatorShift !== 0 && {
|
|
301
|
+
transform: [{ translateX: indicatorShift }],
|
|
302
|
+
},
|
|
303
|
+
]}
|
|
304
|
+
/>
|
|
258
305
|
</View>
|
|
259
306
|
<View
|
|
260
307
|
style={[
|
|
@@ -265,9 +312,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
265
312
|
},
|
|
266
313
|
]}
|
|
267
314
|
>
|
|
268
|
-
{showBack ? (
|
|
269
|
-
<TouchableOpacity style={
|
|
270
|
-
<Icon source={iconBack}
|
|
315
|
+
{showButtons && showBack ? (
|
|
316
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
|
|
317
|
+
<Icon source={iconBack} />
|
|
271
318
|
</TouchableOpacity>
|
|
272
319
|
) : null}
|
|
273
320
|
|
|
@@ -275,9 +322,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
275
322
|
<View style={Styles.flex}>{options.header}</View>
|
|
276
323
|
) : (
|
|
277
324
|
<>
|
|
278
|
-
{!leftOptions ?
|
|
325
|
+
{showButtons && !leftOptions ? (
|
|
326
|
+
<View style={styles.iconButton} />
|
|
327
|
+
) : null}
|
|
279
328
|
<Text
|
|
280
|
-
style={[
|
|
329
|
+
style={[
|
|
330
|
+
Styles.flex,
|
|
331
|
+
styles.title,
|
|
332
|
+
!showButtons && styles.titleInset,
|
|
333
|
+
]}
|
|
281
334
|
numberOfLines={1}
|
|
282
335
|
typography="header_default_bold"
|
|
283
336
|
>
|
|
@@ -285,15 +338,18 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
285
338
|
</Text>
|
|
286
339
|
</>
|
|
287
340
|
)}
|
|
288
|
-
|
|
289
|
-
<
|
|
290
|
-
|
|
341
|
+
{showButtons ? (
|
|
342
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
|
|
343
|
+
<Icon source="navigation_close" />
|
|
344
|
+
</TouchableOpacity>
|
|
345
|
+
) : null}
|
|
291
346
|
</View>
|
|
292
347
|
</View>
|
|
293
348
|
);
|
|
294
349
|
}, [
|
|
350
|
+
hasSideColumn,
|
|
295
351
|
iconBack,
|
|
296
|
-
|
|
352
|
+
indicatorShift,
|
|
297
353
|
leftOptions,
|
|
298
354
|
onPressBack,
|
|
299
355
|
onPressClose,
|
|
@@ -305,6 +361,41 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
305
361
|
useDivider,
|
|
306
362
|
]);
|
|
307
363
|
|
|
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
|
+
|
|
308
399
|
const animatedStyle = useAnimatedStyle(() => {
|
|
309
400
|
return {
|
|
310
401
|
transform: [{ translateY: translateY.value }],
|
|
@@ -316,12 +407,17 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
316
407
|
opacity: interpolate(
|
|
317
408
|
translateY.value,
|
|
318
409
|
[0, heightDevice],
|
|
319
|
-
[
|
|
410
|
+
[1, 0],
|
|
320
411
|
Extrapolation.CLAMP,
|
|
321
412
|
),
|
|
322
413
|
};
|
|
323
414
|
});
|
|
324
415
|
|
|
416
|
+
const sideInsetStyle = isDuoFloating && {
|
|
417
|
+
paddingLeft: sideInsetLeft,
|
|
418
|
+
paddingRight: sideInsetRight,
|
|
419
|
+
};
|
|
420
|
+
|
|
325
421
|
const screen = (
|
|
326
422
|
<Screen
|
|
327
423
|
{...props}
|
|
@@ -362,31 +458,41 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
362
458
|
</Pressable>
|
|
363
459
|
|
|
364
460
|
<Animated.View
|
|
461
|
+
onLayout={
|
|
462
|
+
isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
|
|
463
|
+
}
|
|
365
464
|
style={[
|
|
366
465
|
animatedStyle,
|
|
367
466
|
{
|
|
368
467
|
backgroundColor,
|
|
369
|
-
borderTopLeftRadius:
|
|
370
|
-
borderTopRightRadius:
|
|
468
|
+
borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
|
|
469
|
+
borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
|
|
371
470
|
overflow: 'hidden',
|
|
372
471
|
},
|
|
373
|
-
isDuo && {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
472
|
+
isDuo && {
|
|
473
|
+
maxHeight: windowSize.height - topGap - keyboardLift,
|
|
474
|
+
marginBottom: keyboardLift,
|
|
475
|
+
},
|
|
476
|
+
isDuoFloating && { marginHorizontal: Spacing.M },
|
|
477
|
+
sideColumn && {
|
|
478
|
+
minHeight:
|
|
479
|
+
SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
|
|
480
|
+
},
|
|
481
|
+
isDuoCentered && {
|
|
482
|
+
// keeps the portrait width in landscape, like system sheets
|
|
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
|
+
},
|
|
384
490
|
showBaseLineDebug && styles.debugBaseLine,
|
|
385
491
|
]}
|
|
386
492
|
>
|
|
387
|
-
{
|
|
493
|
+
{isDuo ? (
|
|
388
494
|
// content shrinks so a tall sheet keeps its header and footer
|
|
389
|
-
<View style={styles.shrink}>
|
|
495
|
+
<View style={[styles.shrink, sideInsetStyle]}>
|
|
390
496
|
{renderHeader()}
|
|
391
497
|
<View style={styles.shrink}>{screen}</View>
|
|
392
498
|
</View>
|
|
@@ -404,6 +510,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
404
510
|
paddingBottom: Spacing.S,
|
|
405
511
|
backgroundColor: theme.colors.background.surface,
|
|
406
512
|
},
|
|
513
|
+
sideInsetStyle,
|
|
407
514
|
]}
|
|
408
515
|
>
|
|
409
516
|
{footerComponent}
|
|
@@ -417,6 +524,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
417
524
|
}}
|
|
418
525
|
/>
|
|
419
526
|
)}
|
|
527
|
+
{renderSideColumn()}
|
|
420
528
|
</Animated.View>
|
|
421
529
|
</KeyboardAvoidingView>
|
|
422
530
|
</Container>
|
|
@@ -430,7 +538,7 @@ const styles = StyleSheet.create({
|
|
|
430
538
|
},
|
|
431
539
|
overlay: {
|
|
432
540
|
flex: 1,
|
|
433
|
-
backgroundColor:
|
|
541
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
434
542
|
},
|
|
435
543
|
indicator: {
|
|
436
544
|
width: 40,
|
|
@@ -447,6 +555,7 @@ const styles = StyleSheet.create({
|
|
|
447
555
|
alignItems: 'center',
|
|
448
556
|
},
|
|
449
557
|
title: { textAlign: 'center' },
|
|
558
|
+
titleInset: { paddingHorizontal: Spacing.M },
|
|
450
559
|
iconButton: {
|
|
451
560
|
width: 24,
|
|
452
561
|
height: 24,
|
|
@@ -454,11 +563,13 @@ const styles = StyleSheet.create({
|
|
|
454
563
|
justifyContent: 'center',
|
|
455
564
|
marginHorizontal: Spacing.M,
|
|
456
565
|
},
|
|
457
|
-
foldableIconButton: {
|
|
458
|
-
width: FOLDABLE_ICON_BUTTON_SIZE,
|
|
459
|
-
height: FOLDABLE_ICON_BUTTON_SIZE,
|
|
460
|
-
},
|
|
461
566
|
shrink: { flexShrink: 1 },
|
|
567
|
+
sideColumn: {
|
|
568
|
+
position: 'absolute',
|
|
569
|
+
gap: Spacing.M,
|
|
570
|
+
},
|
|
571
|
+
alignRight: { right: 0 },
|
|
572
|
+
alignLeft: { left: 0 },
|
|
462
573
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
463
574
|
});
|
|
464
575
|
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
useCallback,
|
|
3
|
-
useContext,
|
|
4
|
-
useEffect,
|
|
5
|
-
useMemo,
|
|
6
|
-
useRef,
|
|
7
|
-
} from 'react';
|
|
1
|
+
import React, { useCallback, useContext, useEffect, useRef } from 'react';
|
|
8
2
|
import {
|
|
9
3
|
Animated,
|
|
10
4
|
BackHandler,
|
|
@@ -17,14 +11,14 @@ import {
|
|
|
17
11
|
useWindowDimensions,
|
|
18
12
|
View,
|
|
19
13
|
} from 'react-native';
|
|
14
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
20
15
|
|
|
21
16
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
22
|
-
import {
|
|
17
|
+
import { Styles } from '../Consts';
|
|
23
18
|
import Navigation from './Navigation';
|
|
24
19
|
import { ModalParams } from './types';
|
|
25
20
|
import BottomSheet from './BottomSheet';
|
|
26
|
-
import { isIPhoneDuo } from './device';
|
|
27
|
-
import { useModalLayout } from './modalLayout';
|
|
21
|
+
import { getDuoModalBounds, isIPhoneDuo } from './device';
|
|
28
22
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
29
23
|
|
|
30
24
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -46,9 +40,9 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
46
40
|
title,
|
|
47
41
|
} = props.route.params;
|
|
48
42
|
const Component = useRef(screen).current;
|
|
49
|
-
const
|
|
43
|
+
const insets = useSafeAreaInsets();
|
|
44
|
+
const windowSize = useWindowDimensions();
|
|
50
45
|
const isDuo = isIPhoneDuo();
|
|
51
|
-
const { isFoldable, placement, popup, scrimOpacity } = useModalLayout();
|
|
52
46
|
|
|
53
47
|
/**
|
|
54
48
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -67,15 +61,6 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
67
61
|
|
|
68
62
|
const opacity = useRef(new Animated.Value(0)).current;
|
|
69
63
|
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
|
-
);
|
|
79
64
|
|
|
80
65
|
const navigation = new Navigation(props.navigation);
|
|
81
66
|
const params = {
|
|
@@ -161,7 +146,7 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
161
146
|
style={StyleSheet.absoluteFillObject}
|
|
162
147
|
onPress={() => onDismiss(undefined, barrierDismissible)}
|
|
163
148
|
>
|
|
164
|
-
<Animated.View style={[styles.overlayContent,
|
|
149
|
+
<Animated.View style={[styles.overlayContent, { opacity }]} />
|
|
165
150
|
</Pressable>
|
|
166
151
|
<Animated.View
|
|
167
152
|
style={[
|
|
@@ -169,15 +154,13 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
169
154
|
opacity,
|
|
170
155
|
transform: [{ scale }],
|
|
171
156
|
},
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
maxWidth: popup.width,
|
|
180
|
-
maxHeight: popup.maxHeight,
|
|
157
|
+
isDuo && styles.shrink,
|
|
158
|
+
isDuo && {
|
|
159
|
+
marginTop: insets.top,
|
|
160
|
+
marginBottom: insets.bottom,
|
|
161
|
+
marginLeft: insets.left,
|
|
162
|
+
marginRight: insets.right,
|
|
163
|
+
...getDuoModalBounds(insets, windowSize.width, windowSize.height),
|
|
181
164
|
},
|
|
182
165
|
modalStyle,
|
|
183
166
|
]}
|
|
@@ -194,7 +177,7 @@ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
|
194
177
|
const styles = StyleSheet.create({
|
|
195
178
|
overlayContent: {
|
|
196
179
|
flex: 1,
|
|
197
|
-
backgroundColor:
|
|
180
|
+
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
198
181
|
},
|
|
199
182
|
shrink: { flexShrink: 1 },
|
|
200
183
|
});
|
package/Application/device.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
2
|
+
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
+
import { Spacing } from '../Consts';
|
|
2
4
|
|
|
3
5
|
// widest short side of any single-display iPhone
|
|
4
6
|
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
5
7
|
|
|
8
|
+
const DUO_MODAL_MAX_WIDTH = 440;
|
|
9
|
+
const DUO_MODAL_MAX_HEIGHT = 640;
|
|
10
|
+
|
|
6
11
|
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
7
12
|
// still matches when Split View narrows the window
|
|
8
13
|
const isIPhoneDuo = () => {
|
|
@@ -18,4 +23,30 @@ const isIPhoneDuo = () => {
|
|
|
18
23
|
);
|
|
19
24
|
};
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
// portrait side status bar is about twice as tall as its inset is wide
|
|
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 };
|
package/Application/index.ts
CHANGED
|
@@ -18,8 +18,6 @@ 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';
|
|
23
21
|
|
|
24
22
|
export {
|
|
25
23
|
NavigationContainer,
|
|
@@ -33,8 +31,4 @@ export {
|
|
|
33
31
|
exportHeaderTitle,
|
|
34
32
|
ScaleSizeProvider,
|
|
35
33
|
useTooltipPortal,
|
|
36
|
-
getModalLayout,
|
|
37
|
-
useModalLayout,
|
|
38
34
|
};
|
|
39
|
-
|
|
40
|
-
export type { ModalLayout, ModalRect };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, ReactNode,
|
|
1
|
+
import React, { createContext, ReactNode, useMemo } from 'react';
|
|
2
2
|
import { Platform, StyleSheet } from 'react-native';
|
|
3
3
|
|
|
4
4
|
export type ReservedRegion = {
|
|
@@ -25,15 +25,7 @@ try {
|
|
|
25
25
|
const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
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
|
|
28
|
+
* keeps the occlusion regions reported by the native provider, the only kind layout reads
|
|
37
29
|
*/
|
|
38
30
|
const OcclusionRegions: React.FC<{
|
|
39
31
|
useRegions: NativeReservedRegions['useReservedRegions'];
|
|
@@ -47,19 +39,10 @@ const OcclusionRegions: React.FC<{
|
|
|
47
39
|
.map(region => region.frame),
|
|
48
40
|
[regions],
|
|
49
41
|
);
|
|
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
|
-
);
|
|
59
42
|
|
|
60
43
|
return (
|
|
61
44
|
<ReservedRegionsContext.Provider value={occlusion}>
|
|
62
|
-
|
|
45
|
+
{children}
|
|
63
46
|
</ReservedRegionsContext.Provider>
|
|
64
47
|
);
|
|
65
48
|
};
|
|
@@ -92,4 +75,4 @@ const styles = StyleSheet.create({
|
|
|
92
75
|
},
|
|
93
76
|
});
|
|
94
77
|
|
|
95
|
-
export { ReservedRegionsContext, ReservedRegionsProvider
|
|
78
|
+
export { ReservedRegionsContext, ReservedRegionsProvider };
|
package/Layout/index.ts
CHANGED
|
@@ -10,7 +10,6 @@ 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';
|
|
14
13
|
|
|
15
14
|
const GridContext = createContext<GridContextProps>({
|
|
16
15
|
numberOfColumns: 12,
|
|
@@ -32,5 +31,4 @@ export {
|
|
|
32
31
|
ItemSectionList,
|
|
33
32
|
Screen,
|
|
34
33
|
TrackingScope,
|
|
35
|
-
useHinge,
|
|
36
34
|
};
|
package/Popup/PopupNotify.tsx
CHANGED
|
@@ -8,16 +8,18 @@ import { Image } from '../Image';
|
|
|
8
8
|
import { Text, useScaleSize } from '../Text';
|
|
9
9
|
import { PopupNotifyProps } from './types';
|
|
10
10
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import {
|
|
12
|
+
getPopupRadius,
|
|
13
|
+
splitHighlightText,
|
|
14
|
+
usePopupMaxHeight,
|
|
15
|
+
usePopupWidth,
|
|
16
|
+
} from './utils';
|
|
16
17
|
|
|
17
18
|
const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
18
19
|
image,
|
|
19
20
|
title = 'Title',
|
|
20
21
|
description = 'Description',
|
|
22
|
+
highlightText,
|
|
21
23
|
error,
|
|
22
24
|
secondary,
|
|
23
25
|
primary,
|
|
@@ -28,19 +30,16 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
28
30
|
const context = useContext<any>(MiniAppContext);
|
|
29
31
|
const { theme, navigator, translate } = useContext(ApplicationContext);
|
|
30
32
|
const [scrollContent, setScrollContent] = useState(false);
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
+
const popupWidth = usePopupWidth();
|
|
34
|
+
const maxHeight = usePopupMaxHeight();
|
|
35
|
+
const radius = getPopupRadius();
|
|
33
36
|
const scaledMaxHeight = useScaleSize(172);
|
|
34
37
|
|
|
35
38
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
36
39
|
const closeAction = useRef('touch_outside');
|
|
37
40
|
let descriptionStyle = {};
|
|
38
41
|
let Description: any = View;
|
|
39
|
-
if (
|
|
40
|
-
// foldables cap the popup height, the description takes what is left
|
|
41
|
-
Description = ScrollView;
|
|
42
|
-
descriptionStyle = styles.shrink;
|
|
43
|
-
} else if (scrollContent) {
|
|
42
|
+
if (scrollContent) {
|
|
44
43
|
Description = ScrollView;
|
|
45
44
|
descriptionStyle = { maxHeight: scaledMaxHeight };
|
|
46
45
|
}
|
|
@@ -142,57 +141,58 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
142
141
|
return renderColumn();
|
|
143
142
|
};
|
|
144
143
|
|
|
144
|
+
/**
|
|
145
|
+
* build description text, highlighted keywords keep size/lineHeight and use weight 600
|
|
146
|
+
*/
|
|
147
|
+
const renderDescription = (text: string) => {
|
|
148
|
+
const segments = splitHighlightText(text, highlightText);
|
|
149
|
+
if (!segments) {
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
152
|
+
return segments.map((segment, index) => {
|
|
153
|
+
if (segment.highlight) {
|
|
154
|
+
return (
|
|
155
|
+
<Text key={index} typography={'header_s_semibold'}>
|
|
156
|
+
{segment.text}
|
|
157
|
+
</Text>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return segment.text;
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
145
164
|
/**
|
|
146
165
|
* build close action
|
|
147
166
|
*/
|
|
148
167
|
const buildCloseIcon = () => {
|
|
149
|
-
if (typeof onIconClose
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
if (isFoldable) {
|
|
168
|
+
if (typeof onIconClose === 'function') {
|
|
153
169
|
return (
|
|
154
170
|
<TouchableOpacity
|
|
155
171
|
accessibilityLabel={'ic_popup_close'}
|
|
156
|
-
style={styles.
|
|
172
|
+
style={styles.iconCloseContainer}
|
|
157
173
|
onPress={() => {
|
|
158
174
|
closeAction.current = 'icon_close';
|
|
159
175
|
onAction(onIconClose);
|
|
160
176
|
}}
|
|
161
177
|
>
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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>
|
|
167
193
|
</TouchableOpacity>
|
|
168
194
|
);
|
|
169
195
|
}
|
|
170
|
-
return (
|
|
171
|
-
<TouchableOpacity
|
|
172
|
-
accessibilityLabel={'ic_popup_close'}
|
|
173
|
-
style={styles.iconCloseContainer}
|
|
174
|
-
onPress={() => {
|
|
175
|
-
closeAction.current = 'icon_close';
|
|
176
|
-
onAction(onIconClose);
|
|
177
|
-
}}
|
|
178
|
-
>
|
|
179
|
-
<View
|
|
180
|
-
style={[
|
|
181
|
-
styles.iconClose,
|
|
182
|
-
{
|
|
183
|
-
backgroundColor: theme.colors.text.default,
|
|
184
|
-
borderColor: theme.colors.background.surface,
|
|
185
|
-
},
|
|
186
|
-
]}
|
|
187
|
-
>
|
|
188
|
-
<Icon
|
|
189
|
-
source="navigation_close"
|
|
190
|
-
color={theme.colors.background.surface}
|
|
191
|
-
size={14}
|
|
192
|
-
/>
|
|
193
|
-
</View>
|
|
194
|
-
</TouchableOpacity>
|
|
195
|
-
);
|
|
196
196
|
};
|
|
197
197
|
|
|
198
198
|
const body = (
|
|
@@ -206,18 +206,11 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
206
206
|
]}
|
|
207
207
|
/>
|
|
208
208
|
)}
|
|
209
|
-
<View style={
|
|
209
|
+
<View style={styles.content}>
|
|
210
210
|
<Text
|
|
211
211
|
typography={'header_default_bold'}
|
|
212
212
|
numberOfLines={3}
|
|
213
213
|
accessibilityLabel={'title_popup_notify'}
|
|
214
|
-
// keeps the title clear of the close button on a text only popup
|
|
215
|
-
style={
|
|
216
|
-
isFoldable &&
|
|
217
|
-
!image &&
|
|
218
|
-
typeof onIconClose === 'function' &&
|
|
219
|
-
styles.titleClearClose
|
|
220
|
-
}
|
|
221
214
|
>
|
|
222
215
|
{title}
|
|
223
216
|
</Text>
|
|
@@ -233,7 +226,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
233
226
|
}
|
|
234
227
|
}}
|
|
235
228
|
>
|
|
236
|
-
{description}
|
|
229
|
+
{renderDescription(description)}
|
|
237
230
|
</Text>
|
|
238
231
|
) : (
|
|
239
232
|
description
|
|
@@ -269,7 +262,20 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
269
262
|
showBaseLineDebug && styles.debugBaseLine,
|
|
270
263
|
]}
|
|
271
264
|
>
|
|
272
|
-
{
|
|
265
|
+
{maxHeight === undefined ? (
|
|
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
|
+
)}
|
|
273
279
|
<View style={styles.contentAction}>{buildAction()}</View>
|
|
274
280
|
{buildCloseIcon()}
|
|
275
281
|
</View>
|
|
@@ -282,7 +288,7 @@ const styles = StyleSheet.create({
|
|
|
282
288
|
aspectRatio: 1.7777,
|
|
283
289
|
overflow: 'hidden',
|
|
284
290
|
},
|
|
285
|
-
|
|
291
|
+
body: { flexShrink: 1, overflow: 'hidden' },
|
|
286
292
|
content: { padding: Spacing.XL },
|
|
287
293
|
description: {
|
|
288
294
|
marginTop: Spacing.S,
|
|
@@ -304,16 +310,6 @@ const styles = StyleSheet.create({
|
|
|
304
310
|
right: -Spacing.M,
|
|
305
311
|
padding: Spacing.XS,
|
|
306
312
|
},
|
|
307
|
-
titleClearClose: { paddingRight: Spacing.XL },
|
|
308
|
-
foldableClose: {
|
|
309
|
-
position: 'absolute',
|
|
310
|
-
top: Spacing.M,
|
|
311
|
-
right: Spacing.M,
|
|
312
|
-
width: FOLDABLE_CLOSE_SIZE,
|
|
313
|
-
height: FOLDABLE_CLOSE_SIZE,
|
|
314
|
-
alignItems: 'center',
|
|
315
|
-
justifyContent: 'center',
|
|
316
|
-
},
|
|
317
313
|
iconClose: {
|
|
318
314
|
width: 20,
|
|
319
315
|
height: 20,
|
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 { usePopupMaxHeight, usePopupWidth } from './utils';
|
|
9
9
|
|
|
10
10
|
const ASPECT_RATIO = 0.72;
|
|
11
11
|
const ICON_CLOSE_SIZE = 20;
|
|
@@ -20,10 +20,9 @@ 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
|
-
|
|
26
|
-
// fit the portrait image by height when a foldable leaves too little room
|
|
23
|
+
const popupWidth = usePopupWidth();
|
|
24
|
+
const maxHeight = usePopupMaxHeight();
|
|
25
|
+
// fit the portrait image by height when iPhone Duo leaves too little room
|
|
27
26
|
let imageWidth = popupWidth;
|
|
28
27
|
if (maxHeight !== undefined) {
|
|
29
28
|
const heightFitWidth = (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO;
|
package/Popup/types.ts
CHANGED
|
@@ -20,6 +20,13 @@ export type PopupNotifyProps = {
|
|
|
20
20
|
*/
|
|
21
21
|
description?: string | React.ReactNode;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `highlightText`: Optional. List of words/phrases within `description` to be rendered in semibold.
|
|
25
|
+
* Only applies when `description` is a string. Matching is whole-word and case-sensitive;
|
|
26
|
+
* every occurrence of each string is highlighted.
|
|
27
|
+
*/
|
|
28
|
+
highlightText?: string[];
|
|
29
|
+
|
|
23
30
|
/**
|
|
24
31
|
* `error`: Optional. Represents additional error text to be displayed in the PopupNotify component.
|
|
25
32
|
*/
|
package/Popup/utils.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
type HighlightSegment = { text: string; highlight: boolean };
|
|
7
|
+
|
|
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
|
+
const isWordChar = (char?: string) => {
|
|
29
|
+
if (!char) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const isLetter = char.toLowerCase() !== char.toUpperCase();
|
|
33
|
+
return isLetter || /[0-9_]/.test(char);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split `text` into plain/highlighted segments (same matching as Information `highlightText`):
|
|
38
|
+
* whole-word, case-sensitive, every occurrence, longest keyword first.
|
|
39
|
+
* Returns `undefined` when there is nothing to highlight.
|
|
40
|
+
*/
|
|
41
|
+
const splitHighlightText = (
|
|
42
|
+
text: string,
|
|
43
|
+
highlightText?: string[],
|
|
44
|
+
): HighlightSegment[] | undefined => {
|
|
45
|
+
const keywords = highlightText?.filter(keyword => {
|
|
46
|
+
if (typeof keyword !== 'string') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return keyword.length > 0;
|
|
50
|
+
});
|
|
51
|
+
if (!keywords?.length) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const pattern = [...keywords]
|
|
55
|
+
.sort((a, b) => b.length - a.length)
|
|
56
|
+
.map(keyword => keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
57
|
+
.join('|');
|
|
58
|
+
const regex = new RegExp(pattern, 'g');
|
|
59
|
+
const segments: HighlightSegment[] = [];
|
|
60
|
+
let lastIndex = 0;
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = regex.exec(text))) {
|
|
63
|
+
const start = match.index;
|
|
64
|
+
const end = start + match[0].length;
|
|
65
|
+
if (isWordChar(text[start - 1]) || isWordChar(text[end])) {
|
|
66
|
+
regex.lastIndex = start + 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (start > lastIndex) {
|
|
70
|
+
segments.push({ text: text.slice(lastIndex, start), highlight: false });
|
|
71
|
+
}
|
|
72
|
+
segments.push({ text: match[0], highlight: true });
|
|
73
|
+
lastIndex = end;
|
|
74
|
+
}
|
|
75
|
+
segments.push({ text: text.slice(lastIndex), highlight: false });
|
|
76
|
+
return segments;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export { usePopupWidth, usePopupMaxHeight, getPopupRadius, splitHighlightText };
|
package/package.json
CHANGED
|
@@ -1,128 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,180 +0,0 @@
|
|
|
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
|
-
};
|