@momo-kits/foundation 0.168.1-beta.30 → 0.168.1-beta.32

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.
@@ -16,7 +16,6 @@ import {
16
16
  PanResponder,
17
17
  Platform,
18
18
  Pressable,
19
- StatusBar,
20
19
  StyleSheet,
21
20
  TouchableOpacity,
22
21
  useWindowDimensions,
@@ -40,60 +39,31 @@ import Animated, {
40
39
  } from 'react-native-reanimated';
41
40
  import { scheduleOnRN } from 'react-native-worklets';
42
41
  import layoutStyles from '../Layout/styles';
43
- import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
44
- import { getDuoTopInset, isFoldableDevice } from './device';
45
- import { SideBarButton } from './Components/SideBarButton';
46
-
47
- // foldable inner display: center the sheet instead of spanning the width
48
- const DUO_REGULAR_MIN_WIDTH = 600;
42
+ import { isIPhoneDuo } from './device';
43
+ import { useModalLayout } from './modalLayout';
49
44
 
50
45
  const INDICATOR_HEIGHT = 16;
51
46
  const HEADER_HEIGHT = 56;
52
47
  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);
48
+ // foldable spec: icon button 32, icon 20
49
+ const FOLDABLE_ICON_BUTTON_SIZE = 32;
50
+ const FOLDABLE_ICON_SIZE = 20;
57
51
 
58
52
  const BottomSheet: React.FC<BottomSheetParams> = props => {
59
53
  const { theme, navigator } = useContext(ApplicationContext);
60
54
  const context = useContext<any>(MiniAppContext);
61
55
  const windowSize = useWindowDimensions();
62
- const isDuo = isFoldableDevice();
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;
56
+ const isDuo = isIPhoneDuo();
57
+ const { isFoldable, placement, sheet, scrimOpacity } = useModalLayout();
76
58
  // 'screen' is the outer display on iPhone Duo, even when unfolded
77
59
  const screenHeight = Dimensions.get('screen').height;
78
60
  const heightDevice = isDuo ? windowSize.height : screenHeight;
79
61
  const action = useRef<undefined | string>(undefined);
80
62
  const insets = useSafeAreaInsets();
81
63
  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
64
  const isIOS = Platform.OS === 'ios';
92
65
  const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
93
66
  const keyboardOffset = heightHeader - bottomInset;
94
- // Android shrinks the root for the keyboard but keeps the window height and
95
- // fires no keyboardWill* events, so only the root can bound the sheet
96
- const fitsResizedRoot = isDuo && !isIOS;
97
67
 
98
68
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
99
69
 
@@ -116,7 +86,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
116
86
  const onDismissRef = useRef(props.route.params?.onDismiss);
117
87
  onDismissRef.current = props.route.params?.onDismiss;
118
88
  const [keyboardLift, setKeyboardLift] = useState(0);
119
- const [sheetTop, setSheetTop] = useState(windowSize.height);
120
89
 
121
90
  // KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
122
91
  // so lift it here and shrink maxHeight by the same amount instead
@@ -212,8 +181,8 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
212
181
  }
213
182
 
214
183
  /**
215
- * emit dismiss event, once: folding or rotating a foldable device changes
216
- * the window height while the sheet stays open
184
+ * emit dismiss event, once: folding or rotating iPhone Duo changes the
185
+ * window height while the sheet stays open
217
186
  */
218
187
  useEffect(() => {
219
188
  openAnimation();
@@ -272,39 +241,20 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
272
241
 
273
242
  const showBack = !!leftOptions?.onPressIconLeft;
274
243
  const iconBack = leftOptions?.iconLeft ?? 'ic_back';
275
- const sideColumnCount = showBack ? 2 : 1;
276
- const sideColumnHeight =
277
- sideColumnCount * DuoMetrics.controlSize +
278
- (sideColumnCount - 1) * Spacing.M;
279
- const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
280
-
281
- useEffect(() => {
282
- if (!coversStatusBar) {
283
- return;
284
- }
285
- const entry = StatusBar.pushStackEntry({
286
- barStyle: theme.dark ? 'light-content' : 'dark-content',
287
- animated: true,
288
- });
289
- return () => StatusBar.popStackEntry(entry);
290
- }, [coversStatusBar, theme.dark]);
291
244
 
292
245
  /**
293
246
  * render header
294
247
  */
295
248
  const renderHeader = useCallback(() => {
296
- const showButtons = !hasSideColumn;
249
+ const iconButtonStyle = [
250
+ styles.iconButton,
251
+ isFoldable && styles.foldableIconButton,
252
+ ];
253
+ const iconSize = isFoldable ? FOLDABLE_ICON_SIZE : undefined;
297
254
  return (
298
255
  <View {...panResponder.panHandlers}>
299
256
  <View style={{ height: INDICATOR_HEIGHT }}>
300
- <View
301
- style={[
302
- styles.indicator,
303
- indicatorShift !== 0 && {
304
- transform: [{ translateX: indicatorShift }],
305
- },
306
- ]}
307
- />
257
+ <View style={styles.indicator} />
308
258
  </View>
309
259
  <View
310
260
  style={[
@@ -315,9 +265,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
315
265
  },
316
266
  ]}
317
267
  >
318
- {showButtons && showBack ? (
319
- <TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
320
- <Icon source={iconBack} />
268
+ {showBack ? (
269
+ <TouchableOpacity style={iconButtonStyle} onPress={onPressBack}>
270
+ <Icon source={iconBack} size={iconSize} />
321
271
  </TouchableOpacity>
322
272
  ) : null}
323
273
 
@@ -325,15 +275,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
325
275
  <View style={Styles.flex}>{options.header}</View>
326
276
  ) : (
327
277
  <>
328
- {showButtons && !leftOptions ? (
329
- <View style={styles.iconButton} />
330
- ) : null}
278
+ {!leftOptions ? <View style={iconButtonStyle} /> : null}
331
279
  <Text
332
- style={[
333
- Styles.flex,
334
- styles.title,
335
- !showButtons && styles.titleInset,
336
- ]}
280
+ style={[Styles.flex, styles.title]}
337
281
  numberOfLines={1}
338
282
  typography="header_default_bold"
339
283
  >
@@ -341,18 +285,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
341
285
  </Text>
342
286
  </>
343
287
  )}
344
- {showButtons ? (
345
- <TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
346
- <Icon source="navigation_close" />
347
- </TouchableOpacity>
348
- ) : null}
288
+ <TouchableOpacity style={iconButtonStyle} onPress={onPressClose}>
289
+ <Icon source="navigation_close" size={iconSize} />
290
+ </TouchableOpacity>
349
291
  </View>
350
292
  </View>
351
293
  );
352
294
  }, [
353
- hasSideColumn,
354
295
  iconBack,
355
- indicatorShift,
296
+ isFoldable,
356
297
  leftOptions,
357
298
  onPressBack,
358
299
  onPressClose,
@@ -364,41 +305,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
364
305
  useDivider,
365
306
  ]);
366
307
 
367
- /**
368
- * iPhone Duo folded: back and close move to the side bar column, drawn on
369
- * the part of the sheet under the side safe area. They line up with the
370
- * header row, stay below the status bar cluster on a tall sheet and above a
371
- * camera at the bottom end of the strip.
372
- */
373
- const renderSideColumn = () => {
374
- if (!sideColumn) {
375
- return null;
376
- }
377
- const lowestTop =
378
- windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
379
- const top = Math.min(
380
- Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
381
- lowestTop,
382
- );
383
-
384
- return (
385
- <View
386
- pointerEvents={'box-none'}
387
- style={[
388
- styles.sideColumn,
389
- sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
390
- InnerEdgeStyles[sideColumn.edge],
391
- { top, width: sideColumnWidth },
392
- ]}
393
- >
394
- {showBack ? (
395
- <SideBarButton icon={iconBack} onPress={onPressBack} />
396
- ) : null}
397
- <SideBarButton icon="navigation_close" onPress={onPressClose} />
398
- </View>
399
- );
400
- };
401
-
402
308
  const animatedStyle = useAnimatedStyle(() => {
403
309
  return {
404
310
  transform: [{ translateY: translateY.value }],
@@ -410,17 +316,12 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
410
316
  opacity: interpolate(
411
317
  translateY.value,
412
318
  [0, heightDevice],
413
- [1, 0],
319
+ [scrimOpacity, 0],
414
320
  Extrapolation.CLAMP,
415
321
  ),
416
322
  };
417
323
  });
418
324
 
419
- const sideInsetStyle = isDuoFloating && {
420
- paddingLeft: sideInsetLeft,
421
- paddingRight: sideInsetRight,
422
- };
423
-
424
325
  const screen = (
425
326
  <Screen
426
327
  {...props}
@@ -461,43 +362,31 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
461
362
  </Pressable>
462
363
 
463
364
  <Animated.View
464
- onLayout={
465
- isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
466
- }
467
365
  style={[
468
366
  animatedStyle,
469
367
  {
470
368
  backgroundColor,
471
- borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
472
- borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
369
+ borderTopLeftRadius: sheet.radius,
370
+ borderTopRightRadius: sheet.radius,
473
371
  overflow: 'hidden',
474
372
  },
475
- isDuo && {
476
- maxHeight: windowSize.height - topGap - keyboardLift,
477
- marginBottom: keyboardLift,
478
- },
479
- fitsResizedRoot && styles.shrink,
480
- fitsResizedRoot && { marginTop: topGap },
481
- isDuoFloating && { marginHorizontal: Spacing.M },
482
- sideColumn && {
483
- minHeight:
484
- SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
485
- },
486
- isDuoCentered && {
487
- // keeps the portrait width in landscape, like system sheets
488
- width: Math.min(
489
- Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
490
- windowSize.width -
491
- Math.max(insets.left, insets.right, Spacing.L) * 2,
492
- ),
493
- alignSelf: 'center',
494
- },
373
+ isDuo && { marginBottom: keyboardLift },
374
+ isFoldable &&
375
+ sheet.width !== undefined &&
376
+ sheet.maxHeight !== undefined && {
377
+ // centered in the placement area, clear of the iPhone Duo
378
+ // control column and on the right half when half folded
379
+ width: sheet.width,
380
+ marginLeft: placement.x + (placement.width - sheet.width) / 2,
381
+ maxHeight: sheet.maxHeight - keyboardLift,
382
+ minHeight: sheet.minHeight,
383
+ },
495
384
  showBaseLineDebug && styles.debugBaseLine,
496
385
  ]}
497
386
  >
498
- {isDuo ? (
387
+ {isFoldable ? (
499
388
  // content shrinks so a tall sheet keeps its header and footer
500
- <View style={[styles.shrink, sideInsetStyle]}>
389
+ <View style={styles.shrink}>
501
390
  {renderHeader()}
502
391
  <View style={styles.shrink}>{screen}</View>
503
392
  </View>
@@ -515,7 +404,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
515
404
  paddingBottom: Spacing.S,
516
405
  backgroundColor: theme.colors.background.surface,
517
406
  },
518
- sideInsetStyle,
519
407
  ]}
520
408
  >
521
409
  {footerComponent}
@@ -529,7 +417,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
529
417
  }}
530
418
  />
531
419
  )}
532
- {renderSideColumn()}
533
420
  </Animated.View>
534
421
  </KeyboardAvoidingView>
535
422
  </Container>
@@ -543,7 +430,7 @@ const styles = StyleSheet.create({
543
430
  },
544
431
  overlay: {
545
432
  flex: 1,
546
- backgroundColor: 'rgba(0, 0, 0, 0.6)',
433
+ backgroundColor: Colors.black_20,
547
434
  },
548
435
  indicator: {
549
436
  width: 40,
@@ -560,7 +447,6 @@ const styles = StyleSheet.create({
560
447
  alignItems: 'center',
561
448
  },
562
449
  title: { textAlign: 'center' },
563
- titleInset: { paddingHorizontal: Spacing.M },
564
450
  iconButton: {
565
451
  width: 24,
566
452
  height: 24,
@@ -568,13 +454,11 @@ const styles = StyleSheet.create({
568
454
  justifyContent: 'center',
569
455
  marginHorizontal: Spacing.M,
570
456
  },
571
- shrink: { flexShrink: 1 },
572
- sideColumn: {
573
- position: 'absolute',
574
- gap: Spacing.M,
457
+ foldableIconButton: {
458
+ width: FOLDABLE_ICON_BUTTON_SIZE,
459
+ height: FOLDABLE_ICON_BUTTON_SIZE,
575
460
  },
576
- alignRight: { right: 0 },
577
- alignLeft: { left: 0 },
461
+ shrink: { flexShrink: 1 },
578
462
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
579
463
  });
580
464
 
@@ -1,4 +1,10 @@
1
- import React, { useCallback, useContext, useEffect, useRef } from '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 { getDuoModalBounds, isFoldableDevice } from './device';
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 insets = useSafeAreaInsets();
44
- const windowSize = useWindowDimensions();
45
- const isDuo = isFoldableDevice();
49
+ const { width, height } = useWindowDimensions();
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, { opacity }]} />
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
- 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),
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: 'rgba(0, 0, 0, 0.6)',
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
+ });
@@ -1,13 +1,8 @@
1
- import { Dimensions, NativeModules, Platform, ScaledSize } from 'react-native';
2
- import type { EdgeInsets } from 'react-native-safe-area-context';
3
- import { Spacing } from '../Consts';
1
+ import { Dimensions, Platform, ScaledSize } from 'react-native';
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,44 +18,4 @@ const isIPhoneDuo = () => {
23
18
  );
24
19
  };
25
20
 
26
- // reported by the host; hosts without the constant count as not foldable
27
- const isAndroidFoldable = () =>
28
- Platform.OS === 'android' &&
29
- NativeModules?.FoldStateModule?.isFoldableDevice === true;
30
-
31
- const isFoldableDevice = () => isIPhoneDuo() || isAndroidFoldable();
32
-
33
- // portrait side status bar is about twice as tall as its inset is wide
34
- const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
35
- Math.max(
36
- insets.top,
37
- height > width ? Math.max(insets.left, insets.right) * 2 : 0,
38
- );
39
-
40
- /**
41
- * Max modal size on foldable devices: inside the safe area, so clear of the
42
- * side toolbar strip on iPhone Duo, and capped so it stays a dialog on the
43
- * inner display.
44
- */
45
- const getDuoModalBounds = (
46
- insets: EdgeInsets,
47
- width: number,
48
- height: number,
49
- ) => ({
50
- maxWidth: Math.min(
51
- width - insets.left - insets.right - Spacing.XL * 2,
52
- DUO_MODAL_MAX_WIDTH,
53
- ),
54
- maxHeight: Math.min(
55
- height - insets.top - insets.bottom - Spacing.XL * 2,
56
- DUO_MODAL_MAX_HEIGHT,
57
- ),
58
- });
59
-
60
- export {
61
- isIPhoneDuo,
62
- isAndroidFoldable,
63
- isFoldableDevice,
64
- getDuoTopInset,
65
- getDuoModalBounds,
66
- };
21
+ export { isIPhoneDuo };
@@ -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
+ };
@@ -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
- * keeps the occlusion regions reported by the native provider, the only kind layout reads
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
  };
@@ -8,7 +8,11 @@ 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 { getPopupRadius, usePopupMaxHeight, usePopupWidth } from './utils';
11
+ import { useModalLayout } from '../Application/modalLayout';
12
+
13
+ // foldable spec: icon button 32, icon 20
14
+ const FOLDABLE_CLOSE_SIZE = 32;
15
+ const FOLDABLE_CLOSE_ICON_SIZE = 20;
12
16
 
13
17
  const PopupNotify: React.FC<PopupNotifyProps> = ({
14
18
  image,
@@ -24,16 +28,19 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
24
28
  const context = useContext<any>(MiniAppContext);
25
29
  const { theme, navigator, translate } = useContext(ApplicationContext);
26
30
  const [scrollContent, setScrollContent] = useState(false);
27
- const popupWidth = usePopupWidth();
28
- const maxHeight = usePopupMaxHeight();
29
- const radius = getPopupRadius();
31
+ const { isFoldable, popup } = useModalLayout();
32
+ const { width: popupWidth, maxHeight, radius } = popup;
30
33
  const scaledMaxHeight = useScaleSize(172);
31
34
 
32
35
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
33
36
  const closeAction = useRef('touch_outside');
34
37
  let descriptionStyle = {};
35
38
  let Description: any = View;
36
- if (scrollContent) {
39
+ if (isFoldable) {
40
+ // foldables cap the popup height, the description takes what is left
41
+ Description = ScrollView;
42
+ descriptionStyle = styles.shrink;
43
+ } else if (scrollContent) {
37
44
  Description = ScrollView;
38
45
  descriptionStyle = { maxHeight: scaledMaxHeight };
39
46
  }
@@ -139,34 +146,53 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
139
146
  * build close action
140
147
  */
141
148
  const buildCloseIcon = () => {
142
- if (typeof onIconClose === 'function') {
149
+ if (typeof onIconClose !== 'function') {
150
+ return null;
151
+ }
152
+ if (isFoldable) {
143
153
  return (
144
154
  <TouchableOpacity
145
155
  accessibilityLabel={'ic_popup_close'}
146
- style={styles.iconCloseContainer}
156
+ style={styles.foldableClose}
147
157
  onPress={() => {
148
158
  closeAction.current = 'icon_close';
149
159
  onAction(onIconClose);
150
160
  }}
151
161
  >
152
- <View
153
- style={[
154
- styles.iconClose,
155
- {
156
- backgroundColor: theme.colors.text.default,
157
- borderColor: theme.colors.background.surface,
158
- },
159
- ]}
160
- >
161
- <Icon
162
- source="navigation_close"
163
- color={theme.colors.background.surface}
164
- size={14}
165
- />
166
- </View>
162
+ <Icon
163
+ source="navigation_close"
164
+ color={theme.colors.text.default}
165
+ size={FOLDABLE_CLOSE_ICON_SIZE}
166
+ />
167
167
  </TouchableOpacity>
168
168
  );
169
169
  }
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
+ );
170
196
  };
171
197
 
172
198
  const body = (
@@ -180,11 +206,18 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
180
206
  ]}
181
207
  />
182
208
  )}
183
- <View style={styles.content}>
209
+ <View style={[styles.content, isFoldable && styles.shrink]}>
184
210
  <Text
185
211
  typography={'header_default_bold'}
186
212
  numberOfLines={3}
187
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
+ }
188
221
  >
189
222
  {title}
190
223
  </Text>
@@ -236,20 +269,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
236
269
  showBaseLineDebug && styles.debugBaseLine,
237
270
  ]}
238
271
  >
239
- {maxHeight === undefined ? (
240
- body
241
- ) : (
242
- // scrolls when iPhone Duo landscape is too short; actions stay visible
243
- <ScrollView
244
- style={[
245
- styles.body,
246
- { borderTopLeftRadius: radius, borderTopRightRadius: radius },
247
- ]}
248
- bounces={false}
249
- >
250
- {body}
251
- </ScrollView>
252
- )}
272
+ {body}
253
273
  <View style={styles.contentAction}>{buildAction()}</View>
254
274
  {buildCloseIcon()}
255
275
  </View>
@@ -262,7 +282,7 @@ const styles = StyleSheet.create({
262
282
  aspectRatio: 1.7777,
263
283
  overflow: 'hidden',
264
284
  },
265
- body: { flexShrink: 1, overflow: 'hidden' },
285
+ shrink: { flexShrink: 1 },
266
286
  content: { padding: Spacing.XL },
267
287
  description: {
268
288
  marginTop: Spacing.S,
@@ -284,6 +304,16 @@ const styles = StyleSheet.create({
284
304
  right: -Spacing.M,
285
305
  padding: Spacing.XS,
286
306
  },
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
+ },
287
317
  iconClose: {
288
318
  width: 20,
289
319
  height: 20,
@@ -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 { usePopupMaxHeight, usePopupWidth } from './utils';
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 popupWidth = usePopupWidth();
24
- const maxHeight = usePopupMaxHeight();
25
- // fit the portrait image by height when iPhone Duo leaves too little room
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.30",
3
+ "version": "0.168.1-beta.32",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},
package/Popup/utils.ts DELETED
@@ -1,26 +0,0 @@
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, isFoldableDevice } from '../Application/device';
5
-
6
- const usePopupWidth = () => {
7
- const insets = useSafeAreaInsets();
8
- const { width, height } = useWindowDimensions();
9
- if (!isFoldableDevice()) {
10
- return Dimensions.get('window').width - Spacing.XL * 2;
11
- }
12
- return getDuoModalBounds(insets, width, height).maxWidth;
13
- };
14
-
15
- const usePopupMaxHeight = () => {
16
- const insets = useSafeAreaInsets();
17
- const { width, height } = useWindowDimensions();
18
- if (!isFoldableDevice()) {
19
- return undefined;
20
- }
21
- return getDuoModalBounds(insets, width, height).maxHeight;
22
- };
23
-
24
- const getPopupRadius = () => (isFoldableDevice() ? Radius.XL : Radius.L);
25
-
26
- export { usePopupWidth, usePopupMaxHeight, getPopupRadius };