@umituz/react-native-design-system 1.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 +157 -0
- package/package.json +43 -0
- package/src/index.ts +345 -0
- package/src/presentation/atoms/AtomicAvatar.tsx +157 -0
- package/src/presentation/atoms/AtomicAvatarGroup.tsx +169 -0
- package/src/presentation/atoms/AtomicBadge.tsx +232 -0
- package/src/presentation/atoms/AtomicButton.tsx +124 -0
- package/src/presentation/atoms/AtomicCard.tsx +112 -0
- package/src/presentation/atoms/AtomicChip.tsx +223 -0
- package/src/presentation/atoms/AtomicDatePicker.tsx +347 -0
- package/src/presentation/atoms/AtomicDivider.tsx +114 -0
- package/src/presentation/atoms/AtomicFab.tsx +104 -0
- package/src/presentation/atoms/AtomicFilter.tsx +154 -0
- package/src/presentation/atoms/AtomicFormError.tsx +105 -0
- package/src/presentation/atoms/AtomicIcon.tsx +29 -0
- package/src/presentation/atoms/AtomicImage.tsx +149 -0
- package/src/presentation/atoms/AtomicInput.tsx +232 -0
- package/src/presentation/atoms/AtomicNumberInput.tsx +182 -0
- package/src/presentation/atoms/AtomicPicker.tsx +458 -0
- package/src/presentation/atoms/AtomicProgress.tsx +143 -0
- package/src/presentation/atoms/AtomicSearchBar.tsx +114 -0
- package/src/presentation/atoms/AtomicSkeleton.tsx +146 -0
- package/src/presentation/atoms/AtomicSort.tsx +145 -0
- package/src/presentation/atoms/AtomicSwitch.tsx +166 -0
- package/src/presentation/atoms/AtomicText.tsx +50 -0
- package/src/presentation/atoms/AtomicTextArea.tsx +198 -0
- package/src/presentation/atoms/AtomicTouchable.tsx +233 -0
- package/src/presentation/atoms/fab/styles/fabStyles.ts +69 -0
- package/src/presentation/atoms/fab/types/index.ts +88 -0
- package/src/presentation/atoms/filter/styles/filterStyles.ts +32 -0
- package/src/presentation/atoms/filter/types/index.ts +89 -0
- package/src/presentation/atoms/index.ts +378 -0
- package/src/presentation/atoms/input/hooks/useInputState.ts +15 -0
- package/src/presentation/atoms/input/styles/inputStyles.ts +66 -0
- package/src/presentation/atoms/input/types/index.ts +25 -0
- package/src/presentation/atoms/picker/styles/pickerStyles.ts +200 -0
- package/src/presentation/atoms/picker/types/index.ts +40 -0
- package/src/presentation/atoms/touchable/styles/touchableStyles.ts +71 -0
- package/src/presentation/atoms/touchable/types/index.ts +162 -0
- package/src/presentation/hooks/useAppDesignTokens.ts +78 -0
- package/src/presentation/hooks/useResponsive.ts +180 -0
- package/src/presentation/loading/index.ts +40 -0
- package/src/presentation/loading/presentation/components/LoadingSpinner.tsx +116 -0
- package/src/presentation/loading/presentation/components/LoadingState.tsx +200 -0
- package/src/presentation/loading/presentation/hooks/useLoading.ts +100 -0
- package/src/presentation/molecules/AtomicConfirmationModal.tsx +263 -0
- package/src/presentation/molecules/EmptyState.tsx +130 -0
- package/src/presentation/molecules/FormField.tsx +128 -0
- package/src/presentation/molecules/GridContainer.tsx +124 -0
- package/src/presentation/molecules/IconContainer.tsx +94 -0
- package/src/presentation/molecules/LanguageSwitcher.tsx +42 -0
- package/src/presentation/molecules/ListItem.tsx +36 -0
- package/src/presentation/molecules/ScreenHeader.tsx +140 -0
- package/src/presentation/molecules/SearchBar.tsx +85 -0
- package/src/presentation/molecules/SectionCard.tsx +74 -0
- package/src/presentation/molecules/SectionContainer.tsx +106 -0
- package/src/presentation/molecules/SectionHeader.tsx +125 -0
- package/src/presentation/molecules/confirmation-modal/styles/confirmationModalStyles.ts +133 -0
- package/src/presentation/molecules/confirmation-modal/types/index.ts +107 -0
- package/src/presentation/molecules/index.ts +42 -0
- package/src/presentation/molecules/languageswitcher/config/languageSwitcherConfig.ts +5 -0
- package/src/presentation/molecules/languageswitcher/hooks/useLanguageNavigation.ts +15 -0
- package/src/presentation/molecules/listitem/styles/listItemStyles.ts +19 -0
- package/src/presentation/molecules/listitem/types/index.ts +17 -0
- package/src/presentation/organisms/AppHeader.tsx +136 -0
- package/src/presentation/organisms/FormContainer.tsx +180 -0
- package/src/presentation/organisms/ScreenLayout.tsx +209 -0
- package/src/presentation/organisms/index.ts +25 -0
- package/src/presentation/tokens/AppDesignTokens.ts +57 -0
- package/src/presentation/tokens/commonStyles.ts +253 -0
- package/src/presentation/tokens/core/BaseTokens.ts +394 -0
- package/src/presentation/tokens/core/ColorPalette.ts +398 -0
- package/src/presentation/tokens/core/TokenFactory.ts +120 -0
- package/src/presentation/utils/platformConstants.ts +124 -0
- package/src/presentation/utils/responsive.ts +516 -0
- package/src/presentation/utils/variants/compound.ts +29 -0
- package/src/presentation/utils/variants/core.ts +39 -0
- package/src/presentation/utils/variants/helpers.ts +13 -0
- package/src/presentation/utils/variants.ts +3 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Card as PaperCard } from 'react-native-paper';
|
|
3
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
4
|
+
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
|
5
|
+
import { Pressable } from 'react-native';
|
|
6
|
+
|
|
7
|
+
export type AtomicCardVariant = 'flat' | 'elevated' | 'outlined';
|
|
8
|
+
export type AtomicCardPadding = 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
|
9
|
+
|
|
10
|
+
export interface AtomicCardProps {
|
|
11
|
+
variant?: AtomicCardVariant;
|
|
12
|
+
padding?: AtomicCardPadding;
|
|
13
|
+
onPress?: () => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
children?: React.ReactNode;
|
|
17
|
+
testID?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
21
|
+
|
|
22
|
+
export const AtomicCard: React.FC<AtomicCardProps> = ({
|
|
23
|
+
variant = 'elevated',
|
|
24
|
+
padding = 'md',
|
|
25
|
+
onPress,
|
|
26
|
+
disabled = false,
|
|
27
|
+
style,
|
|
28
|
+
children,
|
|
29
|
+
testID,
|
|
30
|
+
}) => {
|
|
31
|
+
// Animation for tap feedback
|
|
32
|
+
const scale = useSharedValue(1);
|
|
33
|
+
|
|
34
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
35
|
+
transform: [{ scale: scale.value }],
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const handlePressIn = () => {
|
|
39
|
+
if (onPress && !disabled) {
|
|
40
|
+
scale.value = withSpring(0.98, {
|
|
41
|
+
damping: 15,
|
|
42
|
+
stiffness: 150,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handlePressOut = () => {
|
|
48
|
+
scale.value = withSpring(1);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handlePress = () => {
|
|
52
|
+
if (onPress && !disabled) {
|
|
53
|
+
onPress();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Map variants to Paper modes
|
|
58
|
+
const getPaperMode = (): 'elevated' | 'outlined' | 'contained' => {
|
|
59
|
+
switch (variant) {
|
|
60
|
+
case 'elevated':
|
|
61
|
+
return 'elevated';
|
|
62
|
+
case 'outlined':
|
|
63
|
+
return 'outlined';
|
|
64
|
+
case 'flat':
|
|
65
|
+
return 'contained';
|
|
66
|
+
default:
|
|
67
|
+
return 'elevated';
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Map padding to actual values
|
|
72
|
+
const getContentStyle = () => {
|
|
73
|
+
const paddingMap = {
|
|
74
|
+
none: 0,
|
|
75
|
+
sm: 8,
|
|
76
|
+
md: 16,
|
|
77
|
+
lg: 24,
|
|
78
|
+
xl: 32,
|
|
79
|
+
};
|
|
80
|
+
const paddingValue = paddingMap[padding];
|
|
81
|
+
return { padding: paddingValue };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const cardContent = (
|
|
85
|
+
<PaperCard
|
|
86
|
+
mode={getPaperMode()}
|
|
87
|
+
style={style}
|
|
88
|
+
testID={testID}
|
|
89
|
+
>
|
|
90
|
+
<PaperCard.Content style={getContentStyle()}>
|
|
91
|
+
{children}
|
|
92
|
+
</PaperCard.Content>
|
|
93
|
+
</PaperCard>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// If onPress provided, wrap with animated pressable
|
|
97
|
+
if (onPress && !disabled) {
|
|
98
|
+
return (
|
|
99
|
+
<AnimatedPressable
|
|
100
|
+
style={animatedStyle}
|
|
101
|
+
onPressIn={handlePressIn}
|
|
102
|
+
onPressOut={handlePressOut}
|
|
103
|
+
onPress={handlePress}
|
|
104
|
+
>
|
|
105
|
+
{cardContent}
|
|
106
|
+
</AnimatedPressable>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Otherwise just return static card
|
|
111
|
+
return cardContent;
|
|
112
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AtomicChip - Universal Chip/Tag Component
|
|
3
|
+
*
|
|
4
|
+
* Displays small tags, labels, or status indicators
|
|
5
|
+
* Theme: {{THEME_NAME}} ({{CATEGORY}} category)
|
|
6
|
+
*
|
|
7
|
+
* Atomic Design Level: ATOM
|
|
8
|
+
* Purpose: Tag and label display
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* - Category tags
|
|
12
|
+
* - Status indicators
|
|
13
|
+
* - Filter chips
|
|
14
|
+
* - Skill labels
|
|
15
|
+
* - Badge displays
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { View, StyleSheet, ViewStyle, TouchableOpacity } from 'react-native';
|
|
20
|
+
import { AtomicText } from './AtomicText';
|
|
21
|
+
import { AtomicIcon } from './AtomicIcon';
|
|
22
|
+
import { useAppDesignTokens } from '../hooks/useAppDesignTokens';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// TYPE DEFINITIONS
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export interface AtomicChipProps {
|
|
29
|
+
/** Text content of the chip */
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
/** Chip variant */
|
|
32
|
+
variant?: 'filled' | 'outlined' | 'soft';
|
|
33
|
+
/** Chip size */
|
|
34
|
+
size?: 'sm' | 'md' | 'lg';
|
|
35
|
+
/** Chip color theme */
|
|
36
|
+
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
|
|
37
|
+
/** Custom background color */
|
|
38
|
+
backgroundColor?: string;
|
|
39
|
+
/** Custom text color */
|
|
40
|
+
textColor?: string;
|
|
41
|
+
/** Custom border color */
|
|
42
|
+
borderColor?: string;
|
|
43
|
+
/** Leading icon */
|
|
44
|
+
leadingIcon?: string;
|
|
45
|
+
/** Trailing icon */
|
|
46
|
+
trailingIcon?: string;
|
|
47
|
+
/** Whether the chip is clickable */
|
|
48
|
+
clickable?: boolean;
|
|
49
|
+
/** Click handler */
|
|
50
|
+
onPress?: () => void;
|
|
51
|
+
/** Whether the chip is selected */
|
|
52
|
+
selected?: boolean;
|
|
53
|
+
/** Whether the chip is disabled */
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
/** Style overrides */
|
|
56
|
+
style?: ViewStyle;
|
|
57
|
+
/** Test ID for testing */
|
|
58
|
+
testID?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// COMPONENT IMPLEMENTATION
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
export const AtomicChip: React.FC<AtomicChipProps> = ({
|
|
66
|
+
children,
|
|
67
|
+
variant = 'filled',
|
|
68
|
+
size = 'md',
|
|
69
|
+
color = 'primary',
|
|
70
|
+
backgroundColor,
|
|
71
|
+
textColor,
|
|
72
|
+
borderColor,
|
|
73
|
+
leadingIcon,
|
|
74
|
+
trailingIcon,
|
|
75
|
+
clickable = false,
|
|
76
|
+
onPress,
|
|
77
|
+
selected = false,
|
|
78
|
+
disabled = false,
|
|
79
|
+
style,
|
|
80
|
+
testID,
|
|
81
|
+
}) => {
|
|
82
|
+
const tokens = useAppDesignTokens();
|
|
83
|
+
|
|
84
|
+
// Size mapping
|
|
85
|
+
const sizeMap = {
|
|
86
|
+
sm: {
|
|
87
|
+
paddingHorizontal: tokens.spacing.sm,
|
|
88
|
+
paddingVertical: tokens.spacing.xs,
|
|
89
|
+
fontSize: tokens.typography.bodySmall.fontSize,
|
|
90
|
+
iconSize: 'xs' as const
|
|
91
|
+
},
|
|
92
|
+
md: {
|
|
93
|
+
paddingHorizontal: tokens.spacing.md,
|
|
94
|
+
paddingVertical: tokens.spacing.sm,
|
|
95
|
+
fontSize: tokens.typography.bodyMedium.fontSize,
|
|
96
|
+
iconSize: 'sm' as const
|
|
97
|
+
},
|
|
98
|
+
lg: {
|
|
99
|
+
paddingHorizontal: tokens.spacing.md,
|
|
100
|
+
paddingVertical: tokens.spacing.sm,
|
|
101
|
+
fontSize: tokens.typography.bodyLarge.fontSize,
|
|
102
|
+
iconSize: 'sm' as const
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const sizeConfig = sizeMap[size];
|
|
107
|
+
|
|
108
|
+
// Color mapping
|
|
109
|
+
const colorMap = {
|
|
110
|
+
primary: {
|
|
111
|
+
filled: { bg: tokens.colors.primary, text: tokens.colors.onPrimary, border: tokens.colors.primary },
|
|
112
|
+
outlined: { bg: 'transparent', text: tokens.colors.primary, border: tokens.colors.primary },
|
|
113
|
+
soft: { bg: tokens.colors.primaryContainer, text: tokens.colors.onPrimaryContainer, border: 'transparent' },
|
|
114
|
+
},
|
|
115
|
+
secondary: {
|
|
116
|
+
filled: { bg: tokens.colors.secondary, text: tokens.colors.onSecondary, border: tokens.colors.secondary },
|
|
117
|
+
outlined: { bg: 'transparent', text: tokens.colors.secondary, border: tokens.colors.secondary },
|
|
118
|
+
soft: { bg: tokens.colors.secondaryContainer, text: tokens.colors.onSecondaryContainer, border: 'transparent' },
|
|
119
|
+
},
|
|
120
|
+
success: {
|
|
121
|
+
filled: { bg: tokens.colors.success, text: tokens.colors.onSuccess, border: tokens.colors.success },
|
|
122
|
+
outlined: { bg: 'transparent', text: tokens.colors.success, border: tokens.colors.success },
|
|
123
|
+
soft: { bg: tokens.colors.successContainer, text: tokens.colors.onSuccessContainer, border: 'transparent' },
|
|
124
|
+
},
|
|
125
|
+
warning: {
|
|
126
|
+
filled: { bg: tokens.colors.warning, text: tokens.colors.onWarning, border: tokens.colors.warning },
|
|
127
|
+
outlined: { bg: 'transparent', text: tokens.colors.warning, border: tokens.colors.warning },
|
|
128
|
+
soft: { bg: tokens.colors.warningContainer, text: tokens.colors.onWarningContainer, border: 'transparent' },
|
|
129
|
+
},
|
|
130
|
+
error: {
|
|
131
|
+
filled: { bg: tokens.colors.error, text: tokens.colors.onError, border: tokens.colors.error },
|
|
132
|
+
outlined: { bg: 'transparent', text: tokens.colors.error, border: tokens.colors.error },
|
|
133
|
+
soft: { bg: tokens.colors.errorContainer, text: tokens.colors.onErrorContainer, border: 'transparent' },
|
|
134
|
+
},
|
|
135
|
+
info: {
|
|
136
|
+
filled: { bg: tokens.colors.info, text: tokens.colors.onInfo, border: tokens.colors.info },
|
|
137
|
+
outlined: { bg: 'transparent', text: tokens.colors.info, border: tokens.colors.info },
|
|
138
|
+
soft: { bg: tokens.colors.infoContainer, text: tokens.colors.onInfoContainer, border: 'transparent' },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const colorConfig = colorMap[color][variant];
|
|
143
|
+
|
|
144
|
+
// Apply custom colors if provided
|
|
145
|
+
const finalBackgroundColor = backgroundColor || colorConfig.bg;
|
|
146
|
+
const finalTextColor = textColor || colorConfig.text;
|
|
147
|
+
const finalBorderColor = borderColor || colorConfig.border;
|
|
148
|
+
|
|
149
|
+
// Handle disabled state
|
|
150
|
+
const isDisabled = disabled || (!clickable && !onPress);
|
|
151
|
+
const opacity = isDisabled ? 0.5 : 1;
|
|
152
|
+
|
|
153
|
+
// Handle selected state
|
|
154
|
+
const selectedStyle = selected ? {
|
|
155
|
+
borderWidth: tokens.borders.width.medium,
|
|
156
|
+
borderColor: tokens.colors.primary,
|
|
157
|
+
} : {};
|
|
158
|
+
|
|
159
|
+
const chipStyle: ViewStyle = {
|
|
160
|
+
flexDirection: 'row',
|
|
161
|
+
alignItems: 'center',
|
|
162
|
+
justifyContent: 'center',
|
|
163
|
+
paddingHorizontal: sizeConfig.paddingHorizontal,
|
|
164
|
+
paddingVertical: sizeConfig.paddingVertical,
|
|
165
|
+
backgroundColor: finalBackgroundColor,
|
|
166
|
+
borderRadius: tokens.borders.radius.xl,
|
|
167
|
+
borderWidth: variant === 'outlined' ? 1 : 0,
|
|
168
|
+
borderColor: finalBorderColor,
|
|
169
|
+
opacity,
|
|
170
|
+
...selectedStyle,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const textStyle = {
|
|
174
|
+
fontSize: sizeConfig.fontSize,
|
|
175
|
+
fontWeight: tokens.typography.medium,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const iconColor = finalTextColor;
|
|
179
|
+
|
|
180
|
+
const content = (
|
|
181
|
+
<View style={[chipStyle, style]} testID={testID}>
|
|
182
|
+
{leadingIcon && (
|
|
183
|
+
<AtomicIcon
|
|
184
|
+
name={leadingIcon}
|
|
185
|
+
size={sizeConfig.iconSize}
|
|
186
|
+
customColor={iconColor}
|
|
187
|
+
style={{ marginRight: tokens.spacing.xs }}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
<AtomicText
|
|
191
|
+
type="labelMedium"
|
|
192
|
+
color={finalTextColor}
|
|
193
|
+
style={textStyle}
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
</AtomicText>
|
|
197
|
+
{trailingIcon && (
|
|
198
|
+
<AtomicIcon
|
|
199
|
+
name={trailingIcon}
|
|
200
|
+
size={sizeConfig.iconSize}
|
|
201
|
+
customColor={iconColor}
|
|
202
|
+
style={{ marginLeft: tokens.spacing.xs }}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</View>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (clickable && onPress && !disabled) {
|
|
209
|
+
return (
|
|
210
|
+
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
|
211
|
+
{content}
|
|
212
|
+
</TouchableOpacity>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return content;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// EXPORTS
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
export default AtomicChip;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AtomicDatePicker Component
|
|
3
|
+
*
|
|
4
|
+
* A reusable date picker component that wraps the native date picker
|
|
5
|
+
* with consistent styling and behavior across platforms.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Platform-specific native pickers (iOS wheel, Android dialog)
|
|
9
|
+
* - Consistent styling with design tokens
|
|
10
|
+
* - Locale-aware date/time formatting (via timezone domain)
|
|
11
|
+
* - Timezone-aware (respects device timezone)
|
|
12
|
+
* - Automatic language integration (changes with app language)
|
|
13
|
+
* - Optional label and error states
|
|
14
|
+
* - Minimum and maximum date constraints
|
|
15
|
+
* - Disabled state support
|
|
16
|
+
* - Theme-aware styling
|
|
17
|
+
* - Proper keyboard avoidance on iOS
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const [selectedDate, setSelectedDate] = useState(new Date());
|
|
22
|
+
*
|
|
23
|
+
* <AtomicDatePicker
|
|
24
|
+
* value={selectedDate}
|
|
25
|
+
* onChange={setSelectedDate}
|
|
26
|
+
* label="Birth Date"
|
|
27
|
+
* minimumDate={new Date(1900, 0, 1)}
|
|
28
|
+
* maximumDate={new Date()}
|
|
29
|
+
* />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Platform Behavior:
|
|
33
|
+
* - iOS: Opens modal with spinner wheel, requires "Done" button
|
|
34
|
+
* - Android: Opens native dialog, auto-closes on selection
|
|
35
|
+
*
|
|
36
|
+
* @module AtomicDatePicker
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import React, { useState } from 'react';
|
|
40
|
+
import {
|
|
41
|
+
View,
|
|
42
|
+
Text,
|
|
43
|
+
TouchableOpacity,
|
|
44
|
+
StyleSheet,
|
|
45
|
+
Modal,
|
|
46
|
+
useWindowDimensions,
|
|
47
|
+
} from 'react-native';
|
|
48
|
+
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
|
49
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
50
|
+
import { useTimezone } from '@domains/timezone';
|
|
51
|
+
import { useAppDesignTokens } from '../hooks/useAppDesignTokens';
|
|
52
|
+
import { useResponsive } from '../hooks/useResponsive';
|
|
53
|
+
import { AtomicIcon, type AtomicIconColor } from './AtomicIcon';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Props for AtomicDatePicker component
|
|
57
|
+
*/
|
|
58
|
+
export interface AtomicDatePickerProps {
|
|
59
|
+
/** Selected date value */
|
|
60
|
+
value: Date;
|
|
61
|
+
/** Callback when date changes */
|
|
62
|
+
onChange: (date: Date) => void;
|
|
63
|
+
/** Optional label displayed above picker */
|
|
64
|
+
label?: string;
|
|
65
|
+
/** Optional error message displayed below picker */
|
|
66
|
+
error?: string;
|
|
67
|
+
/** Disable picker interaction */
|
|
68
|
+
disabled?: boolean;
|
|
69
|
+
/** Minimum selectable date */
|
|
70
|
+
minimumDate?: Date;
|
|
71
|
+
/** Maximum selectable date */
|
|
72
|
+
maximumDate?: Date;
|
|
73
|
+
/** Picker mode - date, time, or datetime (iOS only) */
|
|
74
|
+
mode?: 'date' | 'time' | 'datetime';
|
|
75
|
+
/** Placeholder text when no value selected */
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
/** Optional test ID for E2E testing */
|
|
78
|
+
testID?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* AtomicDatePicker - Universal date/time picker component
|
|
83
|
+
*
|
|
84
|
+
* Wraps @react-native-community/datetimepicker with:
|
|
85
|
+
* - Theme integration
|
|
86
|
+
* - Platform-specific modal handling
|
|
87
|
+
* - Error states
|
|
88
|
+
* - Disabled states
|
|
89
|
+
* - Responsive sizing
|
|
90
|
+
*/
|
|
91
|
+
export const AtomicDatePicker: React.FC<AtomicDatePickerProps> = ({
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
label,
|
|
95
|
+
error,
|
|
96
|
+
disabled = false,
|
|
97
|
+
minimumDate,
|
|
98
|
+
maximumDate,
|
|
99
|
+
mode = 'date',
|
|
100
|
+
placeholder = 'Select date',
|
|
101
|
+
testID,
|
|
102
|
+
}) => {
|
|
103
|
+
const tokens = useAppDesignTokens();
|
|
104
|
+
const { height } = useWindowDimensions();
|
|
105
|
+
const insets = useSafeAreaInsets();
|
|
106
|
+
const { isTabletDevice } = useResponsive();
|
|
107
|
+
const { formatDate: formatDateTz, formatTime } = useTimezone();
|
|
108
|
+
const [show, setShow] = useState(false);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Handle date/time change
|
|
112
|
+
* Universal handler that works across all platforms
|
|
113
|
+
* Note: event.type can be 'set', 'dismissed', or 'neutralButtonPressed'
|
|
114
|
+
*/
|
|
115
|
+
const handleChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
|
|
116
|
+
// Close picker when user confirms or dismisses
|
|
117
|
+
// iOS: Stays open until "Done" button (handled separately)
|
|
118
|
+
// Android/Web: Auto-closes on selection
|
|
119
|
+
if (event.type === 'set' || event.type === 'dismissed') {
|
|
120
|
+
setShow(false);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Update value only if date was selected (not dismissed)
|
|
124
|
+
if (event.type === 'set' && selectedDate) {
|
|
125
|
+
onChange(selectedDate);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Format date based on mode
|
|
131
|
+
* Uses timezone domain for locale-aware and timezone-aware formatting
|
|
132
|
+
* Automatically respects user's selected language and device timezone
|
|
133
|
+
*/
|
|
134
|
+
const formatDate = (date: Date): string => {
|
|
135
|
+
if (mode === 'time') {
|
|
136
|
+
// Format time only (e.g., "02:30 PM" in English, "14:30" in Turkish)
|
|
137
|
+
return formatTime(date);
|
|
138
|
+
}
|
|
139
|
+
if (mode === 'datetime') {
|
|
140
|
+
// Format date + time (e.g., "January 15, 2024 02:30 PM")
|
|
141
|
+
const dateStr = formatDateTz(date, {
|
|
142
|
+
year: 'numeric',
|
|
143
|
+
month: 'short',
|
|
144
|
+
day: 'numeric',
|
|
145
|
+
});
|
|
146
|
+
const timeStr = formatTime(date);
|
|
147
|
+
return `${dateStr} ${timeStr}`;
|
|
148
|
+
}
|
|
149
|
+
// Format date only (e.g., "January 15, 2024" in English, "15 Ocak 2024" in Turkish)
|
|
150
|
+
return formatDateTz(date);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Determine icon color based on state
|
|
155
|
+
*/
|
|
156
|
+
const getIconColor = (): AtomicIconColor => {
|
|
157
|
+
if (disabled) return 'secondary';
|
|
158
|
+
if (error) return 'error';
|
|
159
|
+
return 'primary';
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const styles = getStyles(tokens, height, insets);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<View style={styles.container} testID={testID}>
|
|
166
|
+
{label && (
|
|
167
|
+
<Text style={styles.label} testID={testID ? `${testID}-label` : undefined}>
|
|
168
|
+
{label}
|
|
169
|
+
</Text>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<TouchableOpacity
|
|
173
|
+
style={[
|
|
174
|
+
styles.button,
|
|
175
|
+
error ? styles.buttonError : undefined,
|
|
176
|
+
disabled ? styles.buttonDisabled : undefined,
|
|
177
|
+
]}
|
|
178
|
+
onPress={() => !disabled && setShow(true)}
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
testID={testID ? `${testID}-button` : undefined}
|
|
181
|
+
accessibilityLabel={label || placeholder}
|
|
182
|
+
accessibilityRole="button"
|
|
183
|
+
accessibilityState={{ disabled }}
|
|
184
|
+
>
|
|
185
|
+
<AtomicIcon
|
|
186
|
+
name="calendar"
|
|
187
|
+
color={getIconColor()}
|
|
188
|
+
size="md"
|
|
189
|
+
/>
|
|
190
|
+
<Text
|
|
191
|
+
style={[
|
|
192
|
+
styles.text,
|
|
193
|
+
disabled ? styles.textDisabled : undefined,
|
|
194
|
+
error ? styles.textError : undefined,
|
|
195
|
+
]}
|
|
196
|
+
>
|
|
197
|
+
{value ? formatDate(value) : placeholder}
|
|
198
|
+
</Text>
|
|
199
|
+
</TouchableOpacity>
|
|
200
|
+
|
|
201
|
+
{error && (
|
|
202
|
+
<Text style={styles.errorText} testID={testID ? `${testID}-error` : undefined}>
|
|
203
|
+
{error}
|
|
204
|
+
</Text>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Universal DatePicker - Works across iOS, Android, Web */}
|
|
208
|
+
{show && (
|
|
209
|
+
<Modal
|
|
210
|
+
transparent
|
|
211
|
+
animationType={isTabletDevice ? 'fade' : 'slide'}
|
|
212
|
+
visible={show}
|
|
213
|
+
onRequestClose={() => setShow(false)}
|
|
214
|
+
>
|
|
215
|
+
<TouchableOpacity
|
|
216
|
+
style={styles.modalOverlay}
|
|
217
|
+
activeOpacity={1}
|
|
218
|
+
onPress={() => setShow(false)}
|
|
219
|
+
accessibilityLabel="Close date picker"
|
|
220
|
+
accessibilityRole="button"
|
|
221
|
+
>
|
|
222
|
+
<View
|
|
223
|
+
style={styles.pickerContainer}
|
|
224
|
+
onStartShouldSetResponder={() => true}
|
|
225
|
+
>
|
|
226
|
+
<DateTimePicker
|
|
227
|
+
value={value || new Date()}
|
|
228
|
+
mode={mode}
|
|
229
|
+
display="spinner"
|
|
230
|
+
onChange={handleChange}
|
|
231
|
+
minimumDate={minimumDate}
|
|
232
|
+
maximumDate={maximumDate}
|
|
233
|
+
testID={testID ? `${testID}-picker` : undefined}
|
|
234
|
+
/>
|
|
235
|
+
<View style={styles.buttonContainer}>
|
|
236
|
+
<TouchableOpacity
|
|
237
|
+
style={styles.doneButton}
|
|
238
|
+
onPress={() => setShow(false)}
|
|
239
|
+
testID={testID ? `${testID}-done` : undefined}
|
|
240
|
+
accessibilityLabel="Done"
|
|
241
|
+
accessibilityRole="button"
|
|
242
|
+
>
|
|
243
|
+
<Text style={styles.doneText}>Done</Text>
|
|
244
|
+
</TouchableOpacity>
|
|
245
|
+
</View>
|
|
246
|
+
</View>
|
|
247
|
+
</TouchableOpacity>
|
|
248
|
+
</Modal>
|
|
249
|
+
)}
|
|
250
|
+
</View>
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get component styles based on design tokens
|
|
256
|
+
*/
|
|
257
|
+
const getStyles = (
|
|
258
|
+
tokens: ReturnType<typeof useAppDesignTokens>,
|
|
259
|
+
height: number,
|
|
260
|
+
insets: { top: number; bottom: number; left: number; right: number },
|
|
261
|
+
) => {
|
|
262
|
+
// Responsive button sizing based on device height
|
|
263
|
+
const buttonMinWidth = height <= 667 ? Math.min(height * 0.25, 150) : 200;
|
|
264
|
+
|
|
265
|
+
return StyleSheet.create({
|
|
266
|
+
container: {
|
|
267
|
+
marginBottom: tokens.spacing.md,
|
|
268
|
+
},
|
|
269
|
+
label: {
|
|
270
|
+
fontSize: tokens.typography.bodyMedium.fontSize,
|
|
271
|
+
fontWeight: tokens.typography.semibold,
|
|
272
|
+
color: tokens.colors.textPrimary,
|
|
273
|
+
marginBottom: tokens.spacing.sm,
|
|
274
|
+
},
|
|
275
|
+
button: {
|
|
276
|
+
flexDirection: 'row',
|
|
277
|
+
alignItems: 'center',
|
|
278
|
+
backgroundColor: tokens.colors.surface,
|
|
279
|
+
borderWidth: 1,
|
|
280
|
+
borderColor: tokens.colors.border,
|
|
281
|
+
borderRadius: tokens.borders.radius.lg,
|
|
282
|
+
paddingHorizontal: tokens.spacing.md,
|
|
283
|
+
paddingVertical: tokens.spacing.md,
|
|
284
|
+
gap: tokens.spacing.sm,
|
|
285
|
+
minHeight: 48, // Apple HIG minimum touch target
|
|
286
|
+
},
|
|
287
|
+
buttonError: {
|
|
288
|
+
borderColor: tokens.colors.error,
|
|
289
|
+
borderWidth: tokens.borders.width.medium,
|
|
290
|
+
},
|
|
291
|
+
buttonDisabled: {
|
|
292
|
+
backgroundColor: tokens.colors.surfaceDisabled,
|
|
293
|
+
opacity: tokens.opacity.disabled,
|
|
294
|
+
},
|
|
295
|
+
text: {
|
|
296
|
+
flex: 1,
|
|
297
|
+
fontSize: tokens.typography.bodyLarge.fontSize,
|
|
298
|
+
color: tokens.colors.textPrimary,
|
|
299
|
+
},
|
|
300
|
+
textDisabled: {
|
|
301
|
+
color: tokens.colors.textDisabled,
|
|
302
|
+
},
|
|
303
|
+
textError: {
|
|
304
|
+
color: tokens.colors.error,
|
|
305
|
+
},
|
|
306
|
+
errorText: {
|
|
307
|
+
fontSize: tokens.typography.bodySmall.fontSize,
|
|
308
|
+
color: tokens.colors.error,
|
|
309
|
+
marginTop: tokens.spacing.xs,
|
|
310
|
+
marginLeft: tokens.spacing.xs,
|
|
311
|
+
},
|
|
312
|
+
modalOverlay: {
|
|
313
|
+
flex: 1,
|
|
314
|
+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
315
|
+
justifyContent: 'flex-start',
|
|
316
|
+
},
|
|
317
|
+
pickerContainer: {
|
|
318
|
+
backgroundColor: tokens.colors.surface,
|
|
319
|
+
borderTopLeftRadius: tokens.borders.radius.xl,
|
|
320
|
+
borderTopRightRadius: tokens.borders.radius.xl,
|
|
321
|
+
paddingTop: tokens.spacing.lg,
|
|
322
|
+
paddingBottom: Math.max(
|
|
323
|
+
insets.bottom + tokens.spacing.md,
|
|
324
|
+
tokens.spacing.xl,
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
buttonContainer: {
|
|
328
|
+
alignItems: 'center',
|
|
329
|
+
marginTop: tokens.spacing.md,
|
|
330
|
+
paddingHorizontal: tokens.spacing.lg,
|
|
331
|
+
},
|
|
332
|
+
doneButton: {
|
|
333
|
+
backgroundColor: tokens.colors.primary,
|
|
334
|
+
paddingHorizontal: tokens.spacing.xl,
|
|
335
|
+
paddingVertical: tokens.spacing.sm,
|
|
336
|
+
borderRadius: tokens.borders.radius.lg,
|
|
337
|
+
minWidth: buttonMinWidth,
|
|
338
|
+
alignItems: 'center',
|
|
339
|
+
minHeight: 44, // Apple HIG minimum touch target
|
|
340
|
+
},
|
|
341
|
+
doneText: {
|
|
342
|
+
color: tokens.colors.onPrimary,
|
|
343
|
+
fontSize: tokens.typography.bodyLarge.fontSize,
|
|
344
|
+
fontWeight: tokens.typography.semibold,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
};
|