@pattern-stack/frontend-patterns 0.0.3 → 0.0.4
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/dist/index.es.js +1 -1
- package/dist/index.js +1 -0
- package/package.json +5 -3
- package/src/App.css +42 -0
- package/src/App.tsx +54 -0
- package/src/__tests__/README.md +221 -0
- package/src/__tests__/atoms/hooks/simple-hooks.test.ts +44 -0
- package/src/__tests__/atoms/ui/button.test.tsx +68 -0
- package/src/__tests__/atoms/utils/simple.test.ts +18 -0
- package/src/__tests__/atoms/utils/utils.test.ts +77 -0
- package/src/__tests__/features/auth/simple-auth.test.tsx +40 -0
- package/src/__tests__/molecules/layout/simple-layout.test.tsx +81 -0
- package/src/__tests__/organisms/showcase/simple-showcase.test.tsx +167 -0
- package/src/__tests__/setup.ts +51 -0
- package/src/__tests__/utils.tsx +123 -0
- package/src/atoms/composed/Accordion/Accordion.tsx +271 -0
- package/src/atoms/composed/Accordion/index.ts +1 -0
- package/src/atoms/composed/Alert/Alert.tsx +132 -0
- package/src/atoms/composed/Alert/index.ts +1 -0
- package/src/atoms/composed/Breadcrumb/Breadcrumb.tsx +83 -0
- package/src/atoms/composed/Breadcrumb/index.ts +1 -0
- package/src/atoms/composed/Chart/Chart.tsx +425 -0
- package/src/atoms/composed/Chart/index.ts +2 -0
- package/src/atoms/composed/ColorSwatch/ColorSwatch.tsx +72 -0
- package/src/atoms/composed/ColorSwatch/index.ts +1 -0
- package/src/atoms/composed/DarkModeToggle.tsx +66 -0
- package/src/atoms/composed/DataBadge/DataBadge.tsx +81 -0
- package/src/atoms/composed/DataBadge/index.ts +1 -0
- package/src/atoms/composed/DataTable/DataTable.tsx +394 -0
- package/src/atoms/composed/DataTable/TableCellWithTooltip.tsx +41 -0
- package/src/atoms/composed/DataTable/index.ts +2 -0
- package/src/atoms/composed/DateTimePicker/DateTimePicker.tsx +611 -0
- package/src/atoms/composed/DateTimePicker/index.ts +2 -0
- package/src/atoms/composed/DetailedCard/DetailedCard.tsx +181 -0
- package/src/atoms/composed/DetailedCard/index.ts +2 -0
- package/src/atoms/composed/EmptyState/EmptyState.tsx +90 -0
- package/src/atoms/composed/EmptyState/index.ts +1 -0
- package/src/atoms/composed/FileUpload/FileUpload.tsx +477 -0
- package/src/atoms/composed/FileUpload/index.ts +2 -0
- package/src/atoms/composed/FormField/FormField.tsx +92 -0
- package/src/atoms/composed/FormField/index.ts +1 -0
- package/src/atoms/composed/GlobalSearch/GlobalSearch.tsx +37 -0
- package/src/atoms/composed/GlobalSearch/index.ts +1 -0
- package/src/atoms/composed/IconBadge/IconBadge.tsx +95 -0
- package/src/atoms/composed/IconBadge/index.ts +2 -0
- package/src/atoms/composed/Modal/Modal.tsx +223 -0
- package/src/atoms/composed/Modal/index.ts +2 -0
- package/src/atoms/composed/PaletteSwitcher.tsx +386 -0
- package/src/atoms/composed/ProgressBar/ProgressBar.tsx +116 -0
- package/src/atoms/composed/ProgressBar/index.ts +1 -0
- package/src/atoms/composed/StatCard/StatCard.tsx +219 -0
- package/src/atoms/composed/StatCard/index.ts +1 -0
- package/src/atoms/composed/StyleGuide.tsx +717 -0
- package/src/atoms/composed/Toast/Toast.tsx +219 -0
- package/src/atoms/composed/Toast/index.ts +1 -0
- package/src/atoms/composed/Tooltip/Tooltip.tsx +213 -0
- package/src/atoms/composed/Tooltip/index.ts +1 -0
- package/src/atoms/composed/UserAvatar/UserAvatar.tsx +139 -0
- package/src/atoms/composed/UserAvatar/index.ts +1 -0
- package/src/atoms/composed/UserMenu/UserMenu.tsx +16 -0
- package/src/atoms/composed/UserMenu/index.ts +1 -0
- package/src/atoms/composed/index.ts +29 -0
- package/src/atoms/hooks/useApi.ts +80 -0
- package/src/atoms/hooks/useHealth.ts +17 -0
- package/src/atoms/index.ts +13 -0
- package/src/atoms/services/api/client.ts +134 -0
- package/src/atoms/services/auth-service.ts +248 -0
- package/src/atoms/services/health.ts +15 -0
- package/src/atoms/services/index.ts +3 -0
- package/src/atoms/shared/config/constants.ts +17 -0
- package/src/atoms/shared/config/dashboard-sizes.ts +111 -0
- package/src/atoms/shared/config/environment.ts +10 -0
- package/src/atoms/shared/index.ts +4 -0
- package/src/atoms/shared/styles/color-palettes.css +566 -0
- package/src/atoms/types/auth.ts +62 -0
- package/src/atoms/types/generated.ts +1469 -0
- package/src/atoms/types/index.ts +4 -0
- package/src/atoms/types/loading.ts +28 -0
- package/src/atoms/ui/Badge.tsx +30 -0
- package/src/atoms/ui/ErrorBoundary.tsx +59 -0
- package/src/atoms/ui/Select.tsx +53 -0
- package/src/atoms/ui/Switch.tsx +42 -0
- package/src/atoms/ui/Tabs.tsx +118 -0
- package/src/atoms/ui/avatar.tsx +48 -0
- package/src/atoms/ui/button.tsx +70 -0
- package/src/atoms/ui/card.tsx +76 -0
- package/src/atoms/ui/dropdown-menu.tsx +199 -0
- package/src/atoms/ui/index.ts +39 -0
- package/src/atoms/ui/input.tsx +23 -0
- package/src/atoms/ui/label.tsx +23 -0
- package/src/atoms/ui/skeleton.tsx +13 -0
- package/src/atoms/ui/spinner.tsx +49 -0
- package/src/atoms/ui/table.tsx +116 -0
- package/src/atoms/utils/animations.ts +135 -0
- package/src/atoms/utils/tooltip-helpers.ts +140 -0
- package/src/atoms/utils/utils.ts +9 -0
- package/src/features/auth/components/LoginForm.tsx +168 -0
- package/src/features/auth/components/LogoutButton.tsx +19 -0
- package/src/features/auth/components/ProtectedRoute.tsx +60 -0
- package/src/features/auth/components/index.ts +4 -0
- package/src/features/auth/hooks/index.ts +2 -0
- package/src/features/auth/hooks/useAuth.tsx +205 -0
- package/src/features/auth/hooks/usePermissions.ts +35 -0
- package/src/features/auth/index.ts +2 -0
- package/src/features/index.ts +2 -0
- package/src/index.css +704 -0
- package/src/index.ts +13 -0
- package/src/main.tsx +48 -0
- package/src/molecules/.gitkeep +0 -0
- package/src/molecules/forms/FormGroup.tsx +75 -0
- package/src/molecules/forms/SearchInput.tsx +259 -0
- package/src/molecules/forms/index.ts +4 -0
- package/src/molecules/index.ts +4 -0
- package/src/molecules/layout/AppHeader/AppHeader.tsx +42 -0
- package/src/molecules/layout/AppHeader/index.ts +1 -0
- package/src/molecules/layout/AppLayout.tsx +29 -0
- package/src/molecules/layout/PageTemplate.tsx +87 -0
- package/src/molecules/layout/SectionHeader/SectionHeader.tsx +87 -0
- package/src/molecules/layout/SectionHeader/index.ts +1 -0
- package/src/molecules/layout/ShowcaseSection.tsx +57 -0
- package/src/molecules/layout/Sidebar.tsx +144 -0
- package/src/molecules/layout/SidebarButton/SidebarButton.tsx +99 -0
- package/src/molecules/layout/SidebarButton/index.ts +1 -0
- package/src/molecules/layout/SidebarContext.tsx +31 -0
- package/src/molecules/layout/index.ts +7 -0
- package/src/molecules/navigation/NavMenu.tsx +188 -0
- package/src/molecules/navigation/Pagination.tsx +172 -0
- package/src/molecules/navigation/index.ts +4 -0
- package/src/organisms/index.ts +5 -0
- package/src/organisms/showcase/ComponentShowcasePage.tsx +2496 -0
- package/src/organisms/showcase/index.ts +1 -0
- package/src/pages/AdminShowcase/AdminCRUDShowcase.tsx +242 -0
- package/src/pages/AdminShowcase/AdminDashboardShowcase.tsx +171 -0
- package/src/pages/AdminShowcase/AdminDetailShowcase.tsx +385 -0
- package/src/pages/AdminShowcase/index.tsx +3 -0
- package/src/pages/ComponentShowcase/BadgesShowcase.tsx +188 -0
- package/src/pages/ComponentShowcase/CardsShowcase.tsx +392 -0
- package/src/pages/ComponentShowcase/PalettesShowcase.tsx +207 -0
- package/src/pages/ComponentShowcase/StatesShowcase.tsx +485 -0
- package/src/pages/ComponentShowcase/TablesShowcase.tsx +134 -0
- package/src/pages/ComponentShowcase/TypographyShowcase.tsx +255 -0
- package/src/pages/ComponentShowcase/index.tsx +188 -0
- package/src/pages/index.ts +2 -0
- package/src/templates/AuthTemplate.tsx +216 -0
- package/src/templates/ComponentShowcaseTemplate.tsx +173 -0
- package/src/templates/DashboardTemplate.tsx +232 -0
- package/src/templates/DataTemplate.tsx +319 -0
- package/src/templates/admin/AdminCRUDTemplate.tsx +630 -0
- package/src/templates/admin/AdminDashboardTemplate.tsx +351 -0
- package/src/templates/admin/AdminDetailTemplate.tsx +563 -0
- package/src/templates/admin/index.ts +29 -0
- package/src/templates/factory.tsx +169 -0
- package/src/templates/index.ts +37 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
3
|
+
import { cn } from '../../utils/utils';
|
|
4
|
+
import { Input } from '../../ui/input';
|
|
5
|
+
import { Button } from '../../ui/button';
|
|
6
|
+
|
|
7
|
+
export interface DateTimePickerProps {
|
|
8
|
+
/** Current selected date/time value */
|
|
9
|
+
value?: Date;
|
|
10
|
+
/** Callback when date/time changes */
|
|
11
|
+
onChange?: (date: Date | null) => void;
|
|
12
|
+
/** Picker mode */
|
|
13
|
+
mode?: 'date' | 'time' | 'datetime';
|
|
14
|
+
/** Visual variant */
|
|
15
|
+
variant?: 'default' | 'compact' | 'inline';
|
|
16
|
+
/** Date format for display */
|
|
17
|
+
dateFormat?: 'MM/dd/yyyy' | 'dd/MM/yyyy' | 'yyyy-MM-dd';
|
|
18
|
+
/** Time format */
|
|
19
|
+
timeFormat?: '12h' | '24h';
|
|
20
|
+
/** Minimum selectable date */
|
|
21
|
+
minDate?: Date;
|
|
22
|
+
/** Maximum selectable date */
|
|
23
|
+
maxDate?: Date;
|
|
24
|
+
/** Enable date range selection */
|
|
25
|
+
isRange?: boolean;
|
|
26
|
+
/** Range values for range mode */
|
|
27
|
+
rangeValue?: { start: Date | null; end: Date | null };
|
|
28
|
+
/** Callback for range changes */
|
|
29
|
+
onRangeChange?: (range: { start: Date | null; end: Date | null }) => void;
|
|
30
|
+
/** Placeholder text */
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
/** Disabled state */
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Error state */
|
|
35
|
+
error?: boolean;
|
|
36
|
+
/** Clear button */
|
|
37
|
+
clearable?: boolean;
|
|
38
|
+
/** Additional CSS classes */
|
|
39
|
+
className?: string;
|
|
40
|
+
/** ARIA label */
|
|
41
|
+
'aria-label'?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MONTHS = [
|
|
45
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
46
|
+
'July', 'August', 'September', 'October', 'November', 'December'
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
50
|
+
|
|
51
|
+
export const DateTimePicker: React.FC<DateTimePickerProps> = ({
|
|
52
|
+
value,
|
|
53
|
+
onChange,
|
|
54
|
+
mode = 'datetime',
|
|
55
|
+
variant = 'default',
|
|
56
|
+
dateFormat = 'MM/dd/yyyy',
|
|
57
|
+
timeFormat = '12h',
|
|
58
|
+
minDate,
|
|
59
|
+
maxDate,
|
|
60
|
+
isRange = false,
|
|
61
|
+
rangeValue,
|
|
62
|
+
onRangeChange,
|
|
63
|
+
placeholder,
|
|
64
|
+
disabled = false,
|
|
65
|
+
error = false,
|
|
66
|
+
clearable = true,
|
|
67
|
+
className,
|
|
68
|
+
'aria-label': ariaLabel,
|
|
69
|
+
...props
|
|
70
|
+
}) => {
|
|
71
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
72
|
+
const [viewDate, setViewDate] = useState(value || new Date());
|
|
73
|
+
const [, setActiveInput] = useState<'start' | 'end' | null>(null);
|
|
74
|
+
const [tempTime, setTempTime] = useState({
|
|
75
|
+
hours: value?.getHours() || 0,
|
|
76
|
+
minutes: value?.getMinutes() || 0
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
80
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
81
|
+
|
|
82
|
+
// Close picker when clicking outside
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
85
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
86
|
+
setIsOpen(false);
|
|
87
|
+
setActiveInput(null);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
92
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// Format date for display
|
|
96
|
+
const formatDate = (date: Date | null): string => {
|
|
97
|
+
if (!date) return '';
|
|
98
|
+
|
|
99
|
+
const day = date.getDate().toString().padStart(2, '0');
|
|
100
|
+
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
101
|
+
const year = date.getFullYear();
|
|
102
|
+
|
|
103
|
+
switch (dateFormat) {
|
|
104
|
+
case 'dd/MM/yyyy':
|
|
105
|
+
return `${day}/${month}/${year}`;
|
|
106
|
+
case 'yyyy-MM-dd':
|
|
107
|
+
return `${year}-${month}-${day}`;
|
|
108
|
+
default:
|
|
109
|
+
return `${month}/${day}/${year}`;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Format time for display
|
|
114
|
+
const formatTime = (date: Date | null): string => {
|
|
115
|
+
if (!date) return '';
|
|
116
|
+
|
|
117
|
+
const hours = date.getHours();
|
|
118
|
+
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
119
|
+
|
|
120
|
+
if (timeFormat === '12h') {
|
|
121
|
+
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
|
122
|
+
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
123
|
+
return `${displayHours}:${minutes} ${ampm}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return `${hours.toString().padStart(2, '0')}:${minutes}`;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Format complete datetime
|
|
130
|
+
const formatDateTime = (date: Date | null): string => {
|
|
131
|
+
if (!date) return '';
|
|
132
|
+
|
|
133
|
+
const datePart = mode === 'time' ? '' : formatDate(date);
|
|
134
|
+
const timePart = mode === 'date' ? '' : formatTime(date);
|
|
135
|
+
|
|
136
|
+
if (mode === 'datetime') {
|
|
137
|
+
return `${datePart} ${timePart}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return datePart || timePart;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Get display value for input
|
|
144
|
+
const getDisplayValue = (): string => {
|
|
145
|
+
if (isRange && rangeValue) {
|
|
146
|
+
const start = rangeValue.start ? formatDateTime(rangeValue.start) : '';
|
|
147
|
+
const end = rangeValue.end ? formatDateTime(rangeValue.end) : '';
|
|
148
|
+
return start && end ? `${start} - ${end}` : start || end || '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return formatDateTime(value || null);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Generate calendar days
|
|
155
|
+
const getCalendarDays = () => {
|
|
156
|
+
const year = viewDate.getFullYear();
|
|
157
|
+
const month = viewDate.getMonth();
|
|
158
|
+
|
|
159
|
+
const firstDay = new Date(year, month, 1);
|
|
160
|
+
const startDate = new Date(firstDay);
|
|
161
|
+
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
|
162
|
+
|
|
163
|
+
const days: Date[] = [];
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < 42; i++) {
|
|
166
|
+
const currentDate = new Date(startDate);
|
|
167
|
+
currentDate.setDate(startDate.getDate() + i);
|
|
168
|
+
days.push(currentDate);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return days;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Handle date selection
|
|
175
|
+
const handleDateSelect = (date: Date) => {
|
|
176
|
+
if (disabled) return;
|
|
177
|
+
|
|
178
|
+
// Check date constraints
|
|
179
|
+
if (minDate && date < minDate) return;
|
|
180
|
+
if (maxDate && date > maxDate) return;
|
|
181
|
+
|
|
182
|
+
if (isRange && onRangeChange) {
|
|
183
|
+
if (!rangeValue?.start || (rangeValue.start && rangeValue.end)) {
|
|
184
|
+
// Start new range
|
|
185
|
+
onRangeChange({ start: date, end: null });
|
|
186
|
+
setActiveInput('end');
|
|
187
|
+
} else if (rangeValue.start && !rangeValue.end) {
|
|
188
|
+
// Complete range
|
|
189
|
+
if (date >= rangeValue.start) {
|
|
190
|
+
onRangeChange({ start: rangeValue.start, end: date });
|
|
191
|
+
} else {
|
|
192
|
+
onRangeChange({ start: date, end: rangeValue.start });
|
|
193
|
+
}
|
|
194
|
+
setActiveInput(null);
|
|
195
|
+
if (mode === 'date') setIsOpen(false);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Single date selection
|
|
199
|
+
const newDate = new Date(date);
|
|
200
|
+
if (value && (mode === 'datetime' || mode === 'time')) {
|
|
201
|
+
// Preserve time when selecting date
|
|
202
|
+
newDate.setHours(value.getHours());
|
|
203
|
+
newDate.setMinutes(value.getMinutes());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
onChange?.(newDate);
|
|
207
|
+
if (mode === 'date') setIsOpen(false);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Handle time change
|
|
212
|
+
const handleTimeChange = (hours: number, minutes: number) => {
|
|
213
|
+
if (disabled) return;
|
|
214
|
+
|
|
215
|
+
const newDate = value ? new Date(value) : new Date();
|
|
216
|
+
newDate.setHours(hours);
|
|
217
|
+
newDate.setMinutes(minutes);
|
|
218
|
+
|
|
219
|
+
setTempTime({ hours, minutes });
|
|
220
|
+
onChange?.(newDate);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Handle input click
|
|
224
|
+
const handleInputClick = () => {
|
|
225
|
+
if (!disabled) {
|
|
226
|
+
setIsOpen(true);
|
|
227
|
+
if (isRange) setActiveInput('start');
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Handle clear
|
|
232
|
+
const handleClear = () => {
|
|
233
|
+
if (isRange && onRangeChange) {
|
|
234
|
+
onRangeChange({ start: null, end: null });
|
|
235
|
+
} else {
|
|
236
|
+
onChange?.(null);
|
|
237
|
+
}
|
|
238
|
+
setIsOpen(false);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Navigate calendar
|
|
242
|
+
const navigateMonth = (direction: 'prev' | 'next') => {
|
|
243
|
+
const newDate = new Date(viewDate);
|
|
244
|
+
newDate.setMonth(viewDate.getMonth() + (direction === 'prev' ? -1 : 1));
|
|
245
|
+
setViewDate(newDate);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Check if date is selected
|
|
249
|
+
const isDateSelected = (date: Date): boolean => {
|
|
250
|
+
if (isRange && rangeValue) {
|
|
251
|
+
return (rangeValue.start ? isSameDay(date, rangeValue.start) : false) ||
|
|
252
|
+
(rangeValue.end ? isSameDay(date, rangeValue.end) : false);
|
|
253
|
+
}
|
|
254
|
+
return value ? isSameDay(date, value) : false;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Check if date is in range
|
|
258
|
+
const isDateInRange = (date: Date): boolean => {
|
|
259
|
+
if (isRange && rangeValue?.start && rangeValue?.end) {
|
|
260
|
+
return date >= rangeValue.start && date <= rangeValue.end;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Utility function to check if dates are same day
|
|
266
|
+
const isSameDay = (date1: Date, date2: Date): boolean => {
|
|
267
|
+
return date1.getDate() === date2.getDate() &&
|
|
268
|
+
date1.getMonth() === date2.getMonth() &&
|
|
269
|
+
date1.getFullYear() === date2.getFullYear();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Check if date is today
|
|
273
|
+
const isToday = (date: Date): boolean => {
|
|
274
|
+
return isSameDay(date, new Date());
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Check if date is disabled
|
|
278
|
+
const isDateDisabled = (date: Date): boolean => {
|
|
279
|
+
if (minDate && date < minDate) return true;
|
|
280
|
+
if (maxDate && date > maxDate) return true;
|
|
281
|
+
return false;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const variantClasses = {
|
|
285
|
+
default: 'h-10',
|
|
286
|
+
compact: 'h-8 text-sm',
|
|
287
|
+
inline: 'border-0 bg-transparent p-0'
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const calendarDays = getCalendarDays();
|
|
291
|
+
|
|
292
|
+
if (variant === 'inline') {
|
|
293
|
+
return (
|
|
294
|
+
<div
|
|
295
|
+
className={cn('inline-block', className)}
|
|
296
|
+
data-component-name="DateTimePicker"
|
|
297
|
+
ref={containerRef}
|
|
298
|
+
>
|
|
299
|
+
{/* Inline Calendar */}
|
|
300
|
+
<div className="bg-card border border-border rounded p-4 shadow-category-1">
|
|
301
|
+
{(mode === 'date' || mode === 'datetime') && (
|
|
302
|
+
<div className="space-y-4">
|
|
303
|
+
{/* Calendar Header */}
|
|
304
|
+
<div className="flex items-center justify-between">
|
|
305
|
+
<Button
|
|
306
|
+
variant="ghost"
|
|
307
|
+
size="sm"
|
|
308
|
+
onClick={() => navigateMonth('prev')}
|
|
309
|
+
disabled={disabled}
|
|
310
|
+
className="h-8 w-8 p-0"
|
|
311
|
+
>
|
|
312
|
+
<ChevronLeft className="w-4 h-4" />
|
|
313
|
+
</Button>
|
|
314
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
315
|
+
{MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()}
|
|
316
|
+
</h3>
|
|
317
|
+
<Button
|
|
318
|
+
variant="ghost"
|
|
319
|
+
size="sm"
|
|
320
|
+
onClick={() => navigateMonth('next')}
|
|
321
|
+
disabled={disabled}
|
|
322
|
+
className="h-8 w-8 p-0"
|
|
323
|
+
>
|
|
324
|
+
<ChevronRight className="w-4 h-4" />
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* Calendar Grid */}
|
|
329
|
+
<div className="grid grid-cols-7 gap-1">
|
|
330
|
+
{/* Weekday headers */}
|
|
331
|
+
{WEEKDAYS.map(day => (
|
|
332
|
+
<div key={day} className="text-xs font-medium text-muted-foreground text-center p-2">
|
|
333
|
+
{day}
|
|
334
|
+
</div>
|
|
335
|
+
))}
|
|
336
|
+
|
|
337
|
+
{/* Calendar days */}
|
|
338
|
+
{calendarDays.map((date, index) => {
|
|
339
|
+
const isCurrentMonth = date.getMonth() === viewDate.getMonth();
|
|
340
|
+
const isSelected = isDateSelected(date);
|
|
341
|
+
const isInRange = isDateInRange(date);
|
|
342
|
+
const isTodayDate = isToday(date);
|
|
343
|
+
const isDisabled = isDateDisabled(date);
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<button
|
|
347
|
+
key={index}
|
|
348
|
+
onClick={() => handleDateSelect(date)}
|
|
349
|
+
disabled={disabled || isDisabled}
|
|
350
|
+
className={cn(
|
|
351
|
+
'h-8 w-8 text-xs rounded transition-colors',
|
|
352
|
+
'hover:bg-accent hover:text-accent-foreground',
|
|
353
|
+
'focus:outline-none focus:ring-2 focus:ring-primary/20',
|
|
354
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
355
|
+
!isCurrentMonth && 'text-muted-foreground/50',
|
|
356
|
+
isSelected && 'bg-primary text-primary-foreground hover:bg-primary-hover',
|
|
357
|
+
isInRange && !isSelected && 'bg-primary/20',
|
|
358
|
+
isTodayDate && !isSelected && 'bg-accent text-accent-foreground font-semibold'
|
|
359
|
+
)}
|
|
360
|
+
>
|
|
361
|
+
{date.getDate()}
|
|
362
|
+
</button>
|
|
363
|
+
);
|
|
364
|
+
})}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Time Picker for inline datetime/time mode */}
|
|
370
|
+
{(mode === 'time' || mode === 'datetime') && (
|
|
371
|
+
<div className={cn('space-y-3', mode === 'datetime' && 'border-t border-border pt-4 mt-4')}>
|
|
372
|
+
<div className="flex items-center gap-2">
|
|
373
|
+
<Clock className="w-4 h-4 text-muted-foreground" />
|
|
374
|
+
<span className="text-sm font-medium text-foreground">Time</span>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<div className="flex items-center gap-2">
|
|
378
|
+
<select
|
|
379
|
+
value={tempTime.hours}
|
|
380
|
+
onChange={(e) => handleTimeChange(parseInt(e.target.value), tempTime.minutes)}
|
|
381
|
+
disabled={disabled}
|
|
382
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
383
|
+
>
|
|
384
|
+
{Array.from({ length: 24 }, (_, i) => (
|
|
385
|
+
<option key={i} value={i}>
|
|
386
|
+
{timeFormat === '12h'
|
|
387
|
+
? (i === 0 ? '12' : i > 12 ? i - 12 : i)
|
|
388
|
+
: i.toString().padStart(2, '0')
|
|
389
|
+
}
|
|
390
|
+
</option>
|
|
391
|
+
))}
|
|
392
|
+
</select>
|
|
393
|
+
<span className="text-foreground">:</span>
|
|
394
|
+
<select
|
|
395
|
+
value={tempTime.minutes}
|
|
396
|
+
onChange={(e) => handleTimeChange(tempTime.hours, parseInt(e.target.value))}
|
|
397
|
+
disabled={disabled}
|
|
398
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
399
|
+
>
|
|
400
|
+
{Array.from({ length: 60 }, (_, i) => (
|
|
401
|
+
<option key={i} value={i}>
|
|
402
|
+
{i.toString().padStart(2, '0')}
|
|
403
|
+
</option>
|
|
404
|
+
))}
|
|
405
|
+
</select>
|
|
406
|
+
{timeFormat === '12h' && (
|
|
407
|
+
<select
|
|
408
|
+
value={tempTime.hours >= 12 ? 'PM' : 'AM'}
|
|
409
|
+
onChange={(e) => {
|
|
410
|
+
const isPM = e.target.value === 'PM';
|
|
411
|
+
const newHours = isPM
|
|
412
|
+
? (tempTime.hours % 12) + 12
|
|
413
|
+
: tempTime.hours % 12;
|
|
414
|
+
handleTimeChange(newHours, tempTime.minutes);
|
|
415
|
+
}}
|
|
416
|
+
disabled={disabled}
|
|
417
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
418
|
+
>
|
|
419
|
+
<option value="AM">AM</option>
|
|
420
|
+
<option value="PM">PM</option>
|
|
421
|
+
</select>
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<div
|
|
433
|
+
className={cn('relative', className)}
|
|
434
|
+
data-component-name="DateTimePicker"
|
|
435
|
+
ref={containerRef}
|
|
436
|
+
>
|
|
437
|
+
{/* Input Field */}
|
|
438
|
+
<div className="relative">
|
|
439
|
+
<Input
|
|
440
|
+
ref={inputRef}
|
|
441
|
+
value={getDisplayValue()}
|
|
442
|
+
onClick={handleInputClick}
|
|
443
|
+
placeholder={placeholder || `Select ${mode}...`}
|
|
444
|
+
readOnly
|
|
445
|
+
disabled={disabled}
|
|
446
|
+
aria-label={ariaLabel}
|
|
447
|
+
className={cn(
|
|
448
|
+
variantClasses[variant],
|
|
449
|
+
'pr-20 cursor-pointer',
|
|
450
|
+
error && 'border-status-error focus:border-status-error focus:ring-status-error/20',
|
|
451
|
+
disabled && 'cursor-not-allowed'
|
|
452
|
+
)}
|
|
453
|
+
{...props}
|
|
454
|
+
/>
|
|
455
|
+
|
|
456
|
+
{/* Input Icons */}
|
|
457
|
+
<div className="absolute inset-y-0 right-0 flex items-center pr-3 gap-1">
|
|
458
|
+
{clearable && getDisplayValue() && !disabled && (
|
|
459
|
+
<Button
|
|
460
|
+
variant="ghost"
|
|
461
|
+
size="sm"
|
|
462
|
+
onClick={handleClear}
|
|
463
|
+
className="h-6 w-6 p-0 hover:bg-muted"
|
|
464
|
+
>
|
|
465
|
+
<X className="w-4 h-4" />
|
|
466
|
+
</Button>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
<div className="text-muted-foreground">
|
|
470
|
+
{mode === 'time' ? (
|
|
471
|
+
<Clock className="w-4 h-4" />
|
|
472
|
+
) : (
|
|
473
|
+
<Calendar className="w-4 h-4" />
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
{/* Dropdown Picker */}
|
|
480
|
+
{isOpen && (
|
|
481
|
+
<div className="absolute top-full left-0 z-50 mt-1 bg-card border border-border rounded shadow-category-1 p-4 min-w-[300px]">
|
|
482
|
+
{(mode === 'date' || mode === 'datetime') && (
|
|
483
|
+
<div className="space-y-4">
|
|
484
|
+
{/* Calendar Header */}
|
|
485
|
+
<div className="flex items-center justify-between">
|
|
486
|
+
<Button
|
|
487
|
+
variant="ghost"
|
|
488
|
+
size="sm"
|
|
489
|
+
onClick={() => navigateMonth('prev')}
|
|
490
|
+
disabled={disabled}
|
|
491
|
+
className="h-8 w-8 p-0"
|
|
492
|
+
>
|
|
493
|
+
<ChevronLeft className="w-4 h-4" />
|
|
494
|
+
</Button>
|
|
495
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
496
|
+
{MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()}
|
|
497
|
+
</h3>
|
|
498
|
+
<Button
|
|
499
|
+
variant="ghost"
|
|
500
|
+
size="sm"
|
|
501
|
+
onClick={() => navigateMonth('next')}
|
|
502
|
+
disabled={disabled}
|
|
503
|
+
className="h-8 w-8 p-0"
|
|
504
|
+
>
|
|
505
|
+
<ChevronRight className="w-4 h-4" />
|
|
506
|
+
</Button>
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
{/* Calendar Grid */}
|
|
510
|
+
<div className="grid grid-cols-7 gap-1">
|
|
511
|
+
{/* Weekday headers */}
|
|
512
|
+
{WEEKDAYS.map(day => (
|
|
513
|
+
<div key={day} className="text-xs font-medium text-muted-foreground text-center p-2">
|
|
514
|
+
{day}
|
|
515
|
+
</div>
|
|
516
|
+
))}
|
|
517
|
+
|
|
518
|
+
{/* Calendar days */}
|
|
519
|
+
{calendarDays.map((date, index) => {
|
|
520
|
+
const isCurrentMonth = date.getMonth() === viewDate.getMonth();
|
|
521
|
+
const isSelected = isDateSelected(date);
|
|
522
|
+
const isInRange = isDateInRange(date);
|
|
523
|
+
const isTodayDate = isToday(date);
|
|
524
|
+
const isDisabled = isDateDisabled(date);
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<button
|
|
528
|
+
key={index}
|
|
529
|
+
onClick={() => handleDateSelect(date)}
|
|
530
|
+
disabled={disabled || isDisabled}
|
|
531
|
+
className={cn(
|
|
532
|
+
'h-8 w-8 text-xs rounded transition-colors',
|
|
533
|
+
'hover:bg-accent hover:text-accent-foreground',
|
|
534
|
+
'focus:outline-none focus:ring-2 focus:ring-primary/20',
|
|
535
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
536
|
+
!isCurrentMonth && 'text-muted-foreground/50',
|
|
537
|
+
isSelected && 'bg-primary text-primary-foreground hover:bg-primary-hover',
|
|
538
|
+
isInRange && !isSelected && 'bg-primary/20',
|
|
539
|
+
isTodayDate && !isSelected && 'bg-accent text-accent-foreground font-semibold'
|
|
540
|
+
)}
|
|
541
|
+
>
|
|
542
|
+
{date.getDate()}
|
|
543
|
+
</button>
|
|
544
|
+
);
|
|
545
|
+
})}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
|
|
550
|
+
{/* Time Picker */}
|
|
551
|
+
{(mode === 'time' || mode === 'datetime') && (
|
|
552
|
+
<div className={cn('space-y-3', mode === 'datetime' && 'border-t border-border pt-4 mt-4')}>
|
|
553
|
+
<div className="flex items-center gap-2">
|
|
554
|
+
<Clock className="w-4 h-4 text-muted-foreground" />
|
|
555
|
+
<span className="text-sm font-medium text-foreground">Time</span>
|
|
556
|
+
</div>
|
|
557
|
+
|
|
558
|
+
<div className="flex items-center gap-2">
|
|
559
|
+
<select
|
|
560
|
+
value={tempTime.hours}
|
|
561
|
+
onChange={(e) => handleTimeChange(parseInt(e.target.value), tempTime.minutes)}
|
|
562
|
+
disabled={disabled}
|
|
563
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
564
|
+
>
|
|
565
|
+
{Array.from({ length: 24 }, (_, i) => (
|
|
566
|
+
<option key={i} value={i}>
|
|
567
|
+
{timeFormat === '12h'
|
|
568
|
+
? (i === 0 ? '12' : i > 12 ? i - 12 : i)
|
|
569
|
+
: i.toString().padStart(2, '0')
|
|
570
|
+
}
|
|
571
|
+
</option>
|
|
572
|
+
))}
|
|
573
|
+
</select>
|
|
574
|
+
<span className="text-foreground">:</span>
|
|
575
|
+
<select
|
|
576
|
+
value={tempTime.minutes}
|
|
577
|
+
onChange={(e) => handleTimeChange(tempTime.hours, parseInt(e.target.value))}
|
|
578
|
+
disabled={disabled}
|
|
579
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
580
|
+
>
|
|
581
|
+
{Array.from({ length: 60 }, (_, i) => (
|
|
582
|
+
<option key={i} value={i}>
|
|
583
|
+
{i.toString().padStart(2, '0')}
|
|
584
|
+
</option>
|
|
585
|
+
))}
|
|
586
|
+
</select>
|
|
587
|
+
{timeFormat === '12h' && (
|
|
588
|
+
<select
|
|
589
|
+
value={tempTime.hours >= 12 ? 'PM' : 'AM'}
|
|
590
|
+
onChange={(e) => {
|
|
591
|
+
const isPM = e.target.value === 'PM';
|
|
592
|
+
const newHours = isPM
|
|
593
|
+
? (tempTime.hours % 12) + 12
|
|
594
|
+
: tempTime.hours % 12;
|
|
595
|
+
handleTimeChange(newHours, tempTime.minutes);
|
|
596
|
+
}}
|
|
597
|
+
disabled={disabled}
|
|
598
|
+
className="px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
|
|
599
|
+
>
|
|
600
|
+
<option value="AM">AM</option>
|
|
601
|
+
<option value="PM">PM</option>
|
|
602
|
+
</select>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
};
|