@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,21 @@
1
+ import { LoadingApp } from './LoadingApp';
2
+ import { Toast } from './Toast';
3
+
4
+ type Props = {
5
+ /** Caption shown under the loading spinner. */
6
+ loadingLabel?: string;
7
+ };
8
+
9
+ /**
10
+ * App-wide overlays (`Toast` + `LoadingApp`). Mount **once** at the root of the
11
+ * app — inside `ThemeProvider`, as a sibling of your navigator. They render
12
+ * above everything and are driven from anywhere via `useToast` / `useLoadingApp`.
13
+ */
14
+ export function Overlays({ loadingLabel }: Props) {
15
+ return (
16
+ <>
17
+ <Toast />
18
+ <LoadingApp label={loadingLabel} />
19
+ </>
20
+ );
21
+ }
@@ -0,0 +1,244 @@
1
+ import React from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ I18nManager,
6
+ Pressable,
7
+ StyleSheet,
8
+ View,
9
+ } from 'react-native';
10
+ import type {
11
+ GestureResponderEvent,
12
+ LayoutChangeEvent,
13
+ PressableProps,
14
+ } from 'react-native';
15
+
16
+ import { useColors } from '../ThemeProvider';
17
+
18
+ type RippleItemType = {
19
+ unique: number;
20
+ locationX: number;
21
+ locationY: number;
22
+ radius: number;
23
+ scale: Animated.Value;
24
+ opacity: Animated.Value;
25
+ };
26
+
27
+ type Props = PressableProps & {
28
+ children: React.ReactNode;
29
+ /** Ripple color. Default: theme `text`. */
30
+ rippleColor?: string;
31
+ rippleOpacity?: number;
32
+ rippleDuration?: number;
33
+ rippleSize?: number;
34
+ rippleContainerBorderRadius?: number;
35
+ rippleCentered?: boolean;
36
+ rippleSequential?: boolean;
37
+ rippleFades?: boolean;
38
+ };
39
+
40
+ const CIRCLE_RADIUS = 10;
41
+ const RELEASE_GROW_DURATION = 150;
42
+ const RELEASE_FADE_DURATION = 250;
43
+ const PRESS_FADE_IN_DURATION = 75;
44
+
45
+ /** Wraps a child and paints a touch ripple at the press location. */
46
+ export function RippleEffect({
47
+ children,
48
+ rippleColor,
49
+ rippleOpacity = 0.3,
50
+ rippleDuration = 400,
51
+ rippleSize = 0,
52
+ rippleContainerBorderRadius = 0,
53
+ rippleCentered = false,
54
+ rippleSequential = false,
55
+ rippleFades = true,
56
+ disabled = false,
57
+ onLayout,
58
+ onPressIn,
59
+ onPressOut,
60
+ ...pressableProps
61
+ }: Props) {
62
+ const colors = useColors();
63
+ const color = rippleColor ?? colors.text;
64
+
65
+ const [layout, setLayout] = React.useState({ width: 0, height: 0 });
66
+ const [rippleIds, setRippleIds] = React.useState<number[]>([]);
67
+ const ripplesRef = React.useRef(new Map<number, RippleItemType>());
68
+ const activeRippleRef = React.useRef<number | null>(null);
69
+ const uniqueRef = React.useRef(0);
70
+
71
+ const handleLayout = (event: LayoutChangeEvent) => {
72
+ const { width, height } = event.nativeEvent.layout;
73
+ setLayout({ width, height });
74
+ onLayout?.(event);
75
+ };
76
+
77
+ const handlePressIn = React.useCallback(
78
+ (event: GestureResponderEvent) => {
79
+ onPressIn?.(event);
80
+
81
+ if (rippleSequential && ripplesRef.current.size) return;
82
+
83
+ const w2 = 0.5 * layout.width;
84
+ const h2 = 0.5 * layout.height;
85
+
86
+ // Fall back to the center when the gesture carries no coordinates.
87
+ const { locationX, locationY } =
88
+ rippleCentered || !event?.nativeEvent
89
+ ? { locationX: w2, locationY: h2 }
90
+ : event.nativeEvent;
91
+
92
+ const offsetX = Math.abs(w2 - locationX);
93
+ const offsetY = Math.abs(h2 - locationY);
94
+
95
+ const radius =
96
+ rippleSize > 0
97
+ ? 0.5 * rippleSize
98
+ : Math.sqrt((w2 + offsetX) ** 2 + (h2 + offsetY) ** 2);
99
+
100
+ const ripple: RippleItemType = {
101
+ unique: uniqueRef.current++,
102
+ locationX,
103
+ locationY,
104
+ radius,
105
+ scale: new Animated.Value(0),
106
+ opacity: new Animated.Value(0),
107
+ };
108
+
109
+ ripplesRef.current.set(ripple.unique, ripple);
110
+ activeRippleRef.current = ripple.unique;
111
+
112
+ Animated.timing(ripple.scale, {
113
+ toValue: 1,
114
+ duration: rippleDuration,
115
+ easing: Easing.out(Easing.ease),
116
+ useNativeDriver: true,
117
+ }).start();
118
+
119
+ Animated.timing(ripple.opacity, {
120
+ toValue: rippleOpacity,
121
+ duration: Math.min(rippleDuration, PRESS_FADE_IN_DURATION),
122
+ useNativeDriver: true,
123
+ }).start();
124
+
125
+ setRippleIds((current) => current.concat(ripple.unique));
126
+ },
127
+ [
128
+ layout.width,
129
+ layout.height,
130
+ onPressIn,
131
+ rippleCentered,
132
+ rippleDuration,
133
+ rippleOpacity,
134
+ rippleSequential,
135
+ rippleSize,
136
+ ]
137
+ );
138
+
139
+ const handlePressOut = React.useCallback(
140
+ (event: GestureResponderEvent) => {
141
+ onPressOut?.(event);
142
+
143
+ const releasedUnique = activeRippleRef.current;
144
+ activeRippleRef.current = null;
145
+ if (releasedUnique === null) return;
146
+
147
+ const ripple = ripplesRef.current.get(releasedUnique);
148
+ if (!ripple) return;
149
+
150
+ const removeRipple = () => {
151
+ ripplesRef.current.delete(releasedUnique);
152
+ setRippleIds((current) =>
153
+ current.filter((id) => id !== releasedUnique)
154
+ );
155
+ };
156
+
157
+ if (!rippleFades) {
158
+ removeRipple();
159
+ return;
160
+ }
161
+
162
+ Animated.parallel([
163
+ Animated.timing(ripple.scale, {
164
+ toValue: 1,
165
+ duration: RELEASE_GROW_DURATION,
166
+ useNativeDriver: true,
167
+ }),
168
+ Animated.timing(ripple.opacity, {
169
+ toValue: 0,
170
+ duration: RELEASE_FADE_DURATION,
171
+ useNativeDriver: true,
172
+ }),
173
+ ]).start(removeRipple);
174
+ },
175
+ [onPressOut, rippleFades]
176
+ );
177
+
178
+ return (
179
+ <Pressable
180
+ {...pressableProps}
181
+ disabled={disabled}
182
+ onLayout={handleLayout}
183
+ onPressIn={handlePressIn}
184
+ onPressOut={handlePressOut}
185
+ >
186
+ {children}
187
+ <View
188
+ pointerEvents="none"
189
+ style={[
190
+ styles.container,
191
+ { borderRadius: rippleContainerBorderRadius },
192
+ ]}
193
+ >
194
+ {rippleIds.map((id) => {
195
+ const ripple = ripplesRef.current.get(id);
196
+ if (!ripple) return null;
197
+ return <RippleCircle key={id} ripple={ripple} color={color} />;
198
+ })}
199
+ </View>
200
+ </Pressable>
201
+ );
202
+ }
203
+
204
+ type RippleCircleProps = {
205
+ ripple: RippleItemType;
206
+ color: string;
207
+ };
208
+
209
+ function RippleCircleComponent({ ripple, color }: RippleCircleProps) {
210
+ const { locationX, locationY, radius, scale, opacity } = ripple;
211
+
212
+ const rippleStyle = {
213
+ top: locationY - CIRCLE_RADIUS,
214
+ [I18nManager.isRTL ? 'right' : 'left']: locationX - CIRCLE_RADIUS,
215
+ backgroundColor: color,
216
+ opacity,
217
+ transform: [
218
+ {
219
+ scale: scale.interpolate({
220
+ inputRange: [0, 1],
221
+ outputRange: [0.5 / CIRCLE_RADIUS, radius / CIRCLE_RADIUS],
222
+ }),
223
+ },
224
+ ],
225
+ };
226
+
227
+ return <Animated.View style={[styles.circle, rippleStyle]} />;
228
+ }
229
+
230
+ const RippleCircle = React.memo(RippleCircleComponent);
231
+
232
+ const styles = StyleSheet.create({
233
+ container: {
234
+ ...StyleSheet.absoluteFillObject,
235
+ overflow: 'hidden',
236
+ },
237
+ circle: {
238
+ width: CIRCLE_RADIUS * 2,
239
+ height: CIRCLE_RADIUS * 2,
240
+ borderRadius: CIRCLE_RADIUS,
241
+ overflow: 'hidden',
242
+ position: 'absolute',
243
+ },
244
+ });
@@ -0,0 +1,219 @@
1
+ import { FlashList } from '@shopify/flash-list';
2
+ import type { FlashListProps } from '@shopify/flash-list';
3
+ import React from 'react';
4
+ import { View as RNView, StyleSheet } from 'react-native';
5
+ import Animated from 'react-native-reanimated';
6
+ import type { ScrollHandlerProcessed } from 'react-native-reanimated';
7
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
8
+
9
+ import { useTheme } from '../ThemeProvider';
10
+ import { APP_BAR_HEIGHT, APP_BAR_LARGE_HEIGHT } from './AppBar';
11
+ import { useTabBarHeight } from './TabBar';
12
+
13
+ /**
14
+ * Which top bar the screen renders, so the scrolling region can reserve the
15
+ * matching top inset: `large` = collapsing `AppBar` (`large`), `small` = fixed
16
+ * `AppBar`/`SearchBar`, `none` = no top bar (just the page inset).
17
+ */
18
+ export type SpaceTopBarType = 'large' | 'small' | 'none';
19
+
20
+ type BarSpaceProps = {
21
+ /** Top inset to reserve for the screen's top bar. Required. */
22
+ spaceTopBar: SpaceTopBarType;
23
+ /** Reserve the bottom inset for the fixed `TabBar`. Required. */
24
+ spaceBottomBar: boolean;
25
+ };
26
+
27
+ type BodyProps = {
28
+ children: React.ReactNode;
29
+ /** Use the elevated `surface` background instead of the base `background`. */
30
+ surface?: boolean;
31
+ testID?: string;
32
+ };
33
+
34
+ type ViewProps = {
35
+ children: React.ReactNode;
36
+ paddingHorizontal?: number;
37
+ paddingTop?: number;
38
+ testID?: string;
39
+ };
40
+
41
+ type ScrollViewProps = BarSpaceProps & {
42
+ children: React.ReactNode;
43
+ paddingHorizontal?: number;
44
+ paddingTop?: number;
45
+ paddingBottom?: number;
46
+ onScroll?: ScrollHandlerProcessed;
47
+ testID?: string;
48
+ };
49
+
50
+ type FlashListSectionProps<TItem> = BarSpaceProps &
51
+ Omit<FlashListProps<TItem>, 'onScroll'> & {
52
+ paddingHorizontal?: number;
53
+ paddingTop?: number;
54
+ paddingBottom?: number;
55
+ onScroll?: ScrollHandlerProcessed;
56
+ drawDistance?: number;
57
+ };
58
+
59
+ const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
60
+
61
+ /**
62
+ * Resolves the top/bottom content insets from the declared bars. `useTabBarHeight`
63
+ * already includes the bottom safe-area inset.
64
+ */
65
+ function useBarSpace(spaceTopBar: SpaceTopBarType, spaceBottomBar: boolean) {
66
+ const { pageSpacing } = useTheme();
67
+ const { bottom: safeBottom } = useSafeAreaInsets();
68
+ const tabBarHeight = useTabBarHeight();
69
+
70
+ let top: number;
71
+ if (spaceTopBar === 'large') {
72
+ top = APP_BAR_LARGE_HEIGHT;
73
+ } else if (spaceTopBar === 'small') {
74
+ top = APP_BAR_HEIGHT;
75
+ } else {
76
+ top = pageSpacing.top;
77
+ }
78
+
79
+ const bottom = spaceBottomBar
80
+ ? tabBarHeight + pageSpacing.bottom
81
+ : pageSpacing.bottom + safeBottom;
82
+
83
+ return { top, bottom, safeBottom };
84
+ }
85
+
86
+ /** Full-screen page container. */
87
+ function Body({ children, surface, testID }: BodyProps) {
88
+ const { colors } = useTheme();
89
+ const backgroundColor = surface ? colors.surface : colors.background;
90
+
91
+ return (
92
+ <RNView testID={testID} style={[styles.body, { backgroundColor }]}>
93
+ {children}
94
+ </RNView>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Non-scrolling content region with standard page insets. Content does not
100
+ * scroll under the fixed bars, so this takes no `spaceTopBar` / `spaceBottomBar`
101
+ * — pass an explicit `paddingTop` when a top bar covers it.
102
+ */
103
+ function View({ children, paddingHorizontal, paddingTop, testID }: ViewProps) {
104
+ const { pageSpacing } = useTheme();
105
+
106
+ return (
107
+ <RNView
108
+ testID={testID}
109
+ style={{
110
+ paddingHorizontal: paddingHorizontal ?? pageSpacing.horizontal,
111
+ paddingTop: paddingTop ?? pageSpacing.top,
112
+ }}
113
+ >
114
+ {children}
115
+ </RNView>
116
+ );
117
+ }
118
+
119
+ /** Scrolling content region with standard page insets. */
120
+ function ScrollView({
121
+ children,
122
+ spaceTopBar,
123
+ spaceBottomBar,
124
+ paddingHorizontal,
125
+ paddingTop,
126
+ paddingBottom,
127
+ onScroll,
128
+ testID,
129
+ }: ScrollViewProps) {
130
+ const { pageSpacing } = useTheme();
131
+ const bar = useBarSpace(spaceTopBar, spaceBottomBar);
132
+
133
+ const hPad = paddingHorizontal ?? pageSpacing.horizontal;
134
+ const tPad = paddingTop ?? bar.top;
135
+ const bPad =
136
+ paddingBottom != null ? paddingBottom + bar.safeBottom : bar.bottom;
137
+
138
+ const contentContainerStyle = React.useMemo(
139
+ () => ({
140
+ paddingHorizontal: hPad,
141
+ paddingTop: tPad,
142
+ paddingBottom: bPad,
143
+ }),
144
+ [hPad, tPad, bPad]
145
+ );
146
+
147
+ return (
148
+ <Animated.ScrollView
149
+ testID={testID}
150
+ contentContainerStyle={contentContainerStyle}
151
+ showsVerticalScrollIndicator={false}
152
+ contentInsetAdjustmentBehavior="never"
153
+ keyboardDismissMode="interactive"
154
+ onScroll={onScroll}
155
+ scrollEventThrottle={onScroll ? 16 : undefined}
156
+ >
157
+ {children}
158
+ </Animated.ScrollView>
159
+ );
160
+ }
161
+
162
+ /** Virtualized list region with standard page insets. */
163
+ function FlashListSection<TItem>({
164
+ spaceTopBar,
165
+ spaceBottomBar,
166
+ paddingHorizontal,
167
+ paddingTop,
168
+ paddingBottom,
169
+ onScroll,
170
+ drawDistance = 250,
171
+ ...rest
172
+ }: FlashListSectionProps<TItem>) {
173
+ const { pageSpacing } = useTheme();
174
+ const bar = useBarSpace(spaceTopBar, spaceBottomBar);
175
+
176
+ const hPad = paddingHorizontal ?? pageSpacing.horizontal;
177
+ const tPad = paddingTop ?? bar.top;
178
+ const bPad =
179
+ paddingBottom != null ? paddingBottom + bar.safeBottom : bar.bottom;
180
+
181
+ const contentContainerStyle = React.useMemo(
182
+ () => ({
183
+ paddingHorizontal: hPad,
184
+ paddingTop: tPad,
185
+ paddingBottom: bPad,
186
+ }),
187
+ [hPad, tPad, bPad]
188
+ );
189
+
190
+ const List = AnimatedFlashList as unknown as React.ComponentType<
191
+ FlashListProps<TItem> & { onScroll?: ScrollHandlerProcessed }
192
+ >;
193
+
194
+ return (
195
+ <List
196
+ contentContainerStyle={contentContainerStyle}
197
+ showsVerticalScrollIndicator={false}
198
+ contentInsetAdjustmentBehavior="never"
199
+ keyboardDismissMode="interactive"
200
+ onScroll={onScroll}
201
+ scrollEventThrottle={onScroll ? 16 : undefined}
202
+ drawDistance={drawDistance}
203
+ {...(rest as FlashListProps<TItem>)}
204
+ />
205
+ );
206
+ }
207
+
208
+ export const Scaffold = {
209
+ Body,
210
+ View,
211
+ ScrollView,
212
+ FlashList: FlashListSection,
213
+ };
214
+
215
+ const styles = StyleSheet.create({
216
+ body: {
217
+ flex: 1,
218
+ },
219
+ });
@@ -0,0 +1,166 @@
1
+ import { BlurView } from 'expo-blur';
2
+ import Constants from 'expo-constants';
3
+ import React from 'react';
4
+ import { Platform, StyleSheet, TextInput, View } from 'react-native';
5
+
6
+ import { Icon } from '../icons/Icon';
7
+ import { useTheme, useThemedStyles } from '../ThemeProvider';
8
+ import { pageSpacing } from '../theme';
9
+ import type { ThemeType } from '../theme';
10
+ import { heightDP } from '../responsive';
11
+ import { Button } from './Button';
12
+
13
+ const CONTENT_HEIGHT = heightDP(7);
14
+ const IS_IOS = Platform.OS === 'ios';
15
+
16
+ /**
17
+ * Total height of the fixed search bar, including the status bar. Uses the
18
+ * default `pageSpacing.top` — a themed override is not reflected here.
19
+ */
20
+ export const SEARCH_BAR_HEIGHT =
21
+ Constants.statusBarHeight + CONTENT_HEIGHT + pageSpacing.top;
22
+
23
+ type Props = {
24
+ /** Placeholder shown while the field is empty. */
25
+ placeholder: string;
26
+ /** Fired with the trimmed query on submit. */
27
+ onEnter: (query: string) => void;
28
+ /** Fired when the back control is pressed. */
29
+ onBack: () => void;
30
+ editable?: boolean;
31
+ autoFocus?: boolean;
32
+ };
33
+
34
+ /** Fixed top search bar with a back control and a text field. */
35
+ export function SearchBar({
36
+ placeholder,
37
+ onEnter,
38
+ onBack,
39
+ editable,
40
+ autoFocus,
41
+ }: Props) {
42
+ const { colors, iconSize } = useTheme();
43
+ const styles = useThemedStyles(makeStyles);
44
+ const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null);
45
+
46
+ const [value, setValue] = React.useState('');
47
+ const [isFocused, setIsFocused] = React.useState(false);
48
+
49
+ function handleSubmit() {
50
+ inputRef.current?.blur();
51
+ onEnter(value.trim());
52
+ }
53
+
54
+ return (
55
+ <View
56
+ style={[
57
+ styles.container,
58
+ {
59
+ backgroundColor: colors.surface_90,
60
+ borderBottomColor: colors.border,
61
+ },
62
+ ]}
63
+ >
64
+ {IS_IOS && <BlurView style={styles.blur} tint="dark" intensity={20} />}
65
+ <View style={styles.content}>
66
+ <View style={styles.radiusOverflow}>
67
+ <Button
68
+ testID="search-bar-back-button"
69
+ hitSlop={{ top: 20, bottom: 20, left: 25, right: 15 }}
70
+ onPress={onBack}
71
+ style={styles.iconButton}
72
+ >
73
+ <Icon name="arrow_back" color={colors.text} />
74
+ </Button>
75
+ </View>
76
+ <View style={styles.inputWrapper}>
77
+ <View style={styles.icon} pointerEvents="none">
78
+ <Icon name="search" size={iconSize.s} color={colors.text} />
79
+ </View>
80
+ <TextInput
81
+ ref={inputRef}
82
+ testID="search-bar-input"
83
+ style={[
84
+ styles.input,
85
+ {
86
+ borderColor: isFocused ? colors.primary : colors.border,
87
+ color: colors.text,
88
+ },
89
+ ]}
90
+ placeholder={placeholder}
91
+ placeholderTextColor={colors.text}
92
+ autoFocus={autoFocus}
93
+ autoCapitalize="words"
94
+ autoCorrect={false}
95
+ autoComplete="off"
96
+ cursorColor={colors.primary}
97
+ onChangeText={setValue}
98
+ value={value}
99
+ onSubmitEditing={handleSubmit}
100
+ onFocus={() => setIsFocused(true)}
101
+ onBlur={() => setIsFocused(false)}
102
+ returnKeyType="search"
103
+ editable={editable}
104
+ />
105
+ </View>
106
+ </View>
107
+ </View>
108
+ );
109
+ }
110
+
111
+ function makeStyles(t: ThemeType) {
112
+ return StyleSheet.create({
113
+ container: {
114
+ position: 'absolute',
115
+ top: 0,
116
+ left: 0,
117
+ right: 0,
118
+ zIndex: 10,
119
+ width: '100%',
120
+ borderStyle: 'solid',
121
+ borderBottomWidth: 1,
122
+ },
123
+ content: {
124
+ width: '100%',
125
+ height: CONTENT_HEIGHT,
126
+ flexDirection: 'row',
127
+ alignItems: 'center',
128
+ gap: t.spaces.horizontal.s,
129
+ marginTop: Constants.statusBarHeight,
130
+ paddingHorizontal: t.spaces.horizontal.xs,
131
+ },
132
+ inputWrapper: {
133
+ position: 'relative',
134
+ flex: 1,
135
+ },
136
+ input: {
137
+ height: heightDP(5.5),
138
+ borderWidth: 1,
139
+ borderRadius: t.borderRadius.radius_15,
140
+ paddingLeft: t.spaces.horizontal.l * 1.7 + t.iconSize.s,
141
+ fontSize: t.fontSizes.s,
142
+ },
143
+ icon: {
144
+ position: 'absolute',
145
+ top: 0,
146
+ bottom: 0,
147
+ left: t.spaces.horizontal.l,
148
+ justifyContent: 'center',
149
+ zIndex: 2,
150
+ },
151
+ iconButton: {
152
+ padding: t.spaces.horizontal.xs,
153
+ },
154
+ radiusOverflow: {
155
+ borderRadius: t.borderRadius.radius_100,
156
+ overflow: 'hidden',
157
+ },
158
+ blur: {
159
+ position: 'absolute',
160
+ top: 0,
161
+ left: 0,
162
+ bottom: 0,
163
+ right: 0,
164
+ },
165
+ });
166
+ }