Retour au catalogue
Dashboard Widget Calendar
Widget calendrier avec vue mensuelle, evenements colores et detail au clic.
dashboard-widgetsmedium Both Responsive a11y
minimalelegantsaasmedicaleducationgrid
Theme
"use client";
import { useState, useRef } from "react";
import { motion, AnimatePresence, useInView } from "framer-motion";
import {
ChevronLeft,
ChevronRight,
Clock,
MapPin,
Plus,
X,
} from "lucide-react";
interface CalendarEvent {
day: number;
title: string;
time: string;
color: string;
location?: string;
}
interface DashboardWidgetCalendarProps {
events?: CalendarEvent[];
title?: string;
month?: string;
year?: number;
daysInMonth?: number;
startDay?: number;
}
const DAYS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
export default function DashboardWidgetCalendar({
events = [],
title = "Calendrier",
month = "Mars",
year = 2026,
daysInMonth = 31,
startDay = 6,
}: DashboardWidgetCalendarProps) {
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const sectionRef = useRef<HTMLDivElement>(null);
const isInView = useInView(sectionRef, { once: true, amount: 0.3 });
const today = 13;
const eventsForDay = (day: number) =>
events.filter((e) => e.day === day);
const selectedEvents = selectedDay ? eventsForDay(selectedDay) : [];
// Build calendar grid
const blanks = Array.from({ length: startDay }, () => null);
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const grid = [...blanks, ...days];
return (
<section ref={sectionRef} className="py-12 px-6">
<div className="max-w-md mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease }}
className="rounded-3xl overflow-hidden"
style={{
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
}}
>
{/* Header */}
<div className="px-6 py-5 flex items-center justify-between">
<div>
<h2
className="text-lg font-bold"
style={{ color: "var(--color-foreground)" }}
>
{month} {year}
</h2>
<p
className="text-xs mt-0.5"
style={{ color: "var(--color-foreground-muted)" }}
>
{events.length} evenements ce mois
</p>
</div>
<div className="flex items-center gap-1">
<button
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background: "var(--color-background-alt)",
color: "var(--color-foreground-muted)",
}}
>
<ChevronLeft size={16} />
</button>
<button
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background: "var(--color-background-alt)",
color: "var(--color-foreground-muted)",
}}
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 px-4">
{DAYS.map((day) => (
<div
key={day}
className="text-center text-[10px] font-semibold uppercase tracking-wider py-2"
style={{ color: "var(--color-foreground-light)" }}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1 px-4 pb-4">
{grid.map((day, i) => {
if (day === null) {
return <div key={`blank-${i}`} />;
}
const dayEvents = eventsForDay(day);
const isToday = day === today;
const isSelected = day === selectedDay;
return (
<motion.button
key={day}
onClick={() =>
setSelectedDay(isSelected ? null : day)
}
className="relative aspect-square rounded-xl flex flex-col items-center justify-center transition-all"
style={{
background: isSelected
? "var(--color-accent)"
: isToday
? "var(--color-accent-subtle)"
: "transparent",
color: isSelected
? "var(--color-background)"
: isToday
? "var(--color-accent)"
: "var(--color-foreground)",
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<span className="text-sm font-medium">{day}</span>
{dayEvents.length > 0 && (
<div className="flex gap-0.5 mt-0.5">
{dayEvents.slice(0, 3).map((ev, ei) => (
<div
key={ei}
className="w-1 h-1 rounded-full"
style={{
background: isSelected
? "var(--color-background)"
: ev.color,
}}
/>
))}
</div>
)}
</motion.button>
);
})}
</div>
{/* Event detail panel */}
<AnimatePresence>
{selectedDay && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease }}
className="overflow-hidden"
style={{ borderTop: "1px solid var(--color-border)" }}
>
<div className="px-6 py-4">
<div className="flex items-center justify-between mb-3">
<span
className="text-sm font-semibold"
style={{ color: "var(--color-foreground)" }}
>
{selectedDay} {month}
</span>
<button
onClick={() => setSelectedDay(null)}
style={{ color: "var(--color-foreground-light)" }}
>
<X size={16} />
</button>
</div>
{selectedEvents.length > 0 ? (
<div className="flex flex-col gap-2">
{selectedEvents.map((ev, i) => (
<motion.div
key={ev.title}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className="flex items-start gap-3 p-3 rounded-xl"
style={{
background: "var(--color-background-alt)",
}}
>
<div
className="w-1 h-full rounded-full self-stretch shrink-0"
style={{ background: ev.color }}
/>
<div className="flex-1">
<div
className="text-sm font-medium"
style={{
color: "var(--color-foreground)",
}}
>
{ev.title}
</div>
<div
className="flex items-center gap-3 mt-1 text-xs"
style={{
color: "var(--color-foreground-muted)",
}}
>
<span className="flex items-center gap-1">
<Clock size={10} /> {ev.time}
</span>
{ev.location && (
<span className="flex items-center gap-1">
<MapPin size={10} /> {ev.location}
</span>
)}
</div>
</div>
</motion.div>
))}
</div>
) : (
<div
className="text-center py-4 text-xs"
style={{
color: "var(--color-foreground-muted)",
}}
>
Aucun evenement ce jour
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</section>
);
}