@momo-kits/foundation 0.168.1-beta.10 → 0.168.1-beta.13

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.
@@ -0,0 +1,374 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
2
+ import { Gesture } from 'react-native-gesture-handler';
3
+ import {
4
+ clamp,
5
+ useAnimatedReaction,
6
+ useAnimatedStyle,
7
+ useReducedMotion,
8
+ useSharedValue,
9
+ withSequence,
10
+ withSpring,
11
+ withTiming,
12
+ } from 'react-native-reanimated';
13
+ import { scheduleOnRN } from 'react-native-worklets';
14
+ import { ApplicationContext } from '../../Context';
15
+
16
+ type Axis = 'x' | 'y';
17
+
18
+ type Options = {
19
+ count: number;
20
+ // distance between two neighbouring tab centers
21
+ pitch: number;
22
+ // position of the first tab's center inside the gesture view
23
+ firstCenter: number;
24
+ axis: Axis;
25
+ index: number;
26
+ onSelect: (index: number) => void;
27
+ };
28
+
29
+ const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
30
+ // the lens trails the finger a little while dragged, like the system tab bar
31
+ const DRAG_SPRING = { damping: 24, stiffness: 600, mass: 0.6 };
32
+ const REDUCED_TIMING = { duration: 200 };
33
+ // while pressed the selection bubble grows into a lens past the bar's edges (80x54 to about 96x70)
34
+ const LENS_SCALE_X = 1.2;
35
+ const LENS_SCALE_Y = 1.3;
36
+ // a moving lens stretches along its path and thins across it, eased per frame so the
37
+ // shape follows the speed smoothly
38
+ const STRETCH_PER_POINT = 0.012;
39
+ const MAX_STRETCH = 0.25;
40
+ const STRETCH_EASING = 0.25;
41
+ // once eased below this the lens is round again
42
+ const REST_STRETCH = 0.002;
43
+ // a tap is shorter than the lift: the lens finishes rising, then settles, one smooth pulse
44
+ const TAP_LIFT_DURATION = 120;
45
+ // the bar swells a little under the finger
46
+ const BAR_SWELL = 0.04;
47
+ // the tab the lens lands on pops
48
+ const ICON_POP = 1.15;
49
+ // movement before a press turns into a drag, below it the tab's touchable handles the tap
50
+ const DRAG_SLOP = 10;
51
+ // UIScrollView rubber band constant
52
+ const RUBBER_BAND = 0.55;
53
+ // a cancelled press or a prevented switch: the lens returns to the selected tab after this
54
+ const RESYNC_DELAY = 100;
55
+
56
+ /**
57
+ * resistance past the first and last tab, the further the finger goes the less the lens follows
58
+ */
59
+ const rubberBand = (value: number, max: number, dimension: number) => {
60
+ 'worklet';
61
+ const band = (overshoot: number) =>
62
+ (1 - 1 / ((overshoot * RUBBER_BAND) / dimension + 1)) * dimension;
63
+ if (value < 0) {
64
+ return -band(-value);
65
+ }
66
+ if (value > max) {
67
+ return max + band(value - max);
68
+ }
69
+ return value;
70
+ };
71
+
72
+ /**
73
+ * iOS 26 tab bar selection motion, without the glass material:
74
+ * - press lifts the selection into a lens over the pressed tab and swells the bar, release
75
+ * settles it back (the tab's touchable drives this through pressIn / pressOut)
76
+ * - dragging slides the lens under the finger, stretched by its speed and rubber banded
77
+ * past the ends, with a tick on each tab it enters; releasing selects the tab under it.
78
+ * The drag cancels the touchable once it activates, so a press is either a tap or a drag
79
+ */
80
+ const useLiquidSelection = ({
81
+ count,
82
+ pitch,
83
+ firstCenter,
84
+ axis,
85
+ index,
86
+ onSelect,
87
+ }: Options) => {
88
+ const { navigator } = useContext<any>(ApplicationContext);
89
+ const reduceMotion = useReducedMotion();
90
+
91
+ const offset = useSharedValue(index * pitch);
92
+ // where the lens is heading, so a re-render doesn't restart a spring already going there
93
+ const destination = useSharedValue(index * pitch);
94
+ const selected = useSharedValue(index);
95
+ const hovered = useSharedValue(index);
96
+ const lift = useSharedValue(0);
97
+ const dragging = useSharedValue(false);
98
+ const stretch = useSharedValue(0);
99
+ const metrics = useSharedValue({ count, pitch, firstCenter });
100
+ const measured = useRef(pitch > 0);
101
+
102
+ const indexRef = useRef(index);
103
+ const pitchRef = useRef(pitch);
104
+ // read through refs so the gesture is never rebuilt mid drag
105
+ const callbacks = useRef({ onSelect, navigator });
106
+ indexRef.current = index;
107
+ pitchRef.current = pitch;
108
+ callbacks.current = { onSelect, navigator };
109
+
110
+ useEffect(() => {
111
+ metrics.value = { count, pitch, firstCenter };
112
+ }, [count, pitch, firstCenter, metrics]);
113
+
114
+ const move = useCallback(
115
+ (target: number, velocity: number, config: typeof SPRING) => {
116
+ 'worklet';
117
+ if (destination.value === target && velocity === 0) {
118
+ return;
119
+ }
120
+ destination.value = target;
121
+ offset.value = reduceMotion
122
+ ? withTiming(target, REDUCED_TIMING)
123
+ : withSpring(target, { ...config, velocity });
124
+ },
125
+ [offset, destination, reduceMotion],
126
+ );
127
+
128
+ useEffect(() => {
129
+ const target = index * pitch;
130
+ selected.value = index;
131
+ // snap on first layout so the lens doesn't slide in from the leading edge
132
+ if (measured.current) {
133
+ move(target, 0, SPRING);
134
+ } else {
135
+ destination.value = target;
136
+ offset.value = target;
137
+ }
138
+ measured.current = pitch > 0;
139
+ }, [index, pitch, offset, destination, selected, move]);
140
+
141
+ useAnimatedReaction(
142
+ () => offset.value,
143
+ (current, previous) => {
144
+ if (previous === null || reduceMotion) {
145
+ return;
146
+ }
147
+ const target = Math.min(
148
+ Math.abs(current - previous) * STRETCH_PER_POINT,
149
+ MAX_STRETCH,
150
+ );
151
+ // eased both ways: a slow frame (e.g. the spring turning at its overshoot) must not
152
+ // snap the shape
153
+ const next = stretch.value + (target - stretch.value) * STRETCH_EASING;
154
+ stretch.value = next < REST_STRETCH ? 0 : next;
155
+ },
156
+ [reduceMotion],
157
+ );
158
+
159
+ /**
160
+ * returns the lens to the selected tab unless a switch to where it is heading happened
161
+ */
162
+ const resync = useCallback(() => {
163
+ setTimeout(() => {
164
+ const target = indexRef.current * pitchRef.current;
165
+ if (!dragging.value && destination.value !== target) {
166
+ move(target, 0, SPRING);
167
+ }
168
+ }, RESYNC_DELAY);
169
+ }, [destination, dragging, move]);
170
+
171
+ const pressIn = (itemIndex: number) => {
172
+ // the lens heads to the pressed tab, not only the selected one
173
+ move(itemIndex * pitch, 0, SPRING);
174
+ if (!reduceMotion) {
175
+ lift.value = withSpring(1, SPRING);
176
+ }
177
+ };
178
+
179
+ /**
180
+ * false when a drag took the press over
181
+ */
182
+ const pressOut = () => {
183
+ // a drag taking over cancels the touchable, the drag owns the lens from there
184
+ if (dragging.value) {
185
+ return false;
186
+ }
187
+ if (!reduceMotion) {
188
+ lift.value = withSequence(
189
+ withTiming(1, { duration: TAP_LIFT_DURATION }),
190
+ withSpring(0, SPRING),
191
+ );
192
+ }
193
+ // the lens keeps heading to the pressed tab: onPress switches to it, and a cancelled
194
+ // press brings it back
195
+ resync();
196
+ return true;
197
+ };
198
+
199
+ const tick = useCallback(() => {
200
+ callbacks.current.navigator?.maxApi?.triggerEventVibration?.('light');
201
+ }, []);
202
+
203
+ const select = useCallback(
204
+ (target: number) => {
205
+ callbacks.current.onSelect(target);
206
+ resync();
207
+ },
208
+ [resync],
209
+ );
210
+
211
+ const gesture = useMemo(() => {
212
+ const position = (e: { x: number; y: number }) => {
213
+ 'worklet';
214
+ return (axis === 'x' ? e.x : e.y) - metrics.value.firstCenter;
215
+ };
216
+ const nearest = (value: number) => {
217
+ 'worklet';
218
+ const { count: tabs, pitch: step } = metrics.value;
219
+ return step > 0 ? clamp(Math.round(value / step), 0, tabs - 1) : 0;
220
+ };
221
+
222
+ return Gesture.Pan()
223
+ .minDistance(DRAG_SLOP)
224
+ .shouldCancelWhenOutside(false)
225
+ .onStart(e => {
226
+ dragging.value = true;
227
+ hovered.value = nearest(position(e));
228
+ if (!reduceMotion) {
229
+ lift.value = withSpring(1, SPRING);
230
+ }
231
+ })
232
+ .onUpdate(e => {
233
+ const { count: tabs, pitch: step } = metrics.value;
234
+ const value = position(e);
235
+ const target = nearest(value);
236
+ if (target !== hovered.value) {
237
+ hovered.value = target;
238
+ scheduleOnRN(tick);
239
+ }
240
+ const follow = rubberBand(value, (tabs - 1) * step, step);
241
+ if (reduceMotion) {
242
+ destination.value = follow;
243
+ offset.value = follow;
244
+ } else {
245
+ move(follow, 0, DRAG_SPRING);
246
+ }
247
+ })
248
+ .onEnd((e, success) => {
249
+ dragging.value = false;
250
+ lift.value = reduceMotion ? 0 : withSpring(0, SPRING);
251
+ const raw = axis === 'x' ? e.velocityX : e.velocityY;
252
+ const velocity = Number.isFinite(raw) ? raw : 0;
253
+ if (!success) {
254
+ move(selected.value * metrics.value.pitch, velocity, SPRING);
255
+ return;
256
+ }
257
+ const target = nearest(position(e));
258
+ move(target * metrics.value.pitch, velocity, SPRING);
259
+ scheduleOnRN(select, target);
260
+ });
261
+ }, [
262
+ axis,
263
+ reduceMotion,
264
+ offset,
265
+ destination,
266
+ selected,
267
+ hovered,
268
+ lift,
269
+ dragging,
270
+ metrics,
271
+ move,
272
+ tick,
273
+ select,
274
+ ]);
275
+
276
+ const bubbleStyle = useAnimatedStyle(() => {
277
+ const along = 1 + stretch.value;
278
+ const across = 1 - stretch.value * 0.5;
279
+ const lensX = 1 + lift.value * (LENS_SCALE_X - 1);
280
+ const lensY = 1 + lift.value * (LENS_SCALE_Y - 1);
281
+ return {
282
+ transform:
283
+ axis === 'x'
284
+ ? [
285
+ { translateX: offset.value },
286
+ { scaleX: lensX * along },
287
+ { scaleY: lensY * across },
288
+ ]
289
+ : [
290
+ { translateY: offset.value },
291
+ { scaleX: lensX * across },
292
+ { scaleY: lensY * along },
293
+ ],
294
+ };
295
+ });
296
+
297
+ const barStyle = useAnimatedStyle(() => ({
298
+ transform: [{ scale: 1 + lift.value * BAR_SWELL }],
299
+ }));
300
+
301
+ return {
302
+ gesture,
303
+ bubbleStyle,
304
+ barStyle,
305
+ pressIn,
306
+ pressOut,
307
+ offset,
308
+ selected,
309
+ pitch,
310
+ reduceMotion,
311
+ };
312
+ };
313
+
314
+ type LiquidSelection = ReturnType<typeof useLiquidSelection>;
315
+
316
+ /**
317
+ * active tint of a tab follows the lens over it, and the tab the lens lands on pops
318
+ */
319
+ const useLiquidItem = (
320
+ { offset, selected, pitch, reduceMotion }: LiquidSelection,
321
+ itemIndex: number,
322
+ ) => {
323
+ const pop = useSharedValue(1);
324
+
325
+ useAnimatedReaction(
326
+ () => selected.value === itemIndex,
327
+ (isSelected, wasSelected) => {
328
+ if (isSelected && wasSelected === false && !reduceMotion) {
329
+ pop.value = withSequence(
330
+ withTiming(ICON_POP, { duration: 120 }),
331
+ withSpring(1, SPRING),
332
+ );
333
+ }
334
+ },
335
+ [itemIndex, reduceMotion],
336
+ );
337
+
338
+ const activeStyle = useAnimatedStyle(() => {
339
+ if (pitch <= 0) {
340
+ return { opacity: selected.value === itemIndex ? 1 : 0 };
341
+ }
342
+ const distance = Math.abs(offset.value - itemIndex * pitch) / pitch;
343
+ return { opacity: clamp(1 - distance, 0, 1) };
344
+ });
345
+
346
+ const popStyle = useAnimatedStyle(() => ({
347
+ transform: [{ scale: pop.value }],
348
+ }));
349
+
350
+ return { activeStyle, popStyle };
351
+ };
352
+
353
+ /**
354
+ * lift for a single button, e.g. the compressed rail
355
+ */
356
+ const usePressLift = () => {
357
+ const reduceMotion = useReducedMotion();
358
+ const lift = useSharedValue(0);
359
+ const style = useAnimatedStyle(() => ({
360
+ transform: [{ scale: 1 + lift.value * (LENS_SCALE_X - 1) * 0.5 }],
361
+ }));
362
+ const onPressIn = () => {
363
+ if (!reduceMotion) {
364
+ lift.value = withSpring(1, SPRING);
365
+ }
366
+ };
367
+ const onPressOut = () => {
368
+ lift.value = withSpring(0, SPRING);
369
+ };
370
+ return { style, onPressIn, onPressOut };
371
+ };
372
+
373
+ export type { LiquidSelection };
374
+ export { useLiquidSelection, useLiquidItem, usePressLift };
@@ -18,7 +18,7 @@ import { Styles } from '../Consts';
18
18
  import Navigation from './Navigation';
19
19
  import { ModalParams } from './types';
20
20
  import BottomSheet from './BottomSheet';
21
- import { getDuoTopInset, isIPhoneDuo } from './device';
21
+ import { getDuoModalBounds, isIPhoneDuo } from './device';
22
22
  import { scheduleOnRN } from 'react-native-worklets';
23
23
 
24
24
  const ModalScreen: React.FC<any> = props => {
@@ -42,6 +42,7 @@ const Modal: React.FC<ModalParams> = props => {
42
42
  const Component = useRef(screen).current;
43
43
  const insets = useSafeAreaInsets();
44
44
  const windowSize = useWindowDimensions();
45
+ const isDuo = isIPhoneDuo();
45
46
 
46
47
  /**
47
48
  * Fire auto_popup_displayed tracking event on mount
@@ -135,6 +136,7 @@ const Modal: React.FC<ModalParams> = props => {
135
136
  onRequestClose={onDismiss}
136
137
  style={StyleSheet.absoluteFillObject}
137
138
  isModalKit={true}
139
+ supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
138
140
  >
139
141
  <KeyboardAvoidingView
140
142
  style={Styles.flexCenter}
@@ -152,14 +154,13 @@ const Modal: React.FC<ModalParams> = props => {
152
154
  opacity,
153
155
  transform: [{ scale }],
154
156
  },
155
- // center below the side status bar and above the home indicator
156
- isIPhoneDuo() && {
157
- marginTop: getDuoTopInset(
158
- insets,
159
- windowSize.width,
160
- windowSize.height,
161
- ),
157
+ isDuo && styles.shrink,
158
+ isDuo && {
159
+ marginTop: insets.top,
162
160
  marginBottom: insets.bottom,
161
+ marginLeft: insets.left,
162
+ marginRight: insets.right,
163
+ ...getDuoModalBounds(insets, windowSize.width, windowSize.height),
163
164
  },
164
165
  modalStyle,
165
166
  ]}
@@ -171,11 +172,14 @@ const Modal: React.FC<ModalParams> = props => {
171
172
  );
172
173
  };
173
174
 
175
+ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
176
+
174
177
  const styles = StyleSheet.create({
175
178
  overlayContent: {
176
179
  flex: 1,
177
180
  backgroundColor: 'rgba(0, 0, 0, 0.6)',
178
181
  },
182
+ shrink: { flexShrink: 1 },
179
183
  });
180
184
 
181
185
  export default ModalScreen;
@@ -1,9 +1,13 @@
1
1
  import { Dimensions, Platform, ScaledSize } from 'react-native';
2
2
  import type { EdgeInsets } from 'react-native-safe-area-context';
3
+ import { Spacing } from '../Consts';
3
4
 
4
5
  // widest short side of any single-display iPhone
5
6
  const IPHONE_MAX_SHORT_SIDE = 440;
6
7
 
8
+ const DUO_MODAL_MAX_WIDTH = 440;
9
+ const DUO_MODAL_MAX_HEIGHT = 640;
10
+
7
11
  // iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
8
12
  // still matches when Split View narrows the window
9
13
  const isIPhoneDuo = () => {
@@ -26,4 +30,23 @@ const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
26
30
  height > width ? Math.max(insets.left, insets.right) * 2 : 0,
27
31
  );
28
32
 
29
- export { isIPhoneDuo, getDuoTopInset };
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 };
@@ -37,8 +37,6 @@ import SystemTextInput from './SystemTextInput';
37
37
 
38
38
  const moneyFormatter = new Intl.NumberFormat('vi-VN');
39
39
 
40
- // Finds the common prefix and suffix of `old` and `text`; `size` counts the
41
- // characters removed plus inserted between them.
42
40
  const findEdit = (old: string, text: string) => {
43
41
  let start = 0;
44
42
  while (start < text.length && old[start] === text[start]) {
@@ -129,11 +127,8 @@ const InputMoney = forwardRef(
129
127
  setLastDisplayValue(displayValue);
130
128
  }, [displayValue]);
131
129
 
132
- // The selection only rides along with the render that reformats the text,
133
- // then it is released so the user can move the caret freely.
134
130
  useEffect(() => {
135
131
  if (!caretSelection) return;
136
- // A newer keystroke may already have queued its own selection; keep it.
137
132
  setCaretSelection(current =>
138
133
  current === caretSelection ? undefined : current,
139
134
  );
@@ -143,9 +138,6 @@ const InputMoney = forwardRef(
143
138
  const next = numeric ? moneyFormatter.format(Number(numeric)) : '';
144
139
  setDisplayValue(next);
145
140
 
146
- // Android moves the caret proportionally when the text is replaced, so
147
- // it lands before the typed digit once a separator is added. Send the
148
- // caret together with the text, keeping the same digits after it.
149
141
  const digitsAfter = caretDigitsAfter.current;
150
142
  caretDigitsAfter.current = undefined;
151
143
  if (Platform.OS !== 'android' || digitsAfter === undefined) return;
@@ -187,9 +179,6 @@ const InputMoney = forwardRef(
187
179
  return;
188
180
  }
189
181
  const digits = parseInputText(text);
190
- // When typing outpaces JS (slow devices) native rejects the stale
191
- // formatted text and keeps the raw one, so the edit may apply to either.
192
- // Pick the one that differs from the new text by a single edit.
193
182
  const raw = lastRawText.current;
194
183
  lastRawText.current = text;
195
184
  const formattedEdit = findEdit(lastDisplayValue, text);
@@ -199,7 +188,6 @@ const InputMoney = forwardRef(
199
188
  caretDigitsAfter.current = parseInputText(
200
189
  text.slice(text.length - suffix),
201
190
  ).length;
202
- // Deleting a separator removes the digit before it.
203
191
  if (text.length < old.length && digits === numericValue) {
204
192
  const index = parseInputText(text.slice(0, start)).length - 1;
205
193
  if (index >= 0) {
@@ -210,6 +198,11 @@ const InputMoney = forwardRef(
210
198
  updateNumericValue(digits);
211
199
  };
212
200
 
201
+ const focusAtEnd = () => {
202
+ inputRef.current?.focus();
203
+ inputRef.current?.setSelection(displayValue.length, displayValue.length);
204
+ };
205
+
213
206
  const _onFocus = (e: TextInputFocusEvent) => {
214
207
  setFocused(true);
215
208
  onFocus?.(e);
@@ -279,7 +272,7 @@ const InputMoney = forwardRef(
279
272
  />
280
273
  <Pressable
281
274
  style={[styles.inputView, styles.moneyInputView]}
282
- onPress={() => inputRef.current?.focus()}
275
+ onPress={focusAtEnd}
283
276
  disabled={disabled}
284
277
  >
285
278
  <SystemTextInput
@@ -291,8 +284,6 @@ const InputMoney = forwardRef(
291
284
  disabled: disabled,
292
285
  ...props.accessibilityState,
293
286
  }}
294
- // No width: the input sizes to its text natively, so the
295
- // currency follows it without waiting for JS.
296
287
  style={[textStyle, styles.moneyTextInput, { color: textColor }]}
297
288
  allowFontScaling={false}
298
289
  textBreakStrategy="highQuality"
package/Input/styles.ts CHANGED
@@ -223,9 +223,8 @@ export default StyleSheet.create({
223
223
  },
224
224
  moneyTextInput: {
225
225
  flexShrink: 1,
226
- height: '100%',
227
226
  paddingLeft: 0,
228
227
  paddingRight: Spacing.XXS,
229
- paddingVertical: Spacing.S,
228
+ paddingVertical: 0,
230
229
  },
231
230
  });
package/Layout/utils.ts CHANGED
@@ -510,6 +510,7 @@ const useGridSystem = () => {
510
510
  export {
511
511
  DuoMetrics,
512
512
  HeaderMetrics,
513
+ InnerEdgeStyles,
513
514
  getHeaderMargin,
514
515
  getHeaderRowOptions,
515
516
  useGridSystem,
@@ -8,7 +8,7 @@ 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 { usePopupMaxHeight, usePopupWidth } from './utils';
11
+ import { getPopupRadius, usePopupMaxHeight, usePopupWidth } from './utils';
12
12
 
13
13
  const PopupNotify: React.FC<PopupNotifyProps> = ({
14
14
  image,
@@ -26,6 +26,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
26
26
  const [scrollContent, setScrollContent] = useState(false);
27
27
  const popupWidth = usePopupWidth();
28
28
  const maxHeight = usePopupMaxHeight();
29
+ const radius = getPopupRadius();
29
30
  const scaledMaxHeight = useScaleSize(172);
30
31
 
31
32
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
@@ -170,7 +171,15 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
170
171
 
171
172
  const body = (
172
173
  <>
173
- {!!image && <Image source={{ uri: image }} style={styles.image} />}
174
+ {!!image && (
175
+ <Image
176
+ source={{ uri: image }}
177
+ style={[
178
+ styles.image,
179
+ { borderTopLeftRadius: radius, borderTopRightRadius: radius },
180
+ ]}
181
+ />
182
+ )}
174
183
  <View style={styles.content}>
175
184
  <Text
176
185
  typography={'header_default_bold'}
@@ -218,9 +227,9 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
218
227
  <View
219
228
  accessibilityLabel={'popup_notify'}
220
229
  style={[
221
- styles.container,
222
230
  {
223
231
  backgroundColor: theme.colors.background.surface,
232
+ borderRadius: radius,
224
233
  width: popupWidth,
225
234
  maxHeight,
226
235
  },
@@ -231,7 +240,13 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
231
240
  body
232
241
  ) : (
233
242
  // scrolls when iPhone Duo landscape is too short; actions stay visible
234
- <ScrollView style={styles.body} bounces={false}>
243
+ <ScrollView
244
+ style={[
245
+ styles.body,
246
+ { borderTopLeftRadius: radius, borderTopRightRadius: radius },
247
+ ]}
248
+ bounces={false}
249
+ >
235
250
  {body}
236
251
  </ScrollView>
237
252
  )}
@@ -242,17 +257,12 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
242
257
  };
243
258
 
244
259
  const styles = StyleSheet.create({
245
- container: {
246
- borderRadius: Radius.L,
247
- },
248
260
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
249
261
  image: {
250
- borderTopLeftRadius: Radius.L,
251
- borderTopRightRadius: Radius.L,
252
262
  aspectRatio: 1.7777,
253
263
  overflow: 'hidden',
254
264
  },
255
- body: { flexShrink: 1 },
265
+ body: { flexShrink: 1, overflow: 'hidden' },
256
266
  content: { padding: Spacing.XL },
257
267
  description: {
258
268
  marginTop: Spacing.S,
package/Popup/utils.ts CHANGED
@@ -1,32 +1,26 @@
1
1
  import { Dimensions, useWindowDimensions } from 'react-native';
2
2
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
- import { Spacing } from '../Consts';
4
- import { getDuoTopInset, isIPhoneDuo } from '../Application/device';
5
-
6
- // width of a system alert on the iPhone Duo inner display, in both orientations
7
- const DUO_POPUP_MAX_WIDTH = 440;
3
+ import { Radius, Spacing } from '../Consts';
4
+ import { getDuoModalBounds, isIPhoneDuo } from '../Application/device';
8
5
 
9
6
  const usePopupWidth = () => {
10
- const windowSize = useWindowDimensions();
7
+ const insets = useSafeAreaInsets();
8
+ const { width, height } = useWindowDimensions();
11
9
  if (!isIPhoneDuo()) {
12
10
  return Dimensions.get('window').width - Spacing.XL * 2;
13
11
  }
14
- return Math.min(windowSize.width - Spacing.XL * 2, DUO_POPUP_MAX_WIDTH);
12
+ return getDuoModalBounds(insets, width, height).maxWidth;
15
13
  };
16
14
 
17
- // room left for a popup on iPhone Duo, where landscape is too short for it
18
15
  const usePopupMaxHeight = () => {
19
16
  const insets = useSafeAreaInsets();
20
17
  const { width, height } = useWindowDimensions();
21
18
  if (!isIPhoneDuo()) {
22
19
  return undefined;
23
20
  }
24
- return (
25
- height -
26
- getDuoTopInset(insets, width, height) -
27
- insets.bottom -
28
- Spacing.XL * 2
29
- );
21
+ return getDuoModalBounds(insets, width, height).maxHeight;
30
22
  };
31
23
 
32
- export { usePopupWidth, usePopupMaxHeight };
24
+ const getPopupRadius = () => (isIPhoneDuo() ? Radius.XL : Radius.L);
25
+
26
+ export { usePopupWidth, usePopupMaxHeight, getPopupRadius };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.10",
3
+ "version": "0.168.1-beta.13",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},