@crestbase/web-shared-ui 1.0.2 → 1.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/components/core/Calendar.d.ts +8 -0
- package/dist/components/core/Calendar.js +397 -55
- package/dist/components/core/CalendarExtended.d.ts +10 -1
- package/dist/components/core/CalendarExtended.js +17 -10
- package/dist/components/core/CustomDropdown.js +2 -2
- package/dist/components/core/OptionsDropdown.d.ts +19 -1
- package/dist/components/core/OptionsDropdown.js +10 -4
- package/dist/components/core/PromptModal.d.ts +13 -0
- package/dist/components/core/PromptModal.js +20 -0
- package/dist/components/core/ShareModal.d.ts +8 -0
- package/dist/components/core/ShareModal.js +22 -0
- package/dist/components/index.d.ts +7 -0
- package/dist/components/index.js +8 -0
- package/dist/components/property-details/MediaItem.d.ts +13 -0
- package/dist/components/property-details/MediaItem.js +12 -0
- package/dist/components/property-details/MediaPreviewModal.d.ts +13 -0
- package/dist/components/property-details/MediaPreviewModal.js +24 -0
- package/dist/components/property-details/PropertyImageGallery.d.ts +7 -0
- package/dist/components/property-details/PropertyImageGallery.js +77 -0
- package/dist/components/property-details/mediaUtils.d.ts +11 -0
- package/dist/components/property-details/mediaUtils.js +64 -0
- package/dist/components/svg/ChevronIcon.d.ts +7 -0
- package/dist/components/svg/ChevronIcon.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/styles.css +1 -1
- package/dist/types/assets.d.ts +6 -0
- package/dist/types/assets.js +2 -0
- package/package.json +1 -1
|
@@ -4,6 +4,14 @@ interface CalendarProps {
|
|
|
4
4
|
onDateSelect: (date: Date) => void;
|
|
5
5
|
bookedDates?: Date[];
|
|
6
6
|
maxSelections?: number;
|
|
7
|
+
/** Enable time selection after clicking a date */
|
|
8
|
+
enableTimeSelection?: boolean;
|
|
9
|
+
/** Allow selecting past dates (default false) */
|
|
10
|
+
allowPastDates?: boolean;
|
|
11
|
+
/** Minute step for available times, default 30 */
|
|
12
|
+
timeStepMinutes?: number;
|
|
13
|
+
/** Display format for time options */
|
|
14
|
+
timeFormat?: "12h" | "24h";
|
|
7
15
|
}
|
|
8
16
|
declare const _default: (props: CalendarProps) => React.JSX.Element;
|
|
9
17
|
export default _default;
|
|
@@ -1,8 +1,49 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useState, useId, useCallback } from "react";
|
|
4
|
+
// Pure helper: parse a 24h time string "HH:MM" into minutes of day
|
|
5
|
+
const parseTimeString = (value) => {
|
|
6
|
+
const parts = value.split(":");
|
|
7
|
+
if (parts.length !== 2)
|
|
8
|
+
return null;
|
|
9
|
+
const hh = Number(parts[0]);
|
|
10
|
+
const mm = Number(parts[1]);
|
|
11
|
+
if (Number.isNaN(hh) || Number.isNaN(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return hh * 60 + mm;
|
|
15
|
+
};
|
|
16
|
+
const Calendar = ({ selectedDates, onDateSelect, bookedDates = [], maxSelections, enableTimeSelection = false, allowPastDates = false, timeStepMinutes = 30, timeFormat = "12h", }) => {
|
|
17
|
+
const defaultPeriod = useMemo(() => (new Date().getHours() >= 12 ? "PM" : "AM"), []);
|
|
5
18
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
19
|
+
const [showTimePanel, setShowTimePanel] = useState(false);
|
|
20
|
+
const [tempDate, setTempDate] = useState(null);
|
|
21
|
+
// Redesigned time selection state
|
|
22
|
+
const [selectedHour, setSelectedHour] = useState(null);
|
|
23
|
+
const [selectedMinute, setSelectedMinute] = useState(null);
|
|
24
|
+
const [period, setPeriod] = useState(defaultPeriod);
|
|
25
|
+
const [editingPart, setEditingPart] = useState("hour");
|
|
26
|
+
const [minuteTens, setMinuteTens] = useState(null);
|
|
27
|
+
const [minuteOnes, setMinuteOnes] = useState(null);
|
|
28
|
+
// Derived minutes from current hour/minute/period selection
|
|
29
|
+
const minutesOfDayFromSelection = useMemo(() => {
|
|
30
|
+
if (selectedHour == null || selectedMinute == null)
|
|
31
|
+
return null;
|
|
32
|
+
const hours24 = timeFormat === "24h"
|
|
33
|
+
? selectedHour
|
|
34
|
+
: (selectedHour % 12) + (period === "PM" ? 12 : 0);
|
|
35
|
+
return hours24 * 60 + selectedMinute;
|
|
36
|
+
}, [selectedHour, selectedMinute, period, timeFormat]);
|
|
37
|
+
// Derived minutes even when only hour or only minute is selected (for preview)
|
|
38
|
+
const minutesOfDayFromPartialSelection = useMemo(() => {
|
|
39
|
+
if (selectedHour == null && selectedMinute == null)
|
|
40
|
+
return null;
|
|
41
|
+
const minutes = selectedMinute ?? 0;
|
|
42
|
+
const hours24 = timeFormat === "24h"
|
|
43
|
+
? selectedHour ?? 0
|
|
44
|
+
: ((selectedHour ?? 12) % 12) + (period === "PM" ? 12 : 0);
|
|
45
|
+
return hours24 * 60 + minutes;
|
|
46
|
+
}, [selectedHour, selectedMinute, period, timeFormat]);
|
|
6
47
|
const today = new Date();
|
|
7
48
|
const year = currentMonth.getFullYear();
|
|
8
49
|
const month = currentMonth.getMonth();
|
|
@@ -26,73 +67,374 @@ const Calendar = ({ selectedDates, onDateSelect, bookedDates = [], maxSelections
|
|
|
26
67
|
"November",
|
|
27
68
|
"December",
|
|
28
69
|
];
|
|
29
|
-
|
|
70
|
+
// Helper: date-only timestamp (moved earlier for predicate/useCallback ordering)
|
|
71
|
+
const toDateOnlyTs = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
72
|
+
// Helper: two-digit pad
|
|
73
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
74
|
+
// Helper: format minutes-of-day to label respecting 12h/24h
|
|
75
|
+
const formatTimeLabel = (minutesOfDay, fmt) => {
|
|
76
|
+
const hh = Math.floor(minutesOfDay / 60);
|
|
77
|
+
const mm = minutesOfDay % 60;
|
|
78
|
+
if (fmt === "24h") {
|
|
79
|
+
return `${pad2(hh)}:${pad2(mm)}`;
|
|
80
|
+
}
|
|
81
|
+
const periodLabel = hh >= 12 ? "PM" : "AM";
|
|
82
|
+
const h12 = hh % 12 === 0 ? 12 : hh % 12;
|
|
83
|
+
return `${h12}:${pad2(mm)} ${periodLabel}`;
|
|
84
|
+
};
|
|
85
|
+
// Predicates defined before usage to avoid TDZ/lint issues
|
|
86
|
+
const isDateSelected = useCallback((day) => {
|
|
87
|
+
const date = new Date(year, month, day);
|
|
88
|
+
const currentDateOnlyTs = toDateOnlyTs(date);
|
|
89
|
+
return selectedDates.some((selectedDate) => toDateOnlyTs(selectedDate) === currentDateOnlyTs);
|
|
90
|
+
}, [year, month, selectedDates]);
|
|
91
|
+
const isDateBooked = useCallback((day) => {
|
|
92
|
+
const date = new Date(year, month, day);
|
|
93
|
+
const currentDateOnlyTs = toDateOnlyTs(date);
|
|
94
|
+
return bookedDates.some((bookedDate) => toDateOnlyTs(bookedDate) === currentDateOnlyTs);
|
|
95
|
+
}, [year, month, bookedDates]);
|
|
96
|
+
const todayOnlyTs = useMemo(() => toDateOnlyTs(today), [today]);
|
|
97
|
+
const isDateDisabled = useCallback((day) => {
|
|
98
|
+
const date = new Date(year, month, day);
|
|
99
|
+
const dateOnlyTs = toDateOnlyTs(date);
|
|
100
|
+
return (!allowPastDates && dateOnlyTs < todayOnlyTs) || isDateBooked(day);
|
|
101
|
+
}, [year, month, todayOnlyTs, allowPastDates, isDateBooked]);
|
|
102
|
+
const handleDateClick = useCallback((day) => {
|
|
30
103
|
const clickedDate = new Date(year, month, day);
|
|
104
|
+
const clickedDateOnlyTs = toDateOnlyTs(clickedDate);
|
|
31
105
|
// Don't allow past dates or booked dates
|
|
32
|
-
if (
|
|
106
|
+
if ((!allowPastDates && clickedDateOnlyTs < todayOnlyTs) || isDateBooked(day))
|
|
33
107
|
return;
|
|
34
108
|
// Check if we've reached max selections and this date isn't already selected
|
|
35
109
|
if (maxSelections &&
|
|
36
110
|
selectedDates.length >= maxSelections &&
|
|
37
111
|
!isDateSelected(day)) {
|
|
112
|
+
// When only one selection is allowed, permit switching days (replace later)
|
|
113
|
+
if (maxSelections !== 1) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!enableTimeSelection) {
|
|
118
|
+
onDateSelect(clickedDate);
|
|
38
119
|
return;
|
|
39
120
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
121
|
+
// Time selection flow
|
|
122
|
+
setTempDate(clickedDate);
|
|
123
|
+
setShowTimePanel(true);
|
|
124
|
+
// Prefill time if this day is already selected; otherwise default to 00 minutes
|
|
125
|
+
const existingForDay = selectedDates.find((selectedDate) => toDateOnlyTs(selectedDate) === clickedDateOnlyTs);
|
|
126
|
+
if (existingForDay) {
|
|
127
|
+
const hours24 = existingForDay.getHours();
|
|
128
|
+
const minutes = existingForDay.getMinutes();
|
|
129
|
+
if (timeFormat === "24h") {
|
|
130
|
+
setSelectedHour(hours24);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const newPeriod = hours24 >= 12 ? "PM" : "AM";
|
|
134
|
+
const hour12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
|
135
|
+
setPeriod(newPeriod);
|
|
136
|
+
setSelectedHour(hour12);
|
|
137
|
+
}
|
|
138
|
+
setSelectedMinute(minutes);
|
|
139
|
+
setMinuteTens(Math.floor(minutes / 10));
|
|
140
|
+
setMinuteOnes(minutes % 10);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// reset redesigned time selection state
|
|
144
|
+
setSelectedHour(null);
|
|
145
|
+
setSelectedMinute(0);
|
|
146
|
+
setMinuteTens(0);
|
|
147
|
+
setMinuteOnes(0);
|
|
148
|
+
setPeriod(defaultPeriod);
|
|
149
|
+
}
|
|
150
|
+
setEditingPart("hour");
|
|
151
|
+
}, [
|
|
152
|
+
year,
|
|
153
|
+
month,
|
|
154
|
+
todayOnlyTs,
|
|
155
|
+
allowPastDates,
|
|
156
|
+
isDateBooked,
|
|
157
|
+
maxSelections,
|
|
158
|
+
selectedDates,
|
|
159
|
+
isDateSelected,
|
|
160
|
+
enableTimeSelection,
|
|
161
|
+
onDateSelect,
|
|
162
|
+
timeFormat,
|
|
163
|
+
defaultPeriod,
|
|
164
|
+
]);
|
|
165
|
+
const handlePrevMonth = useCallback(() => {
|
|
43
166
|
setCurrentMonth(new Date(year, month - 1, 1));
|
|
44
|
-
};
|
|
45
|
-
const handleNextMonth = () => {
|
|
167
|
+
}, [year, month]);
|
|
168
|
+
const handleNextMonth = useCallback(() => {
|
|
46
169
|
setCurrentMonth(new Date(year, month + 1, 1));
|
|
47
|
-
};
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
170
|
+
}, [year, month]);
|
|
171
|
+
const isToday = useMemo(() => {
|
|
172
|
+
if (!tempDate)
|
|
173
|
+
return false;
|
|
174
|
+
const d = tempDate;
|
|
175
|
+
return (d.getFullYear() === today.getFullYear() &&
|
|
176
|
+
d.getMonth() === today.getMonth() &&
|
|
177
|
+
d.getDate() === today.getDate());
|
|
178
|
+
}, [tempDate, today]);
|
|
179
|
+
// Accessibility: dynamic regions for date/time views
|
|
180
|
+
const dateViewHeadingId = useId();
|
|
181
|
+
const timeViewHeadingId = useId();
|
|
182
|
+
// Memoized hour options to avoid recreating arrays per render
|
|
183
|
+
const hourOptions24 = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
|
|
184
|
+
const hourOptions12 = useMemo(() => Array.from({ length: 12 }, (_, i) => i + 1), []);
|
|
185
|
+
// Suggested quick-pick times, driven by timeStepMinutes while preserving defaults
|
|
186
|
+
const suggestedTimes = useMemo(() => {
|
|
187
|
+
const step = Math.max(1, timeStepMinutes ?? 30);
|
|
188
|
+
// Base anchors; for step=30 these remain 10:00, 12:00, 14:00
|
|
189
|
+
const anchors = [10 * 60, 12 * 60, 14 * 60];
|
|
190
|
+
return anchors.map((base) => {
|
|
191
|
+
const m = Math.floor(base / step) * step;
|
|
192
|
+
const label = formatTimeLabel(m, timeFormat);
|
|
193
|
+
let disabled = false;
|
|
194
|
+
if (isToday) {
|
|
195
|
+
const nowMinutes = today.getHours() * 60 + today.getMinutes();
|
|
196
|
+
disabled = m < nowMinutes;
|
|
197
|
+
}
|
|
198
|
+
return { label, minutes: m, disabled };
|
|
58
199
|
});
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
200
|
+
}, [timeStepMinutes, timeFormat, isToday, today]);
|
|
201
|
+
// Manual time entry (input[type="time"]) state
|
|
202
|
+
const [manualTime, setManualTime] = useState("");
|
|
203
|
+
// Sync manual time display when selection changes
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const m = minutesOfDayFromPartialSelection;
|
|
206
|
+
if (m == null)
|
|
207
|
+
return;
|
|
208
|
+
const hh = Math.floor(m / 60);
|
|
209
|
+
const mm = m % 60;
|
|
210
|
+
const value = `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
|
|
211
|
+
setManualTime(value);
|
|
212
|
+
}, [/* sync with selection */ tempDate, minutesOfDayFromPartialSelection]);
|
|
213
|
+
const setSelectionFromMinutes = useCallback((minutesOfDay) => {
|
|
214
|
+
const hours24 = Math.floor(minutesOfDay / 60);
|
|
215
|
+
const minutes = minutesOfDay % 60;
|
|
216
|
+
if (timeFormat === "24h") {
|
|
217
|
+
setSelectedHour(hours24);
|
|
218
|
+
setSelectedMinute(minutes);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const newPeriod = hours24 >= 12 ? "PM" : "AM";
|
|
222
|
+
const hour12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
|
223
|
+
setPeriod(newPeriod);
|
|
224
|
+
setSelectedHour(hour12);
|
|
225
|
+
setSelectedMinute(minutes);
|
|
226
|
+
}
|
|
227
|
+
}, [timeFormat]);
|
|
228
|
+
const handleManualTimeChange = useCallback((value) => {
|
|
229
|
+
setManualTime(value);
|
|
230
|
+
const m = parseTimeString(value);
|
|
231
|
+
if (m != null) {
|
|
232
|
+
setSelectionFromMinutes(m);
|
|
233
|
+
}
|
|
234
|
+
}, [setSelectionFromMinutes]);
|
|
235
|
+
const handleTimeSelect = useCallback((minutesOfDay) => {
|
|
236
|
+
if (!tempDate)
|
|
237
|
+
return;
|
|
238
|
+
const d = new Date(tempDate);
|
|
239
|
+
const hours = Math.floor(minutesOfDay / 60);
|
|
240
|
+
const minutes = minutesOfDay % 60;
|
|
241
|
+
d.setHours(hours, minutes, 0, 0);
|
|
242
|
+
onDateSelect(d);
|
|
243
|
+
setShowTimePanel(false);
|
|
244
|
+
setTempDate(null);
|
|
245
|
+
// clear state
|
|
246
|
+
setSelectedHour(null);
|
|
247
|
+
setSelectedMinute(null);
|
|
248
|
+
setPeriod(defaultPeriod);
|
|
249
|
+
}, [tempDate, onDateSelect, defaultPeriod]);
|
|
250
|
+
// moved above to ensure availability for effects using it
|
|
251
|
+
const effectiveMinutesOfDay = useMemo(() => {
|
|
252
|
+
const manual = parseTimeString(manualTime);
|
|
253
|
+
return manual != null ? manual : minutesOfDayFromSelection;
|
|
254
|
+
}, [manualTime, minutesOfDayFromSelection]);
|
|
255
|
+
const isSelectedTimePast = useMemo(() => {
|
|
256
|
+
if (!isToday || effectiveMinutesOfDay == null)
|
|
257
|
+
return false;
|
|
258
|
+
const nowMinutes = today.getHours() * 60 + today.getMinutes();
|
|
259
|
+
return effectiveMinutesOfDay < nowMinutes;
|
|
260
|
+
}, [isToday, effectiveMinutesOfDay, today]);
|
|
261
|
+
// formatPreviewTime removed; use formatTimeLabel directly where needed
|
|
262
|
+
const handleConfirmSelection = useCallback(() => {
|
|
263
|
+
if (effectiveMinutesOfDay == null)
|
|
264
|
+
return;
|
|
265
|
+
if (isSelectedTimePast)
|
|
266
|
+
return;
|
|
267
|
+
handleTimeSelect(effectiveMinutesOfDay);
|
|
268
|
+
}, [effectiveMinutesOfDay, isSelectedTimePast, handleTimeSelect]);
|
|
269
|
+
// Generate calendar days (memoized)
|
|
270
|
+
const calendarDays = useMemo(() => {
|
|
271
|
+
const days = [];
|
|
272
|
+
// Add empty cells for days before the first day of the month
|
|
273
|
+
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
274
|
+
days.push(_jsx("div", { className: "h-10" }, `empty-${i}`));
|
|
275
|
+
}
|
|
276
|
+
// Add days of the month
|
|
277
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
278
|
+
const isDisabled = isDateDisabled(day);
|
|
279
|
+
const isSelected = isDateSelected(day);
|
|
280
|
+
const isBooked = isDateBooked(day);
|
|
281
|
+
const dateOnly = new Date(year, month, day);
|
|
282
|
+
const todayOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
283
|
+
const isPast = dateOnly < todayOnly;
|
|
284
|
+
const isCurrent = dateOnly.getTime() === todayOnly.getTime();
|
|
285
|
+
let buttonClass = "h-8 w-8 rounded-full flex items-center justify-center text-[11px] transition-all duration-200 ";
|
|
286
|
+
if (isDisabled) {
|
|
287
|
+
if (isBooked) {
|
|
288
|
+
buttonClass +=
|
|
289
|
+
"bg-gray-100 text-gray-400 cursor-not-allowed border border-gray-200";
|
|
290
|
+
}
|
|
291
|
+
else if (isPast) {
|
|
292
|
+
buttonClass += "text-gray-300 cursor-not-allowed";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (isSelected) {
|
|
79
296
|
buttonClass +=
|
|
80
|
-
"bg-
|
|
297
|
+
"bg-blue-500 text-white shadow-md transform scale-105 cursor-pointer";
|
|
81
298
|
}
|
|
82
|
-
else
|
|
83
|
-
buttonClass +=
|
|
299
|
+
else {
|
|
300
|
+
buttonClass +=
|
|
301
|
+
"text-gray-700 hover:bg-blue-50 hover:text-blue-600 cursor-pointer";
|
|
84
302
|
}
|
|
303
|
+
days.push(_jsx("button", { onClick: () => handleDateClick(day), disabled: isDisabled, className: buttonClass, "aria-selected": isSelected, "aria-current": isCurrent ? "date" : undefined, title: isBooked ? "This date is already booked" : isPast ? "Past date" : "", children: day }, day));
|
|
85
304
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
305
|
+
return days;
|
|
306
|
+
}, [
|
|
307
|
+
startingDayOfWeek,
|
|
308
|
+
daysInMonth,
|
|
309
|
+
year,
|
|
310
|
+
month,
|
|
311
|
+
today,
|
|
312
|
+
isDateDisabled,
|
|
313
|
+
isDateSelected,
|
|
314
|
+
isDateBooked,
|
|
315
|
+
handleDateClick,
|
|
316
|
+
]);
|
|
317
|
+
return (_jsxs("div", { className: "calendar ", children: [!(enableTimeSelection && showTimePanel && tempDate) && (_jsxs("div", { role: "region", "aria-labelledby": dateViewHeadingId, "aria-hidden": enableTimeSelection && showTimePanel && !!tempDate, className: "transition-all duration-300 ease-out", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("button", { onClick: handlePrevMonth, className: "p-2 rounded-full hover:bg-gray-100 transition-colors cursor-pointer", "aria-label": "Previous month", children: _jsx("svg", { className: "w-5 h-5 text-gray-600", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsxs("h3", { id: dateViewHeadingId, className: "text-sm font-medium text-gray-800", children: [monthNames[month], " ", year] }), _jsx("button", { onClick: handleNextMonth, className: "p-2 rounded-full hover:bg-gray-100 transition-colors cursor-pointer", "aria-label": "Next month", children: _jsx("svg", { className: "w-5 h-5 text-gray-600", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) }) })] }), _jsx("div", { className: "grid grid-cols-7 gap-1 mb-2", children: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (_jsx("div", { className: "h-8 flex items-center justify-center text-xs font-medium text-gray-500", children: day }, day))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: calendarDays }), _jsx("div", { className: "mt-3", children: _jsxs("div", { className: "flex justify-center gap-4 text-xs", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-blue-500 rounded-full" }), _jsx("span", { className: "text-gray-600", children: "Selected" })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-gray-100 border border-gray-200 rounded-full" }), _jsx("span", { className: "text-gray-600", children: "Booked" })] })] }) })] })), enableTimeSelection && showTimePanel && tempDate && (_jsx("div", { role: "region", "aria-labelledby": timeViewHeadingId, "aria-hidden": !(enableTimeSelection && showTimePanel && tempDate), className: "transition-all duration-300 ease-out", children: _jsxs("div", { className: "", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("button", { onClick: () => {
|
|
318
|
+
setShowTimePanel(false);
|
|
319
|
+
setTempDate(null);
|
|
320
|
+
setSelectedHour(null);
|
|
321
|
+
setSelectedMinute(null);
|
|
322
|
+
setPeriod(defaultPeriod);
|
|
323
|
+
}, className: "p-2 rounded-full hover:bg-gray-100 transition-colors cursor-pointer", "aria-label": "Back to date selection", type: "button", children: _jsx("svg", { className: "w-5 h-5 text-gray-600", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx("h4", { id: timeViewHeadingId, className: "text-sm font-medium text-gray-800", children: "Choose time" }), _jsx("span", { className: "text-xs text-gray-600", children: tempDate.toLocaleDateString(undefined, {
|
|
324
|
+
weekday: "short",
|
|
325
|
+
day: "numeric",
|
|
326
|
+
month: "short",
|
|
327
|
+
year: "numeric",
|
|
328
|
+
}) })] }), _jsxs("div", { className: "mb-3", children: [_jsx("span", { className: "text-xs text-gray-600 mr-2", children: "Quick picks:" }), _jsx("div", { className: "flex flex-wrap gap-2", role: "group", "aria-label": "Quick time suggestions", children: suggestedTimes.map((t) => (_jsx("button", { type: "button", "aria-disabled": t.disabled, disabled: t.disabled, onClick: () => handleTimeSelect(t.minutes), className: `text-[11px] px-3 py-1 rounded-full border transition-colors ${t.disabled
|
|
329
|
+
? "text-gray-400 bg-gray-100 cursor-not-allowed border-gray-200"
|
|
330
|
+
: "text-gray-700 hover:bg-blue-50 hover:text-blue-600 border-gray-300 cursor-pointer"}`, title: t.disabled ? "Unavailable in the past" : `Set ${t.label}`, children: t.label }, `suggest-${t.minutes}`))) })] }), timeFormat === "12h" && (_jsx("div", { className: "mb-3 flex items-center gap-2", role: "group", "aria-label": "AM/PM", children: ["AM", "PM"].map((p) => (_jsx("button", { type: "button", onClick: () => setPeriod(p), className: `text-[11px] px-3 py-1 rounded-full border ${period === p
|
|
331
|
+
? "bg-blue-50 text-blue-700 border-blue-300 cursor-pointer"
|
|
332
|
+
: "text-gray-700 hover:bg-blue-50 hover:text-blue-600 border-gray-300 cursor-pointer"}`, "aria-pressed": period === p, children: p }, p))) })), _jsx("div", { className: "mb-3", role: "group", "aria-label": editingPart === "hour" ? "Select hour" : "Select minute", children: _jsx("div", { className: "flex flex-wrap gap-2", children: (editingPart === "hour"
|
|
333
|
+
? timeFormat === "24h"
|
|
334
|
+
? hourOptions24
|
|
335
|
+
: hourOptions12
|
|
336
|
+
: [
|
|
337
|
+
{ label: "00", value: 0, type: "combo" },
|
|
338
|
+
{ label: "30", value: 30, type: "combo" },
|
|
339
|
+
...Array.from({ length: 10 }, (_, i) => ({
|
|
340
|
+
label: String(i),
|
|
341
|
+
value: i,
|
|
342
|
+
type: "digit",
|
|
343
|
+
})),
|
|
344
|
+
]).map((item) => {
|
|
345
|
+
const val = typeof item === "number" ? item : item.value;
|
|
346
|
+
const isDigit = typeof item !== "number" && item.type === "digit";
|
|
347
|
+
const isCombo = typeof item !== "number" && item.type === "combo";
|
|
348
|
+
const isDisabled = editingPart === "minute" &&
|
|
349
|
+
isDigit &&
|
|
350
|
+
minuteTens == null &&
|
|
351
|
+
val > 5;
|
|
352
|
+
let btnClass = "h-8 w-8 rounded-full flex items-center justify-center text-[11px] transition-all duration-200 border ";
|
|
353
|
+
if (isDisabled) {
|
|
354
|
+
btnClass +=
|
|
355
|
+
"text-gray-400 bg-gray-100 cursor-not-allowed border-gray-200";
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
btnClass +=
|
|
359
|
+
"text-gray-700 hover:bg-blue-50 hover:text-blue-600 cursor-pointer border-gray-300";
|
|
360
|
+
}
|
|
361
|
+
return (_jsx("button", { type: "button", onClick: () => {
|
|
362
|
+
if (editingPart === "hour") {
|
|
363
|
+
setSelectedHour(val);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if (isDisabled)
|
|
367
|
+
return;
|
|
368
|
+
if (isCombo) {
|
|
369
|
+
const minute = val;
|
|
370
|
+
setSelectedMinute(minute);
|
|
371
|
+
setMinuteTens(Math.floor(minute / 10));
|
|
372
|
+
setMinuteOnes(minute % 10);
|
|
373
|
+
setEditingPart("hour");
|
|
374
|
+
}
|
|
375
|
+
else if (isDigit) {
|
|
376
|
+
if (minuteTens == null ||
|
|
377
|
+
(minuteTens != null && minuteOnes != null)) {
|
|
378
|
+
setMinuteTens(val);
|
|
379
|
+
setMinuteOnes(null);
|
|
380
|
+
setSelectedMinute(null);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
setMinuteOnes(val);
|
|
384
|
+
const minute = (minuteTens ?? 0) * 10 + val;
|
|
385
|
+
setSelectedMinute(minute);
|
|
386
|
+
setEditingPart("hour");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}, className: btnClass, children: editingPart === "hour"
|
|
391
|
+
? String(val).padStart(2, "0")
|
|
392
|
+
: typeof item !== "number" &&
|
|
393
|
+
item.type === "combo"
|
|
394
|
+
? item.label
|
|
395
|
+
: String(val) }, `${editingPart}-${typeof item === "number" ? val : item.label}`));
|
|
396
|
+
}) }) }), _jsxs("div", { className: "flex items-center justify-between mt-4", children: [_jsxs("div", { className: "flex items-center justify-center gap-2 flex-1", children: [(() => {
|
|
397
|
+
const hourActive = editingPart === "hour";
|
|
398
|
+
const minuteActive = editingPart === "minute";
|
|
399
|
+
const hourVal = (() => {
|
|
400
|
+
if (selectedHour == null)
|
|
401
|
+
return "--";
|
|
402
|
+
return String(selectedHour).padStart(2, "0");
|
|
403
|
+
})();
|
|
404
|
+
const minuteVal = (() => {
|
|
405
|
+
if (selectedMinute != null)
|
|
406
|
+
return String(selectedMinute).padStart(2, "0");
|
|
407
|
+
if (minuteTens != null || minuteOnes != null) {
|
|
408
|
+
const tens = minuteTens != null ? String(minuteTens) : "-";
|
|
409
|
+
const ones = minuteOnes != null ? String(minuteOnes) : "-";
|
|
410
|
+
return `${tens}${ones}`;
|
|
411
|
+
}
|
|
412
|
+
return "--";
|
|
413
|
+
})();
|
|
414
|
+
const baseCls = "h-8 w-8 rounded-full flex items-center justify-center text-[11px] transition-all duration-200 border ";
|
|
415
|
+
const hourCls = baseCls +
|
|
416
|
+
(hourActive
|
|
417
|
+
? "bg-blue-500 text-white shadow-md transform scale-105 cursor-pointer border-blue-600"
|
|
418
|
+
: "text-gray-700 hover:bg-blue-50 hover:text-blue-600 cursor-pointer border-gray-300");
|
|
419
|
+
const minuteCls = baseCls +
|
|
420
|
+
(minuteActive
|
|
421
|
+
? "bg-blue-500 text-white shadow-md transform scale-105 cursor-pointer border-blue-600"
|
|
422
|
+
: "text-gray-700 hover:bg-blue-50 hover:text-blue-600 cursor-pointer border-gray-300");
|
|
423
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: hourCls, "aria-pressed": hourActive, onClick: () => setEditingPart("hour"), children: hourVal }), _jsx("span", { className: "text-gray-600", children: ":" }), _jsx("button", { type: "button", className: minuteCls, "aria-pressed": minuteActive, onClick: () => {
|
|
424
|
+
setEditingPart("minute");
|
|
425
|
+
setMinuteTens(null);
|
|
426
|
+
setMinuteOnes(null);
|
|
427
|
+
setSelectedMinute(null);
|
|
428
|
+
}, children: minuteVal })] }));
|
|
429
|
+
})(), isSelectedTimePast && (_jsx("span", { className: "text-[11px] text-red-600", children: "Time must be in the future" }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", onClick: handleConfirmSelection, disabled: effectiveMinutesOfDay == null || isSelectedTimePast, className: `text-[11px] px-3 py-2 rounded-xl border transition-colors ${effectiveMinutesOfDay == null || isSelectedTimePast
|
|
430
|
+
? "bg-gray-100 text-gray-400 cursor-not-allowed"
|
|
431
|
+
: "bg-blue-50 text-blue-700 hover:bg-blue-100 cursor-pointer"}`, "aria-label": "Confirm selected time", children: "Confirm" }), _jsx("button", { type: "button", onClick: () => {
|
|
432
|
+
setSelectedHour(null);
|
|
433
|
+
setSelectedMinute(null);
|
|
434
|
+
setPeriod(defaultPeriod);
|
|
435
|
+
setManualTime("");
|
|
436
|
+
setMinuteTens(null);
|
|
437
|
+
setMinuteOnes(null);
|
|
438
|
+
}, className: "text-[11px] px-3 py-2 rounded-xl border text-gray-700 hover:bg-gray-50 cursor-pointer", children: "Reset" })] })] })] }) }))] }));
|
|
97
439
|
};
|
|
98
440
|
export default Calendar;
|
|
@@ -13,6 +13,15 @@ export interface CalendarExtendedProps {
|
|
|
13
13
|
start: MonthSelection;
|
|
14
14
|
end: MonthSelection;
|
|
15
15
|
}) => void;
|
|
16
|
+
/**
|
|
17
|
+
* When provided, highlights this range in the months grid.
|
|
18
|
+
* The component still manages internal clicks, but visual state
|
|
19
|
+
* uses this range preferentially if set.
|
|
20
|
+
*/
|
|
21
|
+
selectedRange?: {
|
|
22
|
+
start: MonthSelection;
|
|
23
|
+
end: MonthSelection;
|
|
24
|
+
};
|
|
16
25
|
minSelectableMonth?: MonthSelection;
|
|
17
26
|
maxSelectableMonth?: MonthSelection;
|
|
18
27
|
/** Convenience flag: when true, disallow selecting months beyond the current month */
|
|
@@ -24,4 +33,4 @@ export interface CalendarExtendedProps {
|
|
|
24
33
|
bookedDates?: Date[];
|
|
25
34
|
maxSelections?: number;
|
|
26
35
|
}
|
|
27
|
-
export default function CalendarExtended({ selectionGranularity, allowRange, startYear, endYear, selectedMonths, onMonthSelectionChange, onMonthRangeChange, minSelectableMonth, maxSelectableMonth, disableFutureMonths, containerHeight, enableYearTransition: _enableYearTransition, selectedDates, onDateSelect, bookedDates, maxSelections, }: Readonly<CalendarExtendedProps>): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export default function CalendarExtended({ selectionGranularity, allowRange, startYear, endYear, selectedMonths, onMonthSelectionChange, onMonthRangeChange, selectedRange, minSelectableMonth, maxSelectableMonth, disableFutureMonths, containerHeight, enableYearTransition: _enableYearTransition, selectedDates, onDateSelect, bookedDates, maxSelections, }: Readonly<CalendarExtendedProps>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -16,7 +16,7 @@ const monthNames = [
|
|
|
16
16
|
"November",
|
|
17
17
|
"December",
|
|
18
18
|
];
|
|
19
|
-
export default function CalendarExtended({ selectionGranularity = "day", allowRange = false, startYear, endYear, selectedMonths = [], onMonthSelectionChange, onMonthRangeChange, minSelectableMonth, maxSelectableMonth, disableFutureMonths, containerHeight = 560, enableYearTransition: _enableYearTransition = true, selectedDates = [], onDateSelect, bookedDates, maxSelections, }) {
|
|
19
|
+
export default function CalendarExtended({ selectionGranularity = "day", allowRange = false, startYear, endYear, selectedMonths = [], onMonthSelectionChange, onMonthRangeChange, selectedRange, minSelectableMonth, maxSelectableMonth, disableFutureMonths, containerHeight = 560, enableYearTransition: _enableYearTransition = true, selectedDates = [], onDateSelect, bookedDates, maxSelections, }) {
|
|
20
20
|
// Derive year bounds
|
|
21
21
|
const currentYear = new Date().getFullYear();
|
|
22
22
|
const DEFAULT_START_YEAR = 1900;
|
|
@@ -26,6 +26,9 @@ export default function CalendarExtended({ selectionGranularity = "day", allowRa
|
|
|
26
26
|
// Internal range state if consumer enables range
|
|
27
27
|
const [rangeStart, setRangeStart] = useState(null);
|
|
28
28
|
const [rangeEnd, setRangeEnd] = useState(null);
|
|
29
|
+
// Effective visual range: prefer controlled selectedRange if provided
|
|
30
|
+
const effectiveRangeStart = selectedRange?.start ?? rangeStart ?? null;
|
|
31
|
+
const effectiveRangeEnd = selectedRange?.end ?? rangeEnd ?? null;
|
|
29
32
|
// Accessibility: grid id for aria-controls
|
|
30
33
|
const gridIdRef = useRef(`calendar-extended-grid-${Math.random().toString(36).slice(2)}`);
|
|
31
34
|
const allMonths = useMemo(() => {
|
|
@@ -42,10 +45,10 @@ export default function CalendarExtended({ selectionGranularity = "day", allowRa
|
|
|
42
45
|
}, [fromYear, toYear]);
|
|
43
46
|
const isSelected = (y, m) => selectedMonths.some((sel) => sel.year === y && sel.month === m);
|
|
44
47
|
const isInRange = (y, m) => {
|
|
45
|
-
if (!
|
|
48
|
+
if (!effectiveRangeStart || !effectiveRangeEnd)
|
|
46
49
|
return false;
|
|
47
|
-
const startKey =
|
|
48
|
-
const endKey =
|
|
50
|
+
const startKey = effectiveRangeStart.year * 12 + effectiveRangeStart.month;
|
|
51
|
+
const endKey = effectiveRangeEnd.year * 12 + effectiveRangeEnd.month;
|
|
49
52
|
const currentKey = y * 12 + m;
|
|
50
53
|
const [minKey, maxKey] = startKey <= endKey ? [startKey, endKey] : [endKey, startKey];
|
|
51
54
|
return currentKey >= minKey && currentKey <= maxKey;
|
|
@@ -109,8 +112,12 @@ export default function CalendarExtended({ selectionGranularity = "day", allowRa
|
|
|
109
112
|
const selected = isSelected(y, m);
|
|
110
113
|
const inRange = isInRange(y, m);
|
|
111
114
|
const disabled = isMonthDisabled(y, m);
|
|
112
|
-
const isStart = !!
|
|
113
|
-
|
|
115
|
+
const isStart = !!effectiveRangeStart &&
|
|
116
|
+
effectiveRangeStart.year === y &&
|
|
117
|
+
effectiveRangeStart.month === m;
|
|
118
|
+
const isEnd = !!effectiveRangeEnd &&
|
|
119
|
+
effectiveRangeEnd.year === y &&
|
|
120
|
+
effectiveRangeEnd.month === m;
|
|
114
121
|
const isCurrent = (() => {
|
|
115
122
|
const d = new Date();
|
|
116
123
|
return d.getFullYear() === y && d.getMonth() === m;
|
|
@@ -120,13 +127,13 @@ export default function CalendarExtended({ selectionGranularity = "day", allowRa
|
|
|
120
127
|
const stateClasses = disabled
|
|
121
128
|
? "opacity-50 cursor-not-allowed bg-gray-50 text-gray-400"
|
|
122
129
|
: isStart && isEnd
|
|
123
|
-
? "bg-primaryblue
|
|
130
|
+
? "bg-primaryblue text-white ring-2 ring-primaryblue"
|
|
124
131
|
: isStart
|
|
125
|
-
? "bg-primaryblue
|
|
132
|
+
? "bg-primaryblue text-white ring-2 ring-primaryblue"
|
|
126
133
|
: isEnd
|
|
127
|
-
? "bg-primaryblue
|
|
134
|
+
? "bg-primaryblue text-white ring-2 ring-primaryblue"
|
|
128
135
|
: inRange
|
|
129
|
-
? "bg-primaryblue/10 text-primaryblue"
|
|
136
|
+
? "bg-primaryblue/10 text-primaryblue ring-1 ring-primaryblue/30"
|
|
130
137
|
: isSinglePreRange || selected
|
|
131
138
|
? "bg-primaryblue/5 text-primaryblue"
|
|
132
139
|
: "bg-gray-100 hover:bg-gray-50";
|
|
@@ -31,9 +31,9 @@ const CustomDropdown = ({ label, placeholder = "Select an option", options, valu
|
|
|
31
31
|
onChange("");
|
|
32
32
|
setIsOpen(false);
|
|
33
33
|
};
|
|
34
|
-
return (_jsxs("div", { className: `w-full ${minimal ? "text-[11px]" : "text-base sm:text-xs"}
|
|
34
|
+
return (_jsxs("div", { className: `w-full ${minimal ? "text-[11px]" : "text-base sm:text-xs"}`, ref: dropdownRef, children: [label && (_jsx("label", { className: `${labelClass} block mb-2 text-[10px] text-gray-500`, children: label })), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => !disabled && setIsOpen(!isOpen), disabled: disabled, className: `${minimal
|
|
35
35
|
? "flex gap-1 items-center"
|
|
36
|
-
: "w-full p-0 px-4 sm:p-4 rounded-xl bg-gray-50 border outline-none text-left flex items-center justify-between h-13.5 sm:h-auto"} ${error ? "border-red-500" : "border-gray-200"} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`, children: [_jsx("span", { className: `${selectedOption ? "text-gray-900" : "text-gray-400"}`, children: selectedOption ? selectedOption.label : placeholder }), !disabled && (_jsx(ChevronDownIcon, { className: `w-4 h-4 text-gray-800 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}` }))] }), isOpen && !disabled && (_jsx("div", { className: "absolute z-50 min-w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-60 overflow-y-auto", children: options.map((option) => (_jsxs("div", { className: `flex items-center justify-between hover:bg-gray-50 transition-colors ${value === option.value
|
|
36
|
+
: "w-full p-0 px-4 sm:p-4 rounded-xl bg-gray-50 border outline-none text-left flex items-center justify-between h-13.5 sm:h-auto"} ${error ? "border-red-500" : "border-gray-200"} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${className}`, children: [_jsx("span", { className: `${selectedOption ? "text-gray-900" : "text-gray-400"}`, children: selectedOption ? selectedOption.label : placeholder }), !disabled && (_jsx(ChevronDownIcon, { className: `w-4 h-4 text-gray-800 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}` }))] }), isOpen && !disabled && (_jsx("div", { className: "absolute z-50 min-w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-60 overflow-y-auto", children: options.map((option) => (_jsxs("div", { className: `flex items-center justify-between hover:bg-gray-50 transition-colors ${value === option.value
|
|
37
37
|
? "bg-blue-50 text-blue-600"
|
|
38
38
|
: "text-gray-900"} first:rounded-t-xl last:rounded-b-xl`, children: [_jsx("button", { type: "button", onClick: () => handleOptionClick(option.value), className: "flex-1 text-left text-nowrap px-4 py-3", children: option.label }), value === option.value && (_jsx("button", { type: "button", onClick: handleClear, className: "p-0.5 hover:bg-gray-100 rounded-full transition-colors ml-2", children: _jsx(X, { className: "w-3 h-3 text-gray-400 hover:text-gray-600" }) }))] }, option.value))) }))] }), error && _jsx("p", { className: "mt-1 text-red-500", children: error })] }));
|
|
39
39
|
};
|