@momo-kits/foundation 0.169.8 → 0.169.9

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.
Files changed (39) hide show
  1. package/Application/BottomSheet.hooks.ts +27 -17
  2. package/Application/BottomSheet.tsx +35 -27
  3. package/Application/BottomTab/BottomTabBar.tsx +6 -6
  4. package/Application/BottomTab/DuoRailTabBar.tsx +5 -5
  5. package/Application/BottomTab/TabScreen.tsx +11 -10
  6. package/Application/BottomTab/index.tsx +5 -5
  7. package/Application/BottomTab/usePressLift.ts +3 -3
  8. package/Application/Components/HeaderToolkitAction.tsx +26 -18
  9. package/Application/Components/useToolkitActions.tsx +22 -21
  10. package/Application/ModalScreen.tsx +19 -15
  11. package/Application/StackScreen.tsx +13 -9
  12. package/Application/modalLayout.ts +1 -3
  13. package/Context/index.ts +3 -1
  14. package/Image/index.tsx +6 -6
  15. package/Input/ErrorView.tsx +59 -0
  16. package/Input/FloatingView.tsx +101 -0
  17. package/Input/Input.tsx +40 -37
  18. package/Input/InputDropDown.tsx +13 -10
  19. package/Input/InputMoney.tsx +17 -17
  20. package/Input/InputMoneyView.tsx +12 -12
  21. package/Input/InputOTP.tsx +46 -34
  22. package/Input/InputPhoneNumber.tsx +48 -43
  23. package/Input/InputSearch.tsx +27 -24
  24. package/Input/InputTextArea.tsx +29 -23
  25. package/Input/RenderTrailing.tsx +79 -0
  26. package/Input/constants.ts +8 -0
  27. package/Input/utils.ts +126 -7
  28. package/Layout/FloatingButton.tsx +38 -24
  29. package/Layout/layoutMetrics.ts +43 -34
  30. package/Layout/screenTypes.ts +2 -3
  31. package/Pagination/PaginationScroll.tsx +5 -3
  32. package/Pagination/types.ts +3 -5
  33. package/Popup/PopupNotify.tsx +16 -13
  34. package/Popup/PopupPromotion.tsx +5 -4
  35. package/Popup/types.ts +2 -4
  36. package/Skeleton/index.tsx +15 -12
  37. package/package.json +1 -1
  38. package/Input/common.tsx +0 -257
  39. package/Input/moneyUtils.ts +0 -23
@@ -1,57 +1,66 @@
1
- /**
2
- * Header geometry
3
- * - barHeight: navigation bar row below the status bar
4
- * - iPhoneMinHeight: header never shorter than this on iPhone (landscape, iPhone Duo)
5
- * - statusBarMinInset: a smaller top inset means the status bar is not on the top edge
6
- * - largeWidth / largeMargin: @react-navigation/elements Header gives its content row
7
- * 5pt side margins on iOS from 414pt wide
8
- */
1
+ /** Header geometry. */
9
2
  export const HeaderMetrics = {
3
+ /** Navigation bar row below the status bar. */
10
4
  barHeight: 52,
5
+ /** Header never shorter than this on iPhone (landscape, iPhone Duo). */
11
6
  iPhoneMinHeight: 72,
7
+ /** A smaller top inset means the status bar is not on the top edge. */
12
8
  statusBarMinInset: 20,
9
+ /**
10
+ * From this width @react-navigation/elements Header gives its content row
11
+ * `largeMargin` (5pt) side margins on iOS.
12
+ */
13
13
  largeWidth: 414,
14
14
  largeMargin: 5,
15
15
  };
16
16
 
17
- /**
18
- * iPhone Duo geometry, iOS 27.1 (Xcode 27.1 simulator)
19
- * - maxAspectRatio: both displays are near 1.42 (inner) and 1.45 (outer), every slab
20
- * iPhone is 1.77 or taller
21
- * - innerMinHeight: window height from which the inner display is in use (outer display
22
- * is 466pt tall in landscape, inner display 669pt)
23
- * - controlSize / itemPitch: vertical bar of 48pt circular controls, item groups as 48pt
24
- * wide capsules at a 50pt pitch, 12pt from the strip's inner edge and 12pt apart
25
- * - firstItemTop: with the status bar in the strip, the first control sits under the
26
- * camera, clock and connectivity cluster (cluster ends near y 151 on the outer display,
27
- * y 98 on the inner display in landscape)
28
- * - outerHeaderHeight / outerCameraCenterY: on the outer display in portrait the header
29
- * is 4pt taller than iPhoneMinHeight and its title lines up with the camera punchout
30
- * (y 24–66 at the top of the strip)
31
- * - edgeInset: on the outer display in landscape the strip holds no status bar, so its
32
- * controls start this far from the top and stay this far above the bottom, unless a
33
- * reserved region (the camera at either end) asks for more
34
- * - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
35
- * clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
36
- * digits centered on y 47); its reserved region, when reported, is taller than the glyphs
37
- * - floatingTabBarHeight / floatingTabBarMargin / floatingTabBarInsetOverlap: the host
38
- * app's iOS 26 system tab bar on the inner display in portrait, a 62pt Liquid Glass pill
39
- * sized to its tabs with the QR as a pinned 62pt platter beside it, centered and at
40
- * least 20pt from the side edges; it overlaps the home indicator inset by 13pt (21pt
41
- * above the screen bottom with a 34pt inset)
42
- */
17
+ /** iPhone Duo geometry, iOS 27.1 (Xcode 27.1 simulator). */
43
18
  export const DuoMetrics = {
19
+ /**
20
+ * Both displays are near 1.42 (inner) and 1.45 (outer); every slab iPhone is
21
+ * 1.77 or taller.
22
+ */
44
23
  maxAspectRatio: 1.6,
24
+ /**
25
+ * Window height from which the inner display is in use (outer display is
26
+ * 466pt tall in landscape, inner display 669pt).
27
+ */
45
28
  innerMinHeight: 600,
29
+ /**
30
+ * Vertical bar of 48pt circular controls; item groups are 48pt wide capsules
31
+ * at a 50pt pitch, 12pt from the strip's inner edge and 12pt apart.
32
+ */
46
33
  controlSize: 48,
47
34
  itemPitch: 50,
35
+ /**
36
+ * With the status bar in the strip, the first control sits under the camera,
37
+ * clock and connectivity cluster (ends near y 151 outer, y 98 inner
38
+ * landscape).
39
+ */
48
40
  firstItemTop: 170,
49
41
  firstItemTopLandscape: 120,
42
+ /**
43
+ * Outer display in portrait: the header is 4pt taller than iPhoneMinHeight
44
+ * and its title lines up with the camera punchout (y 24–66 at the strip's
45
+ * top).
46
+ */
50
47
  outerHeaderHeight: 76,
51
48
  outerCameraCenterY: 45,
49
+ /**
50
+ * Outer display in landscape: no status bar in the strip, so controls keep
51
+ * this gap from both ends unless a reserved region (the camera) needs more.
52
+ */
52
53
  edgeInset: 24,
54
+ /**
55
+ * Inner display in portrait: clock and connectivity cluster at the top right
56
+ * (x 550–643 of 669, digits on y 47); a reported reserved region is taller.
57
+ */
53
58
  innerStatusBarCenterY: 47,
54
59
  innerStatusBarWidth: 120,
60
+ /**
61
+ * Host app's iOS 26 tab bar, inner display in portrait: a 62pt Liquid Glass
62
+ * pill plus 62pt QR platter, ≥ 20pt from the sides, 13pt over the home inset.
63
+ */
55
64
  floatingTabBarHeight: 62,
56
65
  floatingTabBarMargin: 20,
57
66
  floatingTabBarInsetOverlap: 13,
@@ -59,9 +59,8 @@ export interface ScreenProps extends ViewProps {
59
59
 
60
60
  /**
61
61
  * Optional. If `true`, the footer is inset by the standard horizontal margin
62
- * (12pt each side). Set `false` for a footer that should span the full width —
63
- * e.g. one that already carries its own horizontal padding.
64
- * Defaults to `true`, on every platform.
62
+ * (12pt each side); `false` spans the full width, e.g. for a footer with its
63
+ * own horizontal padding. Defaults to `true`, on every platform.
65
64
  */
66
65
  marginFooter?: boolean;
67
66
 
@@ -13,6 +13,8 @@ import { ApplicationContext, MiniAppContext } from '../Context';
13
13
 
14
14
  const INDICATOR_WIDTH = 24;
15
15
  const INDICATOR_CONTAINER_WIDTH = 72;
16
+ // one frame at 60 fps
17
+ const SCROLL_EVENT_THROTTLE = 16;
16
18
 
17
19
  /**
18
20
  * Horizontal scroll view with a small indicator that tracks the scroll offset.
@@ -31,7 +33,7 @@ const PaginationScroll: FC<ScrollIndicatorProps> = ({
31
33
 
32
34
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
33
35
 
34
- const onScroll = useAnimatedScrollHandler({
36
+ const handleScroll = useAnimatedScrollHandler({
35
37
  onScroll: (event) => {
36
38
  left.value = event.contentOffset.x;
37
39
  },
@@ -54,8 +56,8 @@ const PaginationScroll: FC<ScrollIndicatorProps> = ({
54
56
  return (
55
57
  <Animated.ScrollView
56
58
  ref={scrollViewRef as any}
57
- onScroll={onScroll}
58
- scrollEventThrottle={16}
59
+ onScroll={handleScroll}
60
+ scrollEventThrottle={SCROLL_EVENT_THROTTLE}
59
61
  alwaysBounceHorizontal={false}
60
62
  showsHorizontalScrollIndicator={false}
61
63
  horizontal
@@ -3,11 +3,9 @@ import {Ref} from 'react';
3
3
 
4
4
  export type PaginationProps = {
5
5
  /**
6
- * Optional. Defines the visual style of the Pagination component.
7
- * - 'default': Represents the default style.
8
- * - 'black_white': Represents dots on a translucent dark background.
9
- * - 'number': Represents a style where pages are represented by numbers.
10
- * - 'scroll': Rendered as 'default'; use `PaginationScroll` instead.
6
+ * Optional. Visual style: 'default' dots, 'black_white' dots on a translucent
7
+ * dark background, 'number' page numbers. 'scroll' renders as 'default'; use
8
+ * `PaginationScroll` instead.
11
9
  */
12
10
  type?: 'default' | 'black_white' | 'number' | 'scroll';
13
11
 
@@ -12,6 +12,9 @@ import { ModalIconButton } from '../Application/ModalIconButton';
12
12
  import { splitHighlightText } from './utils';
13
13
 
14
14
  const MAX_ROW_TITLE_LENGTH = 12;
15
+ const DESCRIPTION_MAX_HEIGHT = 172;
16
+ const MAX_DESCRIPTION_LINES = 8.5;
17
+ const IMAGE_ASPECT_RATIO = 1.7777;
15
18
 
16
19
  /**
17
20
  * Notify popup body: optional image, title, description, error code and one or
@@ -32,10 +35,10 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
32
35
  }) => {
33
36
  const context = useContext<any>(MiniAppContext);
34
37
  const { theme, navigator, translate } = useContext(ApplicationContext);
35
- const [scrollContent, setScrollContent] = useState(false);
38
+ const [isDescriptionScrollable, setIsDescriptionScrollable] = useState(false);
36
39
  const { isFoldable, popup } = useModalLayout();
37
40
  const { width: popupWidth, maxHeight, radius } = popup;
38
- const scaledMaxHeight = useScaleSize(172);
41
+ const scaledMaxHeight = useScaleSize(DESCRIPTION_MAX_HEIGHT);
39
42
 
40
43
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
41
44
  const closeAction = useRef('touch_outside');
@@ -45,12 +48,12 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
45
48
  // foldables cap the popup height, the description takes what is left
46
49
  Description = ScrollView;
47
50
  descriptionStyle = styles.shrink;
48
- } else if (scrollContent) {
51
+ } else if (isDescriptionScrollable) {
49
52
  Description = ScrollView;
50
53
  descriptionStyle = { maxHeight: scaledMaxHeight };
51
54
  }
52
55
 
53
- const onAction = (callback?: () => void) => {
56
+ const handleAction = (callback?: () => void) => {
54
57
  if (typeof onRequestClose === 'function') {
55
58
  onRequestClose?.(callback);
56
59
  } else {
@@ -72,7 +75,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
72
75
  size="medium"
73
76
  onPress={() => {
74
77
  closeAction.current = 'button_action';
75
- onAction(secondary?.onPress);
78
+ handleAction(secondary?.onPress);
76
79
  }}
77
80
  accessibilityLabel={'btn_popup_cancel'}
78
81
  />
@@ -86,7 +89,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
86
89
  size="medium"
87
90
  onPress={() => {
88
91
  closeAction.current = 'button_action';
89
- onAction(primary.onPress);
92
+ handleAction(primary.onPress);
90
93
  }}
91
94
  accessibilityLabel={'btn_popup_allow'}
92
95
  />
@@ -103,7 +106,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
103
106
  size="medium"
104
107
  onPress={() => {
105
108
  closeAction.current = 'button_action';
106
- onAction(primary.onPress);
109
+ handleAction(primary.onPress);
107
110
  }}
108
111
  />
109
112
  {!!secondary?.title && (
@@ -116,7 +119,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
116
119
  size="medium"
117
120
  onPress={() => {
118
121
  closeAction.current = 'button_action';
119
- onAction(secondary?.onPress);
122
+ handleAction(secondary?.onPress);
120
123
  }}
121
124
  />
122
125
  </>
@@ -172,7 +175,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
172
175
  style={styles.iconClose}
173
176
  onPress={() => {
174
177
  closeAction.current = 'icon_close';
175
- onAction(onIconClose);
178
+ handleAction(onIconClose);
176
179
  }}
177
180
  />
178
181
  );
@@ -189,9 +192,9 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
189
192
  accessibilityLabel={'description_popup_notify'}
190
193
  typography={'body_default_regular'}
191
194
  onTextLayout={({ nativeEvent: { lines } }) => {
192
- const enableScroll = lines.length > 8.5;
193
- if (enableScroll !== scrollContent) {
194
- setScrollContent(enableScroll);
195
+ const shouldScroll = lines.length > MAX_DESCRIPTION_LINES;
196
+ if (shouldScroll !== isDescriptionScrollable) {
197
+ setIsDescriptionScrollable(shouldScroll);
195
198
  }
196
199
  }}
197
200
  >
@@ -260,7 +263,7 @@ const styles = StyleSheet.create({
260
263
  // clips the image and content to the card radius
261
264
  card: { overflow: 'hidden' },
262
265
  image: {
263
- aspectRatio: 1.7777,
266
+ aspectRatio: IMAGE_ASPECT_RATIO,
264
267
  },
265
268
  shrink: { flexShrink: 1 },
266
269
  content: { padding: Spacing.XL },
@@ -9,6 +9,7 @@ import { useModalLayout } from '../Application/modalLayout';
9
9
 
10
10
  const ASPECT_RATIO = 0.72;
11
11
  const ICON_CLOSE_SIZE = 20;
12
+ const CLOSE_GLYPH_SIZE = 14;
12
13
  // close button row under the image: marginTop + vertical padding + icon
13
14
  const CLOSE_ROW_HEIGHT = Spacing.S * 3 + ICON_CLOSE_SIZE;
14
15
 
@@ -37,7 +38,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
37
38
 
38
39
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
39
40
 
40
- const onAction = (callback?: () => void) => {
41
+ const handleAction = (callback?: () => void) => {
41
42
  if (typeof onRequestClose === 'function') {
42
43
  onRequestClose?.(callback);
43
44
  } else {
@@ -53,7 +54,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
53
54
  accessibilityLabel={'ic_popup_close'}
54
55
  onPress={() => {
55
56
  closeAction.current = 'icon_close';
56
- onAction(onIconClose);
57
+ handleAction(onIconClose);
57
58
  }}
58
59
  style={styles.iconButton}
59
60
  >
@@ -69,7 +70,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
69
70
  <Icon
70
71
  source="navigation_close"
71
72
  color={theme.colors.background.surface}
72
- size={14}
73
+ size={CLOSE_GLYPH_SIZE}
73
74
  />
74
75
  </View>
75
76
  </TouchableOpacity>
@@ -82,7 +83,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
82
83
  accessibilityLabel={'popup_promotion'}
83
84
  style={[{ width: imageWidth }, showBaseLineDebug && styles.debugBaseLine]}
84
85
  >
85
- <TouchableOpacity onPress={() => onAction(onActionClick)}>
86
+ <TouchableOpacity onPress={() => handleAction(onActionClick)}>
86
87
  <Image
87
88
  source={{
88
89
  uri: image,
package/Popup/types.ts CHANGED
@@ -43,10 +43,8 @@ export type PopupNotifyProps = {
43
43
  primary: PopupAction;
44
44
 
45
45
  /**
46
- * `buttonDirection`: Optional. Represents the direction in which the action buttons are laid out.
47
- * - 'row': Buttons are laid out horizontally.
48
- * - 'column': Buttons are laid out vertically.
49
- * - 'auto': The layout is automatically determined by title length.
46
+ * `buttonDirection`: Optional. Layout of the action buttons: 'row' horizontal,
47
+ * 'column' vertical, 'auto' column only when a title is too long for a row.
50
48
  */
51
49
  buttonDirection?: PopupActionDirection;
52
50
 
@@ -21,6 +21,12 @@ import { Colors, Styles } from '../Consts';
21
21
  import styles from './styles';
22
22
  import { ScreenContext, SkeletonContext } from '../Context';
23
23
 
24
+ const PRIMARY_COLOR = Colors.black_05;
25
+ const HIGHLIGHT_COLOR1 = Colors.black_05;
26
+ const HIGHLIGHT_COLOR2 = Colors.black_03;
27
+ const SHIMMER_DURATION = 1000;
28
+ const LAYOUT_ANIMATION_DURATION = 500;
29
+
24
30
  /**
25
31
  * Shimmering placeholder that fills its parent. Reports loading to the screen
26
32
  * context while mounted.
@@ -28,9 +34,6 @@ import { ScreenContext, SkeletonContext } from '../Context';
28
34
  const Skeleton: React.FC<SkeletonTypes> = ({ style }) => {
29
35
  const screen = useContext<any>(ScreenContext);
30
36
  const [width, setWidth] = useState(0);
31
- const PRIMARY_COLOR = Colors.black_05;
32
- const HIGHLIGHT_COLOR1 = Colors.black_05;
33
- const HIGHLIGHT_COLOR2 = Colors.black_03;
34
37
  const progress = useSharedValue(0);
35
38
 
36
39
  const shimmerColors = [HIGHLIGHT_COLOR1, HIGHLIGHT_COLOR2, HIGHLIGHT_COLOR1];
@@ -39,7 +42,7 @@ const Skeleton: React.FC<SkeletonTypes> = ({ style }) => {
39
42
  progress.value = 0;
40
43
  progress.value = withRepeat(
41
44
  withTiming(1, {
42
- duration: 1000,
45
+ duration: SHIMMER_DURATION,
43
46
  easing: Easing.linear,
44
47
  }),
45
48
  -1,
@@ -57,7 +60,7 @@ const Skeleton: React.FC<SkeletonTypes> = ({ style }) => {
57
60
  return { transform: [{ translateX }] };
58
61
  });
59
62
 
60
- const onLayout = (newWidth: number) => {
63
+ const handleLayout = (newWidth: number) => {
61
64
  if (newWidth !== width) {
62
65
  setWidth(newWidth);
63
66
  }
@@ -66,7 +69,7 @@ const Skeleton: React.FC<SkeletonTypes> = ({ style }) => {
66
69
  return (
67
70
  <View style={[styles.container, style]}>
68
71
  <View
69
- onLayout={e => onLayout(e.nativeEvent.layout.width)}
72
+ onLayout={e => handleLayout(e.nativeEvent.layout.width)}
70
73
  style={[Styles.flex, { backgroundColor: PRIMARY_COLOR }]}
71
74
  >
72
75
  <Animated.View style={[styles.shimmer, shimmerStyle]}>
@@ -96,28 +99,28 @@ const SkeletonProvider: React.FC<{ loading: boolean; children: any }> = ({
96
99
  loading = true,
97
100
  children,
98
101
  }) => {
99
- const [previous, setPrevious] = useState(loading);
102
+ const [isLoadingShown, setIsLoadingShown] = useState(loading);
100
103
 
101
104
  useEffect(() => {
102
- if (previous !== loading) {
105
+ if (isLoadingShown !== loading) {
103
106
  if (Platform.OS === 'android') {
104
107
  if (UIManager.setLayoutAnimationEnabledExperimental) {
105
108
  UIManager.setLayoutAnimationEnabledExperimental(true);
106
109
  }
107
110
  }
108
111
  LayoutAnimation.configureNext({
109
- duration: 500,
112
+ duration: LAYOUT_ANIMATION_DURATION,
110
113
  create: {
111
114
  type: LayoutAnimation.Types.easeInEaseOut,
112
115
  property: LayoutAnimation.Properties.opacity,
113
116
  },
114
117
  });
115
- setPrevious(loading);
118
+ setIsLoadingShown(loading);
116
119
  }
117
- }, [loading, previous]);
120
+ }, [loading, isLoadingShown]);
118
121
 
119
122
  return (
120
- <SkeletonContext.Provider value={{ loading: previous }}>
123
+ <SkeletonContext.Provider value={{ loading: isLoadingShown }}>
121
124
  {children}
122
125
  </SkeletonContext.Provider>
123
126
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.169.8",
3
+ "version": "0.169.9",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},
package/Input/common.tsx DELETED
@@ -1,257 +0,0 @@
1
- import React, { FC, ReactNode, useContext } from 'react';
2
- import {
3
- GestureResponderEvent,
4
- TouchableOpacity,
5
- View,
6
- ViewStyle,
7
- } from 'react-native';
8
- import { ApplicationContext } from '../Context';
9
- import { Theme } from '../Application/types';
10
- import { Styles } from '../Consts';
11
- import { Icon } from '../Icon';
12
- import { useScaleSize, Text } from '../Text';
13
- import styles from './styles';
14
- import { Loader } from '../Loader';
15
- import IconSources from '../Assets/icon.json';
16
-
17
- type FloatingViewProps = {
18
- floatingValue?: string;
19
- floatingIconColor?: string;
20
- componentId?: string;
21
- disabled?: boolean;
22
- floatingIcon?: string;
23
- required?: boolean;
24
- style?: ViewStyle;
25
- onPress?: (e: GestureResponderEvent) => void;
26
- };
27
-
28
- type TrailingProps = {
29
- loading?: boolean;
30
- color?: string;
31
- icon?: string;
32
- componentId?: string;
33
- onPressIcon?: () => void;
34
- };
35
-
36
- export const MAX_LENGTH = 300;
37
-
38
- /** Border color by state; priority is disabled > error > focused > default. */
39
- export const getBorderColor = (
40
- theme: Theme,
41
- focused: boolean,
42
- errorMessage?: string,
43
- disabled?: boolean,
44
- ) => {
45
- let borderColor = theme.colors.border.default;
46
-
47
- if (focused) {
48
- borderColor = theme.colors.primary;
49
- }
50
-
51
- if (errorMessage) {
52
- borderColor = theme.colors.error.primary;
53
- }
54
-
55
- if (disabled) {
56
- borderColor = theme.colors.border.disable;
57
- }
58
-
59
- return { borderColor };
60
- };
61
-
62
- /**
63
- * Container style for `size`; multiline uses `height` as minHeight so the
64
- * field can grow with its content.
65
- */
66
- export const getSizeStyle = (
67
- size?: 'small' | 'large',
68
- multiline: boolean = false,
69
- height?: number,
70
- ) => {
71
- let scaleStyle: ViewStyle = {};
72
- if (height) {
73
- scaleStyle = { height };
74
- }
75
-
76
- if (multiline) {
77
- return [
78
- styles.multilineContainer,
79
- {
80
- minHeight: height,
81
- },
82
- ];
83
- }
84
-
85
- if (size === 'small') {
86
- return [styles.smallContainer, scaleStyle];
87
- }
88
- return [styles.container, scaleStyle];
89
- };
90
-
91
- /**
92
- * Error or hint row under an input (error wins); with neither, `errorSpacing`
93
- * keeps an empty row so the layout doesn't jump when an error appears.
94
- */
95
- export const ErrorView: FC<{
96
- errorMessage?: string;
97
- errorSpacing?: boolean;
98
- hintText?: string;
99
- componentId?: string;
100
- }> = ({ errorMessage, errorSpacing, hintText, componentId }) => {
101
- const { theme } = useContext(ApplicationContext);
102
- const errorColor = theme.colors.error.primary;
103
- const hintColor = theme.colors.text.hint;
104
- const hintTextDefault = hintText ?? 'Không thể chỉnh sửa';
105
- const scaledIconSize = useScaleSize(16);
106
-
107
- if (errorMessage || hintText) {
108
- const messageColor = errorMessage ? errorColor : hintColor;
109
- const messageType = errorMessage ? 'error' : 'hint';
110
- return (
111
- <View style={styles.errorView}>
112
- <View style={styles.errorIcon}>
113
- <Icon color={messageColor} source="ic_error" size={scaledIconSize} />
114
- </View>
115
- <Text
116
- accessibilityLabel={`${componentId}|${messageType}-text`}
117
- style={Styles.flex}
118
- color={messageColor}
119
- typography={'description_default_regular'}
120
- >
121
- {errorMessage ?? hintTextDefault}
122
- </Text>
123
- </View>
124
- );
125
- }
126
- if (errorSpacing) {
127
- return <View style={[styles.errorView, styles.errorSpacing]} />;
128
- }
129
- return <View />;
130
- };
131
-
132
- /**
133
- * Floating label on the input's top border with a required `*` and an
134
- * optional pressable icon; renders nothing without `floatingValue`.
135
- */
136
- export const FloatingView: FC<FloatingViewProps> = ({
137
- floatingValue,
138
- floatingIconColor,
139
- disabled,
140
- floatingIcon,
141
- required,
142
- style,
143
- onPress,
144
- componentId,
145
- }) => {
146
- const { theme } = useContext(ApplicationContext);
147
- const scaledTop = useScaleSize(10);
148
- if (floatingValue) {
149
- let floatingTextColor = theme.colors.text.hint;
150
- let floatingIconTintColor = floatingIconColor;
151
- if (disabled) {
152
- floatingTextColor = theme.colors.text.disable;
153
- floatingIconTintColor = theme.colors.text.disable;
154
- }
155
-
156
- return (
157
- <View
158
- style={[
159
- styles.floatingView,
160
- styles.floatingViewLayer,
161
- {
162
- backgroundColor: theme.colors.background.surface,
163
- top: -scaledTop,
164
- },
165
- style,
166
- ]}
167
- >
168
- <Text
169
- color={floatingTextColor}
170
- typography={'label_s_medium'}
171
- accessibilityLabel={`${componentId}|floating-text`}
172
- >
173
- {floatingValue}
174
- {required && (
175
- <Text
176
- typography={'label_s_medium'}
177
- color={theme.colors.error.primary}
178
- >
179
- *
180
- </Text>
181
- )}
182
- </Text>
183
- {!!floatingIcon && (
184
- <TouchableOpacity
185
- activeOpacity={onPress ? 0.6 : 1}
186
- onPress={onPress}
187
- accessibilityLabel={`${componentId}|floating-icon-touch`}
188
- >
189
- <Icon
190
- color={floatingIconTintColor}
191
- source={floatingIcon}
192
- size={16}
193
- style={styles.floatingIcon}
194
- accessibilityLabel={`${componentId}|floating-icon`}
195
- />
196
- </TouchableOpacity>
197
- )}
198
- </View>
199
- );
200
- }
201
- return null;
202
- };
203
-
204
- /**
205
- * Trailing slot: spinner while `loading`, an icon when `icon` is a known icon
206
- * name or URL, otherwise `icon` as a text action (first 15 chars).
207
- */
208
- export const RenderTrailing: FC<TrailingProps> = ({
209
- loading,
210
- color,
211
- icon,
212
- onPressIcon,
213
- componentId,
214
- }) => {
215
- const { theme } = useContext(ApplicationContext);
216
- if (loading) {
217
- return <Loader type={'spinner'} color={color} style={styles.icon} />;
218
- }
219
-
220
- if (icon) {
221
- const isIconName = !!(IconSources as any)?.[icon]?.uri;
222
- const isIcon = isIconName || icon.includes('http');
223
- const renderIconTouchable = (content: ReactNode) => {
224
- return (
225
- <TouchableOpacity
226
- accessibilityLabel={`${componentId}|trailing-touch`}
227
- onPress={onPressIcon}
228
- style={styles.icon}
229
- >
230
- {content}
231
- </TouchableOpacity>
232
- );
233
- };
234
-
235
- if (isIcon) {
236
- return renderIconTouchable(
237
- <Icon
238
- color={color}
239
- source={icon as string}
240
- size={24}
241
- accessibilityLabel={`${componentId}|trailing-icon`}
242
- />,
243
- );
244
- }
245
- return renderIconTouchable(
246
- <Text
247
- typography="action_xs_bold"
248
- color={color ?? theme.colors.primary}
249
- numberOfLines={1}
250
- accessibilityLabel={`${componentId}|trailing-text`}
251
- >
252
- {icon!.substring(0, 15)}
253
- </Text>,
254
- );
255
- }
256
- return null;
257
- };