@momo-kits/foundation 0.168.1-beta.32 → 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.
@@ -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 { isIPhoneDuo } from './device';
43
- import { useModalLayout } from './modalLayout';
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
- // foldable spec: icon button 32, icon 20
49
- const FOLDABLE_ICON_BUTTON_SIZE = 32;
50
- const FOLDABLE_ICON_SIZE = 20;
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 { isFoldable, placement, sheet, scrimOpacity } = useModalLayout();
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 iconButtonStyle = [
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 style={styles.indicator} />
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={iconButtonStyle} onPress={onPressBack}>
270
- <Icon source={iconBack} size={iconSize} />
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 ? <View style={iconButtonStyle} /> : null}
325
+ {showButtons && !leftOptions ? (
326
+ <View style={styles.iconButton} />
327
+ ) : null}
279
328
  <Text
280
- style={[Styles.flex, styles.title]}
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
- <TouchableOpacity style={iconButtonStyle} onPress={onPressClose}>
289
- <Icon source="navigation_close" size={iconSize} />
290
- </TouchableOpacity>
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
- isFoldable,
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
- [scrimOpacity, 0],
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: sheet.radius,
370
- borderTopRightRadius: sheet.radius,
468
+ borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
469
+ borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
371
470
  overflow: 'hidden',
372
471
  },
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
- },
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
- {isFoldable ? (
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: Colors.black_20,
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 { Colors, Styles } from '../Consts';
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 { width, height } = useWindowDimensions();
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, scrimStyle]} />
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
- // 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,
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: Colors.black_20,
180
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
198
181
  },
199
182
  shrink: { flexShrink: 1 },
200
183
  });
@@ -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
- export { isIPhoneDuo };
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 };
@@ -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, useContext, useMemo } from 'react';
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
- * 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
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
- <HingeContext.Provider value={hinge}>{children}</HingeContext.Provider>
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, useHinge };
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
  };
@@ -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 { 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;
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 { isFoldable, popup } = useModalLayout();
32
- const { width: popupWidth, maxHeight, radius } = popup;
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 (isFoldable) {
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 !== 'function') {
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.foldableClose}
172
+ style={styles.iconCloseContainer}
157
173
  onPress={() => {
158
174
  closeAction.current = 'icon_close';
159
175
  onAction(onIconClose);
160
176
  }}
161
177
  >
162
- <Icon
163
- source="navigation_close"
164
- color={theme.colors.text.default}
165
- size={FOLDABLE_CLOSE_ICON_SIZE}
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={[styles.content, isFoldable && styles.shrink]}>
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
- {body}
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
- shrink: { flexShrink: 1 },
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,
@@ -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 { useModalLayout } from '../Application/modalLayout';
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
- popup: { width: popupWidth, maxHeight },
25
- } = useModalLayout();
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.32",
3
+ "version": "0.168.1-beta.33",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},
@@ -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
- };