@idealyst/datepicker 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/README.md +88 -0
- package/package.json +77 -0
- package/src/DatePicker/Calendar.native.tsx +159 -0
- package/src/DatePicker/Calendar.styles.tsx +224 -0
- package/src/DatePicker/Calendar.tsx +154 -0
- package/src/DatePicker/DatePicker.native.tsx +33 -0
- package/src/DatePicker/DatePicker.styles.tsx +69 -0
- package/src/DatePicker/DatePicker.web.tsx +31 -0
- package/src/DatePicker/index.native.ts +3 -0
- package/src/DatePicker/index.ts +3 -0
- package/src/DatePicker/types.ts +78 -0
- package/src/DateRangePicker/DateRangePicker.native.tsx +39 -0
- package/src/DateRangePicker/DateRangePicker.styles.tsx +83 -0
- package/src/DateRangePicker/DateRangePicker.web.tsx +40 -0
- package/src/DateRangePicker/RangeCalendar.native.tsx +267 -0
- package/src/DateRangePicker/RangeCalendar.styles.tsx +170 -0
- package/src/DateRangePicker/RangeCalendar.web.tsx +275 -0
- package/src/DateRangePicker/index.native.ts +3 -0
- package/src/DateRangePicker/index.ts +3 -0
- package/src/DateRangePicker/types.ts +98 -0
- package/src/DateTimePicker/DateTimePicker.native.tsx +82 -0
- package/src/DateTimePicker/DateTimePicker.styles.tsx +77 -0
- package/src/DateTimePicker/DateTimePicker.tsx +79 -0
- package/src/DateTimePicker/TimePicker.native.tsx +204 -0
- package/src/DateTimePicker/TimePicker.styles.tsx +116 -0
- package/src/DateTimePicker/TimePicker.tsx +406 -0
- package/src/DateTimePicker/index.native.ts +3 -0
- package/src/DateTimePicker/index.ts +3 -0
- package/src/DateTimePicker/types.ts +84 -0
- package/src/DateTimeRangePicker/DateTimeRangePicker.native.tsx +213 -0
- package/src/DateTimeRangePicker/DateTimeRangePicker.styles.tsx +95 -0
- package/src/DateTimeRangePicker/DateTimeRangePicker.web.tsx +141 -0
- package/src/DateTimeRangePicker/index.native.ts +2 -0
- package/src/DateTimeRangePicker/index.ts +2 -0
- package/src/DateTimeRangePicker/types.ts +72 -0
- package/src/examples/DatePickerExamples.tsx +274 -0
- package/src/examples/index.ts +1 -0
- package/src/index.native.ts +16 -0
- package/src/index.ts +16 -0
- package/src/primitives/CalendarGrid/CalendarGrid.styles.tsx +62 -0
- package/src/primitives/CalendarGrid/CalendarGrid.tsx +138 -0
- package/src/primitives/CalendarGrid/index.ts +1 -0
- package/src/primitives/CalendarHeader/CalendarHeader.styles.tsx +25 -0
- package/src/primitives/CalendarHeader/CalendarHeader.tsx +69 -0
- package/src/primitives/CalendarHeader/index.ts +1 -0
- package/src/primitives/CalendarOverlay/CalendarOverlay.styles.tsx +81 -0
- package/src/primitives/CalendarOverlay/CalendarOverlay.tsx +130 -0
- package/src/primitives/CalendarOverlay/index.ts +1 -0
- package/src/primitives/Wrapper/Wrapper.web.tsx +33 -0
- package/src/primitives/Wrapper/index.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @idealyst/datepicker
|
|
2
|
+
|
|
3
|
+
Cross-platform date and time picker components for React and React Native.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @idealyst/datepicker
|
|
9
|
+
# or
|
|
10
|
+
yarn add @idealyst/datepicker
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Peer Dependencies
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @idealyst/components @idealyst/theme react-native-unistyles
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Components
|
|
20
|
+
|
|
21
|
+
### DatePicker
|
|
22
|
+
|
|
23
|
+
A single date selection component with calendar popup.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { DatePicker } from '@idealyst/datepicker';
|
|
27
|
+
|
|
28
|
+
function MyComponent() {
|
|
29
|
+
const [date, setDate] = useState<Date | null>(null);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<DatePicker
|
|
33
|
+
value={date}
|
|
34
|
+
onChange={setDate}
|
|
35
|
+
label="Select Date"
|
|
36
|
+
placeholder="Choose a date"
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Coming Soon
|
|
43
|
+
|
|
44
|
+
- **DateTimePicker**: Select date and time
|
|
45
|
+
- **DateRangePicker**: Select a range of dates
|
|
46
|
+
- **DateTimeRangePicker**: Select a range of dates with times
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- ✅ Cross-platform (React & React Native)
|
|
51
|
+
- ✅ Theme integration with Unistyles
|
|
52
|
+
- ✅ Accessibility support
|
|
53
|
+
- ✅ Keyboard navigation
|
|
54
|
+
- ✅ Min/max date restrictions
|
|
55
|
+
- ✅ Multiple size variants
|
|
56
|
+
- ✅ Customizable date formats
|
|
57
|
+
- ✅ TypeScript support
|
|
58
|
+
|
|
59
|
+
## API Reference
|
|
60
|
+
|
|
61
|
+
### DatePicker Props
|
|
62
|
+
|
|
63
|
+
| Prop | Type | Default | Description |
|
|
64
|
+
|------|------|---------|-------------|
|
|
65
|
+
| `value` | `Date \| undefined` | - | Current selected date |
|
|
66
|
+
| `onChange` | `(date: Date \| null) => void` | - | Called when date changes |
|
|
67
|
+
| `minDate` | `Date` | - | Minimum selectable date |
|
|
68
|
+
| `maxDate` | `Date` | - | Maximum selectable date |
|
|
69
|
+
| `disabled` | `boolean` | `false` | Disabled state |
|
|
70
|
+
| `placeholder` | `string` | `'Select date'` | Placeholder text |
|
|
71
|
+
| `label` | `string` | - | Label for the picker |
|
|
72
|
+
| `error` | `string` | - | Error message |
|
|
73
|
+
| `helperText` | `string` | - | Helper text |
|
|
74
|
+
| `format` | `string` | `'MM/dd/yyyy'` | Date display format |
|
|
75
|
+
| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | Size variant |
|
|
76
|
+
| `variant` | `'outlined' \| 'filled'` | `'outlined'` | Visual variant |
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
See the examples directory or import examples:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { DatePickerExamples } from '@idealyst/datepicker/examples';
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@idealyst/datepicker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Cross-platform date and time picker components for React and React Native",
|
|
5
|
+
"documentation": "https://github.com/your-username/idealyst-framework/tree/main/packages/datepicker#readme",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"module": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"react-native": "src/index.native.ts",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/your-username/idealyst-framework.git",
|
|
13
|
+
"directory": "packages/datepicker"
|
|
14
|
+
},
|
|
15
|
+
"author": "Your Name <your.email@example.com>",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"react-native": "./src/index.native.ts",
|
|
23
|
+
"import": "./src/index.ts",
|
|
24
|
+
"require": "./src/index.ts",
|
|
25
|
+
"types": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"./examples": {
|
|
28
|
+
"import": "./src/examples/index.ts",
|
|
29
|
+
"require": "./src/examples/index.ts",
|
|
30
|
+
"types": "./src/examples/index.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"prepublishOnly": "echo 'Publishing TypeScript source directly'",
|
|
35
|
+
"publish:npm": "npm publish"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@idealyst/components": "^1.0.40",
|
|
39
|
+
"@idealyst/theme": "^1.0.40",
|
|
40
|
+
"react": ">=16.8.0",
|
|
41
|
+
"react-native": ">=0.60.0",
|
|
42
|
+
"react-native-unistyles": "^3.0.4"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@idealyst/components": {
|
|
46
|
+
"optional": false
|
|
47
|
+
},
|
|
48
|
+
"@idealyst/theme": {
|
|
49
|
+
"optional": false
|
|
50
|
+
},
|
|
51
|
+
"react-native": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"react-native-unistyles": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/react": "^19.1.0",
|
|
60
|
+
"typescript": "^5.0.0"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"src",
|
|
64
|
+
"README.md"
|
|
65
|
+
],
|
|
66
|
+
"keywords": [
|
|
67
|
+
"react",
|
|
68
|
+
"react-native",
|
|
69
|
+
"datepicker",
|
|
70
|
+
"timepicker",
|
|
71
|
+
"date-range",
|
|
72
|
+
"datetime",
|
|
73
|
+
"calendar",
|
|
74
|
+
"cross-platform",
|
|
75
|
+
"picker"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { View, Text, Button } from '@idealyst/components';
|
|
3
|
+
import { TouchableOpacity } from 'react-native';
|
|
4
|
+
import { CalendarProps } from './types';
|
|
5
|
+
import { calendarStyles } from './Calendar.styles';
|
|
6
|
+
|
|
7
|
+
export const Calendar: React.FC<CalendarProps> = ({
|
|
8
|
+
value,
|
|
9
|
+
onChange,
|
|
10
|
+
minDate,
|
|
11
|
+
maxDate,
|
|
12
|
+
disabled = false,
|
|
13
|
+
currentMonth: controlledCurrentMonth,
|
|
14
|
+
onMonthChange,
|
|
15
|
+
style,
|
|
16
|
+
testID,
|
|
17
|
+
}) => {
|
|
18
|
+
const [internalCurrentMonth, setInternalCurrentMonth] = useState(
|
|
19
|
+
controlledCurrentMonth || value || new Date()
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const currentMonth = controlledCurrentMonth || internalCurrentMonth;
|
|
23
|
+
|
|
24
|
+
const handleMonthChange = (newMonth: Date) => {
|
|
25
|
+
if (onMonthChange) {
|
|
26
|
+
onMonthChange(newMonth);
|
|
27
|
+
} else {
|
|
28
|
+
setInternalCurrentMonth(newMonth);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const { monthStart, monthEnd, daysInMonth, startingDayOfWeek } = useMemo(() => {
|
|
33
|
+
const monthStart = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
|
|
34
|
+
const monthEnd = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
|
|
35
|
+
const daysInMonth = monthEnd.getDate();
|
|
36
|
+
const startingDayOfWeek = monthStart.getDay();
|
|
37
|
+
|
|
38
|
+
return { monthStart, monthEnd, daysInMonth, startingDayOfWeek };
|
|
39
|
+
}, [currentMonth]);
|
|
40
|
+
|
|
41
|
+
const isDateDisabled = (date: Date): boolean => {
|
|
42
|
+
if (disabled) return true;
|
|
43
|
+
if (minDate && date < minDate) return true;
|
|
44
|
+
if (maxDate && date > maxDate) return true;
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isDateSelected = (date: Date): boolean => {
|
|
49
|
+
if (!value) return false;
|
|
50
|
+
return (
|
|
51
|
+
date.getDate() === value.getDate() &&
|
|
52
|
+
date.getMonth() === value.getMonth() &&
|
|
53
|
+
date.getFullYear() === value.getFullYear()
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleDateClick = (date: Date) => {
|
|
58
|
+
if (!isDateDisabled(date)) {
|
|
59
|
+
onChange(date);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const goToPreviousMonth = () => {
|
|
64
|
+
const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
|
|
65
|
+
handleMonthChange(newMonth);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const goToNextMonth = () => {
|
|
69
|
+
const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
|
70
|
+
handleMonthChange(newMonth);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const monthName = currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
74
|
+
|
|
75
|
+
// Create calendar grid
|
|
76
|
+
const calendarDays = [];
|
|
77
|
+
|
|
78
|
+
// Add empty cells for days before month starts
|
|
79
|
+
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
80
|
+
calendarDays.push(null);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Add days of the month
|
|
84
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
85
|
+
const date = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
|
|
86
|
+
calendarDays.push(date);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Use Unistyles
|
|
90
|
+
calendarStyles.useVariants({});
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View style={[calendarStyles.container, style]} testID={testID}>
|
|
94
|
+
{/* Header */}
|
|
95
|
+
<View style={calendarStyles.header}>
|
|
96
|
+
<Button
|
|
97
|
+
variant="text"
|
|
98
|
+
size="small"
|
|
99
|
+
onPress={goToPreviousMonth}
|
|
100
|
+
disabled={disabled}
|
|
101
|
+
style={calendarStyles.headerButton}
|
|
102
|
+
>
|
|
103
|
+
←
|
|
104
|
+
</Button>
|
|
105
|
+
<Text weight="semibold">{monthName}</Text>
|
|
106
|
+
<Button
|
|
107
|
+
variant="text"
|
|
108
|
+
size="small"
|
|
109
|
+
onPress={goToNextMonth}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
style={calendarStyles.headerButton}
|
|
112
|
+
>
|
|
113
|
+
→
|
|
114
|
+
</Button>
|
|
115
|
+
</View>
|
|
116
|
+
|
|
117
|
+
{/* Weekday headers */}
|
|
118
|
+
<View style={calendarStyles.weekdayHeader}>
|
|
119
|
+
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((day) => (
|
|
120
|
+
<View key={day} style={calendarStyles.weekdayCell}>
|
|
121
|
+
<Text style={calendarStyles.weekdayText}>
|
|
122
|
+
{day}
|
|
123
|
+
</Text>
|
|
124
|
+
</View>
|
|
125
|
+
))}
|
|
126
|
+
</View>
|
|
127
|
+
|
|
128
|
+
{/* Calendar grid */}
|
|
129
|
+
<View style={calendarStyles.calendarGrid}>
|
|
130
|
+
{calendarDays.map((date, index) => (
|
|
131
|
+
<View key={index} style={calendarStyles.dayCell}>
|
|
132
|
+
{date && (
|
|
133
|
+
<TouchableOpacity
|
|
134
|
+
onPress={() => handleDateClick(date)}
|
|
135
|
+
disabled={isDateDisabled(date)}
|
|
136
|
+
style={[
|
|
137
|
+
calendarStyles.dayButton,
|
|
138
|
+
{
|
|
139
|
+
backgroundColor: isDateSelected(date) ? '#3b82f6' : 'transparent',
|
|
140
|
+
opacity: isDateDisabled(date) ? 0.5 : 1,
|
|
141
|
+
}
|
|
142
|
+
]}
|
|
143
|
+
>
|
|
144
|
+
<Text
|
|
145
|
+
style={{
|
|
146
|
+
color: isDateSelected(date) ? 'white' : 'black',
|
|
147
|
+
fontSize: 13,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{date.getDate()}
|
|
151
|
+
</Text>
|
|
152
|
+
</TouchableOpacity>
|
|
153
|
+
)}
|
|
154
|
+
</View>
|
|
155
|
+
))}
|
|
156
|
+
</View>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native-unistyles';
|
|
2
|
+
|
|
3
|
+
export const calendarStyles = StyleSheet.create((theme) => ({
|
|
4
|
+
container: {
|
|
5
|
+
width: 256,
|
|
6
|
+
position: 'relative',
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
header: {
|
|
10
|
+
display: 'flex',
|
|
11
|
+
flexDirection: 'row',
|
|
12
|
+
justifyContent: 'space-between',
|
|
13
|
+
alignItems: 'center',
|
|
14
|
+
marginBottom: theme.spacing?.md || 16,
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
headerButton: {
|
|
18
|
+
minWidth: 32,
|
|
19
|
+
minHeight: 32,
|
|
20
|
+
paddingHorizontal: theme.spacing?.xs || 8,
|
|
21
|
+
paddingVertical: theme.spacing?.xs || 4,
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
headerTitle: {
|
|
25
|
+
display: 'flex',
|
|
26
|
+
flexDirection: 'row',
|
|
27
|
+
alignItems: 'center',
|
|
28
|
+
gap: theme.spacing?.xs || 8,
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
monthYearButton: {
|
|
32
|
+
paddingHorizontal: theme.spacing?.sm || 8,
|
|
33
|
+
paddingVertical: theme.spacing?.xs || 4,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
pickerContainer: {
|
|
37
|
+
marginBottom: theme.spacing?.md || 16,
|
|
38
|
+
padding: theme.spacing?.sm || 12,
|
|
39
|
+
backgroundColor: theme.colors?.surface?.secondary || '#f9fafb',
|
|
40
|
+
borderRadius: theme.borderRadius?.md || 8,
|
|
41
|
+
border: `1px solid ${theme.colors?.border?.primary || '#e5e7eb'}`,
|
|
42
|
+
|
|
43
|
+
// Native specific styles
|
|
44
|
+
_native: {
|
|
45
|
+
borderWidth: 1,
|
|
46
|
+
borderColor: theme.colors?.border?.primary || '#e5e7eb',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
monthPickerGrid: {
|
|
51
|
+
display: 'flex',
|
|
52
|
+
flexDirection: 'row',
|
|
53
|
+
flexWrap: 'wrap',
|
|
54
|
+
gap: 6,
|
|
55
|
+
width: '100%',
|
|
56
|
+
justifyContent: 'space-between',
|
|
57
|
+
|
|
58
|
+
// Native fallback
|
|
59
|
+
_native: {
|
|
60
|
+
flexDirection: 'row',
|
|
61
|
+
flexWrap: 'wrap',
|
|
62
|
+
justifyContent: 'space-between',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
yearPickerGrid: {
|
|
67
|
+
display: 'flex',
|
|
68
|
+
flexDirection: 'row',
|
|
69
|
+
flexWrap: 'wrap',
|
|
70
|
+
gap: 4,
|
|
71
|
+
maxHeight: 200,
|
|
72
|
+
overflowY: 'auto',
|
|
73
|
+
width: '100%',
|
|
74
|
+
justifyContent: 'space-between',
|
|
75
|
+
|
|
76
|
+
// Native fallback
|
|
77
|
+
_native: {
|
|
78
|
+
flexDirection: 'row',
|
|
79
|
+
flexWrap: 'wrap',
|
|
80
|
+
justifyContent: 'space-between',
|
|
81
|
+
maxHeight: 200,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
pickerButton: {
|
|
86
|
+
fontSize: theme.typography?.sizes?.small || 12,
|
|
87
|
+
paddingHorizontal: theme.spacing?.xs || 6,
|
|
88
|
+
paddingVertical: theme.spacing?.xs || 4,
|
|
89
|
+
minHeight: 32,
|
|
90
|
+
display: 'flex',
|
|
91
|
+
justifyContent: 'center',
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
|
|
94
|
+
// Month buttons: 3 per row with equal spacing
|
|
95
|
+
variants: {
|
|
96
|
+
monthButton: {
|
|
97
|
+
true: {
|
|
98
|
+
flex: '1 0 30%',
|
|
99
|
+
maxWidth: 'calc(33.333% - 4px)',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
yearButton: {
|
|
103
|
+
true: {
|
|
104
|
+
flex: '1 0 22%',
|
|
105
|
+
maxWidth: 'calc(25% - 3px)',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Native specific sizing
|
|
111
|
+
_native: {
|
|
112
|
+
width: '30%',
|
|
113
|
+
marginBottom: 4,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
weekdayHeader: {
|
|
118
|
+
display: 'grid',
|
|
119
|
+
gridTemplateColumns: 'repeat(7, 1fr)',
|
|
120
|
+
gap: 2,
|
|
121
|
+
marginBottom: theme.spacing?.xs || 8,
|
|
122
|
+
|
|
123
|
+
// Native fallback
|
|
124
|
+
_native: {
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
weekdayCell: {
|
|
130
|
+
display: 'flex',
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
justifyContent: 'center',
|
|
133
|
+
paddingVertical: theme.spacing?.xs || 4,
|
|
134
|
+
|
|
135
|
+
// Native fallback
|
|
136
|
+
_native: {
|
|
137
|
+
flex: 1,
|
|
138
|
+
alignItems: 'center',
|
|
139
|
+
paddingVertical: theme.spacing?.xs || 4,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
weekdayText: {
|
|
144
|
+
fontSize: theme.typography?.sizes?.small || 12,
|
|
145
|
+
fontWeight: '500',
|
|
146
|
+
color: theme.colors?.text?.secondary || '#6b7280',
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
calendarGrid: {
|
|
150
|
+
display: 'grid',
|
|
151
|
+
gridTemplateColumns: 'repeat(7, 1fr)',
|
|
152
|
+
gap: 2,
|
|
153
|
+
marginBottom: theme.spacing?.xs || 8,
|
|
154
|
+
|
|
155
|
+
// Native fallback
|
|
156
|
+
_native: {
|
|
157
|
+
flexDirection: 'row',
|
|
158
|
+
flexWrap: 'wrap',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
dayCell: {
|
|
163
|
+
display: 'flex',
|
|
164
|
+
alignItems: 'center',
|
|
165
|
+
justifyContent: 'center',
|
|
166
|
+
aspectRatio: '1',
|
|
167
|
+
minHeight: 32,
|
|
168
|
+
|
|
169
|
+
// Native specific sizing
|
|
170
|
+
_native: {
|
|
171
|
+
width: '14.28%', // 100% / 7 days
|
|
172
|
+
aspectRatio: 1,
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: 'center',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
dayButton: {
|
|
179
|
+
width: '100%',
|
|
180
|
+
height: '100%',
|
|
181
|
+
maxWidth: 36,
|
|
182
|
+
maxHeight: 36,
|
|
183
|
+
minWidth: 28,
|
|
184
|
+
minHeight: 28,
|
|
185
|
+
padding: 0,
|
|
186
|
+
borderRadius: theme.borderRadius?.sm || 6,
|
|
187
|
+
fontSize: theme.typography?.sizes?.small || 13,
|
|
188
|
+
|
|
189
|
+
variants: {
|
|
190
|
+
selected: {
|
|
191
|
+
true: {
|
|
192
|
+
fontWeight: '600',
|
|
193
|
+
},
|
|
194
|
+
false: {
|
|
195
|
+
fontWeight: '400',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// Native specific styling
|
|
201
|
+
_native: {
|
|
202
|
+
width: 28,
|
|
203
|
+
height: 28,
|
|
204
|
+
minWidth: 28,
|
|
205
|
+
minHeight: 28,
|
|
206
|
+
borderRadius: theme.borderRadius?.sm || 6,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
todaySection: {
|
|
211
|
+
paddingTop: theme.spacing?.sm || 12,
|
|
212
|
+
borderTopWidth: 1,
|
|
213
|
+
borderTopColor: theme.colors?.border?.secondary || '#f3f4f6',
|
|
214
|
+
|
|
215
|
+
// Web specific border
|
|
216
|
+
_web: {
|
|
217
|
+
borderTop: `1px solid ${theme.colors?.border?.secondary || '#f3f4f6'}`,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
todayButton: {
|
|
222
|
+
width: '100%',
|
|
223
|
+
},
|
|
224
|
+
}));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { View } from '@idealyst/components';
|
|
3
|
+
import { CalendarProps } from './types';
|
|
4
|
+
import { CalendarHeader } from '../primitives/CalendarHeader';
|
|
5
|
+
import { CalendarGrid } from '../primitives/CalendarGrid';
|
|
6
|
+
import { CalendarOverlay } from '../primitives/CalendarOverlay';
|
|
7
|
+
import { calendarStyles } from './Calendar.styles';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export const Calendar: React.FC<CalendarProps> = ({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
minDate,
|
|
14
|
+
maxDate,
|
|
15
|
+
disabled = false,
|
|
16
|
+
currentMonth: controlledCurrentMonth,
|
|
17
|
+
onMonthChange,
|
|
18
|
+
style,
|
|
19
|
+
testID,
|
|
20
|
+
}) => {
|
|
21
|
+
const [internalCurrentMonth, setInternalCurrentMonth] = useState(
|
|
22
|
+
controlledCurrentMonth || value || new Date()
|
|
23
|
+
);
|
|
24
|
+
const [overlayMode, setOverlayMode] = useState<'month' | 'year' | null>(null);
|
|
25
|
+
const containerRef = useRef<View>(null);
|
|
26
|
+
|
|
27
|
+
const currentMonth = controlledCurrentMonth || internalCurrentMonth;
|
|
28
|
+
|
|
29
|
+
// Close overlay when clicking outside
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
32
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
33
|
+
setOverlayMode(null);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (overlayMode) {
|
|
38
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
43
|
+
};
|
|
44
|
+
}, [overlayMode]);
|
|
45
|
+
|
|
46
|
+
const handleMonthChange = (newMonth: Date) => {
|
|
47
|
+
if (onMonthChange) {
|
|
48
|
+
onMonthChange(newMonth);
|
|
49
|
+
} else {
|
|
50
|
+
setInternalCurrentMonth(newMonth);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const isDateDisabled = (date: Date): boolean => {
|
|
55
|
+
if (disabled) return true;
|
|
56
|
+
if (minDate && date < minDate) return true;
|
|
57
|
+
if (maxDate && date > maxDate) return true;
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleDateSelect = (date: Date) => {
|
|
62
|
+
if (!isDateDisabled(date)) {
|
|
63
|
+
onChange(date);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const goToPreviousMonth = () => {
|
|
68
|
+
const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
|
|
69
|
+
handleMonthChange(newMonth);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const goToNextMonth = () => {
|
|
73
|
+
const newMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
|
74
|
+
handleMonthChange(newMonth);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const createDateWithDayAdjustment = (year: number, month: number, day: number): Date => {
|
|
78
|
+
// Get the last day of the target month
|
|
79
|
+
const lastDayOfMonth = new Date(year, month + 1, 0).getDate();
|
|
80
|
+
// Use the smaller of the requested day or the last day of the month
|
|
81
|
+
const adjustedDay = Math.min(day, lastDayOfMonth);
|
|
82
|
+
return new Date(year, month, adjustedDay);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleMonthSelect = (monthIndex: number) => {
|
|
86
|
+
const newMonth = new Date(currentMonth.getFullYear(), monthIndex, 1);
|
|
87
|
+
handleMonthChange(newMonth);
|
|
88
|
+
|
|
89
|
+
// Update the selected date if one exists
|
|
90
|
+
if (value) {
|
|
91
|
+
const newDate = createDateWithDayAdjustment(
|
|
92
|
+
currentMonth.getFullYear(),
|
|
93
|
+
monthIndex,
|
|
94
|
+
value.getDate()
|
|
95
|
+
);
|
|
96
|
+
if (!isDateDisabled(newDate)) {
|
|
97
|
+
onChange(newDate);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleYearSelect = (year: number) => {
|
|
103
|
+
const newMonth = new Date(year, currentMonth.getMonth(), 1);
|
|
104
|
+
handleMonthChange(newMonth);
|
|
105
|
+
|
|
106
|
+
// Update the selected date if one exists
|
|
107
|
+
if (value) {
|
|
108
|
+
const newDate = createDateWithDayAdjustment(
|
|
109
|
+
year,
|
|
110
|
+
currentMonth.getMonth(),
|
|
111
|
+
value.getDate()
|
|
112
|
+
);
|
|
113
|
+
if (!isDateDisabled(newDate)) {
|
|
114
|
+
onChange(newDate);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<View ref={containerRef} style={[calendarStyles.container, style]} data-testid={testID}>
|
|
121
|
+
<CalendarHeader
|
|
122
|
+
currentMonth={currentMonth}
|
|
123
|
+
onPreviousMonth={goToPreviousMonth}
|
|
124
|
+
onNextMonth={goToNextMonth}
|
|
125
|
+
onMonthClick={() => setOverlayMode('month')}
|
|
126
|
+
onYearClick={() => setOverlayMode('year')}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
<CalendarGrid
|
|
131
|
+
currentMonth={currentMonth}
|
|
132
|
+
selectedDate={value}
|
|
133
|
+
onDateSelect={handleDateSelect}
|
|
134
|
+
onMonthChange={handleMonthChange}
|
|
135
|
+
minDate={minDate}
|
|
136
|
+
maxDate={maxDate}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{/* Overlay for month/year selection */}
|
|
141
|
+
{overlayMode && (
|
|
142
|
+
<CalendarOverlay
|
|
143
|
+
mode={overlayMode}
|
|
144
|
+
currentMonth={currentMonth.getMonth()}
|
|
145
|
+
currentYear={currentMonth.getFullYear()}
|
|
146
|
+
onMonthSelect={handleMonthSelect}
|
|
147
|
+
onYearSelect={handleYearSelect}
|
|
148
|
+
onClose={() => setOverlayMode(null)}
|
|
149
|
+
disabled={disabled}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
};
|