"use client"; import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent, type CSSProperties, } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { ChevronDown, ChevronLeft, ChevronRight, Plus, X } from "lucide-react"; import * as Dialog from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; gsap.registerPlugin(useGSAP); export interface CalendarEvent { id: string; date: string; title: string; time?: string; color?: string; assignee?: string; } export interface CalendarCardProps { value?: Date; defaultValue?: Date; onChange?: (date: Date) => void; className?: string; events?: CalendarEvent[]; onEventClick?: (event: CalendarEvent) => void; onEventAdd?: (event: CalendarEvent) => void; accentColor?: string; density?: "comfortable" | "compact"; weekStartsOn?: 0 | 1; showEvents?: boolean; showProgress?: boolean; } const months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const weekdays = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; const subscribe = () => () => {}; const readToday = () => new Date().setHours(0, 0, 0, 0); const serverToday = () => 0; function inMonth(date: Date, year: number, month: number) { return new Date( year, month, Math.min(date.getDate(), new Date(year, month + 1, 0).getDate()), ); } export function CalendarCard({ value, defaultValue, onChange, className, events = [], onEventClick, onEventAdd, accentColor = "#ff675c", density = "comfortable", weekStartsOn = 1, showEvents = true, showProgress = true, }: CalendarCardProps) { const today = useSyncExternalStore(subscribe, readToday, serverToday); const [internalDate, setInternalDate] = useState(defaultValue); const date = value ?? internalDate ?? new Date(today); const ready = Boolean(value || internalDate || today); const year = date.getFullYear(); const month = date.getMonth(); const day = date.getDate(); const [pickerYear, setPickerYear] = useState(null); const rootRef = useRef(null); const popupRef = useRef(null); const eventTitleRef = useRef(null); const eventCounter = useRef(0); const popupAnchor = useRef(null); const closePopupTween = useRef(null); const [popupDay, setPopupDay] = useState(null); const [eventFormDate, setEventFormDate] = useState(null); const titleRef = useRef(null); const keyboardFocus = useRef(false); const previousNumbers = useRef({ day, year: pickerYear }); const previousHighlight = useRef(null); const panelId = useId(); const popupId = useId(); const count = new Date(year, month + 1, 0).getDate(); const offset = (new Date(year, month, 1).getDay() - weekStartsOn + 7) % 7; const week = weekStartsOn === 1 ? weekdays : [weekdays[6], ...weekdays.slice(0, 6)]; const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`; const eventsByDay = new Map(); for (const event of events) { if (!event.date.startsWith(`${monthKey}-`)) continue; const eventDay = Number(event.date.slice(monthKey.length + 1)); if (event.date !== `${monthKey}-${String(eventDay).padStart(2, "0")}` || eventDay < 1 || eventDay > count) continue; const entries = eventsByDay.get(eventDay) ?? []; entries.push(event); eventsByDay.set(eventDay, entries); } const selectedEvents = eventsByDay.get(day) ?? []; const popupEvents = eventsByDay.get(popupDay ?? 0) ?? []; const popupOpen = showEvents && pickerYear === null && popupDay !== null && popupEvents.length > 0; const dateKey = `${monthKey}-${String(day).padStart(2, "0")}`; const EventTag = onEventClick ? "button" : "div"; const selectedLabel = `${months[month]} ${day}, ${year}`; const buttonClass = "cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[var(--calendar-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#191919]"; useGSAP( () => { if (!ready) return; const media = gsap.matchMedia(); media.add("(prefers-reduced-motion: no-preference)", () => { gsap.from("[data-calendar-panel]", { y: 8, opacity: 0, duration: 0.28, ease: "power3.out", clearProps: "transform,opacity", }); }, rootRef); return () => media.revert(); }, { scope: rootRef, dependencies: [year, month, pickerYear !== null, ready], revertOnUpdate: true, }, ); useGSAP(() => { const previous = previousNumbers.current; previousNumbers.current = { day, year: pickerYear }; if (!ready) return; const media = gsap.matchMedia(); media.add("(prefers-reduced-motion: no-preference)", () => { for (const [name, before, after] of [ ["day", previous.day, day], ["year", previous.year, pickerYear], ] as const) { if (before === null || after === null || before === after) continue; const direction = after > before ? 1 : -1; const duration = name === "year" ? 0.38 : 0.3; const slots = rootRef.current?.querySelectorAll( name === "year" ? "[data-calendar-year-digit]" : "[data-calendar-day-number]", ); slots?.forEach((slot) => { const place = Number(slot.dataset.calendarYearDigit ?? 0); const oldText = name === "year" ? String(before).at(-place - 1) ?? "" : String(before); const newText = name === "year" ? String(after).at(-place - 1) ?? "" : String(after); if (oldText === newText) return; const outgoing = slot.querySelector(`[data-calendar-${name}-out]`); const incoming = slot.querySelector(`[data-calendar-${name}]`); if (!outgoing || !incoming) return; outgoing.textContent = oldText; gsap.fromTo(outgoing, { yPercent: 0, opacity: 1 }, { yPercent: -direction * 100, opacity: 0, duration, ease: "power3.out" }, ); gsap.fromTo(incoming, { yPercent: direction * 100, opacity: 0 }, { yPercent: 0, opacity: 1, duration, ease: "power3.out" }, ); }); } }, rootRef); return () => media.revert(); }, { scope: rootRef, dependencies: [day, pickerYear, ready], revertOnUpdate: true }); useGSAP(() => { const grid = rootRef.current?.querySelector("[data-calendar-grid]"); const hover = grid?.querySelector("[data-calendar-hover]"); if (!grid || !hover) return; const media = gsap.matchMedia(); media.add({ reduced: "(prefers-reduced-motion: reduce)", standard: "(prefers-reduced-motion: no-preference)" }, (context) => { const duration = context.conditions?.reduced ? 0 : 0.24; const xTo = gsap.quickTo(hover, "x", { duration, ease: "power3.out" }); const yTo = gsap.quickTo(hover, "y", { duration, ease: "power3.out" }); const opacityTo = gsap.quickTo(hover, "opacity", { duration: duration * 0.75 }); let hovered: HTMLElement | null = null; const hide = () => { hovered = null; opacityTo(0); }; const move = (event: PointerEvent) => { if (event.pointerType === "touch") return; const target = (event.target as HTMLElement).closest("[data-calendar-date]"); if (!target || !grid.contains(target)) return; const first = !hovered; hovered = target; xTo(target.offsetLeft); yTo(target.offsetTop); if (first) { xTo.tween.progress(1); yTo.tween.progress(1); } opacityTo(1); }; let width = grid.clientWidth; const observer = new ResizeObserver(() => { if (grid.clientWidth === width) return; width = grid.clientWidth; hide(); }); observer.observe(grid); grid.addEventListener("pointerover", move); grid.addEventListener("pointerleave", hide); return () => { observer.disconnect(); grid.removeEventListener("pointerover", move); grid.removeEventListener("pointerleave", hide); }; }, rootRef); return () => media.revert(); }, { scope: rootRef, dependencies: [year, month, pickerYear !== null, ready, density, weekStartsOn], revertOnUpdate: true }); useEffect(() => { if (!ready || !rootRef.current) return; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const fills = rootRef.current.querySelectorAll("[data-calendar-fill]"); const fillTween = fills.length ? gsap.to(fills, { scaleX: (index: number) => Math.max(0, Math.min(1, day / count * 4 - index)), duration: reduced ? 0 : 0.35, ease: "power3.out", overwrite: true, }) : undefined; const highlight = rootRef.current.querySelector("[data-calendar-highlight]"); const selected = rootRef.current.querySelector("[data-selected-day]"); let highlightTween: gsap.core.Tween | undefined; let observer: ResizeObserver | undefined; if (highlight && selected) { const first = previousHighlight.current !== highlight; previousHighlight.current = highlight; const position = { x: selected.offsetLeft, y: selected.offsetTop, opacity: 1 }; highlightTween = gsap.to(highlight, { ...position, duration: reduced || first ? 0 : 0.3, ease: "power3.out", overwrite: true, }); let width = highlight.parentElement!.clientWidth; observer = new ResizeObserver(() => { const nextWidth = highlight.parentElement!.clientWidth; if (nextWidth === width) return; width = nextWidth; highlightTween?.kill(); gsap.set(highlight, { x: selected.offsetLeft, y: selected.offsetTop, opacity: 1 }); }); observer.observe(highlight.parentElement!); } return () => { fillTween?.kill(); highlightTween?.kill(); observer?.disconnect(); }; }, [day, count, year, month, pickerYear, ready, density, weekStartsOn, showProgress]); useEffect(() => { if (!keyboardFocus.current) return; rootRef.current?.querySelector("[data-selected-day]")?.focus(); keyboardFocus.current = false; }, [year, month, day]); useGSAP(() => { const popup = popupRef.current; const anchor = popupAnchor.current; const root = rootRef.current; if (!popupOpen || !popup || !anchor || !root) return; const media = gsap.matchMedia(); const position = () => { const card = root.getBoundingClientRect(); const cell = anchor.getBoundingClientRect(); const width = popup.offsetWidth; const height = popup.offsetHeight; const x = Math.max(12, Math.min(cell.left - card.left + cell.width / 2 - width / 2, root.clientWidth - width - 12)); const below = cell.bottom - card.top + 6; const y = Math.max(12, below + height < root.clientHeight - 12 ? below : cell.top - card.top - height - 6); gsap.set(popup, { left: x, top: y }); }; position(); const observer = new ResizeObserver(position); observer.observe(root); observer.observe(popup); media.add("(prefers-reduced-motion: no-preference)", () => { gsap.from(popup, { y: 5, opacity: 0, duration: 0.18, ease: "power3.out", clearProps: "transform,opacity" }); }, rootRef); return () => { observer.disconnect(); media.revert(); }; }, { scope: rootRef, dependencies: [popupDay, popupOpen, year, month, density, weekStartsOn], revertOnUpdate: true }); useEffect(() => () => { closePopupTween.current?.kill(); }, []); useEffect(() => { if (!popupOpen) return; const dismiss = (event: PointerEvent) => { const target = event.target as Node; if (!popupRef.current?.contains(target) && !popupAnchor.current?.contains(target)) setPopupDay(null); }; document.addEventListener("pointerdown", dismiss, true); return () => document.removeEventListener("pointerdown", dismiss, true); }, [popupOpen]); function showPopup(number: number, anchor: HTMLButtonElement) { closePopupTween.current?.kill(); popupAnchor.current = anchor; setPopupDay(number); } function schedulePopupClose() { closePopupTween.current?.kill(); closePopupTween.current = gsap.delayedCall(0.15, () => { if (!popupRef.current?.contains(document.activeElement)) setPopupDay(null); }); } function pick(next: Date) { closePopupTween.current?.kill(); setPopupDay(null); if (value === undefined) setInternalDate(next); onChange?.(new Date(next)); } function onDayKey(event: KeyboardEvent, number: number) { const current = new Date(year, month, number); const steps: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }; let next: Date; if (event.key in steps) { next = new Date(year, month, number + steps[event.key]); } else if (event.key === "Home") next = new Date(year, month, 1); else if (event.key === "End") next = new Date(year, month, count); else if (event.key === "PageUp" || event.key === "PageDown") { next = inMonth(current, year, month + (event.key === "PageUp" ? -1 : 1)); } else return; event.preventDefault(); keyboardFocus.current = true; pick(next); } return ( { setEventFormDate(open ? dateKey : null); setPopupDay(null); }}>
{ if (event.key === "Escape" && popupOpen) { event.stopPropagation(); popupAnchor.current?.focus(); setPopupDay(null); } }} style={{ "--calendar-accent": accentColor, "--calendar-cell-mobile": density === "compact" ? "36px" : "40px", "--calendar-cell-desktop": density === "compact" ? "36px" : "44px", } as CSSProperties} className={cn( "@container relative w-full max-w-[44rem] rounded-3xl border border-white/10 bg-[#191919] p-4 text-zinc-100 shadow-[0_24px_48px_-16px_#000,0_1px_1px_0_#ffffff18,inset_0_1px_0_0_#ffffff0a] sm:rounded-[2rem] sm:p-8", className, )} >
Selected date
{onEventAdd && ( )}
{showProgress && }
{ if (event.key === "Escape" && pickerYear !== null) { setPickerYear(null); titleRef.current?.focus(); } }}> {pickerYear !== null ? (
{pickerYear} {String(pickerYear).split("").map((digit, index, digits) => (
{months.map((name, index) => ( ))}
) : (
)}
{popupOpen && ( )}
{weekdays[(date.getDay() + 6) % 7].slice(0, 3)}, {months[month].slice(0, 3)} {day}
Selected date: {selectedLabel}{events.length > 0 && `, ${selectedEvents.length} ${selectedEvents.length === 1 ? "event" : "events"}`}
{ event.preventDefault(); eventTitleRef.current?.focus(); }} style={{ "--calendar-accent": accentColor } as CSSProperties} className="fixed left-1/2 top-1/2 z-50 max-h-[90dvh] w-[calc(100%_-_2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-3xl border border-white/10 bg-[#202020] p-6 text-zinc-100 shadow-2xl"> Add an event Choose a date and optionally assign it to someone.
{ event.preventDefault(); const form = event.currentTarget; const fields = new FormData(form); const title = String(fields.get("title") ?? "").trim(); const titleInput = form.elements.namedItem("title") as HTMLInputElement; if (!title) { titleInput.setCustomValidity("Enter an event title."); titleInput.reportValidity(); return; } const eventDate = String(fields.get("date")); onEventAdd?.({ id: `${panelId}-event-${++eventCounter.current}`, date: eventDate, title, time: String(fields.get("time") ?? "") || undefined, assignee: String(fields.get("assignee") ?? "").trim() || undefined, color: String(fields.get("color")) }); pick(new Date(`${eventDate}T00:00:00`)); setEventFormDate(null); }}>
Cancel
); } export default CalendarCard;