@alexisapp/leave-mobile 0.0.1-beta.1

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 (42) hide show
  1. package/README.md +133 -0
  2. package/package.json +43 -0
  3. package/src/components/ElementBoundary.tsx +45 -0
  4. package/src/components/ErrorFallback.tsx +70 -0
  5. package/src/components/cards/LeaveCard.tsx +119 -0
  6. package/src/components/cards/LeaveCardList.tsx +55 -0
  7. package/src/features/balance/components/BalanceTile.tsx +28 -0
  8. package/src/features/balance/components/BalanceTiles.tsx +44 -0
  9. package/src/features/balance/index.ts +1 -0
  10. package/src/features/history/components/LeaveHistory.tsx +10 -0
  11. package/src/features/history/index.ts +1 -0
  12. package/src/features/index.ts +3 -0
  13. package/src/features/upcoming/components/UpcomingLeaves.tsx +10 -0
  14. package/src/features/upcoming/index.ts +1 -0
  15. package/src/index.ts +8 -0
  16. package/src/providers/LeaveProvider.tsx +94 -0
  17. package/src/screens/LeaveScreen.tsx +58 -0
  18. package/src/screens/index.ts +2 -0
  19. package/src/theme/LeaveThemeContext.tsx +21 -0
  20. package/src/theme/index.ts +12 -0
  21. package/src/theme/tokens.ts +211 -0
  22. package/src/ui/components/Button.tsx +107 -0
  23. package/src/ui/components/Card.tsx +41 -0
  24. package/src/ui/components/DatePicker.tsx +220 -0
  25. package/src/ui/components/Dialog.tsx +186 -0
  26. package/src/ui/components/Drawer.tsx +71 -0
  27. package/src/ui/components/InlineItem.tsx +33 -0
  28. package/src/ui/components/Input.tsx +77 -0
  29. package/src/ui/components/RadioGroup.tsx +94 -0
  30. package/src/ui/components/ScrollArea.tsx +23 -0
  31. package/src/ui/components/Select.tsx +145 -0
  32. package/src/ui/components/Sheet.tsx +85 -0
  33. package/src/ui/components/State.tsx +110 -0
  34. package/src/ui/components/Tabs.tsx +115 -0
  35. package/src/ui/components/TextArea.tsx +80 -0
  36. package/src/ui/components/Tooltip.tsx +64 -0
  37. package/src/ui/components/Typography.tsx +30 -0
  38. package/src/ui/components/useToast.ts +20 -0
  39. package/src/ui/index.ts +17 -0
  40. package/src/ui/theme.ts +2 -0
  41. package/src/utils/__tests__/leaveStatusColorsUtils.test.ts +70 -0
  42. package/src/utils/leaveStatusColorsUtils.ts +34 -0
@@ -0,0 +1,186 @@
1
+ import React, { useMemo } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ Modal,
7
+ ScrollView,
8
+ ViewStyle,
9
+ TextStyle,
10
+ } from 'react-native';
11
+ import { useTheme } from '../theme';
12
+
13
+ interface DialogButton {
14
+ children: React.ReactNode;
15
+ onClick?: () => void;
16
+ disabled?: boolean;
17
+ closesDialog?: boolean;
18
+ variant?: 'primary' | 'secondary' | 'tertiary';
19
+ }
20
+
21
+ interface DialogProps {
22
+ open?: boolean;
23
+ onOpenChange?: (open: boolean) => void;
24
+ title?: string;
25
+ body?: React.ReactNode;
26
+ dismissable?: boolean;
27
+ rightFooterButtons?: DialogButton[];
28
+ leftFooterButtons?: DialogButton[];
29
+ zIndex?: number;
30
+ }
31
+
32
+ export const Dialog: React.FC<DialogProps> = ({
33
+ open = false,
34
+ onOpenChange,
35
+ title,
36
+ body,
37
+ dismissable = true,
38
+ rightFooterButtons = [],
39
+ leftFooterButtons = [],
40
+ }) => {
41
+ const { colors, spacing, radius, typography, opacity } = useTheme();
42
+
43
+ const handleClose = () => {
44
+ if (dismissable) onOpenChange?.(false);
45
+ };
46
+
47
+ const handleButtonPress = (btn: DialogButton) => {
48
+ btn.onClick?.();
49
+ if (btn.closesDialog) onOpenChange?.(false);
50
+ };
51
+
52
+ const s = useMemo(() => {
53
+ const btnVariants: Record<string, ViewStyle> = {
54
+ primary: { backgroundColor: colors.primary },
55
+ secondary: { backgroundColor: colors.surfaceSecondary },
56
+ tertiary: { backgroundColor: colors.transparent },
57
+ };
58
+ return {
59
+ overlay: {
60
+ flex: 1,
61
+ backgroundColor: colors.overlay,
62
+ justifyContent: 'center' as const,
63
+ padding: spacing.xxl,
64
+ } as ViewStyle,
65
+ dialog: {
66
+ backgroundColor: colors.surface,
67
+ borderRadius: radius.lg,
68
+ overflow: 'hidden' as const,
69
+ shadowColor: colors.shadow,
70
+ shadowOffset: { width: 0, height: 8 },
71
+ shadowOpacity: 0.2,
72
+ shadowRadius: 24,
73
+ elevation: 12,
74
+ } as ViewStyle,
75
+ header: {
76
+ flexDirection: 'row' as const,
77
+ alignItems: 'center' as const,
78
+ justifyContent: 'space-between' as const,
79
+ paddingHorizontal: spacing.xl,
80
+ paddingVertical: spacing.lg,
81
+ borderBottomWidth: 1,
82
+ borderBottomColor: colors.surfaceSecondary,
83
+ } as ViewStyle,
84
+ title: {
85
+ fontSize: typography.body.fontSize,
86
+ fontWeight: '700' as const,
87
+ color: colors.textPrimary,
88
+ flex: 1,
89
+ } as TextStyle,
90
+ closeBtn: { padding: spacing.xs } as ViewStyle,
91
+ closeText: { fontSize: typography.body.fontSize, color: colors.textMuted } as TextStyle,
92
+ body: {
93
+ paddingHorizontal: spacing.xl,
94
+ paddingVertical: spacing.lg,
95
+ maxHeight: 400,
96
+ } as ViewStyle,
97
+ bodyText: { ...typography.bodySmall, color: colors.textTertiary } as TextStyle,
98
+ footer: {
99
+ flexDirection: 'row' as const,
100
+ justifyContent: 'space-between' as const,
101
+ padding: spacing.lg,
102
+ borderTopWidth: 1,
103
+ borderTopColor: colors.surfaceSecondary,
104
+ } as ViewStyle,
105
+ footerSide: { flexDirection: 'row' as const, gap: spacing.sm } as ViewStyle,
106
+ btn: {
107
+ paddingHorizontal: spacing.lg,
108
+ paddingVertical: spacing.sm,
109
+ borderRadius: radius.md,
110
+ alignItems: 'center' as const,
111
+ } as ViewStyle,
112
+ btnVariants,
113
+ btnDisabled: { opacity: opacity.disabled } as ViewStyle,
114
+ btnText: {
115
+ fontSize: typography.bodySmall.fontSize,
116
+ fontWeight: '600' as const,
117
+ color: colors.onPrimary,
118
+ } as TextStyle,
119
+ btnTextTertiary: { color: colors.primary } as TextStyle,
120
+ };
121
+ }, [colors, spacing, radius, typography, opacity]);
122
+
123
+ return (
124
+ <Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
125
+ <TouchableOpacity style={s.overlay} activeOpacity={1} onPress={handleClose}>
126
+ <TouchableOpacity style={s.dialog} activeOpacity={1} onPress={() => {}}>
127
+ {title && (
128
+ <View style={s.header}>
129
+ <Text style={s.title}>{title}</Text>
130
+ {dismissable && (
131
+ <TouchableOpacity onPress={handleClose} style={s.closeBtn}>
132
+ <Text style={s.closeText}>✕</Text>
133
+ </TouchableOpacity>
134
+ )}
135
+ </View>
136
+ )}
137
+ {body && (
138
+ <ScrollView style={s.body} showsVerticalScrollIndicator={false}>
139
+ {typeof body === 'string' ? <Text style={s.bodyText}>{body}</Text> : body}
140
+ </ScrollView>
141
+ )}
142
+ {(leftFooterButtons.length > 0 || rightFooterButtons.length > 0) && (
143
+ <View style={s.footer}>
144
+ <View style={s.footerSide}>
145
+ {leftFooterButtons.map((btn, i) => (
146
+ <TouchableOpacity
147
+ key={i}
148
+ style={[
149
+ s.btn,
150
+ s.btnVariants[btn.variant ?? 'secondary'],
151
+ btn.disabled && s.btnDisabled,
152
+ ]}
153
+ onPress={() => !btn.disabled && handleButtonPress(btn)}
154
+ disabled={btn.disabled}
155
+ >
156
+ <Text style={[s.btnText, btn.variant === 'tertiary' && s.btnTextTertiary]}>
157
+ {btn.children}
158
+ </Text>
159
+ </TouchableOpacity>
160
+ ))}
161
+ </View>
162
+ <View style={s.footerSide}>
163
+ {rightFooterButtons.map((btn, i) => (
164
+ <TouchableOpacity
165
+ key={i}
166
+ style={[
167
+ s.btn,
168
+ s.btnVariants[btn.variant ?? 'primary'],
169
+ btn.disabled && s.btnDisabled,
170
+ ]}
171
+ onPress={() => !btn.disabled && handleButtonPress(btn)}
172
+ disabled={btn.disabled}
173
+ >
174
+ <Text style={[s.btnText, btn.variant === 'tertiary' && s.btnTextTertiary]}>
175
+ {btn.children}
176
+ </Text>
177
+ </TouchableOpacity>
178
+ ))}
179
+ </View>
180
+ </View>
181
+ )}
182
+ </TouchableOpacity>
183
+ </TouchableOpacity>
184
+ </Modal>
185
+ );
186
+ };
@@ -0,0 +1,71 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, TouchableOpacity, Modal, StyleSheet, Dimensions, ViewStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ const SCREEN_WIDTH = Dimensions.get('window').width;
6
+ const DRAWER_WIDTH = Math.min(400, SCREEN_WIDTH * 0.85);
7
+
8
+ interface DrawerProps {
9
+ open?: boolean;
10
+ onOpenChange?: (open: boolean) => void;
11
+ trigger?: React.ReactNode;
12
+ content?: React.ReactNode;
13
+ dismissable?: boolean;
14
+ overlayMode?: 'colored' | 'transparent';
15
+ position?: 'left' | 'right';
16
+ zIndex?: number;
17
+ }
18
+
19
+ export const Drawer: React.FC<DrawerProps> = ({
20
+ open = false,
21
+ onOpenChange,
22
+ trigger,
23
+ content,
24
+ dismissable = true,
25
+ overlayMode = 'colored',
26
+ position = 'right',
27
+ }) => {
28
+ const { colors, opacity } = useTheme();
29
+
30
+ const handleClose = () => {
31
+ if (dismissable) onOpenChange?.(false);
32
+ };
33
+
34
+ const s = useMemo(
35
+ () => ({
36
+ wrapper: { flex: 1, flexDirection: 'row' as const } as ViewStyle,
37
+ overlay: { ...StyleSheet.absoluteFillObject } as ViewStyle,
38
+ overlayColored: { backgroundColor: colors.overlay } as ViewStyle,
39
+ drawer: {
40
+ width: DRAWER_WIDTH,
41
+ backgroundColor: colors.surface,
42
+ shadowColor: colors.shadow,
43
+ shadowOffset: { width: -4, height: 0 },
44
+ shadowOpacity: opacity.shadowMedium,
45
+ shadowRadius: 12,
46
+ elevation: 12,
47
+ } as ViewStyle,
48
+ drawerRight: { marginLeft: 'auto' as const } as ViewStyle,
49
+ drawerLeft: { marginRight: 'auto' as const } as ViewStyle,
50
+ }),
51
+ [colors, opacity]
52
+ );
53
+
54
+ return (
55
+ <>
56
+ {trigger}
57
+ <Modal visible={open} transparent animationType="slide" onRequestClose={handleClose}>
58
+ <View style={s.wrapper}>
59
+ <TouchableOpacity
60
+ style={[s.overlay, overlayMode === 'colored' && s.overlayColored]}
61
+ activeOpacity={1}
62
+ onPress={handleClose}
63
+ />
64
+ <View style={[s.drawer, position === 'right' ? s.drawerRight : s.drawerLeft]}>
65
+ {content}
66
+ </View>
67
+ </View>
68
+ </Modal>
69
+ </>
70
+ );
71
+ };
@@ -0,0 +1,33 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, Text, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface InlineItemProps {
6
+ leadingIcon?: string;
7
+ children?: React.ReactNode;
8
+ style?: ViewStyle;
9
+ }
10
+
11
+ export const InlineItem: React.FC<InlineItemProps> = ({ leadingIcon, children, style }) => {
12
+ const { colors, spacing, typography } = useTheme();
13
+
14
+ const s = useMemo(
15
+ () => ({
16
+ container: {
17
+ flexDirection: 'row' as const,
18
+ alignItems: 'center' as const,
19
+ gap: spacing.sm,
20
+ } as ViewStyle,
21
+ icon: { fontSize: typography.body.fontSize, color: colors.textMuted } as TextStyle,
22
+ content: { flex: 1 } as ViewStyle,
23
+ }),
24
+ [colors, spacing, typography]
25
+ );
26
+
27
+ return (
28
+ <View style={[s.container, style]}>
29
+ {leadingIcon && <Text style={s.icon}>{leadingIcon}</Text>}
30
+ <View style={s.content}>{children}</View>
31
+ </View>
32
+ );
33
+ };
@@ -0,0 +1,77 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { View, Text, TextInput, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface InputProps {
6
+ label?: string;
7
+ placeholder?: string;
8
+ value?: string;
9
+ onChangeText?: (value: string) => void;
10
+ type?: 'text' | 'password' | 'email' | 'number';
11
+ required?: boolean;
12
+ disabled?: boolean;
13
+ style?: ViewStyle;
14
+ }
15
+
16
+ export const Input: React.FC<InputProps> = ({
17
+ label,
18
+ placeholder,
19
+ value,
20
+ onChangeText,
21
+ type = 'text',
22
+ required = false,
23
+ disabled = false,
24
+ style,
25
+ }) => {
26
+ const [isFocused, setIsFocused] = useState(false);
27
+ const { colors, spacing, radius, typography } = useTheme();
28
+
29
+ const s = useMemo(
30
+ () => ({
31
+ container: { gap: spacing.xs } as ViewStyle,
32
+ label: { ...typography.label, color: colors.textSecondary } as TextStyle,
33
+ required: { color: colors.error } as TextStyle,
34
+ input: {
35
+ borderWidth: 1,
36
+ borderColor: colors.border,
37
+ borderRadius: radius.md,
38
+ paddingHorizontal: spacing.md,
39
+ paddingVertical: 10,
40
+ fontSize: typography.bodySmall.fontSize,
41
+ color: colors.textPrimary,
42
+ backgroundColor: colors.surface,
43
+ } as ViewStyle,
44
+ inputFocused: { borderColor: colors.primary } as ViewStyle,
45
+ inputDisabled: {
46
+ backgroundColor: colors.surfaceSecondary,
47
+ color: colors.textPlaceholder,
48
+ } as ViewStyle,
49
+ }),
50
+ [colors, spacing, radius, typography]
51
+ );
52
+
53
+ return (
54
+ <View style={[s.container, style]}>
55
+ {label && (
56
+ <Text style={s.label}>
57
+ {label}
58
+ {required && <Text style={s.required}> *</Text>}
59
+ </Text>
60
+ )}
61
+ <TextInput
62
+ style={[s.input, isFocused && s.inputFocused, disabled && s.inputDisabled]}
63
+ placeholder={placeholder}
64
+ placeholderTextColor={colors.textPlaceholder}
65
+ value={value}
66
+ onChangeText={onChangeText}
67
+ secureTextEntry={type === 'password'}
68
+ keyboardType={
69
+ type === 'email' ? 'email-address' : type === 'number' ? 'numeric' : 'default'
70
+ }
71
+ editable={!disabled}
72
+ onFocus={() => setIsFocused(true)}
73
+ onBlur={() => setIsFocused(false)}
74
+ />
75
+ </View>
76
+ );
77
+ };
@@ -0,0 +1,94 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, Text, TouchableOpacity, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface RadioItem {
6
+ value: string;
7
+ label: string;
8
+ }
9
+
10
+ interface RadioGroupProps {
11
+ id?: string;
12
+ name?: string;
13
+ label?: string;
14
+ value?: string;
15
+ onChange?: (value: string) => void;
16
+ items?: RadioItem[];
17
+ required?: boolean;
18
+ disabled?: boolean;
19
+ style?: ViewStyle;
20
+ }
21
+
22
+ export const RadioGroup: React.FC<RadioGroupProps> = ({
23
+ label,
24
+ value,
25
+ onChange,
26
+ items = [],
27
+ required = false,
28
+ disabled = false,
29
+ style,
30
+ }) => {
31
+ const { colors, spacing, typography, opacity } = useTheme();
32
+
33
+ const s = useMemo(
34
+ () => ({
35
+ container: { gap: spacing.sm } as ViewStyle,
36
+ label: { ...typography.label, color: colors.textSecondary } as TextStyle,
37
+ required: { color: colors.error } as TextStyle,
38
+ group: { gap: spacing.sm } as ViewStyle,
39
+ option: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 10 },
40
+ radio: {
41
+ width: 20,
42
+ height: 20,
43
+ borderRadius: 10,
44
+ borderWidth: 2,
45
+ borderColor: colors.border,
46
+ alignItems: 'center' as const,
47
+ justifyContent: 'center' as const,
48
+ } as ViewStyle,
49
+ radioSelected: { borderColor: colors.primary } as ViewStyle,
50
+ radioDisabled: { borderColor: colors.borderLight } as ViewStyle,
51
+ radioInner: {
52
+ width: 10,
53
+ height: 10,
54
+ borderRadius: 5,
55
+ backgroundColor: colors.primary,
56
+ } as ViewStyle,
57
+ optionText: {
58
+ fontSize: typography.bodySmall.fontSize,
59
+ color: colors.textPrimary,
60
+ } as TextStyle,
61
+ optionTextDisabled: { color: colors.textPlaceholder } as TextStyle,
62
+ }),
63
+ [colors, spacing, typography]
64
+ );
65
+
66
+ return (
67
+ <View style={[s.container, style]}>
68
+ {label && (
69
+ <Text style={s.label}>
70
+ {label}
71
+ {required && <Text style={s.required}> *</Text>}
72
+ </Text>
73
+ )}
74
+ <View style={s.group}>
75
+ {items.map((item) => {
76
+ const isSelected = item.value === value;
77
+ return (
78
+ <TouchableOpacity
79
+ key={item.value}
80
+ style={s.option}
81
+ onPress={() => !disabled && onChange?.(item.value)}
82
+ activeOpacity={opacity.activePress}
83
+ >
84
+ <View style={[s.radio, isSelected && s.radioSelected, disabled && s.radioDisabled]}>
85
+ {isSelected && <View style={s.radioInner} />}
86
+ </View>
87
+ <Text style={[s.optionText, disabled && s.optionTextDisabled]}>{item.label}</Text>
88
+ </TouchableOpacity>
89
+ );
90
+ })}
91
+ </View>
92
+ </View>
93
+ );
94
+ };
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { ScrollView, StyleSheet, ViewStyle } from 'react-native';
3
+
4
+ interface ScrollAreaProps {
5
+ children?: React.ReactNode;
6
+ style?: ViewStyle;
7
+ horizontal?: boolean;
8
+ }
9
+
10
+ export const ScrollArea: React.FC<ScrollAreaProps> = ({ children, style, horizontal = false }) => (
11
+ <ScrollView
12
+ style={[styles.container, style]}
13
+ horizontal={horizontal}
14
+ showsVerticalScrollIndicator={false}
15
+ showsHorizontalScrollIndicator={false}
16
+ >
17
+ {children}
18
+ </ScrollView>
19
+ );
20
+
21
+ const styles = StyleSheet.create({
22
+ container: { flex: 1 } as ViewStyle,
23
+ });
@@ -0,0 +1,145 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { View, Text, TouchableOpacity, Modal, FlatList, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface SelectItem {
6
+ id?: string;
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ interface SelectProps<T extends SelectItem = SelectItem> {
12
+ id?: string;
13
+ label?: string;
14
+ placeholder?: string;
15
+ value?: string;
16
+ onValueChange?: (value: string) => void;
17
+ items?: T[];
18
+ renderItem?: (item: T) => React.ReactNode;
19
+ required?: boolean;
20
+ disabled?: boolean;
21
+ style?: ViewStyle;
22
+ }
23
+
24
+ export const Select = <T extends SelectItem = SelectItem>({
25
+ label,
26
+ placeholder,
27
+ value,
28
+ onValueChange,
29
+ items = [],
30
+ renderItem,
31
+ required = false,
32
+ disabled = false,
33
+ style,
34
+ }: SelectProps<T>) => {
35
+ const [isOpen, setIsOpen] = useState(false);
36
+ const { colors, spacing, radius, typography, opacity } = useTheme();
37
+
38
+ const selectedItem = items.find((item) => item.value === value);
39
+ const displayValue = selectedItem
40
+ ? renderItem
41
+ ? String(renderItem(selectedItem))
42
+ : selectedItem.label
43
+ : undefined;
44
+
45
+ const s = useMemo(
46
+ () => ({
47
+ container: { gap: spacing.xs } as ViewStyle,
48
+ label: { ...typography.label, color: colors.textSecondary } as TextStyle,
49
+ required: { color: colors.error } as TextStyle,
50
+ trigger: {
51
+ flexDirection: 'row' as const,
52
+ justifyContent: 'space-between' as const,
53
+ alignItems: 'center' as const,
54
+ borderWidth: 1,
55
+ borderColor: colors.border,
56
+ borderRadius: radius.md,
57
+ paddingHorizontal: spacing.md,
58
+ paddingVertical: 10,
59
+ backgroundColor: colors.surface,
60
+ } as ViewStyle,
61
+ triggerDisabled: { backgroundColor: colors.surfaceSecondary } as ViewStyle,
62
+ triggerText: {
63
+ fontSize: typography.bodySmall.fontSize,
64
+ color: colors.textPrimary,
65
+ flex: 1,
66
+ } as TextStyle,
67
+ placeholder: { color: colors.textPlaceholder } as TextStyle,
68
+ chevron: { fontSize: typography.bodySmall.fontSize, color: colors.textMuted } as TextStyle,
69
+ overlay: {
70
+ flex: 1,
71
+ backgroundColor: colors.overlayLight,
72
+ justifyContent: 'center' as const,
73
+ padding: spacing.xxl,
74
+ } as ViewStyle,
75
+ dropdown: {
76
+ backgroundColor: colors.surface,
77
+ borderRadius: radius.md,
78
+ maxHeight: 300,
79
+ shadowColor: colors.shadow,
80
+ shadowOffset: { width: 0, height: 4 },
81
+ shadowOpacity: opacity.shadowMedium,
82
+ shadowRadius: 12,
83
+ elevation: 8,
84
+ } as ViewStyle,
85
+ option: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md } as ViewStyle,
86
+ optionSelected: { backgroundColor: colors.primarySurface } as ViewStyle,
87
+ optionText: {
88
+ fontSize: typography.bodySmall.fontSize,
89
+ color: colors.textPrimary,
90
+ } as TextStyle,
91
+ optionTextSelected: { color: colors.primary, fontWeight: '600' as const } as TextStyle,
92
+ }),
93
+ [colors, spacing, radius, typography, opacity]
94
+ );
95
+
96
+ return (
97
+ <View style={[s.container, style]}>
98
+ {label && (
99
+ <Text style={s.label}>
100
+ {label}
101
+ {required && <Text style={s.required}> *</Text>}
102
+ </Text>
103
+ )}
104
+ <TouchableOpacity
105
+ style={[s.trigger, disabled && s.triggerDisabled]}
106
+ onPress={() => !disabled && setIsOpen(true)}
107
+ activeOpacity={opacity.activePress}
108
+ >
109
+ <Text style={[s.triggerText, !displayValue && s.placeholder]}>
110
+ {displayValue ?? placeholder ?? 'Select...'}
111
+ </Text>
112
+ <Text style={s.chevron}>▾</Text>
113
+ </TouchableOpacity>
114
+
115
+ <Modal
116
+ visible={isOpen}
117
+ transparent
118
+ animationType="fade"
119
+ onRequestClose={() => setIsOpen(false)}
120
+ >
121
+ <TouchableOpacity style={s.overlay} activeOpacity={1} onPress={() => setIsOpen(false)}>
122
+ <View style={s.dropdown}>
123
+ <FlatList
124
+ data={items}
125
+ keyExtractor={(item) => item.id ?? item.value}
126
+ renderItem={({ item }) => (
127
+ <TouchableOpacity
128
+ style={[s.option, item.value === value && s.optionSelected]}
129
+ onPress={() => {
130
+ onValueChange?.(item.value);
131
+ setIsOpen(false);
132
+ }}
133
+ >
134
+ <Text style={[s.optionText, item.value === value && s.optionTextSelected]}>
135
+ {renderItem ? String(renderItem(item)) : item.label}
136
+ </Text>
137
+ </TouchableOpacity>
138
+ )}
139
+ />
140
+ </View>
141
+ </TouchableOpacity>
142
+ </Modal>
143
+ </View>
144
+ );
145
+ };