@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.
- package/LICENSE +20 -0
- package/README.md +721 -0
- package/lib/module/ThemeProvider.js +84 -0
- package/lib/module/ThemeProvider.js.map +1 -0
- package/lib/module/components/AppBar.js +185 -0
- package/lib/module/components/AppBar.js.map +1 -0
- package/lib/module/components/BounceEffect.js +50 -0
- package/lib/module/components/BounceEffect.js.map +1 -0
- package/lib/module/components/Button.js +48 -0
- package/lib/module/components/Button.js.map +1 -0
- package/lib/module/components/Chips.js +94 -0
- package/lib/module/components/Chips.js.map +1 -0
- package/lib/module/components/IconButton.js +71 -0
- package/lib/module/components/IconButton.js.map +1 -0
- package/lib/module/components/InforList.js +92 -0
- package/lib/module/components/InforList.js.map +1 -0
- package/lib/module/components/LoadingApp.js +58 -0
- package/lib/module/components/LoadingApp.js.map +1 -0
- package/lib/module/components/Overlays.js +20 -0
- package/lib/module/components/Overlays.js.map +1 -0
- package/lib/module/components/RippleEffect.js +178 -0
- package/lib/module/components/RippleEffect.js.map +1 -0
- package/lib/module/components/Scaffold.js +172 -0
- package/lib/module/components/Scaffold.js.map +1 -0
- package/lib/module/components/SearchBar.js +161 -0
- package/lib/module/components/SearchBar.js.map +1 -0
- package/lib/module/components/SegmentedButton.js +151 -0
- package/lib/module/components/SegmentedButton.js.map +1 -0
- package/lib/module/components/ShapedButton.js +58 -0
- package/lib/module/components/ShapedButton.js.map +1 -0
- package/lib/module/components/SlideModal.js +196 -0
- package/lib/module/components/SlideModal.js.map +1 -0
- package/lib/module/components/TabBar.js +107 -0
- package/lib/module/components/TabBar.js.map +1 -0
- package/lib/module/components/Text.js +47 -0
- package/lib/module/components/Text.js.map +1 -0
- package/lib/module/components/TextButton.js +53 -0
- package/lib/module/components/TextButton.js.map +1 -0
- package/lib/module/components/TextInput.js +83 -0
- package/lib/module/components/TextInput.js.map +1 -0
- package/lib/module/components/Toast.js +101 -0
- package/lib/module/components/Toast.js.map +1 -0
- package/lib/module/hooks/useAppBarScroll.js +20 -0
- package/lib/module/hooks/useAppBarScroll.js.map +1 -0
- package/lib/module/hooks/usePressAndHoldRepeat.js +71 -0
- package/lib/module/hooks/usePressAndHoldRepeat.js.map +1 -0
- package/lib/module/icons/Icon.js +85 -0
- package/lib/module/icons/Icon.js.map +1 -0
- package/lib/module/index.js +42 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/responsive.js +28 -0
- package/lib/module/responsive.js.map +1 -0
- package/lib/module/stores/useLoadingApp.js +14 -0
- package/lib/module/stores/useLoadingApp.js.map +1 -0
- package/lib/module/stores/useToast.js +24 -0
- package/lib/module/stores/useToast.js.map +1 -0
- package/lib/module/theme.js +86 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/jest.setup.d.ts +2 -0
- package/lib/typescript/jest.setup.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/ThemeProvider.d.ts +39 -0
- package/lib/typescript/src/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/src/components/AppBar.d.ts +33 -0
- package/lib/typescript/src/components/AppBar.d.ts.map +1 -0
- package/lib/typescript/src/components/BounceEffect.d.ts +12 -0
- package/lib/typescript/src/components/BounceEffect.d.ts.map +1 -0
- package/lib/typescript/src/components/Button.d.ts +24 -0
- package/lib/typescript/src/components/Button.d.ts.map +1 -0
- package/lib/typescript/src/components/Chips.d.ts +23 -0
- package/lib/typescript/src/components/Chips.d.ts.map +1 -0
- package/lib/typescript/src/components/IconButton.d.ts +14 -0
- package/lib/typescript/src/components/IconButton.d.ts.map +1 -0
- package/lib/typescript/src/components/InforList.d.ts +28 -0
- package/lib/typescript/src/components/InforList.d.ts.map +1 -0
- package/lib/typescript/src/components/LoadingApp.d.ts +8 -0
- package/lib/typescript/src/components/LoadingApp.d.ts.map +1 -0
- package/lib/typescript/src/components/Overlays.d.ts +12 -0
- package/lib/typescript/src/components/Overlays.d.ts.map +1 -0
- package/lib/typescript/src/components/RippleEffect.d.ts +18 -0
- package/lib/typescript/src/components/RippleEffect.d.ts.map +1 -0
- package/lib/typescript/src/components/Scaffold.d.ts +62 -0
- package/lib/typescript/src/components/Scaffold.d.ts.map +1 -0
- package/lib/typescript/src/components/SearchBar.d.ts +20 -0
- package/lib/typescript/src/components/SearchBar.d.ts.map +1 -0
- package/lib/typescript/src/components/SegmentedButton.d.ts +37 -0
- package/lib/typescript/src/components/SegmentedButton.d.ts.map +1 -0
- package/lib/typescript/src/components/ShapedButton.d.ts +18 -0
- package/lib/typescript/src/components/ShapedButton.d.ts.map +1 -0
- package/lib/typescript/src/components/SlideModal.d.ts +15 -0
- package/lib/typescript/src/components/SlideModal.d.ts.map +1 -0
- package/lib/typescript/src/components/TabBar.d.ts +20 -0
- package/lib/typescript/src/components/TabBar.d.ts.map +1 -0
- package/lib/typescript/src/components/Text.d.ts +26 -0
- package/lib/typescript/src/components/Text.d.ts.map +1 -0
- package/lib/typescript/src/components/TextButton.d.ts +15 -0
- package/lib/typescript/src/components/TextButton.d.ts.map +1 -0
- package/lib/typescript/src/components/TextInput.d.ts +22 -0
- package/lib/typescript/src/components/TextInput.d.ts.map +1 -0
- package/lib/typescript/src/components/Toast.d.ts +4 -0
- package/lib/typescript/src/components/Toast.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useAppBarScroll.d.ts +10 -0
- package/lib/typescript/src/hooks/useAppBarScroll.d.ts.map +1 -0
- package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts +21 -0
- package/lib/typescript/src/hooks/usePressAndHoldRepeat.d.ts.map +1 -0
- package/lib/typescript/src/icons/Icon.d.ts +64 -0
- package/lib/typescript/src/icons/Icon.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +30 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/responsive.d.ts +15 -0
- package/lib/typescript/src/responsive.d.ts.map +1 -0
- package/lib/typescript/src/stores/useLoadingApp.d.ts +14 -0
- package/lib/typescript/src/stores/useLoadingApp.d.ts.map +1 -0
- package/lib/typescript/src/stores/useToast.d.ts +25 -0
- package/lib/typescript/src/stores/useToast.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +51 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +26 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +189 -0
- package/src/ThemeProvider.tsx +108 -0
- package/src/components/AppBar.tsx +226 -0
- package/src/components/BounceEffect.tsx +53 -0
- package/src/components/Button.tsx +70 -0
- package/src/components/Chips.tsx +115 -0
- package/src/components/IconButton.tsx +84 -0
- package/src/components/InforList.tsx +110 -0
- package/src/components/LoadingApp.tsx +55 -0
- package/src/components/Overlays.tsx +21 -0
- package/src/components/RippleEffect.tsx +244 -0
- package/src/components/Scaffold.tsx +219 -0
- package/src/components/SearchBar.tsx +166 -0
- package/src/components/SegmentedButton.tsx +206 -0
- package/src/components/ShapedButton.tsx +74 -0
- package/src/components/SlideModal.tsx +232 -0
- package/src/components/TabBar.tsx +122 -0
- package/src/components/Text.tsx +70 -0
- package/src/components/TextButton.tsx +57 -0
- package/src/components/TextInput.tsx +108 -0
- package/src/components/Toast.tsx +95 -0
- package/src/hooks/useAppBarScroll.ts +19 -0
- package/src/hooks/usePressAndHoldRepeat.ts +85 -0
- package/src/icons/Icon.tsx +51 -0
- package/src/index.tsx +64 -0
- package/src/responsive.ts +32 -0
- package/src/stores/useLoadingApp.ts +19 -0
- package/src/stores/useToast.ts +36 -0
- package/src/theme.ts +125 -0
- 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
|
+
}
|