@srcroot/ui 0.0.54 → 0.0.56

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.
Files changed (107) hide show
  1. package/README.md +151 -151
  2. package/dist/index.d.ts +0 -0
  3. package/dist/index.js +55 -1
  4. package/package.json +7 -2
  5. package/src/registry/analytics/google-analytics.tsx +36 -39
  6. package/src/registry/analytics/google-tag-manager.tsx +62 -65
  7. package/src/registry/analytics/meta-pixel.tsx +44 -47
  8. package/src/registry/analytics/microsoft-clarity.tsx +31 -34
  9. package/src/registry/analytics/tiktok-pixel.tsx +34 -37
  10. package/src/registry/lib/utils.ts +0 -0
  11. package/src/registry/themes/v3/blue.css +157 -157
  12. package/src/registry/themes/v3/glass.css +153 -153
  13. package/src/registry/themes/v3/gray.css +157 -157
  14. package/src/registry/themes/v3/green.css +157 -157
  15. package/src/registry/themes/v3/neutral.css +157 -157
  16. package/src/registry/themes/v3/orange.css +157 -157
  17. package/src/registry/themes/v3/rose.css +157 -157
  18. package/src/registry/themes/v3/slate.css +157 -157
  19. package/src/registry/themes/v3/stone.css +157 -157
  20. package/src/registry/themes/v3/violet.css +186 -186
  21. package/src/registry/themes/v3/zinc.css +157 -157
  22. package/src/registry/themes/v4/blue.css +184 -184
  23. package/src/registry/themes/v4/glass.css +180 -180
  24. package/src/registry/themes/v4/gray.css +184 -184
  25. package/src/registry/themes/v4/green.css +184 -184
  26. package/src/registry/themes/v4/neutral.css +184 -184
  27. package/src/registry/themes/v4/orange.css +184 -184
  28. package/src/registry/themes/v4/rose.css +184 -184
  29. package/src/registry/themes/v4/slate.css +184 -184
  30. package/src/registry/themes/v4/stone.css +184 -184
  31. package/src/registry/themes/v4/violet.css +184 -184
  32. package/src/registry/themes/v4/zinc.css +184 -184
  33. package/src/registry/ui/accordion.tsx +164 -165
  34. package/src/registry/ui/alert-dialog.tsx +213 -214
  35. package/src/registry/ui/alert.tsx +73 -76
  36. package/src/registry/ui/aspect-ratio.tsx +44 -47
  37. package/src/registry/ui/avatar.tsx +96 -97
  38. package/src/registry/ui/badge.tsx +52 -55
  39. package/src/registry/ui/breadcrumb.tsx +147 -150
  40. package/src/registry/ui/button-group.tsx +64 -67
  41. package/src/registry/ui/button.tsx +71 -72
  42. package/src/registry/ui/calendar.tsx +514 -515
  43. package/src/registry/ui/card.tsx +88 -91
  44. package/src/registry/ui/carousel.tsx +214 -214
  45. package/src/registry/ui/chart.tsx +373 -373
  46. package/src/registry/ui/chatbot.tsx +86 -13
  47. package/src/registry/ui/checkbox.tsx +93 -94
  48. package/src/registry/ui/collapsible.tsx +107 -108
  49. package/src/registry/ui/combobox.tsx +171 -171
  50. package/src/registry/ui/command.tsx +300 -300
  51. package/src/registry/ui/container.tsx +44 -47
  52. package/src/registry/ui/context-menu.tsx +221 -221
  53. package/src/registry/ui/date-picker.tsx +228 -228
  54. package/src/registry/ui/dialog.tsx +269 -270
  55. package/src/registry/ui/drawer.tsx +10 -4
  56. package/src/registry/ui/dropdown-menu.tsx +529 -530
  57. package/src/registry/ui/empty-state.tsx +0 -2
  58. package/src/registry/ui/file-upload.tsx +0 -0
  59. package/src/registry/ui/floating-dock.tsx +0 -0
  60. package/src/registry/ui/form-field.tsx +91 -94
  61. package/src/registry/ui/google-analytics.tsx +38 -0
  62. package/src/registry/ui/google-tag-manager.tsx +64 -0
  63. package/src/registry/ui/hover-card.tsx +223 -223
  64. package/src/registry/ui/image.tsx +144 -147
  65. package/src/registry/ui/input-group.tsx +82 -85
  66. package/src/registry/ui/input.tsx +125 -125
  67. package/src/registry/ui/kbd.tsx +60 -63
  68. package/src/registry/ui/label.tsx +36 -37
  69. package/src/registry/ui/loading-spinner.tsx +108 -111
  70. package/src/registry/ui/map.tsx +0 -0
  71. package/src/registry/ui/marquee.tsx +2 -0
  72. package/src/registry/ui/menubar.tsx +246 -246
  73. package/src/registry/ui/meta-pixel.tsx +46 -0
  74. package/src/registry/ui/microsoft-clarity.tsx +33 -0
  75. package/src/registry/ui/native-select.tsx +49 -52
  76. package/src/registry/ui/otp-input.tsx +152 -155
  77. package/src/registry/ui/pagination.tsx +149 -152
  78. package/src/registry/ui/patterns.tsx +28 -0
  79. package/src/registry/ui/popover.tsx +226 -227
  80. package/src/registry/ui/progress.tsx +51 -52
  81. package/src/registry/ui/radio.tsx +99 -102
  82. package/src/registry/ui/resizable.tsx +314 -314
  83. package/src/registry/ui/scroll-animation.tsx +45 -0
  84. package/src/registry/ui/scroll-area.tsx +121 -122
  85. package/src/registry/ui/scroll-to-top.tsx +0 -0
  86. package/src/registry/ui/search.tsx +147 -150
  87. package/src/registry/ui/select.tsx +292 -293
  88. package/src/registry/ui/separator.tsx +46 -47
  89. package/src/registry/ui/sheet.tsx +6 -3
  90. package/src/registry/ui/sidebar.tsx +628 -628
  91. package/src/registry/ui/skeleton.tsx +26 -29
  92. package/src/registry/ui/slider.tsx +196 -197
  93. package/src/registry/ui/slot.tsx +69 -72
  94. package/src/registry/ui/star-rating.tsx +131 -134
  95. package/src/registry/ui/switch.tsx +72 -73
  96. package/src/registry/ui/table-of-contents.tsx +96 -96
  97. package/src/registry/ui/table.tsx +138 -139
  98. package/src/registry/ui/tabs.tsx +124 -125
  99. package/src/registry/ui/text.tsx +61 -64
  100. package/src/registry/ui/textarea.tsx +41 -42
  101. package/src/registry/ui/theme-switcher.tsx +66 -66
  102. package/src/registry/ui/tiktok-pixel.tsx +36 -0
  103. package/src/registry/ui/toast.tsx +97 -98
  104. package/src/registry/ui/toggle-group.tsx +129 -129
  105. package/src/registry/ui/toggle.tsx +72 -72
  106. package/src/registry/ui/tooltip.tsx +143 -144
  107. package/src/registry/ui/whatsapp.tsx +0 -0
@@ -1,515 +1,514 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { cn } from "@/lib/utils"
5
-
6
- interface CalendarContextValue {
7
- currentMonth: Date
8
- setCurrentMonth: (date: Date) => void
9
- selectedDates: Date[]
10
- onSelect: (date: Date) => void
11
- mode: "single" | "multiple" | "range"
12
- rangeStart: Date | null
13
- hoveredDate: Date | null
14
- setHoveredDate: (date: Date | null) => void
15
- }
16
-
17
- const CalendarContext = React.createContext<CalendarContextValue | null>(null)
18
-
19
- export interface CalendarProps {
20
- /** Selection mode */
21
- mode?: "single" | "multiple" | "range"
22
- /** Selected date(s) */
23
- selected?: Date | Date[]
24
- /** Callback when date is selected */
25
- onSelect?: (date: Date | Date[] | undefined) => void
26
- /** Default month to display */
27
- defaultMonth?: Date
28
- /** Minimum selectable date */
29
- minDate?: Date
30
- /** Maximum selectable date */
31
- maxDate?: Date
32
- /** Whether calendar is disabled */
33
- disabled?: boolean
34
- /** Size variant */
35
- size?: "default" | "sm" | "xs" | "md" | "lg"
36
- /** Number of months to display */
37
- numberOfMonths?: number
38
- className?: string
39
- }
40
-
41
- const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
42
- const DAYS_SHORT = ["S", "M", "T", "W", "T", "F", "S"]
43
- const MONTHS = [
44
- "January", "February", "March", "April", "May", "June",
45
- "July", "August", "September", "October", "November", "December"
46
- ]
47
- const MONTHS_SHORT = [
48
- "Jan", "Feb", "Mar", "Apr", "May", "Jun",
49
- "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
50
- ]
51
-
52
- function getDaysInMonth(year: number, month: number): Date[] {
53
- const days: Date[] = []
54
- const firstDay = new Date(year, month, 1)
55
- const lastDay = new Date(year, month + 1, 0)
56
-
57
- // Add days from previous month to fill first week
58
- const startPadding = firstDay.getDay()
59
- for (let i = startPadding - 1; i >= 0; i--) {
60
- days.push(new Date(year, month, -i))
61
- }
62
-
63
- // Add days of current month
64
- for (let d = 1; d <= lastDay.getDate(); d++) {
65
- days.push(new Date(year, month, d))
66
- }
67
-
68
- // Add days from next month to fill last week
69
- const endPadding = 42 - days.length // 6 rows * 7 days
70
- for (let i = 1; i <= endPadding; i++) {
71
- days.push(new Date(year, month + 1, i))
72
- }
73
-
74
- return days
75
- }
76
-
77
- function isSameDay(d1: Date | undefined | null, d2: Date | undefined | null): boolean {
78
- if (!d1 || !d2) return false
79
- return d1.getDate() === d2.getDate() &&
80
- d1.getMonth() === d2.getMonth() &&
81
- d1.getFullYear() === d2.getFullYear()
82
- }
83
-
84
- function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
85
- if (!start || !end) return false
86
- const d = date.getTime()
87
- const s = start.getTime()
88
- const e = end.getTime()
89
- return d >= Math.min(s, e) && d <= Math.max(s, e)
90
- }
91
-
92
- type ViewMode = 'days' | 'months' | 'years'
93
-
94
- /**
95
- * Calendar date picker component - Modern, Responsive, Advanced
96
- *
97
- * Features:
98
- * - Fluid sizing (xs, sm, default, md, lg)
99
- * - Range hover preview
100
- * - Year/Month selection views
101
- * - Animated transitions (CSS)
102
- */
103
- const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
104
- (
105
- (
106
- {
107
- mode = "single",
108
- selected,
109
- onSelect,
110
- defaultMonth = new Date(),
111
- minDate,
112
- maxDate,
113
- disabled,
114
- size = "default",
115
- className,
116
- numberOfMonths = 1,
117
- ...props
118
- },
119
- ref
120
- ) => {
121
- const [currentMonth, setCurrentMonth] = React.useState(defaultMonth)
122
- const [view, setView] = React.useState<ViewMode>('days')
123
- const [rangeStart, setRangeStart] = React.useState<Date | null>(null)
124
- const [hoveredDate, setHoveredDate] = React.useState<Date | null>(null)
125
-
126
- // Reset range start if mode changes or selection clears externally
127
- React.useEffect(() => {
128
- if (mode !== 'range' || !selected) {
129
- setRangeStart(null)
130
- }
131
- }, [mode, selected])
132
-
133
- const selectedDates = React.useMemo(() => {
134
- if (!selected) return []
135
- return Array.isArray(selected) ? selected : [selected]
136
- }, [selected])
137
-
138
- const handleSelect = (date: Date) => {
139
- if (disabled) return
140
- if (minDate && date < minDate) return
141
- if (maxDate && date > maxDate) return
142
-
143
- if (mode === "single") {
144
- onSelect?.(date)
145
- } else if (mode === "multiple") {
146
- const exists = selectedDates.some(d => isSameDay(d, date))
147
- if (exists) {
148
- onSelect?.(selectedDates.filter(d => !isSameDay(d, date)))
149
- } else {
150
- onSelect?.([...selectedDates, date])
151
- }
152
- } else if (mode === "range") {
153
- if (!rangeStart) {
154
- setRangeStart(date)
155
- onSelect?.([date]) // Partial selection
156
- } else {
157
- const start = rangeStart < date ? rangeStart : date
158
- const end = rangeStart < date ? date : rangeStart
159
- onSelect?.([start, end])
160
- setRangeStart(null)
161
- }
162
- }
163
- }
164
-
165
- const navigatePrev = () => {
166
- if (view === 'years') {
167
- setCurrentMonth(new Date(currentMonth.getFullYear() - 12, currentMonth.getMonth(), 1))
168
- } else if (view === 'months') {
169
- setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))
170
- } else {
171
- setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
172
- }
173
- }
174
-
175
- const navigateNext = () => {
176
- if (view === 'years') {
177
- setCurrentMonth(new Date(currentMonth.getFullYear() + 12, currentMonth.getMonth(), 1))
178
- } else if (view === 'months') {
179
- setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))
180
- } else {
181
- setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
182
- }
183
- }
184
-
185
- // --- Sizing Configuration ---
186
- const sizeStyles = {
187
- xs: {
188
- container: "p-2", // width handled by layout now
189
- width: "w-[220px]",
190
- header: "mb-2",
191
- navBtn: "h-5 w-5",
192
- icon: "h-2.5 w-2.5",
193
- headerTitle: "text-[10px] font-medium",
194
- grid: "gap-0.5",
195
- dayName: "text-[9px] h-5",
196
- cell: "text-[10px]",
197
- yearGrid: "grid-cols-3 gap-1",
198
- yearCell: "text-xs py-1.5",
199
- radius: "rounded-sm",
200
- },
201
- sm: {
202
- container: "p-3",
203
- width: "w-[280px]",
204
- header: "mb-3",
205
- navBtn: "h-6 w-6",
206
- icon: "h-3 w-3",
207
- headerTitle: "text-xs font-semibold",
208
- grid: "gap-1",
209
- dayName: "text-[10px] h-6",
210
- cell: "text-xs",
211
- yearGrid: "grid-cols-3 gap-2",
212
- yearCell: "text-xs py-2",
213
- radius: "rounded-md",
214
- },
215
- default: {
216
- container: "p-4",
217
- width: "w-[320px]",
218
- header: "mb-4",
219
- navBtn: "h-8 w-8",
220
- icon: "h-4 w-4",
221
- headerTitle: "text-sm font-semibold",
222
- grid: "gap-1",
223
- dayName: "text-xs h-8",
224
- cell: "text-sm",
225
- yearGrid: "grid-cols-3 gap-3",
226
- yearCell: "text-sm py-2.5",
227
- radius: "rounded-md",
228
- },
229
- md: {
230
- container: "p-5",
231
- width: "w-[380px]",
232
- header: "mb-5",
233
- navBtn: "h-9 w-9",
234
- icon: "h-4 w-4",
235
- headerTitle: "text-base font-semibold",
236
- grid: "gap-2",
237
- dayName: "text-xs h-9",
238
- cell: "text-base",
239
- yearGrid: "grid-cols-4 gap-3",
240
- yearCell: "text-base py-3",
241
- radius: "rounded-lg",
242
- },
243
- lg: {
244
- container: "p-6",
245
- width: "w-[440px]",
246
- header: "mb-6",
247
- navBtn: "h-10 w-10",
248
- icon: "h-5 w-5",
249
- headerTitle: "text-lg font-bold",
250
- grid: "gap-3",
251
- dayName: "text-sm h-10",
252
- cell: "text-lg",
253
- yearGrid: "grid-cols-4 gap-4",
254
- yearCell: "text-lg py-4",
255
- radius: "rounded-xl",
256
- },
257
- }
258
- const s = sizeStyles[size]
259
-
260
- // --- Render Helpers ---
261
-
262
- const renderMonthGrid = (monthOffset: number, isFirst: boolean, isLast: boolean) => {
263
- const displayDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + monthOffset, 1)
264
- const days = getDaysInMonth(displayDate.getFullYear(), displayDate.getMonth())
265
-
266
- // Calculate effective range end (either confirmed end or hover preview)
267
- const rangeEnd = (mode === "range" && selectedDates.length === 2)
268
- ? selectedDates[1]
269
- : (rangeStart && hoveredDate ? hoveredDate : null)
270
-
271
- const label = size === "xs"
272
- ? `${MONTHS_SHORT[displayDate.getMonth()]} ${displayDate.getFullYear()}`
273
- : `${MONTHS[displayDate.getMonth()]} ${displayDate.getFullYear()}`
274
-
275
- const dayLabels = size === "xs" ? DAYS_SHORT : DAYS
276
-
277
- return (
278
- <div className={cn("relative", s.width)}>
279
- <div className={cn("flex justify-between items-center", s.header)}>
280
- {/* Prev Button - only show on first month */}
281
- {isFirst && view === 'days' ? (
282
- <button
283
- type="button"
284
- onClick={navigatePrev}
285
- className={cn(
286
- "inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
287
- s.navBtn
288
- )}
289
- >
290
- <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
291
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
292
- </svg>
293
- </button>
294
- ) : <div className={s.navBtn} />}
295
-
296
- {/* Month Title */}
297
- <button
298
- type="button"
299
- onClick={() => setView(view === 'days' ? 'months' : view === 'months' ? 'years' : 'days')}
300
- className={cn("hover:bg-accent px-2 py-1 rounded transition-colors font-semibold", s.headerTitle)}
301
- >
302
- {label}
303
- </button>
304
-
305
- {/* Next Button - only show on last month */}
306
- {isLast && view === 'days' ? (
307
- <button
308
- type="button"
309
- onClick={navigateNext}
310
- className={cn(
311
- "inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
312
- s.navBtn
313
- )}
314
- >
315
- <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
316
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
317
- </svg>
318
- </button>
319
- ) : <div className={s.navBtn} />}
320
- </div>
321
-
322
- {/* Day Names */}
323
- <div className={cn("grid grid-cols-7 mb-2", s.grid)}>
324
- {dayLabels.map((day, i) => (
325
- <div key={i} className={cn("flex justify-center items-center text-muted-foreground font-medium", s.dayName)}>
326
- {day}
327
- </div>
328
- ))}
329
- </div>
330
-
331
- {/* Days Grid */}
332
- <div className={cn("grid grid-cols-7", s.grid)} role="grid">
333
- {days.map((date, index) => {
334
- const isCurrentMonth = date.getMonth() === displayDate.getMonth()
335
- const isSelected = selectedDates.some(d => isSameDay(d, date))
336
- const isRangeStart = rangeStart && isSameDay(date, rangeStart)
337
- // A date is effectively the "end" if it's the actual confirmed end OR the hovered end
338
- // (but only if we are currently selecting a range)
339
- const isRangeEnd = mode === "range" && rangeEnd && isSameDay(date, rangeEnd)
340
-
341
- // Check if in range of (start -> end/hover)
342
- const inRange = mode === "range" && isInRange(date, rangeStart || selectedDates[0], rangeEnd)
343
-
344
- const isToday = isSameDay(date, new Date())
345
- const isDisabled = disabled || (minDate && date < minDate) || (maxDate && date > maxDate)
346
-
347
- return (
348
- <button
349
- key={index}
350
- type="button"
351
- onClick={() => handleSelect(date)}
352
- onMouseEnter={() => setHoveredDate(date)}
353
- onMouseLeave={() => setHoveredDate(null)}
354
- disabled={isDisabled}
355
- className={cn(
356
- "relative p-0 text-center focus-within:relative focus-within:z-20",
357
- "aspect-square w-full flex items-center justify-center transition-all",
358
- s.cell,
359
- s.radius, // Apply base radius
360
- // Base states
361
- !isCurrentMonth && "text-muted-foreground/30",
362
- isCurrentMonth && "text-foreground font-normal",
363
- isToday && !isSelected && "text-accent-foreground bg-accent/30 font-semibold ring-1 ring-inset ring-accent-foreground/20",
364
- isDisabled && "opacity-30 cursor-not-allowed hover:bg-transparent",
365
-
366
- // Hover states (only if enabled)
367
- !isDisabled && !isSelected && !inRange && isCurrentMonth && "hover:bg-accent hover:text-accent-foreground",
368
-
369
- // Selection states
370
- isSelected && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
371
-
372
- // Range styling
373
- (isRangeStart || isRangeEnd) && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
374
- inRange && !isSelected && "bg-accent/40 text-accent-foreground font-medium hover:bg-accent/60",
375
-
376
- // Range rounding fix (connect bars)
377
- inRange && "rounded-none",
378
- isRangeStart && rangeEnd && "rounded-r-none",
379
- isRangeEnd && rangeStart && "rounded-l-none",
380
-
381
- // Specific case: Start == End (Single day range)
382
- isRangeStart && isRangeEnd && s.radius
383
- )}
384
- >
385
- <time dateTime={date.toISOString().split('T')[0]}>
386
- {date.getDate()}
387
- </time>
388
- </button>
389
- )
390
- })}
391
- </div>
392
- </div>
393
- )
394
- }
395
-
396
- const renderMonths = () => {
397
- const currentYear = currentMonth.getFullYear()
398
- return (
399
- <div className={cn("grid w-full", s.yearGrid)}>
400
- {MONTHS_SHORT.map((month, index) => {
401
- const isCurrent = new Date().getMonth() === index && new Date().getFullYear() === currentYear
402
- const isSelected = currentMonth.getMonth() === index
403
- return (
404
- <button
405
- key={month}
406
- type="button"
407
- onClick={() => {
408
- setCurrentMonth(new Date(currentYear, index, 1))
409
- setView('days')
410
- }}
411
- className={cn(
412
- "flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
413
- s.yearCell,
414
- isCurrent && "border border-primary text-primary",
415
- isSelected && "bg-primary text-primary-foreground hover:bg-primary"
416
- )}
417
- >
418
- {size === 'xs' ? month.charAt(0) : month}
419
- </button>
420
- )
421
- })}
422
- </div>
423
- )
424
- }
425
-
426
- const renderYears = () => {
427
- const bgYear = currentMonth.getFullYear()
428
- const startYear = bgYear - (bgYear % 12)
429
- const years = Array.from({ length: 12 }, (_, i) => startYear + i)
430
-
431
- return (
432
- <div className={cn("grid w-full", s.yearGrid)}>
433
- {years.map((year) => {
434
- const isCurrent = new Date().getFullYear() === year
435
- const isSelected = currentMonth.getFullYear() === year
436
- return (
437
- <button
438
- key={year}
439
- type="button"
440
- onClick={() => {
441
- setCurrentMonth(new Date(year, currentMonth.getMonth(), 1))
442
- setView('months')
443
- }}
444
- className={cn(
445
- "flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
446
- s.yearCell,
447
- isCurrent && "border border-primary text-primary",
448
- isSelected && "bg-primary text-primary-foreground hover:bg-primary"
449
- )}
450
- >
451
- {year}
452
- </button>
453
- )
454
- })}
455
- </div>
456
- )
457
- }
458
-
459
- const monthsToRender = Array.from({ length: numberOfMonths }, (_, i) => i)
460
-
461
- return (
462
- <div
463
- ref={ref}
464
- className={cn(
465
- "bg-background border rounded-lg shadow-sm transition-all duration-200 inline-block relative",
466
- s.container,
467
- className
468
- )}
469
- role="application"
470
- aria-label="Calendar"
471
- {...props}
472
- >
473
- {/* Navigation Overlays */}
474
-
475
- <div className={cn("flex gap-8 relative")}> {/* Gap between months */}
476
- {view === 'days' ? (
477
- monthsToRender.map(offset => (
478
- <React.Fragment key={offset}>
479
- {renderMonthGrid(offset, offset === 0, offset === numberOfMonths - 1)}
480
- </React.Fragment>
481
- ))
482
- ) : (
483
- <div className={cn("w-full", s.width)}>
484
- {/* Render View Controls Header */}
485
- <div className={cn("flex justify-center items-center mb-4 relative")}>
486
- <div className={cn("font-semibold", s.headerTitle)}>
487
- {view === 'months' ? currentMonth.getFullYear() : `${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12)} - ${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12) + 11}`}
488
- </div>
489
- {/* Navigation for views */}
490
- <div className="absolute inset-0 flex justify-between items-center">
491
- <button onClick={navigatePrev} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
492
- <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
493
- </button>
494
- <button onClick={navigateNext} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
495
- <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
496
- </button>
497
- </div>
498
- </div>
499
-
500
- <div className="h-[240px]">
501
- {view === 'months' && renderMonths()}
502
- {view === 'years' && renderYears()}
503
- </div>
504
- </div>
505
- )}
506
- </div>
507
- </div>
508
- )
509
- }
510
- ))
511
-
512
- Calendar.displayName = "Calendar"
513
-
514
- export { Calendar }
515
-
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ interface CalendarContextValue {
7
+ currentMonth: Date
8
+ setCurrentMonth: (date: Date) => void
9
+ selectedDates: Date[]
10
+ onSelect: (date: Date) => void
11
+ mode: "single" | "multiple" | "range"
12
+ rangeStart: Date | null
13
+ hoveredDate: Date | null
14
+ setHoveredDate: (date: Date | null) => void
15
+ }
16
+
17
+ const CalendarContext = React.createContext<CalendarContextValue | null>(null)
18
+
19
+ export interface CalendarProps {
20
+ /** Selection mode */
21
+ mode?: "single" | "multiple" | "range"
22
+ /** Selected date(s) */
23
+ selected?: Date | Date[]
24
+ /** Callback when date is selected */
25
+ onSelect?: (date: Date | Date[] | undefined) => void
26
+ /** Default month to display */
27
+ defaultMonth?: Date
28
+ /** Minimum selectable date */
29
+ minDate?: Date
30
+ /** Maximum selectable date */
31
+ maxDate?: Date
32
+ /** Whether calendar is disabled */
33
+ disabled?: boolean
34
+ /** Size variant */
35
+ size?: "default" | "sm" | "xs" | "md" | "lg"
36
+ /** Number of months to display */
37
+ numberOfMonths?: number
38
+ className?: string
39
+ }
40
+
41
+ const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
42
+ const DAYS_SHORT = ["S", "M", "T", "W", "T", "F", "S"]
43
+ const MONTHS = [
44
+ "January", "February", "March", "April", "May", "June",
45
+ "July", "August", "September", "October", "November", "December"
46
+ ]
47
+ const MONTHS_SHORT = [
48
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
49
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
50
+ ]
51
+
52
+ function getDaysInMonth(year: number, month: number): Date[] {
53
+ const days: Date[] = []
54
+ const firstDay = new Date(year, month, 1)
55
+ const lastDay = new Date(year, month + 1, 0)
56
+
57
+ // Add days from previous month to fill first week
58
+ const startPadding = firstDay.getDay()
59
+ for (let i = startPadding - 1; i >= 0; i--) {
60
+ days.push(new Date(year, month, -i))
61
+ }
62
+
63
+ // Add days of current month
64
+ for (let d = 1; d <= lastDay.getDate(); d++) {
65
+ days.push(new Date(year, month, d))
66
+ }
67
+
68
+ // Add days from next month to fill last week
69
+ const endPadding = 42 - days.length // 6 rows * 7 days
70
+ for (let i = 1; i <= endPadding; i++) {
71
+ days.push(new Date(year, month + 1, i))
72
+ }
73
+
74
+ return days
75
+ }
76
+
77
+ function isSameDay(d1: Date | undefined | null, d2: Date | undefined | null): boolean {
78
+ if (!d1 || !d2) return false
79
+ return d1.getDate() === d2.getDate() &&
80
+ d1.getMonth() === d2.getMonth() &&
81
+ d1.getFullYear() === d2.getFullYear()
82
+ }
83
+
84
+ function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
85
+ if (!start || !end) return false
86
+ const d = date.getTime()
87
+ const s = start.getTime()
88
+ const e = end.getTime()
89
+ return d >= Math.min(s, e) && d <= Math.max(s, e)
90
+ }
91
+
92
+ type ViewMode = 'days' | 'months' | 'years'
93
+
94
+ /**
95
+ * Calendar date picker component - Modern, Responsive, Advanced
96
+ *
97
+ * Features:
98
+ * - Fluid sizing (xs, sm, default, md, lg)
99
+ * - Range hover preview
100
+ * - Year/Month selection views
101
+ * - Animated transitions (CSS)
102
+ */
103
+ const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
104
+ (
105
+ (
106
+ {
107
+ mode = "single",
108
+ selected,
109
+ onSelect,
110
+ defaultMonth = new Date(),
111
+ minDate,
112
+ maxDate,
113
+ disabled,
114
+ size = "default",
115
+ className,
116
+ numberOfMonths = 1,
117
+ ...props
118
+ },
119
+ ref
120
+ ) => {
121
+ const [currentMonth, setCurrentMonth] = React.useState(defaultMonth)
122
+ const [view, setView] = React.useState<ViewMode>('days')
123
+ const [rangeStart, setRangeStart] = React.useState<Date | null>(null)
124
+ const [hoveredDate, setHoveredDate] = React.useState<Date | null>(null)
125
+
126
+ // Reset range start if mode changes or selection clears externally
127
+ React.useEffect(() => {
128
+ if (mode !== 'range' || !selected) {
129
+ setRangeStart(null)
130
+ }
131
+ }, [mode, selected])
132
+
133
+ const selectedDates = React.useMemo(() => {
134
+ if (!selected) return []
135
+ return Array.isArray(selected) ? selected : [selected]
136
+ }, [selected])
137
+
138
+ const handleSelect = (date: Date) => {
139
+ if (disabled) return
140
+ if (minDate && date < minDate) return
141
+ if (maxDate && date > maxDate) return
142
+
143
+ if (mode === "single") {
144
+ onSelect?.(date)
145
+ } else if (mode === "multiple") {
146
+ const exists = selectedDates.some(d => isSameDay(d, date))
147
+ if (exists) {
148
+ onSelect?.(selectedDates.filter(d => !isSameDay(d, date)))
149
+ } else {
150
+ onSelect?.([...selectedDates, date])
151
+ }
152
+ } else if (mode === "range") {
153
+ if (!rangeStart) {
154
+ setRangeStart(date)
155
+ onSelect?.([date]) // Partial selection
156
+ } else {
157
+ const start = rangeStart < date ? rangeStart : date
158
+ const end = rangeStart < date ? date : rangeStart
159
+ onSelect?.([start, end])
160
+ setRangeStart(null)
161
+ }
162
+ }
163
+ }
164
+
165
+ const navigatePrev = () => {
166
+ if (view === 'years') {
167
+ setCurrentMonth(new Date(currentMonth.getFullYear() - 12, currentMonth.getMonth(), 1))
168
+ } else if (view === 'months') {
169
+ setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))
170
+ } else {
171
+ setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))
172
+ }
173
+ }
174
+
175
+ const navigateNext = () => {
176
+ if (view === 'years') {
177
+ setCurrentMonth(new Date(currentMonth.getFullYear() + 12, currentMonth.getMonth(), 1))
178
+ } else if (view === 'months') {
179
+ setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))
180
+ } else {
181
+ setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))
182
+ }
183
+ }
184
+
185
+ // --- Sizing Configuration ---
186
+ const sizeStyles = {
187
+ xs: {
188
+ container: "p-2", // width handled by layout now
189
+ width: "w-[220px]",
190
+ header: "mb-2",
191
+ navBtn: "h-5 w-5",
192
+ icon: "h-2.5 w-2.5",
193
+ headerTitle: "text-[10px] font-medium",
194
+ grid: "gap-0.5",
195
+ dayName: "text-[9px] h-5",
196
+ cell: "text-[10px]",
197
+ yearGrid: "grid-cols-3 gap-1",
198
+ yearCell: "text-xs py-1.5",
199
+ radius: "rounded-sm",
200
+ },
201
+ sm: {
202
+ container: "p-3",
203
+ width: "w-[280px]",
204
+ header: "mb-3",
205
+ navBtn: "h-6 w-6",
206
+ icon: "h-3 w-3",
207
+ headerTitle: "text-xs font-semibold",
208
+ grid: "gap-1",
209
+ dayName: "text-[10px] h-6",
210
+ cell: "text-xs",
211
+ yearGrid: "grid-cols-3 gap-2",
212
+ yearCell: "text-xs py-2",
213
+ radius: "rounded-md",
214
+ },
215
+ default: {
216
+ container: "p-4",
217
+ width: "w-[320px]",
218
+ header: "mb-4",
219
+ navBtn: "h-8 w-8",
220
+ icon: "h-4 w-4",
221
+ headerTitle: "text-sm font-semibold",
222
+ grid: "gap-1",
223
+ dayName: "text-xs h-8",
224
+ cell: "text-sm",
225
+ yearGrid: "grid-cols-3 gap-3",
226
+ yearCell: "text-sm py-2.5",
227
+ radius: "rounded-md",
228
+ },
229
+ md: {
230
+ container: "p-5",
231
+ width: "w-[380px]",
232
+ header: "mb-5",
233
+ navBtn: "h-9 w-9",
234
+ icon: "h-4 w-4",
235
+ headerTitle: "text-base font-semibold",
236
+ grid: "gap-2",
237
+ dayName: "text-xs h-9",
238
+ cell: "text-base",
239
+ yearGrid: "grid-cols-4 gap-3",
240
+ yearCell: "text-base py-3",
241
+ radius: "rounded-lg",
242
+ },
243
+ lg: {
244
+ container: "p-6",
245
+ width: "w-[440px]",
246
+ header: "mb-6",
247
+ navBtn: "h-10 w-10",
248
+ icon: "h-5 w-5",
249
+ headerTitle: "text-lg font-bold",
250
+ grid: "gap-3",
251
+ dayName: "text-sm h-10",
252
+ cell: "text-lg",
253
+ yearGrid: "grid-cols-4 gap-4",
254
+ yearCell: "text-lg py-4",
255
+ radius: "rounded-xl",
256
+ },
257
+ }
258
+ const s = sizeStyles[size]
259
+
260
+ // --- Render Helpers ---
261
+
262
+ const renderMonthGrid = (monthOffset: number, isFirst: boolean, isLast: boolean) => {
263
+ const displayDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + monthOffset, 1)
264
+ const days = getDaysInMonth(displayDate.getFullYear(), displayDate.getMonth())
265
+
266
+ // Calculate effective range end (either confirmed end or hover preview)
267
+ const rangeEnd = (mode === "range" && selectedDates.length === 2)
268
+ ? selectedDates[1]
269
+ : (rangeStart && hoveredDate ? hoveredDate : null)
270
+
271
+ const label = size === "xs"
272
+ ? `${MONTHS_SHORT[displayDate.getMonth()]} ${displayDate.getFullYear()}`
273
+ : `${MONTHS[displayDate.getMonth()]} ${displayDate.getFullYear()}`
274
+
275
+ const dayLabels = size === "xs" ? DAYS_SHORT : DAYS
276
+
277
+ return (
278
+ <div className={cn("relative", s.width)}>
279
+ <div className={cn("flex justify-between items-center", s.header)}>
280
+ {/* Prev Button - only show on first month */}
281
+ {isFirst && view === 'days' ? (
282
+ <button
283
+ type="button"
284
+ onClick={navigatePrev}
285
+ className={cn(
286
+ "inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
287
+ s.navBtn
288
+ )}
289
+ >
290
+ <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
291
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
292
+ </svg>
293
+ </button>
294
+ ) : <div className={s.navBtn} />}
295
+
296
+ {/* Month Title */}
297
+ <button
298
+ type="button"
299
+ onClick={() => setView(view === 'days' ? 'months' : view === 'months' ? 'years' : 'days')}
300
+ className={cn("hover:bg-accent px-2 py-1 rounded transition-colors font-semibold", s.headerTitle)}
301
+ >
302
+ {label}
303
+ </button>
304
+
305
+ {/* Next Button - only show on last month */}
306
+ {isLast && view === 'days' ? (
307
+ <button
308
+ type="button"
309
+ onClick={navigateNext}
310
+ className={cn(
311
+ "inline-flex items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground active:scale-95 transition-all",
312
+ s.navBtn
313
+ )}
314
+ >
315
+ <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
316
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
317
+ </svg>
318
+ </button>
319
+ ) : <div className={s.navBtn} />}
320
+ </div>
321
+
322
+ {/* Day Names */}
323
+ <div className={cn("grid grid-cols-7 mb-2", s.grid)}>
324
+ {dayLabels.map((day, i) => (
325
+ <div key={i} className={cn("flex justify-center items-center text-muted-foreground font-medium", s.dayName)}>
326
+ {day}
327
+ </div>
328
+ ))}
329
+ </div>
330
+
331
+ {/* Days Grid */}
332
+ <div className={cn("grid grid-cols-7", s.grid)} role="grid">
333
+ {days.map((date, index) => {
334
+ const isCurrentMonth = date.getMonth() === displayDate.getMonth()
335
+ const isSelected = selectedDates.some(d => isSameDay(d, date))
336
+ const isRangeStart = rangeStart && isSameDay(date, rangeStart)
337
+ // A date is effectively the "end" if it's the actual confirmed end OR the hovered end
338
+ // (but only if we are currently selecting a range)
339
+ const isRangeEnd = mode === "range" && rangeEnd && isSameDay(date, rangeEnd)
340
+
341
+ // Check if in range of (start -> end/hover)
342
+ const inRange = mode === "range" && isInRange(date, rangeStart || selectedDates[0], rangeEnd)
343
+
344
+ const isToday = isSameDay(date, new Date())
345
+ const isDisabled = disabled || (minDate && date < minDate) || (maxDate && date > maxDate)
346
+
347
+ return (
348
+ <button
349
+ key={index}
350
+ type="button"
351
+ onClick={() => handleSelect(date)}
352
+ onMouseEnter={() => setHoveredDate(date)}
353
+ onMouseLeave={() => setHoveredDate(null)}
354
+ disabled={isDisabled}
355
+ className={cn(
356
+ "relative p-0 text-center focus-within:relative focus-within:z-20",
357
+ "aspect-square w-full flex items-center justify-center transition-all",
358
+ s.cell,
359
+ s.radius, // Apply base radius
360
+ // Base states
361
+ !isCurrentMonth && "text-muted-foreground/30",
362
+ isCurrentMonth && "text-foreground font-normal",
363
+ isToday && !isSelected && "text-accent-foreground bg-accent/30 font-semibold ring-1 ring-inset ring-accent-foreground/20",
364
+ isDisabled && "opacity-30 cursor-not-allowed hover:bg-transparent",
365
+
366
+ // Hover states (only if enabled)
367
+ !isDisabled && !isSelected && !inRange && isCurrentMonth && "hover:bg-accent hover:text-accent-foreground",
368
+
369
+ // Selection states
370
+ isSelected && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
371
+
372
+ // Range styling
373
+ (isRangeStart || isRangeEnd) && "bg-primary text-primary-foreground hover:bg-primary shadow-sm font-semibold z-10",
374
+ inRange && !isSelected && "bg-accent/40 text-accent-foreground font-medium hover:bg-accent/60",
375
+
376
+ // Range rounding fix (connect bars)
377
+ inRange && "rounded-none",
378
+ isRangeStart && rangeEnd && "rounded-r-none",
379
+ isRangeEnd && rangeStart && "rounded-l-none",
380
+
381
+ // Specific case: Start == End (Single day range)
382
+ isRangeStart && isRangeEnd && s.radius
383
+ )}
384
+ >
385
+ <time dateTime={date.toISOString().split('T')[0]}>
386
+ {date.getDate()}
387
+ </time>
388
+ </button>
389
+ )
390
+ })}
391
+ </div>
392
+ </div>
393
+ )
394
+ }
395
+
396
+ const renderMonths = () => {
397
+ const currentYear = currentMonth.getFullYear()
398
+ return (
399
+ <div className={cn("grid w-full", s.yearGrid)}>
400
+ {MONTHS_SHORT.map((month, index) => {
401
+ const isCurrent = new Date().getMonth() === index && new Date().getFullYear() === currentYear
402
+ const isSelected = currentMonth.getMonth() === index
403
+ return (
404
+ <button
405
+ key={month}
406
+ type="button"
407
+ onClick={() => {
408
+ setCurrentMonth(new Date(currentYear, index, 1))
409
+ setView('days')
410
+ }}
411
+ className={cn(
412
+ "flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
413
+ s.yearCell,
414
+ isCurrent && "border border-primary text-primary",
415
+ isSelected && "bg-primary text-primary-foreground hover:bg-primary"
416
+ )}
417
+ >
418
+ {size === 'xs' ? month.charAt(0) : month}
419
+ </button>
420
+ )
421
+ })}
422
+ </div>
423
+ )
424
+ }
425
+
426
+ const renderYears = () => {
427
+ const bgYear = currentMonth.getFullYear()
428
+ const startYear = bgYear - (bgYear % 12)
429
+ const years = Array.from({ length: 12 }, (_, i) => startYear + i)
430
+
431
+ return (
432
+ <div className={cn("grid w-full", s.yearGrid)}>
433
+ {years.map((year) => {
434
+ const isCurrent = new Date().getFullYear() === year
435
+ const isSelected = currentMonth.getFullYear() === year
436
+ return (
437
+ <button
438
+ key={year}
439
+ type="button"
440
+ onClick={() => {
441
+ setCurrentMonth(new Date(year, currentMonth.getMonth(), 1))
442
+ setView('months')
443
+ }}
444
+ className={cn(
445
+ "flex items-center justify-center transition-colors hover:bg-accent rounded-md font-medium",
446
+ s.yearCell,
447
+ isCurrent && "border border-primary text-primary",
448
+ isSelected && "bg-primary text-primary-foreground hover:bg-primary"
449
+ )}
450
+ >
451
+ {year}
452
+ </button>
453
+ )
454
+ })}
455
+ </div>
456
+ )
457
+ }
458
+
459
+ const monthsToRender = Array.from({ length: numberOfMonths }, (_, i) => i)
460
+
461
+ return (
462
+ <div
463
+ ref={ref}
464
+ className={cn(
465
+ "bg-background border rounded-lg shadow-sm transition-all duration-200 inline-block relative",
466
+ s.container,
467
+ className
468
+ )}
469
+ role="application"
470
+ aria-label="Calendar"
471
+ {...props}
472
+ >
473
+ {/* Navigation Overlays */}
474
+
475
+ <div className={cn("flex gap-8 relative")}> {/* Gap between months */}
476
+ {view === 'days' ? (
477
+ monthsToRender.map(offset => (
478
+ <React.Fragment key={offset}>
479
+ {renderMonthGrid(offset, offset === 0, offset === numberOfMonths - 1)}
480
+ </React.Fragment>
481
+ ))
482
+ ) : (
483
+ <div className={cn("w-full", s.width)}>
484
+ {/* Render View Controls Header */}
485
+ <div className={cn("flex justify-center items-center mb-4 relative")}>
486
+ <div className={cn("font-semibold", s.headerTitle)}>
487
+ {view === 'months' ? currentMonth.getFullYear() : `${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12)} - ${currentMonth.getFullYear() - (currentMonth.getFullYear() % 12) + 11}`}
488
+ </div>
489
+ {/* Navigation for views */}
490
+ <div className="absolute inset-0 flex justify-between items-center">
491
+ <button onClick={navigatePrev} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
492
+ <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
493
+ </button>
494
+ <button onClick={navigateNext} className={cn("p-1 rounded-md border hover:bg-accent", s.navBtn, "flex items-center justify-center")}>
495
+ <svg className={s.icon} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
496
+ </button>
497
+ </div>
498
+ </div>
499
+
500
+ <div className="h-[240px]">
501
+ {view === 'months' && renderMonths()}
502
+ {view === 'years' && renderYears()}
503
+ </div>
504
+ </div>
505
+ )}
506
+ </div>
507
+ </div>
508
+ )
509
+ }
510
+ ))
511
+
512
+ Calendar.displayName = "Calendar"
513
+
514
+ export { Calendar }