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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/package.json +56 -0
  4. package/src/__tests__/components.test.tsx +368 -0
  5. package/src/__tests__/tokens.test.ts +47 -0
  6. package/src/components/badges/Badge.tsx +74 -0
  7. package/src/components/buttons/DestructiveButton.tsx +82 -0
  8. package/src/components/buttons/GhostButton.tsx +77 -0
  9. package/src/components/buttons/IconButton.tsx +61 -0
  10. package/src/components/buttons/PrimaryButton.tsx +82 -0
  11. package/src/components/buttons/SecondaryButton.tsx +84 -0
  12. package/src/components/cards/CardView.tsx +45 -0
  13. package/src/components/display/Avatar.tsx +53 -0
  14. package/src/components/display/Chip.tsx +64 -0
  15. package/src/components/display/CountBadge.tsx +58 -0
  16. package/src/components/display/DividerLine.tsx +29 -0
  17. package/src/components/display/LazyImageView.tsx +57 -0
  18. package/src/components/display/ListRow.tsx +76 -0
  19. package/src/components/display/SectionHeader.tsx +55 -0
  20. package/src/components/display/StarRatingView.tsx +70 -0
  21. package/src/components/feedback/EmptyStateView.tsx +93 -0
  22. package/src/components/feedback/ErrorStateView.tsx +75 -0
  23. package/src/components/feedback/LoadingView.tsx +57 -0
  24. package/src/components/feedback/ProgressBar.tsx +42 -0
  25. package/src/components/feedback/PullToRefresh.tsx +38 -0
  26. package/src/components/feedback/ShimmerView.tsx +47 -0
  27. package/src/components/feedback/ToastView.tsx +71 -0
  28. package/src/components/inputs/CheckboxInput.tsx +73 -0
  29. package/src/components/inputs/Dropdown.tsx +110 -0
  30. package/src/components/inputs/QuantityStepper.tsx +81 -0
  31. package/src/components/inputs/RadioButtonInput.tsx +73 -0
  32. package/src/components/inputs/SearchInput.tsx +107 -0
  33. package/src/components/inputs/SecureInput.tsx +105 -0
  34. package/src/components/inputs/SegmentedControl.tsx +70 -0
  35. package/src/components/inputs/SliderInput.tsx +131 -0
  36. package/src/components/inputs/TextInput.tsx +90 -0
  37. package/src/components/inputs/ToggleSwitch.tsx +50 -0
  38. package/src/components/layout/KeyboardAvoidingScrollView.tsx +40 -0
  39. package/src/components/navigation/AppBar.tsx +53 -0
  40. package/src/components/navigation/BackButton.tsx +63 -0
  41. package/src/components/navigation/BottomNavigationBar.tsx +65 -0
  42. package/src/components/navigation/PagerView.tsx +40 -0
  43. package/src/components/navigation/TabBar.tsx +68 -0
  44. package/src/components/navigation/TabBarItem.ts +8 -0
  45. package/src/components/overlay/BottomSheet.tsx +64 -0
  46. package/src/components/overlay/CollapsibleView.tsx +82 -0
  47. package/src/components/overlay/ModalDialog.tsx +47 -0
  48. package/src/index.ts +54 -0
  49. package/src/tokens/colors.ts +84 -0
  50. package/src/tokens/index.ts +4 -0
  51. package/src/tokens/radius.ts +8 -0
  52. package/src/tokens/spacing.ts +10 -0
  53. package/src/tokens/typography.ts +27 -0
  54. 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
+ });