| @@ -10,8 +10,12 @@ | ||
| 10 | 10 | isSameMonth, |
| 11 | 11 | isSameDay, |
| 12 | 12 | addMonths, |
| 13 | 13 | subMonths, |
| 14 | + setMonth, | |
| 15 | + setYear, | |
| 16 | + getMonth, | |
| 17 | + getYear, | |
| 14 | 18 | isToday, |
| 15 | 19 | } from "date-fns"; |
| 16 | 20 | |
| 17 | 21 | export interface CalendarProps { |
| @@ -22,8 +26,23 @@ | ||
| 22 | 26 | maxDate?: Date; |
| 23 | 27 | className?: string; |
| 24 | 28 | } |
| 25 | 29 | |
| 30 | +const MONTH_LABELS = [ | |
| 31 | + "January", | |
| 32 | + "February", | |
| 33 | + "March", | |
| 34 | + "April", | |
| 35 | + "May", | |
| 36 | + "June", | |
| 37 | + "July", | |
| 38 | + "August", | |
| 39 | + "September", | |
| 40 | + "October", | |
| 41 | + "November", | |
| 42 | + "December", | |
| 43 | +]; | |
| 44 | + | |
| 26 | 45 | export const Calendar: React.FC<CalendarProps> = ({ |
| 27 | 46 | selected, |
| 28 | 47 | onSelect, |
| 29 | 48 | disabled, |
| @@ -30,10 +49,39 @@ | ||
| 30 | 49 | minDate, |
| 31 | 50 | maxDate, |
| 32 | 51 | className = "", |
| 33 | 52 | }) => { |
| 34 | - const [currentMonth, setCurrentMonth] = React.useState(new Date()); | |
| 53 | + // Open on the selected date (e.g. a saved date of birth lands on its own | |
| 54 | + // year), falling back to today for a blank field. | |
| 55 | + const [currentMonth, setCurrentMonth] = React.useState<Date>( | |
| 56 | + () => selected ?? new Date(), | |
| 57 | + ); | |
| 35 | 58 | |
| 59 | + // When the bound value changes (popover reopened with an existing value), | |
| 60 | + // jump the grid to that month so the user isn't stranded on today. | |
| 61 | + React.useEffect(() => { | |
| 62 | + if (selected) setCurrentMonth(selected); | |
| 63 | + }, [selected]); | |
| 64 | + | |
| 65 | + // Year range for the dropdown: honour min/max when provided, else a wide | |
| 66 | + // span that comfortably covers dates of birth (back ~120 years) and future | |
| 67 | + // trip dates (~10 years ahead). Listed newest-first. | |
| 68 | + const today = new Date(); | |
| 69 | + const fromYear = minDate ? getYear(minDate) : getYear(today) - 120; | |
| 70 | + const toYear = maxDate ? getYear(maxDate) : getYear(today) + 10; | |
| 71 | + const years: number[] = []; | |
| 72 | + for (let y = toYear; y >= fromYear; y--) { | |
| 73 | + years.push(y); | |
| 74 | + } | |
| 75 | + | |
| 76 | + const handleMonthSelect = (monthIndex: number) => { | |
| 77 | + setCurrentMonth(setMonth(currentMonth, monthIndex)); | |
| 78 | + }; | |
| 79 | + | |
| 80 | + const handleYearSelect = (year: number) => { | |
| 81 | + setCurrentMonth(setYear(currentMonth, year)); | |
| 82 | + }; | |
| 83 | + | |
| 36 | 84 | const monthStart = startOfMonth(currentMonth); |
| 37 | 85 | const monthEnd = endOfMonth(currentMonth); |
| 38 | 86 | const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 }); |
| 39 | 87 | const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 }); |
| @@ -62,23 +110,53 @@ | ||
| 62 | 110 | const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; |
| 63 | 111 | |
| 64 | 112 | return ( |
| 65 | 113 | <div className={`p-3 ${className}`}> |
| 66 | - <div className="flex items-center justify-between mb-4"> | |
| 114 | + <div className="flex items-center justify-between gap-1 mb-4"> | |
| 67 | 115 | <button |
| 68 | 116 | type="button" |
| 69 | 117 | onClick={previousMonth} |
| 70 | - className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors" | |
| 118 | + aria-label="Previous month" | |
| 119 | + className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors shrink-0" | |
| 71 | 120 | > |
| 72 | 121 | <ChevronLeft className="w-4 h-4" /> |
| 73 | 122 | </button> |
| 74 | - <h3 className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 75 | - {format(currentMonth, "MMMM yyyy")} | |
| 76 | - </h3> | |
| 123 | + | |
| 124 | + <div className="flex items-center gap-1"> | |
| 125 | + {/* Month dropdown — jump to any month in one click */} | |
| 126 | + <select | |
| 127 | + value={getMonth(currentMonth)} | |
| 128 | + onChange={(e) => handleMonthSelect(Number(e.target.value))} | |
| 129 | + aria-label="Month" | |
| 130 | + className="text-sm font-semibold text-gray-900 dark:text-white bg-transparent border border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-blue-500 rounded-md px-1 py-0.5 cursor-pointer focus:outline-none dark:bg-gray-800" | |
| 131 | + > | |
| 132 | + {MONTH_LABELS.map((label, index) => ( | |
| 133 | + <option key={label} value={index}> | |
| 134 | + {label} | |
| 135 | + </option> | |
| 136 | + ))} | |
| 137 | + </select> | |
| 138 | + | |
| 139 | + {/* Year dropdown — jump straight to e.g. 1990 without month-stepping */} | |
| 140 | + <select | |
| 141 | + value={getYear(currentMonth)} | |
| 142 | + onChange={(e) => handleYearSelect(Number(e.target.value))} | |
| 143 | + aria-label="Year" | |
| 144 | + className="text-sm font-semibold text-gray-900 dark:text-white bg-transparent border border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-blue-500 rounded-md px-1 py-0.5 cursor-pointer focus:outline-none dark:bg-gray-800" | |
| 145 | + > | |
| 146 | + {years.map((year) => ( | |
| 147 | + <option key={year} value={year}> | |
| 148 | + {year} | |
| 149 | + </option> | |
| 150 | + ))} | |
| 151 | + </select> | |
| 152 | + </div> | |
| 153 | + | |
| 77 | 154 | <button |
| 78 | 155 | type="button" |
| 79 | 156 | onClick={nextMonth} |
| 80 | - className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors" | |
| 157 | + aria-label="Next month" | |
| 158 | + className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors shrink-0" | |
| 81 | 159 | > |
| 82 | 160 | <ChevronRight className="w-4 h-4" /> |
| 83 | 161 | </button> |
| 84 | 162 | </div> |