@cyberkaidev/ui 0.1.0

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 (152) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +721 -0
  3. package/lib/module/ThemeProvider.js +84 -0
  4. package/lib/module/ThemeProvider.js.map +1 -0
  5. package/lib/module/components/AppBar.js +185 -0
  6. package/lib/module/components/AppBar.js.map +1 -0
  7. package/lib/module/components/BounceEffect.js +50 -0
  8. package/lib/module/components/BounceEffect.js.map +1 -0
  9. package/lib/module/components/Button.js +48 -0
  10. package/lib/module/components/Button.js.map +1 -0
  11. package/lib/module/components/Chips.js +94 -0
  12. package/lib/module/components/Chips.js.map +1 -0
  13. package/lib/module/components/IconButton.js +71 -0
  14. package/lib/module/components/IconButton.js.map +1 -0
  15. package/lib/module/components/InforList.js +92 -0
  16. package/lib/module/components/InforList.js.map +1 -0
  17. package/lib/module/components/LoadingApp.js +58 -0
  18. package/lib/module/components/LoadingApp.js.map +1 -0
  19. package/lib/module/components/Overlays.js +20 -0
  20. package/lib/module/components/Overlays.js.map +1 -0
  21. package/lib/module/components/RippleEffect.js +178 -0
  22. package/lib/module/components/RippleEffect.js.map +1 -0
  23. package/lib/module/components/Scaffold.js +172 -0
  24. package/lib/module/components/Scaffold.js.map +1 -0
  25. package/lib/module/components/SearchBar.js +161 -0
  26. package/lib/module/components/SearchBar.js.map +1 -0
  27. package/lib/module/components/SegmentedButton.js +151 -0
  28. package/lib/module/components/SegmentedButton.js.map +1 -0
  29. package/lib/module/components/ShapedButton.js +58 -0
  30. package/lib/module/components/ShapedButton.js.map +1 -0
  31. package/lib/module/components/SlideModal.js +196 -0
  32. package/lib/module/components/SlideModal.js.map +1 -0
  33. package/lib/module/components/TabBar.js +107 -0
  34. package/lib/module/components/TabBar.js.map +1 -0
  35. package/lib/module/components/Text.js +47 -0
  36. package/lib/module/components/Text.js.map +1 -0
  37. package/lib/module/components/TextButton.js +53 -0
  38. package/lib/module/components/TextButton.js.map +1 -0
  39. package/lib/module/components/TextInput.js +83 -0
  40. package/lib/module/components/TextInput.js.map +1 -0
  41. package/lib/module/components/Toast.js +101 -0
  42. package/lib/module/components/Toast.js.map +1 -0
  43. package/lib/module/hooks/useAppBarScroll.js +20 -0
  44. package/lib/module/hooks/useAppBarScroll.js.map +1 -0
  45. package/lib/module/hooks/usePressAndHoldRepeat.js +71 -0
  46. package/lib/module/hooks/usePressAndHoldRepeat.js.map +1 -0
  47. package/lib/module/icons/Icon.js +85 -0
  48. package/lib/module/icons/Icon.js.map +1 -0
  49. package/lib/module/index.js +42 -0
  50. package/lib/module/index.js.map +1 -0
  51. package/lib/module/package.json +1 -0
  52. package/lib/module/responsive.js +28 -0
  53. package/lib/module/responsive.js.map +1 -0
  54. package/lib/module/stores/useLoadingApp.js +14 -0
  55. package/lib/module/stores/useLoadingApp.js.map +1 -0
  56. package/lib/module/stores/useToast.js +24 -0
  57. package/lib/module/stores/useToast.js.map +1 -0
  58. package/lib/module/theme.js +86 -0
  59. package/lib/module/theme.js.map +1 -0
  60. package/lib/module/types.js +2 -0
  61. package/lib/module/types.js.map +1 -0
  62. package/lib/typescript/jest.setup.d.ts +2 -0
  63. package/lib/typescript/jest.setup.d.ts.map +1 -0
  64. package/lib/typescript/package.json +1 -0
  65. package/lib/typescript/src/ThemeProvider.d.ts +39 -0
  66. package/lib/typescript/src/ThemeProvider.d.ts.map +1 -0
  67. package/lib/typescript/src/components/AppBar.d.ts +33 -0
  68. package/lib/typescript/src/components/AppBar.d.ts.map +1 -0
  69. package/lib/typescript/src/components/BounceEffect.d.ts +12 -0
  70. package/lib/typescript/src/components/BounceEffect.d.ts.map +1 -0
  71. package/lib/typescript/src/components/Button.d.ts +24 -0
  72. package/lib/typescript/src/components/Button.d.ts.map +1 -0
  73. package/lib/typescript/src/components/Chips.d.ts +23 -0
  74. package/lib/typescript/src/components/Chips.d.ts.map +1 -0
  75. package/lib/typescript/src/components/IconButton.d.ts +14 -0
  76. package/lib/typescript/src/components/IconButton.d.ts.map +1 -0
  77. package/lib/typescript/src/components/InforList.d.ts +28 -0
  78. package/lib/typescript/src/components/InforList.d.ts.map +1 -0
  79. package/lib/typescript/src/components/LoadingApp.d.ts +8 -0
  80. package/lib/typescript/src/components/LoadingApp.d.ts.map +1 -0
  81. package/lib/typescript/src/components/Overlays.d.ts +12 -0
  82. package/lib/typescript/src/components/Overlays.d.ts.map +1 -0
  83. package/lib/typescript/src/components/RippleEffect.d.ts +18 -0
  84. package/lib/typescript/src/components/RippleEffect.d.ts.map +1 -0
  85. package/lib/typescript/src/components/Scaffold.d.ts +62 -0
  86. package/lib/typescript/src/components/Scaffold.d.ts.map +1 -0
  87. package/lib/typescript/src/components/SearchBar.d.ts +20 -0
  88. package/lib/typescript/src/components/SearchBar.d.ts.map +1 -0
  89. package/lib/typescript/src/components/SegmentedButton.d.ts +37 -0
  90. package/lib/typescript/src/components/SegmentedButton.d.ts.map +1 -0
  91. package/lib/typescript/src/components/ShapedButton.d.ts +18 -0
  92. package/lib/typescript/src/components/ShapedButton.d.ts.map +1 -0
  93. package/lib/typescript/src/components/SlideModal.d.ts +15 -0
  94. package/lib/typescript/src/components/SlideModal.d.ts.map +1 -0
  95. package/lib/typescript/src/components/TabBar.d.ts +20 -0
  96. package/lib/typescript/src/components/TabBar.d.ts.map +1 -0
  97. package/lib/typescript/src/components/Text.d.ts +26 -0
  98. package/lib/typescript/src/components/Text.d.ts.map +1 -0
  99. package/lib/typescript/src/components/TextButton.d.ts +15 -0
  100. package/lib/typescript/src/components/TextButton.d.ts.map +1 -0
  101. package/lib/typescript/src/components/TextInput.d.ts +22 -0
  102. package/lib/typescript/src/components/TextInput.d.ts.map +1 -0
  103. package/lib/typescript/src/components/Toast.d.ts +4 -0
  104. package/lib/typescript/src/components/Toast.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/useAppBarScroll.d.ts +10 -0
  106. package/lib/typescript/src/hooks/useAppBarScroll.d.ts.map +1 -0
  107. package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts +21 -0
  108. package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts.map +1 -0
  109. package/lib/typescript/src/icons/Icon.d.ts +64 -0
  110. package/lib/typescript/src/icons/Icon.d.ts.map +1 -0
  111. package/lib/typescript/src/index.d.ts +30 -0
  112. package/lib/typescript/src/index.d.ts.map +1 -0
  113. package/lib/typescript/src/responsive.d.ts +15 -0
  114. package/lib/typescript/src/responsive.d.ts.map +1 -0
  115. package/lib/typescript/src/stores/useLoadingApp.d.ts +14 -0
  116. package/lib/typescript/src/stores/useLoadingApp.d.ts.map +1 -0
  117. package/lib/typescript/src/stores/useToast.d.ts +25 -0
  118. package/lib/typescript/src/stores/useToast.d.ts.map +1 -0
  119. package/lib/typescript/src/theme.d.ts +51 -0
  120. package/lib/typescript/src/theme.d.ts.map +1 -0
  121. package/lib/typescript/src/types.d.ts +26 -0
  122. package/lib/typescript/src/types.d.ts.map +1 -0
  123. package/package.json +189 -0
  124. package/src/ThemeProvider.tsx +108 -0
  125. package/src/components/AppBar.tsx +226 -0
  126. package/src/components/BounceEffect.tsx +53 -0
  127. package/src/components/Button.tsx +70 -0
  128. package/src/components/Chips.tsx +115 -0
  129. package/src/components/IconButton.tsx +84 -0
  130. package/src/components/InforList.tsx +110 -0
  131. package/src/components/LoadingApp.tsx +55 -0
  132. package/src/components/Overlays.tsx +21 -0
  133. package/src/components/RippleEffect.tsx +244 -0
  134. package/src/components/Scaffold.tsx +219 -0
  135. package/src/components/SearchBar.tsx +166 -0
  136. package/src/components/SegmentedButton.tsx +206 -0
  137. package/src/components/ShapedButton.tsx +74 -0
  138. package/src/components/SlideModal.tsx +232 -0
  139. package/src/components/TabBar.tsx +122 -0
  140. package/src/components/Text.tsx +70 -0
  141. package/src/components/TextButton.tsx +57 -0
  142. package/src/components/TextInput.tsx +108 -0
  143. package/src/components/Toast.tsx +95 -0
  144. package/src/hooks/useAppBarScroll.ts +19 -0
  145. package/src/hooks/usePressAndHoldRepeat.ts +85 -0
  146. package/src/icons/Icon.tsx +51 -0
  147. package/src/index.tsx +64 -0
  148. package/src/responsive.ts +32 -0
  149. package/src/stores/useLoadingApp.ts +19 -0
  150. package/src/stores/useToast.ts +36 -0
  151. package/src/theme.ts +125 -0
  152. package/src/types.ts +27 -0
@@ -0,0 +1,206 @@
1
+ import React from 'react';
2
+ import { Animated, Pressable, StyleSheet, View } from 'react-native';
3
+
4
+ import { Icon } from '../icons/Icon';
5
+ import type { IconNameType } from '../icons/Icon';
6
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
7
+ import type { ThemeType } from '../theme';
8
+ import { heightDP } from '../responsive';
9
+ import { Text } from './Text';
10
+
11
+ export type SegmentedButtonSizeType = 's' | 'm' | 'l';
12
+
13
+ /** Track heights per size — the Material 3 Expressive button size scale. */
14
+ const HEIGHTS: Record<SegmentedButtonSizeType, number> = {
15
+ s: heightDP(5),
16
+ m: heightDP(6),
17
+ l: heightDP(7),
18
+ };
19
+
20
+ /** Default track height (size `'m'`). */
21
+ export const SEGMENTED_BUTTON_HEIGHT = HEIGHTS.m;
22
+
23
+ /** Gap between the track edge and the segment pills, and between pills. */
24
+ const TRACK_INSET = 3;
25
+
26
+ export type SegmentedButtonItemType<Value> = {
27
+ /** Value reported to `onChange` when this segment is picked. */
28
+ value: Value;
29
+ /** Text shown in the segment. */
30
+ label: string;
31
+ /** Optional leading icon (replaced by a check on the selected segment). */
32
+ icon?: IconNameType;
33
+ disabled?: boolean;
34
+ };
35
+
36
+ type Props<Value> = {
37
+ /** 2–5 connected segments. */
38
+ segments: SegmentedButtonItemType<Value>[];
39
+ /** The currently selected `value` (single-select, controlled). */
40
+ value: Value;
41
+ /** Called with the picked segment's `value`. */
42
+ onChange: (value: Value) => void;
43
+ /** Track size. Default `'m'`. */
44
+ size?: SegmentedButtonSizeType;
45
+ /** Disable the whole control. */
46
+ disabled?: boolean;
47
+ /** Show the leading check on the selected segment. Default `true`. */
48
+ showSelectedCheck?: boolean;
49
+ testID?: string;
50
+ };
51
+
52
+ /**
53
+ * Single-select segmented button in the Material 3 **Expressive** style — a
54
+ * rounded track holding equal-width segments. The selected segment morphs into a
55
+ * filled pill with a springy pop; every segment squishes on press.
56
+ */
57
+ export function SegmentedButton<Value>({
58
+ segments,
59
+ value,
60
+ onChange,
61
+ size = 'm',
62
+ disabled = false,
63
+ showSelectedCheck = true,
64
+ testID,
65
+ }: Props<Value>) {
66
+ const { colors } = useTheme();
67
+ const styles = useThemedStyles(makeStyles);
68
+ const height = HEIGHTS[size];
69
+
70
+ return (
71
+ <View
72
+ testID={testID ?? 'segmented-button'}
73
+ style={[
74
+ styles.track,
75
+ {
76
+ height,
77
+ borderColor: colors.border,
78
+ backgroundColor: colors.on_surface,
79
+ },
80
+ disabled ? styles.dimmed : null,
81
+ ]}
82
+ >
83
+ {segments.map((segment) => (
84
+ <Segment
85
+ key={String(segment.value)}
86
+ segment={segment}
87
+ selected={segment.value === value}
88
+ wholeDisabled={disabled}
89
+ showSelectedCheck={showSelectedCheck}
90
+ fullRadius={(height - TRACK_INSET * 2) / 2}
91
+ onPress={() => onChange(segment.value)}
92
+ />
93
+ ))}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ function Segment<Value>({
99
+ segment,
100
+ selected,
101
+ wholeDisabled,
102
+ showSelectedCheck,
103
+ fullRadius,
104
+ onPress,
105
+ }: {
106
+ segment: SegmentedButtonItemType<Value>;
107
+ selected: boolean;
108
+ wholeDisabled: boolean;
109
+ showSelectedCheck: boolean;
110
+ fullRadius: number;
111
+ onPress: () => void;
112
+ }) {
113
+ const { colors, iconSize } = useTheme();
114
+ const styles = useThemedStyles(makeStyles);
115
+ const pressScale = React.useRef(new Animated.Value(1)).current;
116
+ const popScale = React.useRef(new Animated.Value(1)).current;
117
+ // Composed once — not a fresh Animated node per render.
118
+ const scale = React.useRef(Animated.multiply(pressScale, popScale)).current;
119
+
120
+ const isDisabled = wholeDisabled || segment.disabled;
121
+ const contentColor = selected ? colors.background : colors.text;
122
+ const iconName: IconNameType | undefined =
123
+ selected && showSelectedCheck ? 'check' : segment.icon;
124
+
125
+ React.useEffect(() => {
126
+ if (!selected) return;
127
+ popScale.setValue(0.85);
128
+ Animated.spring(popScale, {
129
+ toValue: 1,
130
+ useNativeDriver: true,
131
+ friction: 5,
132
+ tension: 140,
133
+ }).start();
134
+ }, [selected, popScale]);
135
+
136
+ const springPress = React.useCallback(
137
+ (toValue: number) =>
138
+ Animated.spring(pressScale, {
139
+ toValue,
140
+ useNativeDriver: true,
141
+ friction: 6,
142
+ tension: 160,
143
+ }).start(),
144
+ [pressScale]
145
+ );
146
+
147
+ return (
148
+ <Pressable
149
+ testID={`segmented-button-${String(segment.value)}`}
150
+ disabled={isDisabled}
151
+ onPress={onPress}
152
+ onPressIn={() => springPress(0.93)}
153
+ onPressOut={() => springPress(1)}
154
+ style={styles.segmentTouch}
155
+ >
156
+ <Animated.View
157
+ style={[
158
+ styles.pill,
159
+ {
160
+ borderRadius: fullRadius,
161
+ backgroundColor: selected ? colors.primary : colors.transparent,
162
+ transform: [{ scale }],
163
+ },
164
+ segment.disabled ? styles.dimmed : null,
165
+ ]}
166
+ >
167
+ {iconName ? (
168
+ <Icon name={iconName} size={iconSize.s} color={contentColor} />
169
+ ) : null}
170
+ <Text weight="bold" color={contentColor} numberOfLines={1}>
171
+ {segment.label}
172
+ </Text>
173
+ </Animated.View>
174
+ </Pressable>
175
+ );
176
+ }
177
+
178
+ function makeStyles(t: ThemeType) {
179
+ return StyleSheet.create({
180
+ track: {
181
+ width: '100%',
182
+ flexDirection: 'row',
183
+ alignItems: 'stretch',
184
+ padding: TRACK_INSET,
185
+ gap: TRACK_INSET,
186
+ borderWidth: 1,
187
+ borderStyle: 'solid',
188
+ borderRadius: SEGMENTED_BUTTON_HEIGHT,
189
+ overflow: 'hidden',
190
+ },
191
+ segmentTouch: {
192
+ flex: 1,
193
+ },
194
+ pill: {
195
+ flex: 1,
196
+ flexDirection: 'row',
197
+ alignItems: 'center',
198
+ justifyContent: 'center',
199
+ gap: t.spaces.horizontal.s,
200
+ paddingHorizontal: t.spaces.horizontal.s,
201
+ },
202
+ dimmed: {
203
+ opacity: 0.5,
204
+ },
205
+ });
206
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react';
2
+ import { ActivityIndicator, StyleSheet } from 'react-native';
3
+
4
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
5
+ import type { ThemeType } from '../theme';
6
+ import type { FontSizeType } from '../types';
7
+ import { Button } from './Button';
8
+ import { Text } from './Text';
9
+
10
+ type Props = {
11
+ children: ReactNode;
12
+ /** Text size token. */
13
+ size?: FontSizeType;
14
+ /** `'primary'` = filled; `'secondary'` = transparent with primary text. */
15
+ type?: 'primary' | 'secondary';
16
+ disabled?: boolean;
17
+ /** Swaps the label for a spinner and blocks presses. */
18
+ isLoading?: boolean;
19
+ onPress: () => void;
20
+ testID?: string;
21
+ };
22
+
23
+ /** Filled call-to-action button with a loading state. */
24
+ export function ShapedButton({
25
+ children,
26
+ size,
27
+ type = 'primary',
28
+ disabled,
29
+ isLoading = false,
30
+ onPress,
31
+ testID,
32
+ }: Props) {
33
+ const { colors } = useTheme();
34
+ const styles = useThemedStyles(makeStyles);
35
+ const opacity = disabled || isLoading ? 0.7 : 1;
36
+ const backgroundColor =
37
+ type === 'primary' ? colors.primary : colors.transparent;
38
+ const textColor = type === 'primary' ? colors.surface : colors.primary;
39
+
40
+ return (
41
+ <Button
42
+ testID={testID ?? 'shaped-button'}
43
+ style={[styles.container, { opacity, backgroundColor }]}
44
+ disabled={disabled || isLoading}
45
+ onPress={onPress}
46
+ >
47
+ {isLoading ? (
48
+ <ActivityIndicator
49
+ testID="activity-indicator"
50
+ size="small"
51
+ color={textColor}
52
+ />
53
+ ) : (
54
+ <Text weight="bold" color={textColor} size={size}>
55
+ {children}
56
+ </Text>
57
+ )}
58
+ </Button>
59
+ );
60
+ }
61
+
62
+ function makeStyles(t: ThemeType) {
63
+ return StyleSheet.create({
64
+ container: {
65
+ width: '100%',
66
+ paddingVertical: t.spaces.vertical.m,
67
+ borderRadius: t.borderRadius.radius_20,
68
+ justifyContent: 'center',
69
+ alignItems: 'center',
70
+ alignSelf: 'center',
71
+ overflow: 'hidden',
72
+ },
73
+ });
74
+ }
@@ -0,0 +1,232 @@
1
+ import React, {
2
+ forwardRef,
3
+ useCallback,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ } from 'react';
8
+ import {
9
+ ActivityIndicator,
10
+ Animated,
11
+ Dimensions,
12
+ Easing,
13
+ Modal,
14
+ PanResponder,
15
+ Platform,
16
+ Pressable,
17
+ StyleSheet,
18
+ View,
19
+ } from 'react-native';
20
+ import { BlurView } from 'expo-blur';
21
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
22
+
23
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
24
+ import type { ThemeType } from '../theme';
25
+ import { heightDP, widthDP } from '../responsive';
26
+ import { IconButton } from './IconButton';
27
+ import { Scaffold } from './Scaffold';
28
+ import { Text } from './Text';
29
+
30
+ const SCREEN_HEIGHT = Dimensions.get('window').height;
31
+ const DRAG_DISMISS_DISTANCE = SCREEN_HEIGHT * 0.2;
32
+ const DRAG_DISMISS_VELOCITY = 0.5;
33
+ const IS_IOS = Platform.OS === 'ios';
34
+
35
+ export type SlideModalRef = {
36
+ show: () => void;
37
+ hide: () => void;
38
+ };
39
+
40
+ type Props = {
41
+ children: React.ReactNode;
42
+ title: string;
43
+ /** Swaps the content for a spinner and blocks the drag-to-dismiss gesture. */
44
+ isLoading?: boolean;
45
+ };
46
+
47
+ /** Bottom sheet that slides up; controlled imperatively through its ref. */
48
+ export const SlideModal = forwardRef<SlideModalRef, Props>(
49
+ ({ children, title, isLoading = false }, ref) => {
50
+ const { colors } = useTheme();
51
+ const styles = useThemedStyles(makeStyles);
52
+ const { bottom } = useSafeAreaInsets();
53
+
54
+ const [visible, setVisible] = React.useState(false);
55
+ const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
56
+
57
+ const animateIn = useCallback(() => {
58
+ Animated.timing(translateY, {
59
+ toValue: 0,
60
+ duration: 300,
61
+ easing: Easing.out(Easing.cubic),
62
+ useNativeDriver: true,
63
+ }).start();
64
+ }, [translateY]);
65
+
66
+ const animateOut = useCallback(
67
+ (callback?: () => void) => {
68
+ Animated.timing(translateY, {
69
+ toValue: SCREEN_HEIGHT,
70
+ duration: 250,
71
+ easing: Easing.in(Easing.cubic),
72
+ useNativeDriver: true,
73
+ }).start(() => callback?.());
74
+ },
75
+ [translateY]
76
+ );
77
+
78
+ const handleClose = useCallback(() => {
79
+ animateOut(() => setVisible(false));
80
+ }, [animateOut]);
81
+
82
+ useImperativeHandle(
83
+ ref,
84
+ () => ({
85
+ show: () => {
86
+ setVisible(true);
87
+ requestAnimationFrame(animateIn);
88
+ },
89
+ hide: handleClose,
90
+ }),
91
+ [animateIn, handleClose]
92
+ );
93
+
94
+ // Rebuilt only when `isLoading` flips — not on every render.
95
+ const panResponder = useMemo(
96
+ () =>
97
+ PanResponder.create({
98
+ onStartShouldSetPanResponder: () => !isLoading,
99
+ onMoveShouldSetPanResponder: (_, gesture) =>
100
+ !isLoading && Math.abs(gesture.dy) > 5,
101
+ onPanResponderMove: (_, gesture) => {
102
+ if (gesture.dy > 0) translateY.setValue(gesture.dy);
103
+ },
104
+ onPanResponderRelease: (_, gesture) => {
105
+ if (
106
+ gesture.dy > DRAG_DISMISS_DISTANCE ||
107
+ gesture.vy > DRAG_DISMISS_VELOCITY
108
+ ) {
109
+ handleClose();
110
+ } else {
111
+ animateIn();
112
+ }
113
+ },
114
+ }),
115
+ [isLoading, translateY, handleClose, animateIn]
116
+ );
117
+
118
+ return (
119
+ <Modal visible={visible} transparent statusBarTranslucent>
120
+ <Pressable
121
+ style={[styles.background, { backgroundColor: colors.background_60 }]}
122
+ disabled={isLoading}
123
+ onPress={handleClose}
124
+ >
125
+ {IS_IOS && (
126
+ <BlurView
127
+ style={styles.blur}
128
+ tint="dark"
129
+ intensity={20}
130
+ pointerEvents="none"
131
+ />
132
+ )}
133
+ <Animated.View style={[styles.page, { transform: [{ translateY }] }]}>
134
+ <View {...panResponder.panHandlers}>
135
+ <Scaffold.Body surface>
136
+ <Scaffold.View paddingHorizontal={0} paddingTop={0}>
137
+ <View style={styles.dragHandleContainer}>
138
+ <View
139
+ style={[
140
+ styles.dragHandle,
141
+ { backgroundColor: colors.border },
142
+ ]}
143
+ />
144
+ </View>
145
+
146
+ <View style={styles.header}>
147
+ <View style={styles.title}>
148
+ <Text size="l" weight="medium">
149
+ {title}
150
+ </Text>
151
+ </View>
152
+ <IconButton
153
+ icon="close"
154
+ disabled={isLoading}
155
+ onPress={handleClose}
156
+ />
157
+ </View>
158
+
159
+ <View style={[styles.content, { paddingBottom: bottom }]}>
160
+ {isLoading ? (
161
+ <View style={styles.loadingContainer}>
162
+ <ActivityIndicator size="small" color={colors.text} />
163
+ </View>
164
+ ) : (
165
+ children
166
+ )}
167
+ </View>
168
+ </Scaffold.View>
169
+ </Scaffold.Body>
170
+ </View>
171
+ </Animated.View>
172
+ </Pressable>
173
+ </Modal>
174
+ );
175
+ }
176
+ );
177
+
178
+ SlideModal.displayName = 'SlideModal';
179
+
180
+ function makeStyles(t: ThemeType) {
181
+ return StyleSheet.create({
182
+ background: {
183
+ flex: 1,
184
+ position: 'relative',
185
+ },
186
+ blur: {
187
+ position: 'absolute',
188
+ top: 0,
189
+ left: 0,
190
+ bottom: 0,
191
+ right: 0,
192
+ },
193
+ page: {
194
+ height: 'auto',
195
+ position: 'absolute',
196
+ borderTopRightRadius: t.borderRadius.radius_20,
197
+ borderTopLeftRadius: t.borderRadius.radius_20,
198
+ overflow: 'hidden',
199
+ left: 0,
200
+ right: 0,
201
+ bottom: 0,
202
+ },
203
+ dragHandleContainer: {
204
+ width: '100%',
205
+ alignItems: 'center',
206
+ paddingVertical: t.spaces.vertical.xs,
207
+ },
208
+ dragHandle: {
209
+ width: widthDP(12),
210
+ height: 4,
211
+ borderRadius: t.borderRadius.radius_5,
212
+ },
213
+ header: {
214
+ flexDirection: 'row',
215
+ justifyContent: 'space-between',
216
+ alignItems: 'center',
217
+ paddingHorizontal: t.pageSpacing.horizontal,
218
+ },
219
+ title: {
220
+ width: '70%',
221
+ },
222
+ content: {
223
+ width: '100%',
224
+ },
225
+ loadingContainer: {
226
+ width: '100%',
227
+ height: heightDP(25),
228
+ justifyContent: 'center',
229
+ alignItems: 'center',
230
+ },
231
+ });
232
+ }
@@ -0,0 +1,122 @@
1
+ import { BlurView } from 'expo-blur';
2
+ import React from 'react';
3
+ import { Platform, Pressable, StyleSheet } from 'react-native';
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from 'react-native-reanimated';
9
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
10
+
11
+ import { Icon } from '../icons/Icon';
12
+ import type { IconNameType } from '../icons/Icon';
13
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
14
+ import type { ThemeType } from '../theme';
15
+ import { heightDP } from '../responsive';
16
+ import { Text } from './Text';
17
+
18
+ const IS_IOS = Platform.OS === 'ios';
19
+ const VISIBILITY_ANIMATION_DURATION = 200;
20
+ const CONTENT_HEIGHT = heightDP(8);
21
+
22
+ export type TabItemType = {
23
+ key: string;
24
+ icon: IconNameType;
25
+ label: string;
26
+ active: boolean;
27
+ onPress: () => void;
28
+ };
29
+
30
+ type Props = {
31
+ items: TabItemType[];
32
+ /** Fade the bar out (and disable touches) when false. Default `true`. */
33
+ visible?: boolean;
34
+ };
35
+
36
+ /** Height a screen should reserve for the fixed tab bar. */
37
+ export function useTabBarHeight() {
38
+ const insets = useSafeAreaInsets();
39
+ return CONTENT_HEIGHT + insets.bottom;
40
+ }
41
+
42
+ /** Fixed bottom tab bar driven by a list of items — knows nothing about routing. */
43
+ export function TabBar({ items, visible = true }: Props) {
44
+ const { colors, iconSize } = useTheme();
45
+ const styles = useThemedStyles(makeStyles);
46
+ const insets = useSafeAreaInsets();
47
+
48
+ const visibility = useSharedValue(visible ? 1 : 0);
49
+
50
+ React.useEffect(() => {
51
+ visibility.value = withTiming(visible ? 1 : 0, {
52
+ duration: VISIBILITY_ANIMATION_DURATION,
53
+ });
54
+ }, [visible, visibility]);
55
+
56
+ const visibilityAnimatedStyle = useAnimatedStyle(() => ({
57
+ opacity: visibility.value,
58
+ }));
59
+
60
+ return (
61
+ <Animated.View
62
+ style={[
63
+ styles.container,
64
+ { backgroundColor: colors.surface_90, borderTopColor: colors.border },
65
+ visibilityAnimatedStyle,
66
+ ]}
67
+ pointerEvents={visible ? 'auto' : 'none'}
68
+ >
69
+ {IS_IOS && <BlurView style={styles.blur} tint="dark" intensity={20} />}
70
+
71
+ {items.map((item) => {
72
+ const tintColor = item.active ? colors.primary : colors.text;
73
+ return (
74
+ <Pressable
75
+ key={item.key}
76
+ testID={`tab-${item.key}`}
77
+ onPress={item.onPress}
78
+ accessibilityRole="button"
79
+ accessibilityState={item.active ? { selected: true } : {}}
80
+ style={[styles.button, { marginBottom: insets.bottom }]}
81
+ >
82
+ <Icon name={item.icon} color={tintColor} size={iconSize.l} />
83
+ <Text color={tintColor} size="xs">
84
+ {item.label}
85
+ </Text>
86
+ </Pressable>
87
+ );
88
+ })}
89
+ </Animated.View>
90
+ );
91
+ }
92
+
93
+ function makeStyles(t: ThemeType) {
94
+ return StyleSheet.create({
95
+ container: {
96
+ position: 'absolute',
97
+ bottom: 0,
98
+ left: 0,
99
+ right: 0,
100
+ zIndex: 10,
101
+ elevation: 10,
102
+ width: '100%',
103
+ flexDirection: 'row',
104
+ borderStyle: 'solid',
105
+ borderTopWidth: 1,
106
+ },
107
+ button: {
108
+ flex: 1,
109
+ height: CONTENT_HEIGHT,
110
+ justifyContent: 'center',
111
+ alignItems: 'center',
112
+ paddingTop: t.spaces.vertical.xs,
113
+ },
114
+ blur: {
115
+ position: 'absolute',
116
+ top: 0,
117
+ left: 0,
118
+ bottom: 0,
119
+ right: 0,
120
+ },
121
+ });
122
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Text as RNText } from 'react-native';
3
+ import type { ColorValue } from 'react-native';
4
+
5
+ import { useTheme } from '../ThemeProvider';
6
+ import type { FontSizeType, FontWeightType, TextAlignType } from '../types';
7
+
8
+ const fontFamilyByWeight: Record<FontWeightType, string> = {
9
+ regular: 'Inter-Regular',
10
+ medium: 'Inter-Medium',
11
+ bold: 'Inter-Bold',
12
+ };
13
+
14
+ type Props = {
15
+ children: ReactNode;
16
+ /** Font weight → font family (`Inter-*`). Default `'regular'`. */
17
+ weight?: FontWeightType;
18
+ /** Font size token. Default `'s'`. */
19
+ size?: FontSizeType;
20
+ /** Text color. Default: theme `text`. */
21
+ color?: string | ColorValue;
22
+ /** Max lines before truncation. */
23
+ numberOfLines?: number;
24
+ textAlign?: TextAlignType;
25
+ /** Margins in dp. */
26
+ marginT?: number;
27
+ marginB?: number;
28
+ marginR?: number;
29
+ marginL?: number;
30
+ opacity?: number;
31
+ testID?: string;
32
+ };
33
+
34
+ /** Themed text primitive. All display copy comes from `children`. */
35
+ export function Text({
36
+ children,
37
+ weight = 'regular',
38
+ size = 's',
39
+ color,
40
+ numberOfLines,
41
+ textAlign,
42
+ marginT = 0,
43
+ marginB = 0,
44
+ marginR = 0,
45
+ marginL = 0,
46
+ opacity,
47
+ testID,
48
+ }: Props) {
49
+ const { colors, fontSizes } = useTheme();
50
+
51
+ return (
52
+ <RNText
53
+ testID={testID ?? 'text'}
54
+ numberOfLines={numberOfLines}
55
+ style={{
56
+ fontFamily: fontFamilyByWeight[weight],
57
+ fontSize: fontSizes[size],
58
+ color: color ?? colors.text,
59
+ marginTop: marginT,
60
+ marginBottom: marginB,
61
+ marginRight: marginR,
62
+ marginLeft: marginL,
63
+ textAlign,
64
+ opacity,
65
+ }}
66
+ >
67
+ {children}
68
+ </RNText>
69
+ );
70
+ }