@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,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
|
+
}
|