syzygy-ui-rn 2.0.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 +21 -0
- package/README.md +151 -0
- package/package.json +56 -0
- package/src/__tests__/components.test.tsx +368 -0
- package/src/__tests__/tokens.test.ts +47 -0
- package/src/components/badges/Badge.tsx +74 -0
- package/src/components/buttons/DestructiveButton.tsx +82 -0
- package/src/components/buttons/GhostButton.tsx +77 -0
- package/src/components/buttons/IconButton.tsx +61 -0
- package/src/components/buttons/PrimaryButton.tsx +82 -0
- package/src/components/buttons/SecondaryButton.tsx +84 -0
- package/src/components/cards/CardView.tsx +45 -0
- package/src/components/display/Avatar.tsx +53 -0
- package/src/components/display/Chip.tsx +64 -0
- package/src/components/display/CountBadge.tsx +58 -0
- package/src/components/display/DividerLine.tsx +29 -0
- package/src/components/display/LazyImageView.tsx +57 -0
- package/src/components/display/ListRow.tsx +76 -0
- package/src/components/display/SectionHeader.tsx +55 -0
- package/src/components/display/StarRatingView.tsx +70 -0
- package/src/components/feedback/EmptyStateView.tsx +93 -0
- package/src/components/feedback/ErrorStateView.tsx +75 -0
- package/src/components/feedback/LoadingView.tsx +57 -0
- package/src/components/feedback/ProgressBar.tsx +42 -0
- package/src/components/feedback/PullToRefresh.tsx +38 -0
- package/src/components/feedback/ShimmerView.tsx +47 -0
- package/src/components/feedback/ToastView.tsx +71 -0
- package/src/components/inputs/CheckboxInput.tsx +73 -0
- package/src/components/inputs/Dropdown.tsx +110 -0
- package/src/components/inputs/QuantityStepper.tsx +81 -0
- package/src/components/inputs/RadioButtonInput.tsx +73 -0
- package/src/components/inputs/SearchInput.tsx +107 -0
- package/src/components/inputs/SecureInput.tsx +105 -0
- package/src/components/inputs/SegmentedControl.tsx +70 -0
- package/src/components/inputs/SliderInput.tsx +131 -0
- package/src/components/inputs/TextInput.tsx +90 -0
- package/src/components/inputs/ToggleSwitch.tsx +50 -0
- package/src/components/layout/KeyboardAvoidingScrollView.tsx +40 -0
- package/src/components/navigation/AppBar.tsx +53 -0
- package/src/components/navigation/BackButton.tsx +63 -0
- package/src/components/navigation/BottomNavigationBar.tsx +65 -0
- package/src/components/navigation/PagerView.tsx +40 -0
- package/src/components/navigation/TabBar.tsx +68 -0
- package/src/components/navigation/TabBarItem.ts +8 -0
- package/src/components/overlay/BottomSheet.tsx +64 -0
- package/src/components/overlay/CollapsibleView.tsx +82 -0
- package/src/components/overlay/ModalDialog.tsx +47 -0
- package/src/index.ts +54 -0
- package/src/tokens/colors.ts +84 -0
- package/src/tokens/index.ts +4 -0
- package/src/tokens/radius.ts +8 -0
- package/src/tokens/spacing.ts +10 -0
- package/src/tokens/typography.ts +27 -0
- package/src/transitions/navigationTransitions.ts +66 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
useColorScheme,
|
|
7
|
+
View,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
|
|
11
|
+
import { getColors } from '../../tokens/colors';
|
|
12
|
+
import { radius } from '../../tokens/radius';
|
|
13
|
+
import { spacing } from '../../tokens/spacing';
|
|
14
|
+
import { fontSizes, fontWeights } from '../../tokens/typography';
|
|
15
|
+
|
|
16
|
+
export type ToastVariant = 'success' | 'warning' | 'error';
|
|
17
|
+
|
|
18
|
+
export interface ToastViewProps {
|
|
19
|
+
message: string;
|
|
20
|
+
variant?: ToastVariant;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
accessibilityLabel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ToastView: React.FC<ToastViewProps> = ({
|
|
26
|
+
message,
|
|
27
|
+
variant = 'success',
|
|
28
|
+
style,
|
|
29
|
+
accessibilityLabel,
|
|
30
|
+
}) => {
|
|
31
|
+
const scheme = useColorScheme();
|
|
32
|
+
const colors = getColors(scheme);
|
|
33
|
+
|
|
34
|
+
const backgroundColor =
|
|
35
|
+
variant === 'success'
|
|
36
|
+
? colors.success
|
|
37
|
+
: variant === 'warning'
|
|
38
|
+
? colors.warning
|
|
39
|
+
: colors.error;
|
|
40
|
+
|
|
41
|
+
const textColor =
|
|
42
|
+
variant === 'success'
|
|
43
|
+
? colors.successText
|
|
44
|
+
: variant === 'warning'
|
|
45
|
+
? colors.warningText
|
|
46
|
+
: colors.errorText;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View
|
|
50
|
+
style={[styles.container, { backgroundColor }, style]}
|
|
51
|
+
accessibilityRole="alert"
|
|
52
|
+
accessibilityLabel={accessibilityLabel ?? message}
|
|
53
|
+
>
|
|
54
|
+
<Text style={[styles.text, { color: textColor }]}>{message}</Text>
|
|
55
|
+
</View>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const styles = StyleSheet.create({
|
|
60
|
+
container: {
|
|
61
|
+
minHeight: 44,
|
|
62
|
+
borderRadius: radius.md,
|
|
63
|
+
paddingHorizontal: spacing.md,
|
|
64
|
+
paddingVertical: spacing.sm,
|
|
65
|
+
justifyContent: 'center',
|
|
66
|
+
},
|
|
67
|
+
text: {
|
|
68
|
+
fontSize: fontSizes.sm,
|
|
69
|
+
fontWeight: fontWeights.medium,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
useColorScheme,
|
|
8
|
+
View,
|
|
9
|
+
ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { getColors } from '../../tokens/colors';
|
|
13
|
+
import { radius } from '../../tokens/radius';
|
|
14
|
+
import { spacing } from '../../tokens/spacing';
|
|
15
|
+
|
|
16
|
+
export interface CheckboxInputProps {
|
|
17
|
+
label: string;
|
|
18
|
+
checked: boolean;
|
|
19
|
+
onValueChange: (checked: boolean) => void;
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
accessibilityLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A labeled checkbox. React Native has no core `Checkbox`, so this owns the name. */
|
|
25
|
+
export const CheckboxInput: React.FC<CheckboxInputProps> = ({
|
|
26
|
+
label,
|
|
27
|
+
checked,
|
|
28
|
+
onValueChange,
|
|
29
|
+
style,
|
|
30
|
+
accessibilityLabel,
|
|
31
|
+
}) => {
|
|
32
|
+
const scheme = useColorScheme();
|
|
33
|
+
const colors = getColors(scheme);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<TouchableOpacity
|
|
37
|
+
onPress={() => onValueChange(!checked)}
|
|
38
|
+
accessibilityRole="checkbox"
|
|
39
|
+
accessibilityState={{ checked }}
|
|
40
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
41
|
+
style={[styles.container, style]}
|
|
42
|
+
>
|
|
43
|
+
<View
|
|
44
|
+
style={[
|
|
45
|
+
styles.box,
|
|
46
|
+
{
|
|
47
|
+
backgroundColor: checked ? colors.primary : 'transparent',
|
|
48
|
+
borderColor: checked ? colors.primary : colors.border,
|
|
49
|
+
},
|
|
50
|
+
]}
|
|
51
|
+
>
|
|
52
|
+
{checked ? <Text style={{ color: colors.primaryText }}>{'✓'}</Text> : null}
|
|
53
|
+
</View>
|
|
54
|
+
<Text style={{ color: colors.textPrimary, marginLeft: spacing.sm }}>{label}</Text>
|
|
55
|
+
</TouchableOpacity>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const styles = StyleSheet.create({
|
|
60
|
+
container: {
|
|
61
|
+
minHeight: 44,
|
|
62
|
+
flexDirection: 'row',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
},
|
|
65
|
+
box: {
|
|
66
|
+
width: 22,
|
|
67
|
+
height: 22,
|
|
68
|
+
borderWidth: 2,
|
|
69
|
+
borderRadius: radius.sm,
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
FlatList,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleProp,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TouchableOpacity,
|
|
10
|
+
useColorScheme,
|
|
11
|
+
View,
|
|
12
|
+
ViewStyle,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
|
|
15
|
+
import { getColors } from '../../tokens/colors';
|
|
16
|
+
import { radius } from '../../tokens/radius';
|
|
17
|
+
import { spacing } from '../../tokens/spacing';
|
|
18
|
+
import { fontSizes } from '../../tokens/typography';
|
|
19
|
+
|
|
20
|
+
export interface DropdownProps<T> {
|
|
21
|
+
label: string;
|
|
22
|
+
selection: T;
|
|
23
|
+
options: T[];
|
|
24
|
+
onSelectionChange: (option: T) => void;
|
|
25
|
+
optionTitle: (option: T) => string;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
accessibilityLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A labeled dropdown / picker, selecting from a fixed list of options. */
|
|
31
|
+
export function Dropdown<T>({
|
|
32
|
+
label,
|
|
33
|
+
selection,
|
|
34
|
+
options,
|
|
35
|
+
onSelectionChange,
|
|
36
|
+
optionTitle,
|
|
37
|
+
style,
|
|
38
|
+
accessibilityLabel,
|
|
39
|
+
}: DropdownProps<T>) {
|
|
40
|
+
const scheme = useColorScheme();
|
|
41
|
+
const colors = getColors(scheme);
|
|
42
|
+
const [open, setOpen] = useState(false);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View style={style}>
|
|
46
|
+
<Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
|
|
47
|
+
<TouchableOpacity
|
|
48
|
+
onPress={() => setOpen(true)}
|
|
49
|
+
accessibilityRole="button"
|
|
50
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
51
|
+
style={[styles.trigger, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
|
52
|
+
>
|
|
53
|
+
<Text style={{ color: colors.textPrimary }}>{optionTitle(selection)}</Text>
|
|
54
|
+
<Text style={{ color: colors.textSecondary }}>{'⌄'}</Text>
|
|
55
|
+
</TouchableOpacity>
|
|
56
|
+
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
|
57
|
+
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
|
58
|
+
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
|
|
59
|
+
<FlatList
|
|
60
|
+
data={options}
|
|
61
|
+
keyExtractor={(item, index) => `${optionTitle(item)}-${index}`}
|
|
62
|
+
renderItem={({ item }) => (
|
|
63
|
+
<TouchableOpacity
|
|
64
|
+
onPress={() => {
|
|
65
|
+
onSelectionChange(item);
|
|
66
|
+
setOpen(false);
|
|
67
|
+
}}
|
|
68
|
+
style={styles.option}
|
|
69
|
+
>
|
|
70
|
+
<Text style={{ color: colors.textPrimary }}>{optionTitle(item)}</Text>
|
|
71
|
+
</TouchableOpacity>
|
|
72
|
+
)}
|
|
73
|
+
/>
|
|
74
|
+
</View>
|
|
75
|
+
</Pressable>
|
|
76
|
+
</Modal>
|
|
77
|
+
</View>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const styles = StyleSheet.create({
|
|
82
|
+
label: {
|
|
83
|
+
fontSize: fontSizes.sm,
|
|
84
|
+
marginBottom: spacing.xs,
|
|
85
|
+
},
|
|
86
|
+
trigger: {
|
|
87
|
+
minHeight: 44,
|
|
88
|
+
borderWidth: 1,
|
|
89
|
+
borderRadius: radius.md,
|
|
90
|
+
paddingHorizontal: spacing.md,
|
|
91
|
+
flexDirection: 'row',
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
justifyContent: 'space-between',
|
|
94
|
+
},
|
|
95
|
+
backdrop: {
|
|
96
|
+
flex: 1,
|
|
97
|
+
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
98
|
+
justifyContent: 'flex-end',
|
|
99
|
+
},
|
|
100
|
+
sheet: {
|
|
101
|
+
borderTopLeftRadius: radius.lg,
|
|
102
|
+
borderTopRightRadius: radius.lg,
|
|
103
|
+
maxHeight: '60%',
|
|
104
|
+
},
|
|
105
|
+
option: {
|
|
106
|
+
minHeight: 44,
|
|
107
|
+
justifyContent: 'center',
|
|
108
|
+
paddingHorizontal: spacing.md,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle, useColorScheme } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { getColors } from '../../tokens/colors';
|
|
5
|
+
import { radius } from '../../tokens/radius';
|
|
6
|
+
import { spacing } from '../../tokens/spacing';
|
|
7
|
+
import { fontSizes, fontWeights } from '../../tokens/typography';
|
|
8
|
+
|
|
9
|
+
export interface QuantityStepperProps {
|
|
10
|
+
value: number;
|
|
11
|
+
onValueChange: (value: number) => void;
|
|
12
|
+
minimumValue?: number;
|
|
13
|
+
maximumValue?: number;
|
|
14
|
+
step?: number;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A +/- quantity control, bounded to [minimumValue, maximumValue]. */
|
|
19
|
+
export const QuantityStepper: React.FC<QuantityStepperProps> = ({
|
|
20
|
+
value,
|
|
21
|
+
onValueChange,
|
|
22
|
+
minimumValue = 0,
|
|
23
|
+
maximumValue = 99,
|
|
24
|
+
step = 1,
|
|
25
|
+
style,
|
|
26
|
+
}) => {
|
|
27
|
+
const scheme = useColorScheme();
|
|
28
|
+
const colors = getColors(scheme);
|
|
29
|
+
const atMin = value <= minimumValue;
|
|
30
|
+
const atMax = value >= maximumValue;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<View
|
|
34
|
+
style={[styles.container, { borderColor: colors.border }, style]}
|
|
35
|
+
accessibilityLabel={`Quantity: ${value}`}
|
|
36
|
+
>
|
|
37
|
+
<TouchableOpacity
|
|
38
|
+
onPress={() => onValueChange(Math.max(minimumValue, value - step))}
|
|
39
|
+
disabled={atMin}
|
|
40
|
+
accessibilityRole="button"
|
|
41
|
+
accessibilityLabel="Decrease"
|
|
42
|
+
style={styles.button}
|
|
43
|
+
>
|
|
44
|
+
<Text style={{ color: atMin ? colors.disabledText : colors.primary, fontWeight: fontWeights.bold }}>
|
|
45
|
+
{'−'}
|
|
46
|
+
</Text>
|
|
47
|
+
</TouchableOpacity>
|
|
48
|
+
<Text style={{ color: colors.textPrimary, fontSize: fontSizes.md, minWidth: 24, textAlign: 'center' }}>
|
|
49
|
+
{value}
|
|
50
|
+
</Text>
|
|
51
|
+
<TouchableOpacity
|
|
52
|
+
onPress={() => onValueChange(Math.min(maximumValue, value + step))}
|
|
53
|
+
disabled={atMax}
|
|
54
|
+
accessibilityRole="button"
|
|
55
|
+
accessibilityLabel="Increase"
|
|
56
|
+
style={styles.button}
|
|
57
|
+
>
|
|
58
|
+
<Text style={{ color: atMax ? colors.disabledText : colors.primary, fontWeight: fontWeights.bold }}>
|
|
59
|
+
{'+'}
|
|
60
|
+
</Text>
|
|
61
|
+
</TouchableOpacity>
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const styles = StyleSheet.create({
|
|
67
|
+
container: {
|
|
68
|
+
flexDirection: 'row',
|
|
69
|
+
alignItems: 'center',
|
|
70
|
+
borderWidth: 1,
|
|
71
|
+
borderRadius: radius.md,
|
|
72
|
+
alignSelf: 'flex-start',
|
|
73
|
+
},
|
|
74
|
+
button: {
|
|
75
|
+
width: 44,
|
|
76
|
+
height: 44,
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
justifyContent: 'center',
|
|
79
|
+
paddingHorizontal: spacing.xs,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
useColorScheme,
|
|
8
|
+
View,
|
|
9
|
+
ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { getColors } from '../../tokens/colors';
|
|
13
|
+
import { spacing } from '../../tokens/spacing';
|
|
14
|
+
|
|
15
|
+
export interface RadioButtonInputProps {
|
|
16
|
+
label: string;
|
|
17
|
+
selected: boolean;
|
|
18
|
+
onPress: () => void;
|
|
19
|
+
style?: StyleProp<ViewStyle>;
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single labeled radio option. Compose several with shared parent state to
|
|
25
|
+
* build a radio group. React Native has no core `RadioButton`, so this owns
|
|
26
|
+
* the name.
|
|
27
|
+
*/
|
|
28
|
+
export const RadioButtonInput: React.FC<RadioButtonInputProps> = ({
|
|
29
|
+
label,
|
|
30
|
+
selected,
|
|
31
|
+
onPress,
|
|
32
|
+
style,
|
|
33
|
+
accessibilityLabel,
|
|
34
|
+
}) => {
|
|
35
|
+
const scheme = useColorScheme();
|
|
36
|
+
const colors = getColors(scheme);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<TouchableOpacity
|
|
40
|
+
onPress={onPress}
|
|
41
|
+
accessibilityRole="radio"
|
|
42
|
+
accessibilityState={{ selected }}
|
|
43
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
44
|
+
style={[styles.container, style]}
|
|
45
|
+
>
|
|
46
|
+
<View style={[styles.outer, { borderColor: selected ? colors.primary : colors.border }]}>
|
|
47
|
+
{selected ? <View style={[styles.inner, { backgroundColor: colors.primary }]} /> : null}
|
|
48
|
+
</View>
|
|
49
|
+
<Text style={{ color: colors.textPrimary, marginLeft: spacing.sm }}>{label}</Text>
|
|
50
|
+
</TouchableOpacity>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const styles = StyleSheet.create({
|
|
55
|
+
container: {
|
|
56
|
+
minHeight: 44,
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
},
|
|
60
|
+
outer: {
|
|
61
|
+
width: 22,
|
|
62
|
+
height: 22,
|
|
63
|
+
borderRadius: 11,
|
|
64
|
+
borderWidth: 2,
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
},
|
|
68
|
+
inner: {
|
|
69
|
+
width: 12,
|
|
70
|
+
height: 12,
|
|
71
|
+
borderRadius: 6,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
TextInput as RNTextInput,
|
|
7
|
+
TouchableOpacity,
|
|
8
|
+
useColorScheme,
|
|
9
|
+
View,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
|
|
13
|
+
import { getColors } from '../../tokens/colors';
|
|
14
|
+
import { radius } from '../../tokens/radius';
|
|
15
|
+
import { spacing } from '../../tokens/spacing';
|
|
16
|
+
import { fontSizes } from '../../tokens/typography';
|
|
17
|
+
|
|
18
|
+
export interface SearchInputProps {
|
|
19
|
+
value: string;
|
|
20
|
+
onChangeText: (text: string) => void;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
debounceMs?: number;
|
|
23
|
+
onSearchTextChanged?: (text: string) => void;
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
25
|
+
accessibilityLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A search field with a leading icon, trailing clear button, and built-in
|
|
30
|
+
* debounce. Named `SearchInput` to match this library's `*Input` naming
|
|
31
|
+
* convention (see `TextInput`/`SecureInput`).
|
|
32
|
+
*/
|
|
33
|
+
export const SearchInput: React.FC<SearchInputProps> = ({
|
|
34
|
+
value,
|
|
35
|
+
onChangeText,
|
|
36
|
+
placeholder = 'Search',
|
|
37
|
+
debounceMs = 300,
|
|
38
|
+
onSearchTextChanged,
|
|
39
|
+
style,
|
|
40
|
+
accessibilityLabel,
|
|
41
|
+
}) => {
|
|
42
|
+
const scheme = useColorScheme();
|
|
43
|
+
const colors = getColors(scheme);
|
|
44
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!onSearchTextChanged) return;
|
|
48
|
+
if (timer.current !== undefined) clearTimeout(timer.current);
|
|
49
|
+
timer.current = setTimeout(() => onSearchTextChanged(value), debounceMs);
|
|
50
|
+
return () => {
|
|
51
|
+
if (timer.current !== undefined) clearTimeout(timer.current);
|
|
52
|
+
};
|
|
53
|
+
}, [value, debounceMs, onSearchTextChanged]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View
|
|
57
|
+
style={[
|
|
58
|
+
styles.container,
|
|
59
|
+
{ backgroundColor: colors.surface, borderColor: colors.border },
|
|
60
|
+
style,
|
|
61
|
+
]}
|
|
62
|
+
>
|
|
63
|
+
<Text style={{ color: colors.textSecondary }}>{'\u{1F50D}'}</Text>
|
|
64
|
+
<RNTextInput
|
|
65
|
+
value={value}
|
|
66
|
+
onChangeText={onChangeText}
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
placeholderTextColor={colors.textSecondary}
|
|
69
|
+
accessibilityLabel={accessibilityLabel ?? placeholder}
|
|
70
|
+
style={[styles.input, { color: colors.textPrimary }]}
|
|
71
|
+
/>
|
|
72
|
+
{value.length > 0 ? (
|
|
73
|
+
<TouchableOpacity
|
|
74
|
+
onPress={() => onChangeText('')}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel="Clear search"
|
|
77
|
+
style={styles.clear}
|
|
78
|
+
>
|
|
79
|
+
<Text style={{ color: colors.textSecondary }}>{'✕'}</Text>
|
|
80
|
+
</TouchableOpacity>
|
|
81
|
+
) : null}
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const styles = StyleSheet.create({
|
|
87
|
+
container: {
|
|
88
|
+
minHeight: 44,
|
|
89
|
+
flexDirection: 'row',
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
borderWidth: 1,
|
|
92
|
+
borderRadius: radius.md,
|
|
93
|
+
paddingHorizontal: spacing.sm,
|
|
94
|
+
},
|
|
95
|
+
input: {
|
|
96
|
+
flex: 1,
|
|
97
|
+
fontSize: fontSizes.md,
|
|
98
|
+
minHeight: 44,
|
|
99
|
+
marginLeft: spacing.xs,
|
|
100
|
+
},
|
|
101
|
+
clear: {
|
|
102
|
+
minWidth: 44,
|
|
103
|
+
minHeight: 44,
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
justifyContent: 'center',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
StyleProp,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
TextInput as RNTextInput,
|
|
7
|
+
TextInputProps as RNTextInputProps,
|
|
8
|
+
TouchableOpacity,
|
|
9
|
+
useColorScheme,
|
|
10
|
+
View,
|
|
11
|
+
ViewStyle,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
|
|
14
|
+
import { getColors } from '../../tokens/colors';
|
|
15
|
+
import { radius } from '../../tokens/radius';
|
|
16
|
+
import { spacing } from '../../tokens/spacing';
|
|
17
|
+
import { fontSizes, fontWeights } from '../../tokens/typography';
|
|
18
|
+
|
|
19
|
+
export interface SecureInputProps
|
|
20
|
+
extends Omit<RNTextInputProps, 'style' | 'secureTextEntry'> {
|
|
21
|
+
label: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
style?: StyleProp<ViewStyle>;
|
|
24
|
+
accessibilityLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SecureInput: React.FC<SecureInputProps> = ({
|
|
28
|
+
label,
|
|
29
|
+
error,
|
|
30
|
+
style,
|
|
31
|
+
accessibilityLabel,
|
|
32
|
+
...rest
|
|
33
|
+
}) => {
|
|
34
|
+
const scheme = useColorScheme();
|
|
35
|
+
const colors = getColors(scheme);
|
|
36
|
+
const [visible, setVisible] = useState(false);
|
|
37
|
+
const hasError = Boolean(error);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={style}>
|
|
41
|
+
<Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
|
|
42
|
+
<View
|
|
43
|
+
style={[
|
|
44
|
+
styles.inputRow,
|
|
45
|
+
{
|
|
46
|
+
backgroundColor: colors.surface,
|
|
47
|
+
borderColor: hasError ? colors.error : colors.border,
|
|
48
|
+
},
|
|
49
|
+
]}
|
|
50
|
+
>
|
|
51
|
+
<RNTextInput
|
|
52
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
53
|
+
placeholderTextColor={colors.textSecondary}
|
|
54
|
+
secureTextEntry={!visible}
|
|
55
|
+
style={[styles.input, { color: colors.textPrimary }]}
|
|
56
|
+
{...rest}
|
|
57
|
+
/>
|
|
58
|
+
<TouchableOpacity
|
|
59
|
+
onPress={() => setVisible((v) => !v)}
|
|
60
|
+
accessibilityRole="button"
|
|
61
|
+
accessibilityLabel={visible ? 'Hide password' : 'Show password'}
|
|
62
|
+
style={styles.toggle}
|
|
63
|
+
>
|
|
64
|
+
<Text style={{ color: colors.primary, fontWeight: fontWeights.medium }}>
|
|
65
|
+
{visible ? 'Hide' : 'Show'}
|
|
66
|
+
</Text>
|
|
67
|
+
</TouchableOpacity>
|
|
68
|
+
</View>
|
|
69
|
+
{hasError ? (
|
|
70
|
+
<Text style={[styles.error, { color: colors.error }]}>{error}</Text>
|
|
71
|
+
) : null}
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
label: {
|
|
78
|
+
fontSize: fontSizes.sm,
|
|
79
|
+
fontWeight: fontWeights.medium,
|
|
80
|
+
marginBottom: spacing.xs,
|
|
81
|
+
},
|
|
82
|
+
inputRow: {
|
|
83
|
+
minHeight: 44,
|
|
84
|
+
flexDirection: 'row',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
borderWidth: 1,
|
|
87
|
+
borderRadius: radius.md,
|
|
88
|
+
paddingHorizontal: spacing.md,
|
|
89
|
+
},
|
|
90
|
+
input: {
|
|
91
|
+
flex: 1,
|
|
92
|
+
fontSize: fontSizes.md,
|
|
93
|
+
minHeight: 44,
|
|
94
|
+
},
|
|
95
|
+
toggle: {
|
|
96
|
+
minWidth: 44,
|
|
97
|
+
minHeight: 44,
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
},
|
|
101
|
+
error: {
|
|
102
|
+
fontSize: fontSizes.xs,
|
|
103
|
+
marginTop: spacing.xs,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle, useColorScheme } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { getColors } from '../../tokens/colors';
|
|
5
|
+
import { radius } from '../../tokens/radius';
|
|
6
|
+
import { spacing } from '../../tokens/spacing';
|
|
7
|
+
import { fontSizes } from '../../tokens/typography';
|
|
8
|
+
|
|
9
|
+
export interface SegmentedControlProps<T> {
|
|
10
|
+
options: T[];
|
|
11
|
+
selection: T;
|
|
12
|
+
onSelectionChange: (option: T) => void;
|
|
13
|
+
optionTitle: (option: T) => string;
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An inline, single-row segmented picker for switching between a small set
|
|
19
|
+
* of content states — distinct from `TabBar`/`BottomNavigationBar`, which
|
|
20
|
+
* are for primary app navigation.
|
|
21
|
+
*/
|
|
22
|
+
export function SegmentedControl<T>({
|
|
23
|
+
options,
|
|
24
|
+
selection,
|
|
25
|
+
onSelectionChange,
|
|
26
|
+
optionTitle,
|
|
27
|
+
style,
|
|
28
|
+
}: SegmentedControlProps<T>) {
|
|
29
|
+
const scheme = useColorScheme();
|
|
30
|
+
const colors = getColors(scheme);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<View style={[styles.container, { backgroundColor: colors.surface, borderColor: colors.border }, style]}>
|
|
34
|
+
{options.map((option, index) => {
|
|
35
|
+
const selected = option === selection;
|
|
36
|
+
return (
|
|
37
|
+
<TouchableOpacity
|
|
38
|
+
key={index}
|
|
39
|
+
onPress={() => onSelectionChange(option)}
|
|
40
|
+
accessibilityRole="button"
|
|
41
|
+
accessibilityState={{ selected }}
|
|
42
|
+
accessibilityLabel={optionTitle(option)}
|
|
43
|
+
style={[styles.segment, selected ? { backgroundColor: colors.primary } : null]}
|
|
44
|
+
>
|
|
45
|
+
<Text style={{ color: selected ? colors.primaryText : colors.textPrimary, fontSize: fontSizes.sm }}>
|
|
46
|
+
{optionTitle(option)}
|
|
47
|
+
</Text>
|
|
48
|
+
</TouchableOpacity>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
container: {
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
borderWidth: 1,
|
|
59
|
+
borderRadius: radius.md,
|
|
60
|
+
padding: 2,
|
|
61
|
+
},
|
|
62
|
+
segment: {
|
|
63
|
+
flex: 1,
|
|
64
|
+
minHeight: 40,
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
borderRadius: radius.sm,
|
|
68
|
+
paddingHorizontal: spacing.sm,
|
|
69
|
+
},
|
|
70
|
+
});
|