CalendarPicker.tsx163 lines · main
1import { useMemo } from 'react'
2import { cn } from 'ui'
3
4interface CalendarPickerProps {
5 monthOffset: number
6 availableMonthOffsets: number[]
7 timezone: string
8 availableDates: Set<string>
9 selectedDate: string | null
10 onSelectDate: (date: string) => void
11 onChangeMonth: (offset: number) => void
12}
13
14const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
15
16export default function CalendarPicker({
17 monthOffset,
18 availableMonthOffsets,
19 timezone,
20 availableDates,
21 selectedDate,
22 onSelectDate,
23 onChangeMonth,
24}: CalendarPickerProps) {
25 const { year, month, days, monthLabel } = useMemo(() => {
26 const now = new Date()
27 const target = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1)
28 const y = target.getFullYear()
29 const m = target.getMonth()
30
31 const firstDay = new Date(y, m, 1).getDay()
32 const daysInMonth = new Date(y, m + 1, 0).getDate()
33
34 const daysList: Array<{ day: number; dateStr: string } | null> = []
35
36 // Leading empty cells
37 for (let i = 0; i < firstDay; i++) {
38 daysList.push(null)
39 }
40
41 for (let d = 1; d <= daysInMonth; d++) {
42 const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
43 daysList.push({ day: d, dateStr })
44 }
45
46 const targetUtcNoon = new Date(Date.UTC(y, m, 1, 12, 0, 0))
47 const label = targetUtcNoon.toLocaleDateString('en-US', {
48 month: 'long',
49 year: 'numeric',
50 timeZone: timezone,
51 })
52
53 return { year: y, month: m, days: daysList, monthLabel: label }
54 }, [monthOffset, timezone])
55
56 const today = new Date().toLocaleDateString('en-CA', { timeZone: timezone })
57
58 const hasPrev = availableMonthOffsets.some((o) => o < monthOffset)
59 const hasNext = availableMonthOffsets.some((o) => o > monthOffset)
60
61 return (
62 <div className="w-full">
63 <div className="flex items-center justify-between mb-4">
64 <button
65 type="button"
66 onClick={() => onChangeMonth(monthOffset - 1)}
67 disabled={!hasPrev}
68 className={cn(
69 'p-2 rounded-md text-foreground-lighter transition-colors',
70 hasPrev
71 ? 'hover:bg-surface-300/50 hover:text-foreground'
72 : 'opacity-30 cursor-not-allowed'
73 )}
74 aria-label="Previous month"
75 >
76 <ChevronLeftIcon />
77 </button>
78 <span className="text-foreground font-medium text-sm">{monthLabel}</span>
79 <button
80 type="button"
81 onClick={() => onChangeMonth(monthOffset + 1)}
82 disabled={!hasNext}
83 className={cn(
84 'p-2 rounded-md text-foreground-lighter transition-colors',
85 hasNext
86 ? 'hover:bg-surface-300/50 hover:text-foreground'
87 : 'opacity-30 cursor-not-allowed'
88 )}
89 aria-label="Next month"
90 >
91 <ChevronRightIcon />
92 </button>
93 </div>
94
95 <div className="grid grid-cols-7 gap-1 mb-2">
96 {DAY_LABELS.map((label) => (
97 <div key={label} className="text-center text-xs text-foreground-lighter font-medium py-1">
98 {label}
99 </div>
100 ))}
101 </div>
102
103 <div className="grid grid-cols-7 gap-1">
104 {days.map((cell, i) => {
105 if (!cell) {
106 return <div key={`empty-${i}`} />
107 }
108
109 const isAvailable = availableDates.has(cell.dateStr)
110 const isSelected = selectedDate === cell.dateStr
111 const isPast = cell.dateStr < today
112
113 return (
114 <button
115 key={cell.dateStr}
116 type="button"
117 disabled={!isAvailable || isPast}
118 onClick={() => onSelectDate(cell.dateStr)}
119 className={cn(
120 'aspect-square flex items-center justify-center rounded-md text-sm transition-colors',
121 isSelected
122 ? 'bg-brand-500 text-white font-medium'
123 : isAvailable && !isPast
124 ? 'text-foreground hover:bg-surface-300/50 font-medium'
125 : 'text-foreground-lighter/40 cursor-not-allowed'
126 )}
127 >
128 {cell.day}
129 </button>
130 )
131 })}
132 </div>
133 </div>
134 )
135}
136
137function ChevronLeftIcon() {
138 return (
139 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
140 <path
141 d="M10 12L6 8L10 4"
142 stroke="currentColor"
143 strokeWidth="1.5"
144 strokeLinecap="round"
145 strokeLinejoin="round"
146 />
147 </svg>
148 )
149}
150
151function ChevronRightIcon() {
152 return (
153 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
154 <path
155 d="M6 4L10 8L6 12"
156 stroke="currentColor"
157 strokeWidth="1.5"
158 strokeLinecap="round"
159 strokeLinejoin="round"
160 />
161 </svg>
162 )
163}