@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,58 @@
1
+ import { useMemo } from 'react';
2
+ import { ScrollView, StyleSheet } from 'react-native';
3
+ import { ElementBoundary } from '../components/ElementBoundary';
4
+ import { BalanceTiles, LeaveHistory, UpcomingLeaves } from '../features';
5
+ import { LeaveProvider } from '../providers/LeaveProvider';
6
+ import { useLeaveTheme, type LeaveTheme } from '../theme';
7
+
8
+ export interface LeaveScreenProps {
9
+ endpoint: string;
10
+ gatewayEndpoint?: string | undefined;
11
+ locale?: string | undefined;
12
+ getToken?: (() => Promise<string | null>) | undefined;
13
+ onAuthError?: (() => void) | undefined;
14
+ devToken?: string | undefined;
15
+ theme?: LeaveTheme | undefined;
16
+ onDismiss: () => void;
17
+ onError?: ((error: { code: string; message: string }) => void) | undefined;
18
+ }
19
+
20
+ export function LeaveScreen(props: LeaveScreenProps) {
21
+ const { endpoint, onDismiss, onError, ...providerProps } = props;
22
+
23
+ return (
24
+ <LeaveProvider endpoint={endpoint} {...providerProps}>
25
+ <LeaveScreenContent onDismiss={onDismiss} onError={onError} />
26
+ </LeaveProvider>
27
+ );
28
+ }
29
+
30
+ function LeaveScreenContent({
31
+ onDismiss,
32
+ onError,
33
+ }: Pick<LeaveScreenProps, 'onDismiss' | 'onError'>) {
34
+ const { colors, spacing } = useLeaveTheme();
35
+
36
+ const styles = useMemo(
37
+ () =>
38
+ StyleSheet.create({
39
+ container: { flex: 1, width: '100%', backgroundColor: colors.background },
40
+ content: { gap: spacing.xxxl, paddingHorizontal: spacing.xxs },
41
+ }),
42
+ [colors, spacing]
43
+ );
44
+
45
+ return (
46
+ <ScrollView style={styles.container} contentContainerStyle={styles.content}>
47
+ <ElementBoundary onDismiss={onDismiss} onError={onError}>
48
+ <BalanceTiles />
49
+ </ElementBoundary>
50
+ <ElementBoundary onDismiss={onDismiss} onError={onError}>
51
+ <UpcomingLeaves />
52
+ </ElementBoundary>
53
+ <ElementBoundary onDismiss={onDismiss} onError={onError}>
54
+ <LeaveHistory />
55
+ </ElementBoundary>
56
+ </ScrollView>
57
+ );
58
+ }
@@ -0,0 +1,2 @@
1
+ export { LeaveScreen } from './LeaveScreen';
2
+ export type { LeaveScreenProps } from './LeaveScreen';
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import { defaultLeaveTheme, type LeaveTheme } from './tokens';
3
+
4
+ const LeaveThemeContext = createContext<LeaveTheme>(defaultLeaveTheme);
5
+
6
+ export interface LeaveThemeProviderProps {
7
+ theme?: LeaveTheme | undefined;
8
+ children: ReactNode;
9
+ }
10
+
11
+ export function LeaveThemeProvider({ theme, children }: LeaveThemeProviderProps) {
12
+ return (
13
+ <LeaveThemeContext.Provider value={theme ?? defaultLeaveTheme}>
14
+ {children}
15
+ </LeaveThemeContext.Provider>
16
+ );
17
+ }
18
+
19
+ export function useLeaveTheme(): LeaveTheme {
20
+ return useContext(LeaveThemeContext);
21
+ }
@@ -0,0 +1,12 @@
1
+ export { LeaveThemeProvider, useLeaveTheme } from './LeaveThemeContext';
2
+ export type { LeaveThemeProviderProps } from './LeaveThemeContext';
3
+ export { defaultLeaveTheme, darkLeaveTheme } from './tokens';
4
+ export type {
5
+ LeaveTheme,
6
+ LeaveColorTokens,
7
+ LeaveSpacingTokens,
8
+ LeaveRadiusTokens,
9
+ LeaveTypographyTokens,
10
+ LeaveOpacityTokens,
11
+ TypographyStyle,
12
+ } from './tokens';
@@ -0,0 +1,211 @@
1
+ export interface TypographyStyle {
2
+ fontSize: number;
3
+ fontWeight: '400' | '500' | '600' | '700' | '800' | 'normal' | 'bold';
4
+ lineHeight?: number | undefined;
5
+ }
6
+
7
+ export interface LeaveColorTokens {
8
+ primary: string;
9
+ onPrimary: string;
10
+ primarySurface: string;
11
+ surface: string;
12
+ surfaceSecondary: string;
13
+ background: string;
14
+ textPrimary: string;
15
+ textSecondary: string;
16
+ textTertiary: string;
17
+ textMuted: string;
18
+ textPlaceholder: string;
19
+ border: string;
20
+ borderLight: string;
21
+ shadow: string;
22
+ overlay: string;
23
+ overlayLight: string;
24
+ disabled: string;
25
+ transparent: string;
26
+ error: string;
27
+ success: string;
28
+ warning: string;
29
+ info: string;
30
+ errorSurface: string;
31
+ errorBorder: string;
32
+ successSurface: string;
33
+ successBorder: string;
34
+ warningSurface: string;
35
+ warningBorder: string;
36
+ infoSurface: string;
37
+ infoBorder: string;
38
+ }
39
+
40
+ export interface LeaveSpacingTokens {
41
+ xxs: number;
42
+ xs: number;
43
+ sm: number;
44
+ md: number;
45
+ lg: number;
46
+ xl: number;
47
+ xxl: number;
48
+ xxxl: number;
49
+ }
50
+
51
+ export interface LeaveRadiusTokens {
52
+ xs: number;
53
+ sm: number;
54
+ md: number;
55
+ lg: number;
56
+ xl: number;
57
+ }
58
+
59
+ export interface LeaveTypographyTokens {
60
+ h1: TypographyStyle;
61
+ h2: TypographyStyle;
62
+ h3: TypographyStyle;
63
+ body: TypographyStyle;
64
+ bodySmall: TypographyStyle;
65
+ caption: TypographyStyle;
66
+ label: TypographyStyle;
67
+ }
68
+
69
+ export interface LeaveOpacityTokens {
70
+ disabled: number;
71
+ activePress: number;
72
+ shadowLight: number;
73
+ shadowMedium: number;
74
+ }
75
+
76
+ export interface LeaveTheme {
77
+ colors: LeaveColorTokens;
78
+ spacing: LeaveSpacingTokens;
79
+ radius: LeaveRadiusTokens;
80
+ typography: LeaveTypographyTokens;
81
+ opacity: LeaveOpacityTokens;
82
+ avatarPalette: string[];
83
+ }
84
+
85
+ export const defaultLeaveTheme: LeaveTheme = {
86
+ colors: {
87
+ primary: '#6366f1',
88
+ onPrimary: '#ffffff',
89
+ primarySurface: '#eef2ff',
90
+ surface: '#ffffff',
91
+ surfaceSecondary: '#f1f5f9',
92
+ background: '#f8fafc',
93
+ textPrimary: '#0f172a',
94
+ textSecondary: '#1e293b',
95
+ textTertiary: '#334155',
96
+ textMuted: '#64748b',
97
+ textPlaceholder: '#94a3b8',
98
+ border: '#cbd5e1',
99
+ borderLight: '#e2e8f0',
100
+ shadow: '#000000',
101
+ overlay: 'rgba(0,0,0,0.5)',
102
+ overlayLight: 'rgba(0,0,0,0.2)',
103
+ disabled: '#94a3b8',
104
+ transparent: 'transparent',
105
+ error: '#ef4444',
106
+ success: '#22c55e',
107
+ warning: '#f59e0b',
108
+ info: '#3b82f6',
109
+ errorSurface: '#fef2f2',
110
+ errorBorder: '#fecaca',
111
+ successSurface: '#f0fdf4',
112
+ successBorder: '#bbf7d0',
113
+ warningSurface: '#fffbeb',
114
+ warningBorder: '#fde68a',
115
+ infoSurface: '#eff6ff',
116
+ infoBorder: '#bfdbfe',
117
+ },
118
+ spacing: { xxs: 2, xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32 },
119
+ radius: { xs: 4, sm: 6, md: 8, lg: 12, xl: 16 },
120
+ typography: {
121
+ h1: { fontSize: 32, fontWeight: '800' },
122
+ h2: { fontSize: 24, fontWeight: '700' },
123
+ h3: { fontSize: 20, fontWeight: '700' },
124
+ body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
125
+ bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 22 },
126
+ caption: { fontSize: 12, fontWeight: '400' },
127
+ label: { fontSize: 14, fontWeight: '500' },
128
+ },
129
+ opacity: {
130
+ disabled: 0.5,
131
+ activePress: 0.7,
132
+ shadowLight: 0.1,
133
+ shadowMedium: 0.15,
134
+ },
135
+ avatarPalette: [
136
+ '#6366f1',
137
+ '#8b5cf6',
138
+ '#ec4899',
139
+ '#f43f5e',
140
+ '#f97316',
141
+ '#eab308',
142
+ '#22c55e',
143
+ '#14b8a6',
144
+ '#06b6d4',
145
+ '#3b82f6',
146
+ ],
147
+ };
148
+
149
+ export const darkLeaveTheme: LeaveTheme = {
150
+ colors: {
151
+ primary: '#818cf8',
152
+ onPrimary: '#1e1b4b',
153
+ primarySurface: '#312e81',
154
+ surface: '#1f2937',
155
+ surfaceSecondary: '#374151',
156
+ background: '#111827',
157
+ textPrimary: '#f9fafb',
158
+ textSecondary: '#e5e7eb',
159
+ textTertiary: '#d1d5db',
160
+ textMuted: '#9ca3af',
161
+ textPlaceholder: '#6b7280',
162
+ border: '#4b5563',
163
+ borderLight: '#374151',
164
+ shadow: '#000000',
165
+ overlay: 'rgba(0,0,0,0.7)',
166
+ overlayLight: 'rgba(0,0,0,0.4)',
167
+ disabled: '#6b7280',
168
+ transparent: 'transparent',
169
+ error: '#f87171',
170
+ success: '#34d399',
171
+ warning: '#fbbf24',
172
+ info: '#60a5fa',
173
+ errorSurface: '#450a0a',
174
+ errorBorder: '#7f1d1d',
175
+ successSurface: '#052e16',
176
+ successBorder: '#14532d',
177
+ warningSurface: '#451a03',
178
+ warningBorder: '#78350f',
179
+ infoSurface: '#172554',
180
+ infoBorder: '#1e3a5f',
181
+ },
182
+ spacing: { xxs: 2, xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24, xxxl: 32 },
183
+ radius: { xs: 4, sm: 6, md: 8, lg: 12, xl: 16 },
184
+ typography: {
185
+ h1: { fontSize: 32, fontWeight: '800' },
186
+ h2: { fontSize: 24, fontWeight: '700' },
187
+ h3: { fontSize: 20, fontWeight: '700' },
188
+ body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
189
+ bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 22 },
190
+ caption: { fontSize: 12, fontWeight: '400' },
191
+ label: { fontSize: 14, fontWeight: '500' },
192
+ },
193
+ opacity: {
194
+ disabled: 0.5,
195
+ activePress: 0.7,
196
+ shadowLight: 0.1,
197
+ shadowMedium: 0.15,
198
+ },
199
+ avatarPalette: [
200
+ '#818cf8',
201
+ '#a78bfa',
202
+ '#f472b6',
203
+ '#fb7185',
204
+ '#fb923c',
205
+ '#facc15',
206
+ '#34d399',
207
+ '#2dd4bf',
208
+ '#22d3ee',
209
+ '#60a5fa',
210
+ ],
211
+ };
@@ -0,0 +1,107 @@
1
+ import React, { useMemo } from 'react';
2
+ import { TouchableOpacity, Text, ActivityIndicator } from 'react-native';
3
+ import type { ViewStyle, TextStyle } from 'react-native';
4
+ import { useTheme } from '../theme';
5
+
6
+ interface ButtonProps {
7
+ children?: React.ReactNode;
8
+ title?: string;
9
+ onPress?: () => void;
10
+ variant?: 'primary' | 'secondary' | 'tertiary' | 'outline';
11
+ size?: 'small' | 'medium' | 'large';
12
+ disabled?: boolean;
13
+ loading?: boolean;
14
+ leadingIcon?: React.ReactNode;
15
+ trailingIcon?: React.ReactNode;
16
+ style?: ViewStyle;
17
+ }
18
+
19
+ export const Button: React.FC<ButtonProps> = ({
20
+ title,
21
+ children,
22
+ onPress,
23
+ variant = 'primary',
24
+ size = 'medium',
25
+ disabled = false,
26
+ loading = false,
27
+ leadingIcon,
28
+ trailingIcon,
29
+ style,
30
+ }) => {
31
+ const { colors, spacing, radius, typography, opacity } = useTheme();
32
+ const label = children ?? title ?? '';
33
+ const isDisabled = disabled || loading;
34
+ const hasLabel = label !== '';
35
+
36
+ const s = useMemo(() => {
37
+ const base: ViewStyle = {
38
+ flexDirection: 'row',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ borderRadius: radius.md,
42
+ gap: spacing.xs,
43
+ };
44
+ const sizes: Record<string, ViewStyle> = {
45
+ small: { paddingHorizontal: spacing.md, paddingVertical: 6 },
46
+ medium: { paddingHorizontal: spacing.lg, paddingVertical: 10 },
47
+ large: { paddingHorizontal: spacing.xxl, paddingVertical: 14 },
48
+ };
49
+ const variants: Record<string, ViewStyle> = {
50
+ primary: { backgroundColor: colors.primary },
51
+ secondary: { backgroundColor: colors.surfaceSecondary },
52
+ tertiary: { backgroundColor: colors.transparent },
53
+ outline: {
54
+ backgroundColor: colors.transparent,
55
+ borderWidth: 2,
56
+ borderColor: colors.primary,
57
+ },
58
+ };
59
+ const textVariants: Record<string, TextStyle> = {
60
+ primary: { color: colors.onPrimary },
61
+ secondary: { color: colors.textTertiary },
62
+ tertiary: { color: colors.primary },
63
+ outline: { color: colors.primary },
64
+ };
65
+ return { base, sizes, variants, textVariants };
66
+ }, [colors, spacing, radius]);
67
+
68
+ return (
69
+ <TouchableOpacity
70
+ onPress={onPress}
71
+ disabled={isDisabled}
72
+ style={[
73
+ s.base,
74
+ s.sizes[size],
75
+ s.variants[variant],
76
+ isDisabled && { opacity: opacity.disabled },
77
+ style,
78
+ ]}
79
+ activeOpacity={opacity.activePress}
80
+ >
81
+ {loading ? (
82
+ <ActivityIndicator
83
+ color={variant === 'primary' ? colors.onPrimary : colors.primary}
84
+ size="small"
85
+ />
86
+ ) : (
87
+ <>
88
+ {leadingIcon}
89
+ {hasLabel && (
90
+ <Text
91
+ style={[
92
+ {
93
+ fontSize: typography.bodySmall.fontSize,
94
+ fontWeight: '600' as const,
95
+ },
96
+ s.textVariants[variant],
97
+ ]}
98
+ >
99
+ {label}
100
+ </Text>
101
+ )}
102
+ {trailingIcon}
103
+ </>
104
+ )}
105
+ </TouchableOpacity>
106
+ );
107
+ };
@@ -0,0 +1,41 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, Text, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface CardProps {
6
+ title?: string;
7
+ children?: React.ReactNode;
8
+ }
9
+
10
+ export const Card: React.FC<CardProps> = ({ title, children }) => {
11
+ const { colors, spacing, radius, opacity } = useTheme();
12
+
13
+ const s = useMemo(
14
+ () => ({
15
+ container: {
16
+ backgroundColor: colors.surface,
17
+ borderRadius: radius.lg,
18
+ padding: spacing.md,
19
+ shadowColor: colors.shadow,
20
+ shadowOffset: { width: 0, height: 1 },
21
+ shadowOpacity: opacity.shadowLight,
22
+ shadowRadius: 4,
23
+ elevation: 1,
24
+ } as ViewStyle,
25
+ title: {
26
+ fontSize: 18,
27
+ fontWeight: '700' as const,
28
+ color: colors.textSecondary,
29
+ marginBottom: spacing.md,
30
+ } as TextStyle,
31
+ }),
32
+ [colors, spacing, radius, opacity]
33
+ );
34
+
35
+ return (
36
+ <View style={s.container}>
37
+ {title && <Text style={s.title}>{title}</Text>}
38
+ <View>{children}</View>
39
+ </View>
40
+ );
41
+ };
@@ -0,0 +1,220 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { View, Text, TouchableOpacity, Modal, ViewStyle, TextStyle } from 'react-native';
3
+ import { useTheme } from '../theme';
4
+
5
+ interface DatePickerProps {
6
+ id?: string;
7
+ label?: string;
8
+ placeholder?: string;
9
+ value?: Date;
10
+ onChange?: (date: Date | undefined) => void;
11
+ required?: boolean;
12
+ disabled?: boolean;
13
+ style?: ViewStyle;
14
+ }
15
+
16
+ const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
17
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
18
+
19
+ export const DatePicker: React.FC<DatePickerProps> = ({
20
+ label,
21
+ placeholder,
22
+ value,
23
+ onChange,
24
+ required = false,
25
+ disabled = false,
26
+ style,
27
+ }) => {
28
+ const [isOpen, setIsOpen] = useState(false);
29
+ const [viewDate, setViewDate] = useState(value ?? new Date());
30
+ const { colors, spacing, radius, typography, opacity } = useTheme();
31
+
32
+ const displayValue = value
33
+ ? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
34
+ : undefined;
35
+
36
+ const year = viewDate.getFullYear();
37
+ const month = viewDate.getMonth();
38
+ const firstDay = new Date(year, month, 1).getDay();
39
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
40
+
41
+ const cells: (number | null)[] = [
42
+ ...Array(firstDay).fill(null),
43
+ ...Array.from({ length: daysInMonth }, (_, i) => i + 1),
44
+ ];
45
+
46
+ const handleDayPress = (day: number) => {
47
+ const selected = new Date(year, month, day);
48
+ onChange?.(selected);
49
+ setIsOpen(false);
50
+ };
51
+
52
+ const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
53
+ const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
54
+
55
+ const s = useMemo(
56
+ () => ({
57
+ container: { gap: spacing.xs } as ViewStyle,
58
+ label: { ...typography.label, color: colors.textSecondary } as TextStyle,
59
+ required: { color: colors.error } as TextStyle,
60
+ trigger: {
61
+ flexDirection: 'row' as const,
62
+ justifyContent: 'space-between' as const,
63
+ alignItems: 'center' as const,
64
+ borderWidth: 1,
65
+ borderColor: colors.border,
66
+ borderRadius: radius.md,
67
+ paddingHorizontal: spacing.md,
68
+ paddingVertical: 10,
69
+ backgroundColor: colors.surface,
70
+ } as ViewStyle,
71
+ triggerDisabled: { backgroundColor: colors.surfaceSecondary } as ViewStyle,
72
+ triggerText: {
73
+ fontSize: typography.bodySmall.fontSize,
74
+ color: colors.textPrimary,
75
+ flex: 1,
76
+ } as TextStyle,
77
+ placeholder: { color: colors.textPlaceholder } as TextStyle,
78
+ icon: { fontSize: typography.body.fontSize } as TextStyle,
79
+ overlay: {
80
+ flex: 1,
81
+ backgroundColor: colors.overlayLight,
82
+ justifyContent: 'center' as const,
83
+ padding: spacing.xxl,
84
+ } as ViewStyle,
85
+ calendar: {
86
+ backgroundColor: colors.surface,
87
+ borderRadius: radius.lg,
88
+ padding: spacing.lg,
89
+ shadowColor: colors.shadow,
90
+ shadowOffset: { width: 0, height: 4 },
91
+ shadowOpacity: opacity.shadowMedium,
92
+ shadowRadius: 12,
93
+ elevation: 8,
94
+ } as ViewStyle,
95
+ header: {
96
+ flexDirection: 'row' as const,
97
+ alignItems: 'center' as const,
98
+ justifyContent: 'space-between' as const,
99
+ marginBottom: spacing.md,
100
+ } as ViewStyle,
101
+ navBtn: { padding: spacing.sm } as ViewStyle,
102
+ navText: { fontSize: 20, color: colors.primary, fontWeight: '600' as const } as TextStyle,
103
+ monthLabel: {
104
+ fontSize: 15,
105
+ fontWeight: '600' as const,
106
+ color: colors.textSecondary,
107
+ } as TextStyle,
108
+ grid: { flexDirection: 'row' as const, flexWrap: 'wrap' as const } as ViewStyle,
109
+ dayHeader: {
110
+ width: '14.28%' as unknown as number,
111
+ textAlign: 'center' as const,
112
+ fontSize: typography.caption.fontSize,
113
+ color: colors.textMuted,
114
+ paddingVertical: spacing.xs,
115
+ } as TextStyle,
116
+ cell: {
117
+ width: '14.28%' as unknown as number,
118
+ alignItems: 'center' as const,
119
+ paddingVertical: 6,
120
+ borderRadius: radius.sm,
121
+ } as ViewStyle,
122
+ cellSelected: { backgroundColor: colors.primary } as ViewStyle,
123
+ cellText: { fontSize: typography.bodySmall.fontSize, color: colors.textPrimary } as TextStyle,
124
+ cellTextSelected: { color: colors.onPrimary, fontWeight: '600' as const } as TextStyle,
125
+ cellEmpty: { color: colors.transparent } as TextStyle,
126
+ clearBtn: {
127
+ marginTop: spacing.md,
128
+ alignItems: 'center' as const,
129
+ paddingVertical: spacing.sm,
130
+ } as ViewStyle,
131
+ clearText: { color: colors.primary, fontSize: typography.bodySmall.fontSize } as TextStyle,
132
+ }),
133
+ [colors, spacing, radius, typography, opacity]
134
+ );
135
+
136
+ return (
137
+ <View style={[s.container, style]}>
138
+ {label && (
139
+ <Text style={s.label}>
140
+ {label}
141
+ {required && <Text style={s.required}> *</Text>}
142
+ </Text>
143
+ )}
144
+ <TouchableOpacity
145
+ style={[s.trigger, disabled && s.triggerDisabled]}
146
+ onPress={() => !disabled && setIsOpen(true)}
147
+ activeOpacity={opacity.activePress}
148
+ >
149
+ <Text style={[s.triggerText, !displayValue && s.placeholder]}>
150
+ {displayValue ?? placeholder ?? 'Pick a date'}
151
+ </Text>
152
+ <Text style={s.icon}>📅</Text>
153
+ </TouchableOpacity>
154
+
155
+ <Modal
156
+ visible={isOpen}
157
+ transparent
158
+ animationType="fade"
159
+ onRequestClose={() => setIsOpen(false)}
160
+ >
161
+ <TouchableOpacity style={s.overlay} activeOpacity={1} onPress={() => setIsOpen(false)}>
162
+ <View style={s.calendar} onStartShouldSetResponder={() => true}>
163
+ <View style={s.header}>
164
+ <TouchableOpacity onPress={prevMonth} style={s.navBtn}>
165
+ <Text style={s.navText}>‹</Text>
166
+ </TouchableOpacity>
167
+ <Text style={s.monthLabel}>
168
+ {MONTHS[month]} {year}
169
+ </Text>
170
+ <TouchableOpacity onPress={nextMonth} style={s.navBtn}>
171
+ <Text style={s.navText}>›</Text>
172
+ </TouchableOpacity>
173
+ </View>
174
+ <View style={s.grid}>
175
+ {DAYS.map((d) => (
176
+ <Text key={d} style={s.dayHeader}>
177
+ {d}
178
+ </Text>
179
+ ))}
180
+ {cells.map((day, i) => {
181
+ const isSelected =
182
+ day !== null &&
183
+ value?.getFullYear() === year &&
184
+ value?.getMonth() === month &&
185
+ value?.getDate() === day;
186
+ return (
187
+ <TouchableOpacity
188
+ key={i}
189
+ style={[s.cell, isSelected && s.cellSelected]}
190
+ onPress={() => day !== null && handleDayPress(day)}
191
+ disabled={day === null}
192
+ >
193
+ <Text
194
+ style={[
195
+ s.cellText,
196
+ isSelected && s.cellTextSelected,
197
+ day === null && s.cellEmpty,
198
+ ]}
199
+ >
200
+ {day ?? ''}
201
+ </Text>
202
+ </TouchableOpacity>
203
+ );
204
+ })}
205
+ </View>
206
+ <TouchableOpacity
207
+ style={s.clearBtn}
208
+ onPress={() => {
209
+ onChange?.(undefined);
210
+ setIsOpen(false);
211
+ }}
212
+ >
213
+ <Text style={s.clearText}>Clear</Text>
214
+ </TouchableOpacity>
215
+ </View>
216
+ </TouchableOpacity>
217
+ </Modal>
218
+ </View>
219
+ );
220
+ };