@idealyst/datepicker 1.1.4 → 1.1.5
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/package.json +6 -5
- package/src/DateInput.native.tsx +155 -0
- package/src/DateInput.tsx +2 -0
- package/src/DateInput.web.tsx +146 -0
- package/src/DatePicker.tsx +276 -0
- package/src/DateTimePicker.tsx +89 -0
- package/src/TimeInput.native.tsx +175 -0
- package/src/TimeInput.tsx +2 -0
- package/src/TimeInput.web.tsx +171 -0
- package/src/TimePicker.tsx +106 -0
- package/src/examples/DatePickerExamples.tsx +113 -149
- package/src/examples/index.ts +1 -1
- package/src/index.native.ts +15 -20
- package/src/index.ts +14 -19
- package/src/styles.ts +127 -0
- package/src/types.ts +56 -0
- package/src/DateInput/DateInput.native.tsx +0 -61
- package/src/DateInput/DateInput.styles.tsx +0 -26
- package/src/DateInput/DateInput.web.tsx +0 -61
- package/src/DateInput/DateInputBase.tsx +0 -228
- package/src/DateInput/index.native.ts +0 -2
- package/src/DateInput/index.ts +0 -2
- package/src/DateInput/types.ts +0 -60
- package/src/DatePicker/Calendar.native.tsx +0 -261
- package/src/DatePicker/Calendar.styles.tsx +0 -230
- package/src/DatePicker/Calendar.web.tsx +0 -159
- package/src/DatePicker/DatePicker.native.tsx +0 -51
- package/src/DatePicker/DatePicker.styles.tsx +0 -76
- package/src/DatePicker/DatePicker.web.tsx +0 -31
- package/src/DatePicker/index.native.ts +0 -3
- package/src/DatePicker/index.ts +0 -3
- package/src/DatePicker/types.ts +0 -78
- package/src/DateRangePicker/DateRangePicker.native.tsx +0 -39
- package/src/DateRangePicker/DateRangePicker.styles.tsx +0 -83
- package/src/DateRangePicker/DateRangePicker.web.tsx +0 -40
- package/src/DateRangePicker/RangeCalendar.native.tsx +0 -355
- package/src/DateRangePicker/RangeCalendar.styles.tsx +0 -200
- package/src/DateRangePicker/RangeCalendar.web.tsx +0 -384
- package/src/DateRangePicker/index.native.ts +0 -3
- package/src/DateRangePicker/index.ts +0 -3
- package/src/DateRangePicker/types.ts +0 -107
- package/src/DateTimePicker/DateTimePicker.native.tsx +0 -24
- package/src/DateTimePicker/DateTimePicker.styles.tsx +0 -104
- package/src/DateTimePicker/DateTimePicker.tsx +0 -21
- package/src/DateTimePicker/DateTimePickerBase.tsx +0 -185
- package/src/DateTimePicker/TimePicker.native.tsx +0 -17
- package/src/DateTimePicker/TimePicker.styles.tsx +0 -115
- package/src/DateTimePicker/TimePicker.tsx +0 -14
- package/src/DateTimePicker/TimePickerBase.tsx +0 -232
- package/src/DateTimePicker/index.native.ts +0 -3
- package/src/DateTimePicker/index.ts +0 -3
- package/src/DateTimePicker/primitives/ClockFace.native.tsx +0 -195
- package/src/DateTimePicker/primitives/ClockFace.web.tsx +0 -168
- package/src/DateTimePicker/primitives/TimeInput.native.tsx +0 -53
- package/src/DateTimePicker/primitives/TimeInput.web.tsx +0 -66
- package/src/DateTimePicker/primitives/index.native.ts +0 -2
- package/src/DateTimePicker/primitives/index.ts +0 -2
- package/src/DateTimePicker/primitives/index.web.ts +0 -2
- package/src/DateTimePicker/types.ts +0 -80
- package/src/DateTimePicker/utils/dimensions.native.ts +0 -9
- package/src/DateTimePicker/utils/dimensions.ts +0 -9
- package/src/DateTimePicker/utils/dimensions.web.ts +0 -33
- package/src/DateTimeRangePicker/DateTimeRangePicker.native.tsx +0 -24
- package/src/DateTimeRangePicker/DateTimeRangePicker.styles.tsx +0 -118
- package/src/DateTimeRangePicker/DateTimeRangePicker.web.tsx +0 -21
- package/src/DateTimeRangePicker/DateTimeRangePickerBase.tsx +0 -327
- package/src/DateTimeRangePicker/index.native.ts +0 -2
- package/src/DateTimeRangePicker/index.ts +0 -2
- package/src/DateTimeRangePicker/types.ts +0 -70
- package/src/primitives/CalendarGrid/CalendarGrid.styles.tsx +0 -51
- package/src/primitives/CalendarGrid/CalendarGrid.tsx +0 -146
- package/src/primitives/CalendarGrid/index.ts +0 -1
- package/src/primitives/CalendarHeader/CalendarHeader.styles.tsx +0 -25
- package/src/primitives/CalendarHeader/CalendarHeader.tsx +0 -69
- package/src/primitives/CalendarHeader/index.ts +0 -1
- package/src/primitives/CalendarOverlay/CalendarOverlay.styles.tsx +0 -86
- package/src/primitives/CalendarOverlay/CalendarOverlay.tsx +0 -136
- package/src/primitives/CalendarOverlay/index.ts +0 -1
- package/src/primitives/Wrapper/Wrapper.web.tsx +0 -33
- package/src/primitives/Wrapper/index.ts +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/datepicker",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Cross-platform date and time picker components for React and React Native",
|
|
5
5
|
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/datepicker#readme",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"publish:npm": "npm publish"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@idealyst/components": "^1.1.
|
|
40
|
-
"@idealyst/theme": "^1.1.
|
|
39
|
+
"@idealyst/components": "^1.1.5",
|
|
40
|
+
"@idealyst/theme": "^1.1.5",
|
|
41
41
|
"react": ">=16.8.0",
|
|
42
42
|
"react-native": ">=0.60.0",
|
|
43
43
|
"react-native-svg": ">=13.0.0",
|
|
@@ -61,11 +61,12 @@
|
|
|
61
61
|
}
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@idealyst/components": "^1.1.
|
|
65
|
-
"@idealyst/theme": "^1.1.
|
|
64
|
+
"@idealyst/components": "^1.1.5",
|
|
65
|
+
"@idealyst/theme": "^1.1.5",
|
|
66
66
|
"@types/react": "^19.1.0",
|
|
67
67
|
"react": "^19.1.0",
|
|
68
68
|
"react-native": "^0.80.1",
|
|
69
|
+
"react-native-svg": "^15.15.1",
|
|
69
70
|
"react-native-unistyles": "^3.0.10",
|
|
70
71
|
"typescript": "^5.0.0"
|
|
71
72
|
},
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Modal, TextInput as RNTextInput } from 'react-native';
|
|
3
|
+
import { View, Text, Button, Icon } from '@idealyst/components';
|
|
4
|
+
import { DatePicker } from './DatePicker';
|
|
5
|
+
import { datePickerStyles } from './styles';
|
|
6
|
+
import type { DateInputProps } from './types';
|
|
7
|
+
|
|
8
|
+
export const DateInput: React.FC<DateInputProps> = ({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
label,
|
|
12
|
+
placeholder = 'MM/DD/YYYY',
|
|
13
|
+
minDate,
|
|
14
|
+
maxDate,
|
|
15
|
+
disabled = false,
|
|
16
|
+
error,
|
|
17
|
+
style,
|
|
18
|
+
}) => {
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const [inputValue, setInputValue] = useState('');
|
|
21
|
+
|
|
22
|
+
// Format date to string
|
|
23
|
+
const formatDate = (date: Date | undefined): string => {
|
|
24
|
+
if (!date) return '';
|
|
25
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
26
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
27
|
+
const year = date.getFullYear();
|
|
28
|
+
return `${month}/${day}/${year}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Parse string to date
|
|
32
|
+
const parseDate = (str: string): Date | null => {
|
|
33
|
+
const match = str.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
34
|
+
if (!match) return null;
|
|
35
|
+
const [, month, day, year] = match;
|
|
36
|
+
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
|
37
|
+
if (isNaN(date.getTime())) return null;
|
|
38
|
+
return date;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Sync input value with prop value
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setInputValue(formatDate(value ?? undefined));
|
|
44
|
+
}, [value]);
|
|
45
|
+
|
|
46
|
+
const handleInputChange = (newValue: string) => {
|
|
47
|
+
setInputValue(newValue);
|
|
48
|
+
|
|
49
|
+
// Try to parse and update if valid
|
|
50
|
+
const parsed = parseDate(newValue);
|
|
51
|
+
if (parsed) {
|
|
52
|
+
onChange(parsed);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleInputBlur = () => {
|
|
57
|
+
// On blur, reset to formatted value if invalid
|
|
58
|
+
const parsed = parseDate(inputValue);
|
|
59
|
+
if (!parsed && value) {
|
|
60
|
+
setInputValue(formatDate(value));
|
|
61
|
+
} else if (!parsed) {
|
|
62
|
+
setInputValue('');
|
|
63
|
+
onChange(null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleDateSelect = (date: Date) => {
|
|
68
|
+
onChange(date);
|
|
69
|
+
setOpen(false);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View style={style}>
|
|
74
|
+
{label && (
|
|
75
|
+
<Text typography="body2" weight="medium" style={{ marginBottom: 4 }}>
|
|
76
|
+
{label}
|
|
77
|
+
</Text>
|
|
78
|
+
)}
|
|
79
|
+
<View
|
|
80
|
+
style={{
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
borderWidth: 1,
|
|
84
|
+
borderColor: error ? '#ef4444' : '#d1d5db',
|
|
85
|
+
borderRadius: 6,
|
|
86
|
+
backgroundColor: disabled ? '#f3f4f6' : 'white',
|
|
87
|
+
overflow: 'hidden',
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<RNTextInput
|
|
91
|
+
value={inputValue}
|
|
92
|
+
onChangeText={handleInputChange}
|
|
93
|
+
onBlur={handleInputBlur}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
editable={!disabled}
|
|
96
|
+
style={{
|
|
97
|
+
flex: 1,
|
|
98
|
+
padding: 8,
|
|
99
|
+
paddingHorizontal: 12,
|
|
100
|
+
fontSize: 14,
|
|
101
|
+
backgroundColor: 'transparent',
|
|
102
|
+
color: disabled ? '#9ca3af' : '#111827',
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
<Button
|
|
106
|
+
type="text"
|
|
107
|
+
size="sm"
|
|
108
|
+
onPress={() => !disabled && setOpen(true)}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
style={{ marginRight: 4 }}
|
|
111
|
+
>
|
|
112
|
+
<Icon name="calendar" size={18} />
|
|
113
|
+
</Button>
|
|
114
|
+
</View>
|
|
115
|
+
{error && (
|
|
116
|
+
<Text typography="caption" style={{ marginTop: 4, color: '#ef4444' }}>
|
|
117
|
+
{error}
|
|
118
|
+
</Text>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
<Modal
|
|
122
|
+
visible={open}
|
|
123
|
+
transparent
|
|
124
|
+
animationType="fade"
|
|
125
|
+
onRequestClose={() => setOpen(false)}
|
|
126
|
+
>
|
|
127
|
+
<View
|
|
128
|
+
style={{
|
|
129
|
+
flex: 1,
|
|
130
|
+
justifyContent: 'center',
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<View style={datePickerStyles.popoverContent}>
|
|
136
|
+
<DatePicker
|
|
137
|
+
value={value ?? undefined}
|
|
138
|
+
onChange={handleDateSelect}
|
|
139
|
+
minDate={minDate}
|
|
140
|
+
maxDate={maxDate}
|
|
141
|
+
disabled={disabled}
|
|
142
|
+
/>
|
|
143
|
+
<Button
|
|
144
|
+
type="text"
|
|
145
|
+
onPress={() => setOpen(false)}
|
|
146
|
+
style={{ marginTop: 8 }}
|
|
147
|
+
>
|
|
148
|
+
Close
|
|
149
|
+
</Button>
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
</Modal>
|
|
153
|
+
</View>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { View, Text, Button, Icon } from '@idealyst/components';
|
|
3
|
+
import { PositionedPortal } from '@idealyst/components/internal';
|
|
4
|
+
import { DatePicker } from './DatePicker';
|
|
5
|
+
import { datePickerStyles } from './styles';
|
|
6
|
+
import type { DateInputProps } from './types';
|
|
7
|
+
|
|
8
|
+
export const DateInput: React.FC<DateInputProps> = ({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
label,
|
|
12
|
+
placeholder = 'MM/DD/YYYY',
|
|
13
|
+
minDate,
|
|
14
|
+
maxDate,
|
|
15
|
+
disabled = false,
|
|
16
|
+
error,
|
|
17
|
+
style,
|
|
18
|
+
}) => {
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const [inputValue, setInputValue] = useState('');
|
|
21
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
// Format date to string
|
|
24
|
+
const formatDate = (date: Date | undefined): string => {
|
|
25
|
+
if (!date) return '';
|
|
26
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
27
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
28
|
+
const year = date.getFullYear();
|
|
29
|
+
return `${month}/${day}/${year}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Parse string to date
|
|
33
|
+
const parseDate = (str: string): Date | null => {
|
|
34
|
+
const match = str.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const [, month, day, year] = match;
|
|
37
|
+
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
|
38
|
+
if (isNaN(date.getTime())) return null;
|
|
39
|
+
return date;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Sync input value with prop value
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setInputValue(formatDate(value ?? undefined));
|
|
45
|
+
}, [value]);
|
|
46
|
+
|
|
47
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
48
|
+
const newValue = e.target.value;
|
|
49
|
+
setInputValue(newValue);
|
|
50
|
+
|
|
51
|
+
// Try to parse and update if valid
|
|
52
|
+
const parsed = parseDate(newValue);
|
|
53
|
+
if (parsed) {
|
|
54
|
+
onChange(parsed);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleInputBlur = () => {
|
|
59
|
+
// On blur, reset to formatted value if invalid
|
|
60
|
+
const parsed = parseDate(inputValue);
|
|
61
|
+
if (!parsed && value) {
|
|
62
|
+
setInputValue(formatDate(value));
|
|
63
|
+
} else if (!parsed) {
|
|
64
|
+
setInputValue('');
|
|
65
|
+
onChange(null);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleDateSelect = (date: Date) => {
|
|
70
|
+
onChange(date);
|
|
71
|
+
setOpen(false);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View style={style}>
|
|
76
|
+
{label && (
|
|
77
|
+
<Text typography="body2" weight="medium" style={{ marginBottom: 4 }}>
|
|
78
|
+
{label}
|
|
79
|
+
</Text>
|
|
80
|
+
)}
|
|
81
|
+
<div
|
|
82
|
+
ref={triggerRef}
|
|
83
|
+
style={{
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
border: `1px solid ${error ? '#ef4444' : '#d1d5db'}`,
|
|
87
|
+
borderRadius: 6,
|
|
88
|
+
backgroundColor: disabled ? '#f3f4f6' : 'white',
|
|
89
|
+
overflow: 'hidden',
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<input
|
|
93
|
+
type="text"
|
|
94
|
+
value={inputValue}
|
|
95
|
+
onChange={handleInputChange}
|
|
96
|
+
onBlur={handleInputBlur}
|
|
97
|
+
placeholder={placeholder}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
style={{
|
|
100
|
+
flex: 1,
|
|
101
|
+
padding: '8px 12px',
|
|
102
|
+
fontSize: 14,
|
|
103
|
+
border: 'none',
|
|
104
|
+
outline: 'none',
|
|
105
|
+
backgroundColor: 'transparent',
|
|
106
|
+
color: disabled ? '#9ca3af' : '#111827',
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
<Button
|
|
110
|
+
type="text"
|
|
111
|
+
size="sm"
|
|
112
|
+
onPress={() => !disabled && setOpen(!open)}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
style={{ marginRight: 4 }}
|
|
115
|
+
>
|
|
116
|
+
<Icon name="calendar" size={18} />
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
{error && (
|
|
120
|
+
<Text typography="caption" style={{ marginTop: 4, color: '#ef4444' }}>
|
|
121
|
+
{error}
|
|
122
|
+
</Text>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<PositionedPortal
|
|
126
|
+
open={open}
|
|
127
|
+
anchor={triggerRef}
|
|
128
|
+
placement="bottom-start"
|
|
129
|
+
offset={4}
|
|
130
|
+
onClickOutside={() => setOpen(false)}
|
|
131
|
+
onEscapeKey={() => setOpen(false)}
|
|
132
|
+
zIndex={9999}
|
|
133
|
+
>
|
|
134
|
+
<View style={datePickerStyles.popoverContent}>
|
|
135
|
+
<DatePicker
|
|
136
|
+
value={value ?? undefined}
|
|
137
|
+
onChange={handleDateSelect}
|
|
138
|
+
minDate={minDate}
|
|
139
|
+
maxDate={maxDate}
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
/>
|
|
142
|
+
</View>
|
|
143
|
+
</PositionedPortal>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { View, Text, Button, Icon } from '@idealyst/components';
|
|
3
|
+
import { datePickerStyles } from './styles';
|
|
4
|
+
import type { DatePickerProps } from './types';
|
|
5
|
+
|
|
6
|
+
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
|
7
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
8
|
+
|
|
9
|
+
type ViewMode = 'calendar' | 'months' | 'years';
|
|
10
|
+
|
|
11
|
+
export const DatePicker: React.FC<DatePickerProps> = ({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
minDate,
|
|
15
|
+
maxDate,
|
|
16
|
+
disabled = false,
|
|
17
|
+
style,
|
|
18
|
+
}) => {
|
|
19
|
+
const [currentMonth, setCurrentMonth] = useState(() => value || new Date());
|
|
20
|
+
const [viewMode, setViewMode] = useState<ViewMode>('calendar');
|
|
21
|
+
|
|
22
|
+
datePickerStyles.useVariants({ disabled });
|
|
23
|
+
|
|
24
|
+
const { days, monthLabel, monthShort, year } = useMemo(() => {
|
|
25
|
+
const year = currentMonth.getFullYear();
|
|
26
|
+
const month = currentMonth.getMonth();
|
|
27
|
+
const firstDay = new Date(year, month, 1);
|
|
28
|
+
const lastDay = new Date(year, month + 1, 0);
|
|
29
|
+
const startPadding = firstDay.getDay();
|
|
30
|
+
const daysInMonth = lastDay.getDate();
|
|
31
|
+
|
|
32
|
+
const days: Array<{ date: Date; isCurrentMonth: boolean }> = [];
|
|
33
|
+
|
|
34
|
+
// Previous month padding
|
|
35
|
+
const prevMonthEnd = new Date(year, month, 0);
|
|
36
|
+
for (let i = startPadding - 1; i >= 0; i--) {
|
|
37
|
+
days.push({
|
|
38
|
+
date: new Date(year, month - 1, prevMonthEnd.getDate() - i),
|
|
39
|
+
isCurrentMonth: false,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Current month
|
|
44
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
45
|
+
days.push({ date: new Date(year, month, day), isCurrentMonth: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Next month padding (fill to complete last week)
|
|
49
|
+
const remaining = 7 - (days.length % 7);
|
|
50
|
+
if (remaining < 7) {
|
|
51
|
+
for (let day = 1; day <= remaining; day++) {
|
|
52
|
+
days.push({ date: new Date(year, month + 1, day), isCurrentMonth: false });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const monthLabel = firstDay.toLocaleDateString('en-US', {
|
|
57
|
+
month: 'short',
|
|
58
|
+
year: 'numeric',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { days, monthLabel, monthShort: MONTHS[month], year };
|
|
62
|
+
}, [currentMonth]);
|
|
63
|
+
|
|
64
|
+
const isSelected = (date: Date): boolean => {
|
|
65
|
+
if (!value) return false;
|
|
66
|
+
return (
|
|
67
|
+
date.getDate() === value.getDate() &&
|
|
68
|
+
date.getMonth() === value.getMonth() &&
|
|
69
|
+
date.getFullYear() === value.getFullYear()
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const isToday = (date: Date): boolean => {
|
|
74
|
+
const today = new Date();
|
|
75
|
+
return (
|
|
76
|
+
date.getDate() === today.getDate() &&
|
|
77
|
+
date.getMonth() === today.getMonth() &&
|
|
78
|
+
date.getFullYear() === today.getFullYear()
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const isDisabled = (date: Date): boolean => {
|
|
83
|
+
if (disabled) return true;
|
|
84
|
+
if (minDate && date < new Date(minDate.setHours(0, 0, 0, 0))) return true;
|
|
85
|
+
if (maxDate && date > new Date(maxDate.setHours(23, 59, 59, 999))) return true;
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const goToPrevMonth = () => {
|
|
90
|
+
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const goToNextMonth = () => {
|
|
94
|
+
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleDayPress = (date: Date) => {
|
|
98
|
+
if (!isDisabled(date)) {
|
|
99
|
+
onChange(date);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleMonthSelect = (monthIndex: number) => {
|
|
104
|
+
setCurrentMonth(new Date(currentMonth.getFullYear(), monthIndex, 1));
|
|
105
|
+
setViewMode('calendar');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleYearSelect = (selectedYear: number) => {
|
|
109
|
+
setCurrentMonth(new Date(selectedYear, currentMonth.getMonth(), 1));
|
|
110
|
+
setViewMode('months');
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Generate year range (10 years before and after current)
|
|
114
|
+
const yearRange = useMemo(() => {
|
|
115
|
+
const currentYear = currentMonth.getFullYear();
|
|
116
|
+
const startYear = Math.floor(currentYear / 10) * 10 - 10;
|
|
117
|
+
return Array.from({ length: 21 }, (_, i) => startYear + i);
|
|
118
|
+
}, [currentMonth]);
|
|
119
|
+
|
|
120
|
+
const goToPrevYearRange = () => {
|
|
121
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() - 10, currentMonth.getMonth(), 1));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const goToNextYearRange = () => {
|
|
125
|
+
setCurrentMonth(new Date(currentMonth.getFullYear() + 10, currentMonth.getMonth(), 1));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Render month selector
|
|
129
|
+
if (viewMode === 'months') {
|
|
130
|
+
return (
|
|
131
|
+
<View style={[datePickerStyles.calendar, style]}>
|
|
132
|
+
<View style={datePickerStyles.calendarHeader}>
|
|
133
|
+
<Button type="text" size="sm" onPress={() => setViewMode('calendar')} disabled={disabled}>
|
|
134
|
+
<Icon name="chevron-left" size={16} />
|
|
135
|
+
</Button>
|
|
136
|
+
<Button type="text" size="sm" onPress={() => setViewMode('years')} disabled={disabled}>
|
|
137
|
+
<Text typography="body2" weight="semibold">{year}</Text>
|
|
138
|
+
</Button>
|
|
139
|
+
<View style={{ width: 28 }} />
|
|
140
|
+
</View>
|
|
141
|
+
<View style={datePickerStyles.monthGrid}>
|
|
142
|
+
{MONTHS.map((month, index) => {
|
|
143
|
+
const isCurrentMonth = index === currentMonth.getMonth();
|
|
144
|
+
return (
|
|
145
|
+
<Button
|
|
146
|
+
key={month}
|
|
147
|
+
type={isCurrentMonth ? 'contained' : 'text'}
|
|
148
|
+
size="sm"
|
|
149
|
+
onPress={() => handleMonthSelect(index)}
|
|
150
|
+
disabled={disabled}
|
|
151
|
+
style={{ minWidth: 48, margin: 2 }}
|
|
152
|
+
>
|
|
153
|
+
<Text typography="caption" color={isCurrentMonth ? 'inverse' : 'primary'}>
|
|
154
|
+
{month}
|
|
155
|
+
</Text>
|
|
156
|
+
</Button>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Render year selector
|
|
165
|
+
if (viewMode === 'years') {
|
|
166
|
+
return (
|
|
167
|
+
<View style={[datePickerStyles.calendar, style]}>
|
|
168
|
+
<View style={datePickerStyles.calendarHeader}>
|
|
169
|
+
<Button type="text" size="sm" onPress={goToPrevYearRange} disabled={disabled}>
|
|
170
|
+
<Icon name="chevron-left" size={16} />
|
|
171
|
+
</Button>
|
|
172
|
+
<Text typography="body2" weight="semibold">
|
|
173
|
+
{yearRange[0]} - {yearRange[yearRange.length - 1]}
|
|
174
|
+
</Text>
|
|
175
|
+
<Button type="text" size="sm" onPress={goToNextYearRange} disabled={disabled}>
|
|
176
|
+
<Icon name="chevron-right" size={16} />
|
|
177
|
+
</Button>
|
|
178
|
+
</View>
|
|
179
|
+
<View style={datePickerStyles.yearGrid}>
|
|
180
|
+
{yearRange.map((yr) => {
|
|
181
|
+
const isCurrentYear = yr === currentMonth.getFullYear();
|
|
182
|
+
return (
|
|
183
|
+
<Button
|
|
184
|
+
key={yr}
|
|
185
|
+
type={isCurrentYear ? 'contained' : 'text'}
|
|
186
|
+
size="sm"
|
|
187
|
+
onPress={() => handleYearSelect(yr)}
|
|
188
|
+
disabled={disabled}
|
|
189
|
+
style={{ minWidth: 48, margin: 2 }}
|
|
190
|
+
>
|
|
191
|
+
<Text typography="caption" color={isCurrentYear ? 'inverse' : 'primary'}>
|
|
192
|
+
{yr}
|
|
193
|
+
</Text>
|
|
194
|
+
</Button>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</View>
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Render calendar (default)
|
|
203
|
+
return (
|
|
204
|
+
<View style={[datePickerStyles.calendar, style]}>
|
|
205
|
+
{/* Header */}
|
|
206
|
+
<View style={datePickerStyles.calendarHeader}>
|
|
207
|
+
<Button type="text" size="sm" onPress={goToPrevMonth} disabled={disabled}>
|
|
208
|
+
<Icon name="chevron-left" size={16} />
|
|
209
|
+
</Button>
|
|
210
|
+
<Button type="text" size="sm" onPress={() => setViewMode('months')} disabled={disabled}>
|
|
211
|
+
<Text typography="body2" weight="semibold">
|
|
212
|
+
{monthShort} {year}
|
|
213
|
+
</Text>
|
|
214
|
+
</Button>
|
|
215
|
+
<Button type="text" size="sm" onPress={goToNextMonth} disabled={disabled}>
|
|
216
|
+
<Icon name="chevron-right" size={16} />
|
|
217
|
+
</Button>
|
|
218
|
+
</View>
|
|
219
|
+
|
|
220
|
+
{/* Weekday headers */}
|
|
221
|
+
<View style={datePickerStyles.weekdayRow}>
|
|
222
|
+
{WEEKDAYS.map((day, i) => (
|
|
223
|
+
<View key={i} style={datePickerStyles.weekdayCell}>
|
|
224
|
+
<Text typography="caption" color="secondary">
|
|
225
|
+
{day}
|
|
226
|
+
</Text>
|
|
227
|
+
</View>
|
|
228
|
+
))}
|
|
229
|
+
</View>
|
|
230
|
+
|
|
231
|
+
{/* Calendar grid */}
|
|
232
|
+
<View style={datePickerStyles.calendarGrid}>
|
|
233
|
+
{days.map(({ date, isCurrentMonth }, index) => {
|
|
234
|
+
const selected = isSelected(date);
|
|
235
|
+
const today = isToday(date);
|
|
236
|
+
const dayDisabled = isDisabled(date);
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<View
|
|
240
|
+
key={index}
|
|
241
|
+
style={[
|
|
242
|
+
datePickerStyles.dayCell,
|
|
243
|
+
selected && {
|
|
244
|
+
backgroundColor: '#3b82f6',
|
|
245
|
+
borderRadius: 4,
|
|
246
|
+
},
|
|
247
|
+
!isCurrentMonth && { opacity: 0.3 },
|
|
248
|
+
today && !selected && {
|
|
249
|
+
borderWidth: 1,
|
|
250
|
+
borderColor: '#3b82f6',
|
|
251
|
+
borderRadius: 4,
|
|
252
|
+
},
|
|
253
|
+
dayDisabled && { opacity: 0.3 },
|
|
254
|
+
]}
|
|
255
|
+
>
|
|
256
|
+
<Button
|
|
257
|
+
type="text"
|
|
258
|
+
size="sm"
|
|
259
|
+
onPress={() => handleDayPress(date)}
|
|
260
|
+
disabled={dayDisabled}
|
|
261
|
+
style={{ minWidth: 24, minHeight: 24, padding: 0 }}
|
|
262
|
+
>
|
|
263
|
+
<Text
|
|
264
|
+
typography="caption"
|
|
265
|
+
color={selected ? 'inverse' : 'primary'}
|
|
266
|
+
>
|
|
267
|
+
{date.getDate()}
|
|
268
|
+
</Text>
|
|
269
|
+
</Button>
|
|
270
|
+
</View>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
273
|
+
</View>
|
|
274
|
+
</View>
|
|
275
|
+
);
|
|
276
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text } from '@idealyst/components';
|
|
3
|
+
import { DateInput } from './DateInput';
|
|
4
|
+
import { TimeInput } from './TimeInput';
|
|
5
|
+
import { datePickerStyles } from './styles';
|
|
6
|
+
import type { DateTimePickerProps } from './types';
|
|
7
|
+
|
|
8
|
+
export const DateTimePicker: React.FC<DateTimePickerProps> = ({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
label,
|
|
12
|
+
minDate,
|
|
13
|
+
maxDate,
|
|
14
|
+
timeMode = '12h',
|
|
15
|
+
minuteStep = 1,
|
|
16
|
+
disabled = false,
|
|
17
|
+
error,
|
|
18
|
+
style,
|
|
19
|
+
}) => {
|
|
20
|
+
const handleDateChange = (date: Date | null) => {
|
|
21
|
+
if (!date) {
|
|
22
|
+
onChange(null);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Preserve time from current value, or use noon as default
|
|
26
|
+
const hours = value?.getHours() ?? 12;
|
|
27
|
+
const minutes = value?.getMinutes() ?? 0;
|
|
28
|
+
const updated = new Date(date);
|
|
29
|
+
updated.setHours(hours, minutes, 0, 0);
|
|
30
|
+
onChange(updated);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleTimeChange = (time: Date | null) => {
|
|
34
|
+
if (!time) {
|
|
35
|
+
// Only clear time component, keep date if it exists
|
|
36
|
+
if (value) {
|
|
37
|
+
const updated = new Date(value);
|
|
38
|
+
updated.setHours(12, 0, 0, 0);
|
|
39
|
+
onChange(updated);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Preserve date from current value, or use today
|
|
44
|
+
const baseDate = value || new Date();
|
|
45
|
+
const updated = new Date(
|
|
46
|
+
baseDate.getFullYear(),
|
|
47
|
+
baseDate.getMonth(),
|
|
48
|
+
baseDate.getDate(),
|
|
49
|
+
time.getHours(),
|
|
50
|
+
time.getMinutes(),
|
|
51
|
+
0,
|
|
52
|
+
0
|
|
53
|
+
);
|
|
54
|
+
onChange(updated);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<View style={style}>
|
|
59
|
+
{label && (
|
|
60
|
+
<Text typography="body2" weight="medium" style={{ marginBottom: 4 }}>
|
|
61
|
+
{label}
|
|
62
|
+
</Text>
|
|
63
|
+
)}
|
|
64
|
+
<View style={datePickerStyles.inputRow}>
|
|
65
|
+
<View style={{ flex: 1 }}>
|
|
66
|
+
<DateInput
|
|
67
|
+
value={value ?? undefined}
|
|
68
|
+
onChange={handleDateChange}
|
|
69
|
+
placeholder="MM/DD/YYYY"
|
|
70
|
+
minDate={minDate}
|
|
71
|
+
maxDate={maxDate}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
error={error}
|
|
74
|
+
/>
|
|
75
|
+
</View>
|
|
76
|
+
<View style={{ flex: 1 }}>
|
|
77
|
+
<TimeInput
|
|
78
|
+
value={value ?? undefined}
|
|
79
|
+
onChange={handleTimeChange}
|
|
80
|
+
placeholder={timeMode === '24h' ? '14:30' : '2:30 PM'}
|
|
81
|
+
mode={timeMode}
|
|
82
|
+
minuteStep={minuteStep}
|
|
83
|
+
disabled={disabled}
|
|
84
|
+
/>
|
|
85
|
+
</View>
|
|
86
|
+
</View>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
};
|