@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,57 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { 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
|
+
size?: FontSizeType;
|
|
13
|
+
/** Text color. Default: theme `primary`. */
|
|
14
|
+
color?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
onPress: () => void;
|
|
17
|
+
testID?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Text-only button, no fill. */
|
|
21
|
+
export function TextButton({
|
|
22
|
+
children,
|
|
23
|
+
size,
|
|
24
|
+
color,
|
|
25
|
+
disabled = false,
|
|
26
|
+
onPress,
|
|
27
|
+
testID,
|
|
28
|
+
}: Props) {
|
|
29
|
+
const { colors } = useTheme();
|
|
30
|
+
const styles = useThemedStyles(makeStyles);
|
|
31
|
+
const opacity = disabled ? 0.5 : 1;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Button
|
|
35
|
+
testID={testID ?? 'text-button'}
|
|
36
|
+
style={[styles.container, { opacity }]}
|
|
37
|
+
onPress={onPress}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
hitSlop={{ top: 20, bottom: 20, left: 25, right: 15 }}
|
|
40
|
+
>
|
|
41
|
+
<Text weight="medium" color={color ?? colors.primary} size={size}>
|
|
42
|
+
{children}
|
|
43
|
+
</Text>
|
|
44
|
+
</Button>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeStyles(t: ThemeType) {
|
|
49
|
+
return StyleSheet.create({
|
|
50
|
+
container: {
|
|
51
|
+
paddingHorizontal: t.spaces.horizontal.s,
|
|
52
|
+
paddingVertical: t.spaces.vertical.xs,
|
|
53
|
+
borderRadius: t.borderRadius.radius_20,
|
|
54
|
+
overflow: 'hidden',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet, TextInput as RNTextInput, View } from 'react-native';
|
|
3
|
+
import type { TextInputProps } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { useTheme, useThemedStyles } from '../ThemeProvider';
|
|
6
|
+
import type { ThemeType } from '../theme';
|
|
7
|
+
import { heightDP } from '../responsive';
|
|
8
|
+
import { Text } from './Text';
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
value: string;
|
|
12
|
+
placeholder: string;
|
|
13
|
+
/** Helper text shown below the field (in the error color). */
|
|
14
|
+
supportingText?: string;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
autoFocus?: TextInputProps['autoFocus'];
|
|
17
|
+
autoCapitalize?: TextInputProps['autoCapitalize'];
|
|
18
|
+
autoCorrect?: TextInputProps['autoCorrect'];
|
|
19
|
+
autoComplete?: TextInputProps['autoComplete'];
|
|
20
|
+
inputMode?: TextInputProps['inputMode'];
|
|
21
|
+
onChangeText: (text: string) => void;
|
|
22
|
+
onBlur?: TextInputProps['onBlur'];
|
|
23
|
+
/** Called on submit when the value is non-empty. */
|
|
24
|
+
onEnter?: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Single-line text field with a supporting message and error state. */
|
|
28
|
+
export function TextInput({
|
|
29
|
+
value,
|
|
30
|
+
placeholder,
|
|
31
|
+
supportingText,
|
|
32
|
+
isError = false,
|
|
33
|
+
autoFocus,
|
|
34
|
+
autoCapitalize,
|
|
35
|
+
autoCorrect,
|
|
36
|
+
autoComplete,
|
|
37
|
+
inputMode,
|
|
38
|
+
onEnter,
|
|
39
|
+
onChangeText,
|
|
40
|
+
onBlur,
|
|
41
|
+
}: Props) {
|
|
42
|
+
const { colors, spaces } = useTheme();
|
|
43
|
+
const styles = useThemedStyles(makeStyles);
|
|
44
|
+
const [isFocused, setIsFocused] = React.useState(false);
|
|
45
|
+
|
|
46
|
+
const borderColor = isError
|
|
47
|
+
? colors.red
|
|
48
|
+
: isFocused
|
|
49
|
+
? colors.primary
|
|
50
|
+
: colors.border;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View style={styles.container}>
|
|
54
|
+
<RNTextInput
|
|
55
|
+
testID="text-input-field"
|
|
56
|
+
style={[
|
|
57
|
+
styles.input,
|
|
58
|
+
{
|
|
59
|
+
backgroundColor: colors.on_surface,
|
|
60
|
+
color: colors.text,
|
|
61
|
+
borderColor,
|
|
62
|
+
},
|
|
63
|
+
]}
|
|
64
|
+
placeholder={placeholder}
|
|
65
|
+
autoFocus={autoFocus}
|
|
66
|
+
autoCapitalize={autoCapitalize}
|
|
67
|
+
autoCorrect={autoCorrect}
|
|
68
|
+
autoComplete={autoComplete}
|
|
69
|
+
inputMode={inputMode}
|
|
70
|
+
cursorColor={colors.primary}
|
|
71
|
+
placeholderTextColor={colors.text}
|
|
72
|
+
onChangeText={onChangeText}
|
|
73
|
+
onFocus={() => setIsFocused(true)}
|
|
74
|
+
onBlur={(event) => {
|
|
75
|
+
setIsFocused(false);
|
|
76
|
+
onBlur?.(event);
|
|
77
|
+
}}
|
|
78
|
+
value={value}
|
|
79
|
+
onSubmitEditing={() => {
|
|
80
|
+
if (value.trim().length > 0) onEnter?.();
|
|
81
|
+
}}
|
|
82
|
+
returnKeyType="done"
|
|
83
|
+
/>
|
|
84
|
+
{supportingText ? (
|
|
85
|
+
<Text color={colors.red} size="xs" marginL={spaces.horizontal.s}>
|
|
86
|
+
{supportingText}
|
|
87
|
+
</Text>
|
|
88
|
+
) : null}
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function makeStyles(t: ThemeType) {
|
|
94
|
+
return StyleSheet.create({
|
|
95
|
+
container: {
|
|
96
|
+
width: '100%',
|
|
97
|
+
gap: t.spaces.vertical.xs,
|
|
98
|
+
},
|
|
99
|
+
input: {
|
|
100
|
+
width: '100%',
|
|
101
|
+
height: heightDP(7),
|
|
102
|
+
borderWidth: 1,
|
|
103
|
+
borderRadius: t.borderRadius.radius_15,
|
|
104
|
+
paddingHorizontal: t.spaces.horizontal.l,
|
|
105
|
+
fontSize: t.fontSizes.s,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Animated, 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 { useToast } from '../stores/useToast';
|
|
8
|
+
import type { ThemeType } from '../theme';
|
|
9
|
+
import type { ToastVariantType } from '../types';
|
|
10
|
+
import { Text } from './Text';
|
|
11
|
+
|
|
12
|
+
const VISIBLE_MS = 3000;
|
|
13
|
+
const FADE_MS = 300;
|
|
14
|
+
|
|
15
|
+
const iconByVariant: Record<ToastVariantType, IconNameType | null> = {
|
|
16
|
+
error: 'error',
|
|
17
|
+
success: 'check',
|
|
18
|
+
info: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Transient centered notice. Drive it with `useToast().showToast(...)`. */
|
|
22
|
+
export function Toast() {
|
|
23
|
+
const { colors } = useTheme();
|
|
24
|
+
const styles = useThemedStyles(makeStyles);
|
|
25
|
+
const { visible, variant, message, hideToast } = useToast();
|
|
26
|
+
const opacity = React.useRef(new Animated.Value(0)).current;
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!visible) return;
|
|
30
|
+
|
|
31
|
+
let hideTimer: ReturnType<typeof setTimeout>;
|
|
32
|
+
const enter = Animated.timing(opacity, {
|
|
33
|
+
toValue: 1,
|
|
34
|
+
duration: FADE_MS,
|
|
35
|
+
useNativeDriver: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
enter.start(() => {
|
|
39
|
+
hideTimer = setTimeout(() => {
|
|
40
|
+
Animated.timing(opacity, {
|
|
41
|
+
toValue: 0,
|
|
42
|
+
duration: FADE_MS,
|
|
43
|
+
useNativeDriver: true,
|
|
44
|
+
}).start(() => hideToast());
|
|
45
|
+
}, VISIBLE_MS);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
enter.stop();
|
|
50
|
+
clearTimeout(hideTimer);
|
|
51
|
+
};
|
|
52
|
+
// `message`/`variant` restart the fade + auto-hide cycle when a new toast is
|
|
53
|
+
// shown while one is already visible.
|
|
54
|
+
}, [visible, message, variant, opacity, hideToast]);
|
|
55
|
+
|
|
56
|
+
if (!visible) return null;
|
|
57
|
+
|
|
58
|
+
const iconName = iconByVariant[variant];
|
|
59
|
+
const iconColor = variant === 'error' ? colors.red : colors.green;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Animated.View style={[styles.background, { opacity }]}>
|
|
63
|
+
<View style={[styles.content, { backgroundColor: colors.background }]}>
|
|
64
|
+
{iconName && <Icon name={iconName} color={iconColor} />}
|
|
65
|
+
<Text weight="medium" size="xs" textAlign="center">
|
|
66
|
+
{message}
|
|
67
|
+
</Text>
|
|
68
|
+
</View>
|
|
69
|
+
</Animated.View>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeStyles(t: ThemeType) {
|
|
74
|
+
return StyleSheet.create({
|
|
75
|
+
background: {
|
|
76
|
+
position: 'absolute',
|
|
77
|
+
top: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
right: 0,
|
|
80
|
+
bottom: 0,
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
pointerEvents: 'none',
|
|
84
|
+
},
|
|
85
|
+
content: {
|
|
86
|
+
flexDirection: 'row',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
gap: t.spaces.horizontal.s,
|
|
89
|
+
borderRadius: t.borderRadius.radius_20,
|
|
90
|
+
paddingVertical: t.spaces.vertical.s,
|
|
91
|
+
paddingHorizontal: t.spaces.horizontal.l,
|
|
92
|
+
pointerEvents: 'none',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useAnimatedScrollHandler,
|
|
3
|
+
useSharedValue,
|
|
4
|
+
} from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tracks vertical scroll offset for the collapsing `AppBar`. Spread
|
|
8
|
+
* `scrollHandler` onto an `Animated.ScrollView`/`Scaffold` `onScroll` and pass
|
|
9
|
+
* `scrollY` to `<AppBar scrollY={...} />`.
|
|
10
|
+
*/
|
|
11
|
+
export function useAppBarScroll() {
|
|
12
|
+
const scrollY = useSharedValue(0);
|
|
13
|
+
|
|
14
|
+
const scrollHandler = useAnimatedScrollHandler((event) => {
|
|
15
|
+
scrollY.value = event.contentOffset.y;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return { scrollY, scrollHandler };
|
|
19
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
/** Called once on press and repeatedly (accelerating) while held. */
|
|
5
|
+
onTrigger: () => void;
|
|
6
|
+
/** When true, the control fires nothing and any in-flight repeat stops. */
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Delay after the initial press before the automatic repeat kicks in.
|
|
11
|
+
const HOLD_START_DELAY_MS = 350;
|
|
12
|
+
// Interval between the first few repeats.
|
|
13
|
+
const INITIAL_REPEAT_INTERVAL_MS = 250;
|
|
14
|
+
// Fastest the repeat is allowed to get.
|
|
15
|
+
const MIN_REPEAT_INTERVAL_MS = 40;
|
|
16
|
+
// Each repeat multiplies the interval by this, so the value accelerates while held.
|
|
17
|
+
const ACCELERATION_FACTOR = 0.82;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Press-and-hold with acceleration, decoupled from any domain.
|
|
21
|
+
*
|
|
22
|
+
* Wire the returned handlers to a pressable: `onPressIn` fires `onTrigger` once
|
|
23
|
+
* immediately (so a quick tap steps by one) and, if the press is held past
|
|
24
|
+
* `HOLD_START_DELAY_MS`, keeps firing it on a shrinking interval until `onPressOut`
|
|
25
|
+
* (or unmount). Clamping / bounds are the caller's concern — firing `onTrigger`
|
|
26
|
+
* when the value is already at a limit is expected to be a no-op.
|
|
27
|
+
*/
|
|
28
|
+
export function usePressAndHoldRepeat({ onTrigger, disabled }: Props) {
|
|
29
|
+
const onTriggerRef = React.useRef(onTrigger);
|
|
30
|
+
const startTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
31
|
+
null
|
|
32
|
+
);
|
|
33
|
+
const repeatTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
34
|
+
null
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
onTriggerRef.current = onTrigger;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const clearTimers = React.useCallback(() => {
|
|
42
|
+
if (startTimeoutRef.current !== null) {
|
|
43
|
+
clearTimeout(startTimeoutRef.current);
|
|
44
|
+
startTimeoutRef.current = null;
|
|
45
|
+
}
|
|
46
|
+
if (repeatTimeoutRef.current !== null) {
|
|
47
|
+
clearTimeout(repeatTimeoutRef.current);
|
|
48
|
+
repeatTimeoutRef.current = null;
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => clearTimers, [clearTimers]);
|
|
53
|
+
|
|
54
|
+
// Stop an in-flight repeat the moment the consumer disables the control.
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
if (disabled) clearTimers();
|
|
57
|
+
}, [disabled, clearTimers]);
|
|
58
|
+
|
|
59
|
+
const onPressIn = React.useCallback(() => {
|
|
60
|
+
if (disabled) return;
|
|
61
|
+
|
|
62
|
+
clearTimers();
|
|
63
|
+
onTriggerRef.current();
|
|
64
|
+
|
|
65
|
+
function scheduleRepeat(interval: number) {
|
|
66
|
+
repeatTimeoutRef.current = setTimeout(() => {
|
|
67
|
+
onTriggerRef.current();
|
|
68
|
+
scheduleRepeat(
|
|
69
|
+
Math.max(MIN_REPEAT_INTERVAL_MS, interval * ACCELERATION_FACTOR)
|
|
70
|
+
);
|
|
71
|
+
}, interval);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
startTimeoutRef.current = setTimeout(() => {
|
|
75
|
+
startTimeoutRef.current = null;
|
|
76
|
+
scheduleRepeat(INITIAL_REPEAT_INTERVAL_MS);
|
|
77
|
+
}, HOLD_START_DELAY_MS);
|
|
78
|
+
}, [disabled, clearTimers]);
|
|
79
|
+
|
|
80
|
+
const onPressOut = React.useCallback(() => {
|
|
81
|
+
clearTimers();
|
|
82
|
+
}, [clearTimers]);
|
|
83
|
+
|
|
84
|
+
return { onPressIn, onPressOut };
|
|
85
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SymbolView } from 'expo-symbols';
|
|
2
|
+
import type { AndroidSymbol, SFSymbol } from 'expo-symbols';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../ThemeProvider';
|
|
5
|
+
|
|
6
|
+
// Curated set of generic UI icons. Each entry maps a public name to a
|
|
7
|
+
// per-platform SF Symbol / Android symbol.
|
|
8
|
+
const names = {
|
|
9
|
+
check: { ios: 'checkmark', android: 'check' },
|
|
10
|
+
remove: { ios: 'minus', android: 'remove' },
|
|
11
|
+
add: { ios: 'plus', android: 'add' },
|
|
12
|
+
settings: { ios: 'gear', android: 'settings' },
|
|
13
|
+
arrow_back: { ios: 'chevron.left', android: 'arrow_back' },
|
|
14
|
+
search: { ios: 'magnifyingglass', android: 'search' },
|
|
15
|
+
edit: { ios: 'pencil', android: 'edit' },
|
|
16
|
+
error: { ios: 'x.circle', android: 'error' },
|
|
17
|
+
close: { ios: 'xmark', android: 'close' },
|
|
18
|
+
folder: { ios: 'folder', android: 'folder' },
|
|
19
|
+
question: { ios: 'questionmark', android: 'question_mark' },
|
|
20
|
+
filter: { ios: 'line.3.horizontal.decrease', android: 'filter_list' },
|
|
21
|
+
} satisfies Record<string, { ios: SFSymbol; android: AndroidSymbol }>;
|
|
22
|
+
|
|
23
|
+
export type IconNameType = keyof typeof names;
|
|
24
|
+
|
|
25
|
+
type Props = {
|
|
26
|
+
/** One of the curated icon names. */
|
|
27
|
+
name: IconNameType;
|
|
28
|
+
/** Icon color. Defaults to the theme `primary`. */
|
|
29
|
+
color?: string;
|
|
30
|
+
/** Size in dp. Defaults to the theme `iconSize.m`. */
|
|
31
|
+
size?: number;
|
|
32
|
+
testID?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Renders a curated UI icon via `expo-symbols` (`SymbolView`). */
|
|
36
|
+
export function Icon({ name, color, size, testID }: Props) {
|
|
37
|
+
const { colors, iconSize } = useTheme();
|
|
38
|
+
|
|
39
|
+
// Built as an object and spread: under the react-native-strict-api types,
|
|
40
|
+
// `SymbolViewProps` doesn't surface the inherited `ViewProps` members
|
|
41
|
+
// (`testID`, a11y props) to JSX excess-property checks, so a literal
|
|
42
|
+
// `testID=` attribute fails to typecheck even though it's forwarded natively.
|
|
43
|
+
const props = {
|
|
44
|
+
testID: testID ?? `icon-${name}`,
|
|
45
|
+
name: names[name],
|
|
46
|
+
tintColor: color ?? colors.primary,
|
|
47
|
+
size: size ?? iconSize.m,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return <SymbolView {...props} />;
|
|
51
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// components
|
|
2
|
+
export * from './components/AppBar';
|
|
3
|
+
export * from './components/BounceEffect';
|
|
4
|
+
export * from './components/Button';
|
|
5
|
+
export * from './components/Chips';
|
|
6
|
+
export * from './components/IconButton';
|
|
7
|
+
export * from './components/InforList';
|
|
8
|
+
export * from './components/LoadingApp';
|
|
9
|
+
export * from './components/Overlays';
|
|
10
|
+
export * from './components/RippleEffect';
|
|
11
|
+
export * from './components/Scaffold';
|
|
12
|
+
export * from './components/SearchBar';
|
|
13
|
+
export * from './components/SegmentedButton';
|
|
14
|
+
export * from './components/ShapedButton';
|
|
15
|
+
export * from './components/SlideModal';
|
|
16
|
+
export * from './components/TabBar';
|
|
17
|
+
export * from './components/Text';
|
|
18
|
+
export * from './components/TextButton';
|
|
19
|
+
export * from './components/TextInput';
|
|
20
|
+
export * from './components/Toast';
|
|
21
|
+
|
|
22
|
+
// icons
|
|
23
|
+
export * from './icons/Icon';
|
|
24
|
+
|
|
25
|
+
// theme + provider
|
|
26
|
+
export {
|
|
27
|
+
ThemeProvider,
|
|
28
|
+
useTheme,
|
|
29
|
+
useColors,
|
|
30
|
+
useThemedStyles,
|
|
31
|
+
} from './ThemeProvider';
|
|
32
|
+
export {
|
|
33
|
+
colors,
|
|
34
|
+
fontSizes,
|
|
35
|
+
iconSize,
|
|
36
|
+
spaces,
|
|
37
|
+
borderRadius,
|
|
38
|
+
pageSpacing,
|
|
39
|
+
defaultTokens,
|
|
40
|
+
} from './theme';
|
|
41
|
+
export type {
|
|
42
|
+
ThemeType,
|
|
43
|
+
ThemeTokensType,
|
|
44
|
+
FontSizesType,
|
|
45
|
+
IconSizesType,
|
|
46
|
+
SpacesType,
|
|
47
|
+
SpaceScaleType,
|
|
48
|
+
BorderRadiusType,
|
|
49
|
+
PageSpacingType,
|
|
50
|
+
} from './theme';
|
|
51
|
+
|
|
52
|
+
// responsive helpers
|
|
53
|
+
export { widthDP, heightDP } from './responsive';
|
|
54
|
+
|
|
55
|
+
// hooks
|
|
56
|
+
export * from './hooks/useAppBarScroll';
|
|
57
|
+
export * from './hooks/usePressAndHoldRepeat';
|
|
58
|
+
|
|
59
|
+
// global state
|
|
60
|
+
export { useToast } from './stores/useToast';
|
|
61
|
+
export { useLoadingApp } from './stores/useLoadingApp';
|
|
62
|
+
|
|
63
|
+
// shared style types
|
|
64
|
+
export type * from './types';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Dimensions, PixelRatio } from 'react-native';
|
|
2
|
+
|
|
3
|
+
const screenWidth = Dimensions.get('window').width;
|
|
4
|
+
const screenHeight = Dimensions.get('window').height;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a percentage of the screen width to a density-independent pixel value.
|
|
8
|
+
*
|
|
9
|
+
* @param widthPercent Percentage of the screen width (number, or numeric string).
|
|
10
|
+
* @returns The value in dp, rounded to the nearest physical pixel.
|
|
11
|
+
*/
|
|
12
|
+
export function widthDP(widthPercent: string | number): number {
|
|
13
|
+
const elemWidth =
|
|
14
|
+
typeof widthPercent === 'number' ? widthPercent : parseFloat(widthPercent);
|
|
15
|
+
|
|
16
|
+
return PixelRatio.roundToNearestPixel((screenWidth * elemWidth) / 100);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a percentage of the screen height to a density-independent pixel value.
|
|
21
|
+
*
|
|
22
|
+
* @param heightPercent Percentage of the screen height (number, or numeric string).
|
|
23
|
+
* @returns The value in dp, rounded to the nearest physical pixel.
|
|
24
|
+
*/
|
|
25
|
+
export function heightDP(heightPercent: string | number): number {
|
|
26
|
+
const elemHeight =
|
|
27
|
+
typeof heightPercent === 'number'
|
|
28
|
+
? heightPercent
|
|
29
|
+
: parseFloat(heightPercent);
|
|
30
|
+
|
|
31
|
+
return PixelRatio.roundToNearestPixel((screenHeight * elemHeight) / 100);
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
type State = {
|
|
4
|
+
visible: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type Actions = {
|
|
8
|
+
/** Toggle the blocking loading overlay. */
|
|
9
|
+
setLoadingVisible: (visible: boolean) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Global state for the blocking `<LoadingApp />` overlay. Render it once near
|
|
14
|
+
* the root and drive it with `useLoadingApp.getState().setLoadingVisible(...)`.
|
|
15
|
+
*/
|
|
16
|
+
export const useLoadingApp = create<State & Actions>((set) => ({
|
|
17
|
+
visible: false,
|
|
18
|
+
setLoadingVisible: (visible) => set({ visible }),
|
|
19
|
+
}));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
|
|
3
|
+
import type { ToastVariantType } from '../types';
|
|
4
|
+
|
|
5
|
+
type ShowToastArgs = {
|
|
6
|
+
/** Text shown in the toast. */
|
|
7
|
+
message: string;
|
|
8
|
+
/** Visual intent. Defaults to `'info'`. */
|
|
9
|
+
variant?: ToastVariantType;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type State = {
|
|
13
|
+
visible: boolean;
|
|
14
|
+
variant: ToastVariantType;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type Actions = {
|
|
19
|
+
/** Show the toast with a message and optional variant. */
|
|
20
|
+
showToast: (args: ShowToastArgs) => void;
|
|
21
|
+
/** Hide the toast. */
|
|
22
|
+
hideToast: () => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Global toast state. Render `<Toast />` once near the root; call
|
|
27
|
+
* `useToast.getState().showToast(...)` (or the hook) from anywhere.
|
|
28
|
+
*/
|
|
29
|
+
export const useToast = create<State & Actions>((set) => ({
|
|
30
|
+
visible: false,
|
|
31
|
+
variant: 'info',
|
|
32
|
+
message: '',
|
|
33
|
+
showToast: ({ message, variant = 'info' }) =>
|
|
34
|
+
set({ message, variant, visible: true }),
|
|
35
|
+
hideToast: () => set({ visible: false }),
|
|
36
|
+
}));
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { heightDP, widthDP } from './responsive';
|
|
2
|
+
import type { ColorsType, FontSizeType, IconSizeType } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A ready-made color palette. `ThemeProvider` **requires** a `colors` prop — pass
|
|
6
|
+
* this, spread from it, or provide your own `ColorsType`.
|
|
7
|
+
*/
|
|
8
|
+
export const colors: ColorsType = {
|
|
9
|
+
background: 'black',
|
|
10
|
+
background_60: 'rgba(0,0,0,0.6)',
|
|
11
|
+
on_surface: '#1A191E',
|
|
12
|
+
surface: '#100e12',
|
|
13
|
+
surface_90: 'rgba(16, 14, 18, 0.9)',
|
|
14
|
+
border: '#3D3B40',
|
|
15
|
+
text: '#f6f1ff',
|
|
16
|
+
primary: '#4284ff',
|
|
17
|
+
red: '#ff0004',
|
|
18
|
+
green: '#00ff2a',
|
|
19
|
+
amber: '#FFC107',
|
|
20
|
+
transparent: 'transparent',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type FontSizesType = Record<FontSizeType, number>;
|
|
24
|
+
|
|
25
|
+
/** Default font sizes, in dp. */
|
|
26
|
+
export const fontSizes: FontSizesType = {
|
|
27
|
+
xxxl: heightDP(4.3),
|
|
28
|
+
xxl: heightDP(3.8),
|
|
29
|
+
xl: heightDP(3.2),
|
|
30
|
+
l: heightDP(2.8),
|
|
31
|
+
m: heightDP(2.3),
|
|
32
|
+
s: heightDP(2),
|
|
33
|
+
xs: heightDP(1.5),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type IconSizesType = Record<IconSizeType, number>;
|
|
37
|
+
|
|
38
|
+
/** Default icon sizes, in dp. */
|
|
39
|
+
export const iconSize: IconSizesType = {
|
|
40
|
+
xl: heightDP(5),
|
|
41
|
+
l: heightDP(4),
|
|
42
|
+
m: heightDP(3.4),
|
|
43
|
+
s: heightDP(2.3),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type SpaceScaleType = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl';
|
|
47
|
+
|
|
48
|
+
export type SpacesType = {
|
|
49
|
+
horizontal: Record<SpaceScaleType, number>;
|
|
50
|
+
vertical: Record<SpaceScaleType, number>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Default spacing scale, in dp. */
|
|
54
|
+
export const spaces: SpacesType = {
|
|
55
|
+
horizontal: {
|
|
56
|
+
xs: heightDP(1),
|
|
57
|
+
s: widthDP(2),
|
|
58
|
+
m: widthDP(3),
|
|
59
|
+
l: widthDP(4),
|
|
60
|
+
xl: widthDP(5),
|
|
61
|
+
xxl: widthDP(8),
|
|
62
|
+
},
|
|
63
|
+
vertical: {
|
|
64
|
+
xs: heightDP(0.7),
|
|
65
|
+
s: heightDP(1),
|
|
66
|
+
m: heightDP(2),
|
|
67
|
+
l: heightDP(3),
|
|
68
|
+
xl: heightDP(4),
|
|
69
|
+
xxl: heightDP(7),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type BorderRadiusType = {
|
|
74
|
+
radius_5: number;
|
|
75
|
+
radius_10: number;
|
|
76
|
+
radius_15: number;
|
|
77
|
+
radius_20: number;
|
|
78
|
+
radius_100: number | string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Default border radius scale. */
|
|
82
|
+
export const borderRadius: BorderRadiusType = {
|
|
83
|
+
radius_5: 5,
|
|
84
|
+
radius_10: 10,
|
|
85
|
+
radius_15: 15,
|
|
86
|
+
radius_20: 20,
|
|
87
|
+
radius_100: '100%',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type PageSpacingType = {
|
|
91
|
+
horizontal: number;
|
|
92
|
+
top: number;
|
|
93
|
+
bottom: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Default page insets used by `Scaffold` and the fixed bars. */
|
|
97
|
+
export const pageSpacing: PageSpacingType = {
|
|
98
|
+
horizontal: spaces.horizontal.l,
|
|
99
|
+
top: spaces.vertical.l,
|
|
100
|
+
bottom: spaces.horizontal.l,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type ThemeType = {
|
|
104
|
+
colors: ColorsType;
|
|
105
|
+
fontSizes: FontSizesType;
|
|
106
|
+
iconSize: IconSizesType;
|
|
107
|
+
spaces: SpacesType;
|
|
108
|
+
borderRadius: BorderRadiusType;
|
|
109
|
+
pageSpacing: PageSpacingType;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Everything in `ThemeType` except `colors`. */
|
|
113
|
+
export type ThemeTokensType = Omit<ThemeType, 'colors'>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Defaults for the non-color token groups. `ThemeProvider` merges any override
|
|
117
|
+
* on top of these (there is no default palette — `colors` is required).
|
|
118
|
+
*/
|
|
119
|
+
export const defaultTokens: ThemeTokensType = {
|
|
120
|
+
fontSizes,
|
|
121
|
+
iconSize,
|
|
122
|
+
spaces,
|
|
123
|
+
borderRadius,
|
|
124
|
+
pageSpacing,
|
|
125
|
+
};
|