"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
/* ---------------------------------- types ---------------------------------- */
export interface CalendarEvent {
id: string;
title: string;
/** "YYYY-MM-DD" for all-day events, or local wall time "YYYY-MM-DDTHH:mm". */
start: string;
/** All-day: the last day, inclusive ("YYYY-MM-DD"). Timed: the end time, exclusive. Defaults to start. */
end?: string;
/** Forces all-day; inferred when `start` has no time. */
allDay?: boolean;
/** Overrides the calendar colour. */
color?: string;
/** Id of a `calendars` entry, which supplies the colour and legend label. */
calendar?: string;
}
export interface CalendarSource {
id: string;
label: string;
color: string;
}
export type CalendarView = "month" | "week";
export interface EventCalendarProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onSelect" | "children" | "defaultValue"> {
events: CalendarEvent[];
/** Colour sources for events (and the legend). */
calendars?: CalendarSource[];
view?: CalendarView;
defaultView?: CalendarView;
onViewChange?: (view: CalendarView) => void;
/** Controlled anchor date "YYYY-MM-DD" (any day inside the visible month or week). */
date?: string;
/** Initial anchor date; defaults to today (after mount) or the first event. */
defaultDate?: string;
onDateChange?: (date: string) => void;
/** Today as "YYYY-MM-DD". When omitted it is read in the browser after hydration (SSR-safe). */
today?: string;
/** BCP 47 locale for month and weekday names, dates and times. */
locale?: string;
weekStartsOn?: "monday" | "sunday";
/** Event chips per month cell before "+N more". */
maxChips?: number;
/** Height of one hour in the week view, in px. */
hourHeight?: number;
/** Hour the week view scrolls to. */
scrollToHour?: number;
/** Calendar legend under the grid. */
showLegend?: boolean;
/** Called with "YYYY-MM-DD" (month view, day popover) or "YYYY-MM-DDTHH:mm" (week view time grid). */
onCreate?: (date: string) => void;
/** Called when an event chip or block is activated. */
onSelect?: (event: CalendarEvent) => void;
/** Today marker, focus rings and the default event colour. */
accent?: string;
/** Accessible name prefix for the grid. */
label?: string;
}
/* ------------------------------- date helpers ------------------------------ */
const DAY_MS = 86_400_000;
const mod = (n: number, m: number) => ((n % m) + m) % m;
const pad = (n: number) => String(n).padStart(2, "0");
const tidy = (s: string) => s.replace(/[ ]/g, " ");
function parseDay(s: string | undefined): number | null {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s ?? "");
return m ? Math.round(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / DAY_MS) : null;
}
function parseMinutes(s: string | undefined): number | null {
const m = /T(\d{2}):(\d{2})/.exec(s ?? "");
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
}
const toDate = (day: number) => new Date(day * DAY_MS);
const isoDay = (day: number) => toDate(day).toISOString().slice(0, 10);
const weekdayOf = (day: number) => toDate(day).getUTCDay();
const dayOfMonth = (day: number) => toDate(day).getUTCDate();
const monthKey = (day: number) => toDate(day).getUTCFullYear() * 12 + toDate(day).getUTCMonth();
function firstOfMonth(day: number) {
const d = toDate(day);
return Math.round(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / DAY_MS);
}
function addMonths(day: number, n: number) {
const d = toDate(day);
const y = d.getUTCFullYear();
const m = d.getUTCMonth() + n;
const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
return Math.round(Date.UTC(y, m, Math.min(d.getUTCDate(), last)) / DAY_MS);
}
/* --------------------------- browser-only values --------------------------- */
function subscribeMinute(cb: () => void) {
const id = window.setInterval(cb, 30_000);
return () => window.clearInterval(id);
}
/** Local "now" in minutes since the epoch day start, refreshed every 30 s; null on the server. */
function useLocalNow() {
return React.useSyncExternalStore(
subscribeMinute,
() => {
const d = new Date();
// Wall-clock day number and minute, encoded as one stable number per minute.
return Math.round(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / DAY_MS) * 1440 + d.getHours() * 60 + d.getMinutes();
},
() => null,
);
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ------------------------------ event helpers ------------------------------ */
interface Placed {
ev: CalendarEvent;
/** First and last day (inclusive). */
s: number;
e: number;
/** Start / end minutes for timed events. */
sMin: number;
eMin: number;
allDay: boolean;
color: string;
}
function normalise(events: CalendarEvent[], calendars: CalendarSource[], fallback: string): Placed[] {
const out: Placed[] = [];
for (const ev of events) {
const s = parseDay(ev.start);
if (s === null) continue;
const sMin = parseMinutes(ev.start);
const allDay = ev.allDay ?? sMin === null;
let e = parseDay(ev.end) ?? s;
let eMin = parseMinutes(ev.end) ?? (sMin === null ? 1440 : Math.min(1440, sMin + 60));
if (!allDay && eMin === 0 && e > s) {
// Ends at midnight: belongs to the previous day.
e -= 1;
eMin = 1440;
}
if (e < s) e = s;
const color = ev.color ?? calendars.find((c) => c.id === ev.calendar)?.color ?? fallback;
out.push({ ev, s, e, sMin: allDay ? 0 : (sMin ?? 0), eMin: allDay ? 1440 : eMin, allDay, color });
}
return out;
}
/** Day ordering: spanning / all-day first, then by start, longer first. */
function byDisplayOrder(a: Placed, b: Placed) {
const aSpan = a.allDay || a.e > a.s ? 0 : 1;
const bSpan = b.allDay || b.e > b.s ? 0 : 1;
return aSpan - bSpan || a.s - b.s || a.sMin - b.sMin || b.e - b.s - (a.e - a.s) || b.eMin - b.sMin - (a.eMin - a.sMin);
}
/** Side-by-side columns for overlapping timed events in one day. */
function layoutDay(items: Placed[]) {
const sorted = [...items].sort((a, b) => a.sMin - b.sMin || b.eMin - a.eMin);
const result = new Map<Placed, { col: number; cols: number }>();
let cluster: Placed[] = [];
let colsEnd: number[] = [];
let clusterEnd = -1;
const flush = () => {
for (const p of cluster) result.set(p, { col: result.get(p)?.col ?? 0, cols: colsEnd.length });
cluster = [];
colsEnd = [];
};
for (const p of sorted) {
if (p.sMin >= clusterEnd) flush();
let col = colsEnd.findIndex((end) => end <= p.sMin);
if (col === -1) col = colsEnd.push(0) - 1;
colsEnd[col] = p.eMin;
clusterEnd = Math.max(clusterEnd, p.eMin);
result.set(p, { col, cols: 0 });
cluster.push(p);
}
flush();
return result;
}
/* ------------------------------ locale helpers ----------------------------- */
function makeFormats(locale: string) {
const make = (options: Intl.DateTimeFormatOptions) => {
try {
return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" });
} catch {
return new Intl.DateTimeFormat("en", { ...options, timeZone: "UTC" });
}
};
const month = make({ month: "long", year: "numeric" });
const weekdayShort = make({ weekday: "short" });
const weekdayLong = make({ weekday: "long" });
const full = make({ weekday: "long", day: "numeric", month: "long", year: "numeric" });
const short = make({ day: "numeric", month: "short" });
const time = make({ hour: "numeric", minute: "2-digit" });
const hour = make({ hour: "numeric" });
return {
month: (day: number) => tidy(month.format(toDate(day))),
weekdayShort: (day: number) => tidy(weekdayShort.format(toDate(day))),
weekdayLong: (day: number) => tidy(weekdayLong.format(toDate(day))),
full: (day: number) => tidy(full.format(toDate(day))),
short: (day: number) => tidy(short.format(toDate(day))),
time: (minutes: number) => tidy(time.format(new Date(Date.UTC(2000, 0, 1) + minutes * 60_000))),
hour: (h: number) => tidy(hour.format(new Date(Date.UTC(2000, 0, 1, h)))),
};
}
/* -------------------------------- component -------------------------------- */
/**
* A month / week calendar. Month cells hold colour-coded event chips with a "+N more" popover;
* the week view positions timed events on an hour grid with side-by-side overlaps. Both views
* are ARIA grids with arrow-key navigation, slide in the direction you travel, and format every
* name and time through Intl. Today is marked without breaking SSR.
*/
export function EventCalendar({
events,
calendars = [],
view: viewProp,
defaultView = "month",
onViewChange,
date,
defaultDate,
onDateChange,
today: todayProp,
locale = "en-GB",
weekStartsOn = "monday",
maxChips = 3,
hourHeight = 44,
scrollToHour = 8,
showLegend = true,
onCreate,
onSelect,
accent = "#FF7448",
label = "Calendar",
className = "",
style,
...props
}: EventCalendarProps) {
const uid = React.useId();
const titleId = `${uid}-title`;
const hintId = `${uid}-hint`;
const popoverId = `${uid}-day`;
const reduceMotion = usePrefersReducedMotion();
const now = useLocalNow();
const fmt = makeFormats(locale);
const ws = weekStartsOn === "sunday" ? 0 : 1;
const today = parseDay(todayProp) ?? (now === null ? null : Math.floor(now / 1440));
const placed = normalise(events, calendars, accent);
const firstEventDay = placed.length ? Math.min(...placed.map((p) => p.s)) : 0;
const [innerView, setInnerView] = React.useState<CalendarView>(defaultView);
const view = viewProp ?? innerView;
const [innerDate, setInnerDate] = React.useState<number | null>(() => parseDay(defaultDate));
// Until someone navigates, follow today (known after mount) or fall back to the first event.
const anchor = parseDay(date) ?? innerDate ?? today ?? firstEventDay;
const [direction, setDirection] = React.useState(0);
const [focusDay, setFocusDay] = React.useState<number | null>(null);
const [popDay, setPopDay] = React.useState<number | null>(null);
const gridRef = React.useRef<HTMLDivElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null);
const pendingFocus = React.useRef(false);
/* -------------------------------- period -------------------------------- */
const weekStart = (day: number) => day - mod(weekdayOf(day) - ws, 7);
const monthFirst = firstOfMonth(anchor);
const rangeStart = view === "month" ? weekStart(monthFirst) : weekStart(anchor);
const rangeDays = view === "month" ? 42 : 7;
const periodKey = view === "month" ? `m${monthKey(anchor)}` : `w${rangeStart}`;
const inView = (day: number) => (view === "month" ? monthKey(day) === monthKey(anchor) : day >= rangeStart && day < rangeStart + 7);
const tabDay = focusDay !== null && inView(focusDay) ? focusDay : today !== null && inView(today) ? today : view === "month" ? monthFirst : rangeStart;
const goTo = (day: number, focus = false) => {
const nextKey = view === "month" ? monthKey(day) : weekStart(day);
const curKey = view === "month" ? monthKey(anchor) : rangeStart;
if (nextKey !== curKey) setDirection(nextKey > curKey ? 1 : -1);
if (date === undefined) setInnerDate(day);
onDateChange?.(isoDay(day));
if (focus) {
setFocusDay(day);
pendingFocus.current = true;
}
};
const step = (n: number) => goTo(view === "month" ? addMonths(anchor, n) : anchor + 7 * n);
const setView = (next: CalendarView) => {
if (next === view) return;
setDirection(0);
if (viewProp === undefined) setInnerView(next);
onViewChange?.(next);
// Keep the focused day in the new view.
if (focusDay !== null && focusDay !== anchor) {
if (date === undefined) setInnerDate(focusDay);
onDateChange?.(isoDay(focusDay));
}
};
React.useEffect(() => {
if (!pendingFocus.current) return;
pendingFocus.current = false;
gridRef.current?.querySelector<HTMLElement>(`[data-cell-day="${tabDay}"][tabindex="0"]`)?.focus({ preventScroll: true });
});
// Week view: start scrolled to the working day.
React.useEffect(() => {
if (view !== "week" || !scrollRef.current) return;
scrollRef.current.scrollTop = Math.max(0, scrollToHour * hourHeight - 8);
}, [view, scrollToHour, hourHeight]);
/* ------------------------------- day data -------------------------------- */
const eventsOn = (day: number) => placed.filter((p) => p.s <= day && day <= p.e).sort(byDisplayOrder);
const openDay = (day: number) => {
const list = eventsOn(day);
if (list.length === 0) {
onCreate?.(isoDay(day));
return;
}
setPopDay(day);
};
/* -------------------------------- popover -------------------------------- */
React.useEffect(() => {
const pop = popoverRef.current;
if (!pop || popDay === null) return;
const cell = gridRef.current?.querySelector<HTMLElement>(`[data-cell-day="${popDay}"][tabindex="0"]`);
if (!pop.matches(":popover-open")) pop.showPopover();
const place = () => {
const rect = (cell ?? gridRef.current)?.getBoundingClientRect();
if (!rect) return;
const width = pop.offsetWidth;
const height = pop.offsetHeight;
const left = Math.min(Math.max(rect.left + rect.width / 2 - width / 2, 8), window.innerWidth - width - 8);
const top = rect.top + height + 8 > window.innerHeight - 8 ? Math.max(8, rect.bottom - height) : rect.top;
pop.style.left = `${Math.round(left)}px`;
pop.style.top = `${Math.round(top)}px`;
};
place();
pop.querySelector<HTMLElement>("[data-pop-item]")?.focus({ preventScroll: true });
const onToggle = (event: Event) => {
if ((event as ToggleEvent).newState === "closed") {
setPopDay(null);
if (pop.contains(document.activeElement) || document.activeElement === document.body) {
setFocusDay(popDay);
pendingFocus.current = true;
}
}
};
pop.addEventListener("toggle", onToggle);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, { capture: true, passive: true });
return () => {
pop.removeEventListener("toggle", onToggle);
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, { capture: true });
};
}, [popDay]);
const closePopover = () => {
const pop = popoverRef.current;
if (pop?.matches(":popover-open")) pop.hidePopover();
};
/* -------------------------------- keyboard -------------------------------- */
const handleGridKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const from = tabDay;
const moves: Record<string, () => number> = {
ArrowLeft: () => from - 1,
ArrowRight: () => from + 1,
ArrowUp: () => from - 7,
ArrowDown: () => from + 7,
Home: () => weekStart(from),
End: () => weekStart(from) + 6,
PageUp: () => (view === "month" ? addMonths(from, -1) : from - 7),
PageDown: () => (view === "month" ? addMonths(from, 1) : from + 7),
};
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDay(from);
return;
}
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const target = move();
if (inView(target)) {
setFocusDay(target);
pendingFocus.current = true;
} else {
goTo(target, true);
}
};
/* --------------------------------- pieces --------------------------------- */
const eventLabel = (p: Placed) => {
const when = p.allDay
? p.e > p.s
? `${fmt.short(p.s)} to ${fmt.short(p.e)}, all day`
: "all day"
: p.e > p.s
? `${fmt.short(p.s)} ${fmt.time(p.sMin)} to ${fmt.short(p.e)} ${fmt.time(p.eMin)}`
: `${fmt.time(p.sMin)} to ${fmt.time(p.eMin)}`;
const cal = calendars.find((c) => c.id === p.ev.calendar)?.label;
return `${p.ev.title}, ${when}${cal ? `, ${cal}` : ""}`;
};
const chip = (p: Placed, day: number) => {
const spans = p.allDay || p.e > p.s;
const startsHere = day === p.s;
const endsHere = day === p.e;
const showTitle = startsHere || weekdayOf(day) === ws;
return (
<button
key={p.ev.id}
type="button"
tabIndex={-1}
aria-label={eventLabel(p)}
onClick={(event) => {
event.stopPropagation();
onSelect?.(p.ev);
}}
style={{ ["--ec-color" as string]: p.color }}
className={`flex h-[18px] min-w-0 shrink-0 items-center self-stretch gap-1 px-1.5 text-left text-[11px] leading-none outline-hidden transition-[filter] hover:brightness-95 dark:hover:brightness-110 ${
spans
? `bg-[color-mix(in_oklab,var(--ec-color)_22%,transparent)] font-medium text-zinc-900 dark:text-zinc-50 ${startsHere ? "rounded-l-[4px] border-l-2 border-[var(--ec-color)]" : "-ml-1.5 pl-3"} ${endsHere ? "rounded-r-[4px]" : "-mr-1.5"}`
: "rounded-[4px] text-zinc-700 hover:bg-zinc-900/[0.05] dark:text-zinc-200 dark:hover:bg-white/[0.06]"
}`}
>
{spans ? null : <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-[var(--ec-color)]" />}
{spans ? null : <span className="hidden shrink-0 font-mono text-[9.5px] @lg:inline tabular-nums text-zinc-500 dark:text-zinc-400">{fmt.time(p.sMin)}</span>}
<span className={`truncate ${spans && !showTitle ? "opacity-0" : ""}`}>{p.ev.title}</span>
</button>
);
};
const navButton = (dir: -1 | 1) => (
<button
type="button"
onClick={() => step(dir)}
aria-label={view === "month" ? (dir < 0 ? "Previous month" : "Next month") : dir < 0 ? "Previous week" : "Next week"}
className="inline-flex size-8 items-center justify-center rounded-md border border-zinc-900/10 text-zinc-600 outline-hidden transition-colors hover:bg-zinc-900/[0.04] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/[0.06] dark:hover:text-white"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-3.5">
<path d={dir < 0 ? "M10 3.5 5.5 8l4.5 4.5" : "m6 3.5 4.5 4.5L6 12.5"} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
const days = Array.from({ length: rangeDays }, (_, i) => rangeStart + i);
const weekdayHeads = Array.from({ length: 7 }, (_, i) => rangeStart + i);
const title =
view === "month"
? fmt.month(anchor)
: monthKey(rangeStart) === monthKey(rangeStart + 6)
? fmt.month(rangeStart)
: `${fmt.short(rangeStart)} – ${fmt.short(rangeStart + 6)}`;
const cellLabel = (day: number) => {
const n = eventsOn(day).length;
return `${fmt.full(day)}${day === today ? ", today" : ""}, ${n === 0 ? "no events" : n === 1 ? "1 event" : `${n} events`}`;
};
const slide = {
initial: (dir: number) => ({ x: reduceMotion ? 0 : dir * 36, opacity: dir === 0 ? 1 : 0 }),
animate: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: reduceMotion ? 0 : dir * -36, opacity: 0 }),
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 40, mass: 0.8 };
/* ------------------------------- month view ------------------------------- */
const rowHeight = 30 + Math.max(1, maxChips) * 20;
const monthGrid = (
<div role="rowgroup">
{Array.from({ length: 6 }, (_, w) => (
<div key={w} role="row" className="grid grid-cols-7" style={{ height: rowHeight }}>
{days.slice(w * 7, w * 7 + 7).map((day) => {
const list = eventsOn(day);
const overflow = list.length > maxChips;
const shown = overflow ? list.slice(0, Math.max(0, maxChips - 1)) : list;
const outside = monthKey(day) !== monthKey(anchor);
const isToday = day === today;
return (
<div
key={day}
role="gridcell"
data-cell-day={day}
tabIndex={day === tabDay ? 0 : -1}
aria-label={cellLabel(day)}
aria-current={isToday ? "date" : undefined}
onFocus={() => setFocusDay(day)}
onClick={() => {
setFocusDay(day);
onCreate?.(isoDay(day));
}}
className={`group/cell relative flex min-w-0 cursor-pointer flex-col gap-0.5 overflow-hidden border-r border-t border-zinc-900/[0.07] px-1.5 pb-1 pt-1 outline-hidden transition-colors last:border-r-0 hover:bg-zinc-900/[0.02] focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] dark:border-white/[0.07] dark:hover:bg-white/[0.02] ${
outside ? "bg-zinc-900/[0.018] dark:bg-white/[0.015]" : ""
}`}
>
<span
aria-hidden="true"
className={`mb-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full font-mono text-[11px] tabular-nums ${
isToday
? "bg-[var(--ec-accent)] font-semibold text-zinc-950"
: outside
? "text-zinc-400 dark:text-zinc-600"
: "text-zinc-700 dark:text-zinc-300"
}`}
>
{dayOfMonth(day)}
</span>
{shown.map((p) => chip(p, day))}
{overflow ? (
<button
type="button"
tabIndex={-1}
aria-label={`${list.length - shown.length} more events on ${fmt.full(day)}`}
onClick={(event) => {
event.stopPropagation();
setFocusDay(day);
setPopDay(day);
}}
className="h-[18px] shrink-0 rounded-[4px] px-1.5 text-left font-mono text-[10px] tracking-[0.04em] text-zinc-500 outline-hidden hover:bg-zinc-900/[0.05] hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-zinc-100"
>
+{list.length - shown.length} more
</button>
) : null}
</div>
);
})}
</div>
))}
</div>
);
/* -------------------------------- week view ------------------------------- */
const weekDays = days.slice(0, 7);
const spanning = placed
.filter((p) => (p.allDay || p.e > p.s) && p.e >= rangeStart && p.s < rangeStart + 7)
.sort(byDisplayOrder);
const nowDay = now === null ? null : Math.floor(now / 1440);
const nowMin = now === null ? null : now % 1440;
const weekGrid = (
<div className="flex flex-col">
{spanning.length ? (
<div className="grid grid-cols-[3.25rem_minmax(0,1fr)] border-t border-zinc-900/[0.07] dark:border-white/[0.07]">
<span className="px-1 py-1.5 text-right font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-400 dark:text-zinc-500">All day</span>
<div className="grid grid-cols-7 gap-y-0.5 px-0.5 py-1">
{spanning.map((p) => {
const from = Math.max(p.s, rangeStart) - rangeStart;
const to = Math.min(p.e, rangeStart + 6) - rangeStart;
return (
<div key={p.ev.id} className="px-0.5" style={{ gridColumn: `${from + 1} / ${to + 2}` }}>
{chip(p, Math.max(p.s, rangeStart))}
</div>
);
})}
</div>
</div>
) : null}
<div ref={scrollRef} className="relative max-h-[26rem] overflow-y-auto overscroll-contain border-t border-zinc-900/[0.07] dark:border-white/[0.07]">
<div className="grid grid-cols-[3.25rem_minmax(0,1fr)]" style={{ height: 24 * hourHeight }}>
<div aria-hidden="true" className="relative">
{Array.from({ length: 23 }, (_, i) => (
<span
key={i}
className="absolute right-2 -translate-y-1/2 font-mono text-[9.5px] tabular-nums text-zinc-400 dark:text-zinc-500"
style={{ top: (i + 1) * hourHeight }}
>
{fmt.hour(i + 1)}
</span>
))}
</div>
<div role="row" className="relative grid grid-cols-7">
{/* Hour lines */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{Array.from({ length: 23 }, (_, i) => (
<div key={i} className="absolute inset-x-0 border-t border-zinc-900/[0.06] dark:border-white/[0.06]" style={{ top: (i + 1) * hourHeight }} />
))}
</div>
{weekDays.map((day) => {
const timed = placed.filter((p) => !p.allDay && p.s === day && p.e === day);
const layout = layoutDay(timed);
const isToday = day === today;
return (
<div
key={day}
role="gridcell"
data-cell-day={day}
tabIndex={day === tabDay ? 0 : -1}
aria-label={cellLabel(day)}
aria-current={isToday ? "date" : undefined}
onFocus={() => setFocusDay(day)}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const minutes = Math.max(0, Math.min(1410, Math.floor(((event.clientY - rect.top) / hourHeight) * 2) * 30));
setFocusDay(day);
onCreate?.(`${isoDay(day)}T${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`);
}}
className={`relative cursor-pointer border-l border-zinc-900/[0.07] outline-hidden focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] dark:border-white/[0.07] ${
isToday ? "bg-[color-mix(in_oklab,var(--ec-accent)_5%,transparent)]" : ""
}`}
>
{timed.map((p) => {
const slot = layout.get(p) ?? { col: 0, cols: 1 };
const top = (p.sMin / 60) * hourHeight;
const height = Math.max(18, ((p.eMin - p.sMin) / 60) * hourHeight - 2);
return (
<button
key={p.ev.id}
type="button"
tabIndex={-1}
aria-label={eventLabel(p)}
onClick={(event) => {
event.stopPropagation();
onSelect?.(p.ev);
}}
style={{
["--ec-color" as string]: p.color,
top: Math.round(top),
height: Math.round(height),
left: `calc(${((slot.col / slot.cols) * 100).toFixed(2)}% + 2px)`,
width: `calc(${(100 / slot.cols).toFixed(2)}% - 4px)`,
}}
className="absolute flex flex-col items-start overflow-hidden rounded-[5px] border-l-2 border-[var(--ec-color)] bg-[color-mix(in_oklab,var(--ec-color)_20%,var(--ec-surface))] px-1.5 py-1 text-left outline-hidden transition-[filter] hover:brightness-95 dark:hover:brightness-110"
>
<span className="w-full truncate text-[11px] font-medium leading-tight text-zinc-900 dark:text-zinc-50">{p.ev.title}</span>
{height > 30 ? (
<span className="w-full truncate font-mono text-[9.5px] tabular-nums text-zinc-600 dark:text-zinc-300">
{slot.cols > 1 ? fmt.time(p.sMin) : `${fmt.time(p.sMin)}–${fmt.time(p.eMin)}`}
</span>
) : null}
</button>
);
})}
{isToday && nowDay === day && nowMin !== null ? (
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 z-10" style={{ top: Math.round((nowMin / 60) * hourHeight) }}>
<div className="relative h-[2px] bg-[var(--ec-accent)]">
<span className="absolute -left-1 -top-[3px] size-2 rounded-full bg-[var(--ec-accent)]" />
</div>
</div>
) : null}
</div>
);
})}
</div>
</div>
</div>
</div>
);
/* --------------------------------- render --------------------------------- */
const popList = popDay === null ? [] : eventsOn(popDay);
const rootStyle = { ["--ec-accent" as string]: accent, ...style };
const usedCalendars = calendars.filter((c) => events.some((e) => e.calendar === c.id));
return (
<div
className={`@container flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-zinc-900/10 bg-white text-zinc-900 [--ec-surface:white] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:[--ec-surface:var(--color-zinc-900)] ${className}`}
style={rootStyle}
{...props}
>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
<h3 id={titleId} aria-live="polite" className="min-w-0 text-[15px] font-semibold capitalize tracking-[-0.01em]">
{title}
</h3>
<div className="flex items-center gap-2">
<div role="group" aria-label="View" className="flex rounded-md border border-zinc-900/10 p-0.5 dark:border-white/10">
{(["month", "week"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={view === v}
onClick={() => setView(v)}
className="rounded-[5px] px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] aria-pressed:bg-zinc-900 aria-pressed:text-white dark:text-zinc-400 dark:hover:text-zinc-100 dark:aria-pressed:bg-white dark:aria-pressed:text-zinc-900"
>
{v}
</button>
))}
</div>
{navButton(-1)}
<button
type="button"
onClick={() => today !== null && goTo(today)}
disabled={today === null}
className="h-8 rounded-md border border-zinc-900/10 px-2.5 text-[12px] font-medium outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/[0.06]"
>
Today
</button>
{navButton(1)}
</div>
</div>
<div
ref={gridRef}
role="grid"
aria-label={`${label}, ${title}`}
aria-describedby={hintId}
onKeyDown={handleGridKey}
className="min-w-0"
>
<div role="row" className={`grid ${view === "week" ? "grid-cols-[3.25rem_repeat(7,minmax(0,1fr))]" : "grid-cols-7"}`}>
{view === "week" ? (
<span role="columnheader" className="pb-2 text-right font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-400 dark:text-zinc-500">
<span className="sr-only">Time</span>
</span>
) : null}
{weekdayHeads.map((day) => (
<div
key={day}
role="columnheader"
aria-label={view === "week" ? fmt.full(day) : fmt.weekdayLong(day)}
className={`flex items-baseline justify-center gap-1.5 pb-2 font-mono text-[10px] uppercase tracking-[0.14em] ${
view === "week" && day === today ? "text-[var(--ec-accent)]" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{fmt.weekdayShort(day)}
{view === "week" ? (
<span className={`font-sans text-[13px] font-semibold tracking-normal tabular-nums ${day === today ? "" : "text-zinc-800 dark:text-zinc-200"}`}>
{dayOfMonth(day)}
</span>
) : null}
</div>
))}
</div>
<div className="relative overflow-hidden">
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={`${view}-${periodKey}`}
custom={direction}
variants={slide}
initial="initial"
animate="animate"
exit="exit"
transition={transition}
>
{view === "month" ? monthGrid : weekGrid}
</motion.div>
</AnimatePresence>
</div>
</div>
{showLegend && usedCalendars.length ? (
<ul className="flex flex-wrap gap-x-4 gap-y-1 border-t border-zinc-900/[0.07] px-4 py-2.5 dark:border-white/[0.07]">
{usedCalendars.map((c) => (
<li key={c.id} className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true" className="size-2 rounded-full" style={{ background: c.color }} />
{c.label}
</li>
))}
</ul>
) : null}
<p id={hintId} className="sr-only">
Arrow keys move between days, Page Up and Page Down change {view === "month" ? "month" : "week"}. Enter lists the day's events, or creates one on an empty day.
</p>
<div
ref={popoverRef}
id={popoverId}
popover="auto"
role="dialog"
aria-label={popDay === null ? "Day" : fmt.full(popDay)}
onKeyDown={(event) => {
if (event.key === "Escape") {
// Close only this layer.
event.preventDefault();
event.stopPropagation();
closePopover();
}
}}
className="fixed inset-auto m-0 w-64 rounded-xl border border-zinc-900/10 bg-white p-2 text-zinc-900 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35)] [--ec-surface:white] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:[--ec-surface:var(--color-zinc-900)]"
style={rootStyle}
>
{popDay !== null ? (
<>
<p className="px-2 pb-1.5 pt-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{fmt.full(popDay)}</p>
<ul className="flex flex-col">
{popList.map((p) => (
<li key={p.ev.id}>
<button
type="button"
data-pop-item=""
onClick={() => {
closePopover();
onSelect?.(p.ev);
}}
style={{ ["--ec-color" as string]: p.color }}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] outline-hidden hover:bg-zinc-900/[0.05] focus-visible:bg-zinc-900/[0.05] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] dark:hover:bg-white/[0.06] dark:focus-visible:bg-white/[0.06]"
>
<span aria-hidden="true" className="size-2 shrink-0 rounded-full bg-[var(--ec-color)]" />
<span className="min-w-0 flex-1 truncate">{p.ev.title}</span>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400">
{p.allDay || p.e > p.s ? "All day" : fmt.time(p.sMin)}
</span>
</button>
</li>
))}
</ul>
{onCreate ? (
<button
type="button"
data-pop-item=""
onClick={() => {
const day = popDay;
closePopover();
onCreate(isoDay(day));
}}
className="mt-1 flex w-full items-center gap-2 rounded-md border-t border-zinc-900/[0.07] px-2 py-1.5 text-left text-[12px] font-medium text-zinc-600 outline-hidden hover:text-zinc-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ec-accent)] dark:border-white/[0.07] dark:text-zinc-300 dark:hover:text-white"
>
<span aria-hidden="true" className="text-[var(--ec-accent)]">+</span> New event
</button>
) : null}
</>
) : null}
</div>
</div>
);
}