Calendar
Interactive responsive calendar with event details and inbox surfaces.
Month
Event details and inbox surfaces.
August 2026
5
Sun
Mon
Tue
Wed
Thu
Fri
Sat
+2 more
function CalendarDemo() {
return (
<BoardCalendar
events={CALENDAR_EVENTS}
inboxAccounts={CALENDAR_INBOX_ACCOUNTS}
notifications={CALENDAR_NOTIFICATIONS}
/>
);
}function CalendarDemo() {
return (
<BoardCalendar
events={CALENDAR_EVENTS}
inboxAccounts={CALENDAR_INBOX_ACCOUNTS}
notifications={CALENDAR_NOTIFICATIONS}
/>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/calendar.jsonnpx shadcn@latest add https://boardcn.dev/r/calendar.jsonnpm packages
- @internationalized/date
- @remixicon/react
- motion
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import type { HTMLAttributes, KeyboardEvent, Ref } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Calendar as AriaCalendar, Dialog, Popover } from "react-aria-components";
import { CalendarDate } from "@internationalized/date";
import {
RiAddLine,
RiArrowRightLine,
RiInboxLine,
RiNotification3Line,
RiRssFill,
RiTimeLine,
RiUserAddLine,
RiGlobalLine,
} from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Avatar, type AvatarProps } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import { MonthPanel } from "@/components/base/date-picker/shared";
import {
Dropdown,
DropdownDivider,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import {
NotificationCenter,
type NotificationCenterItem,
} from "@/components/blocks/notification-center/notification-center";
import { cx } from "@/utils/cx";
export type CalendarEventColor = "blue" | "pink" | "purple" | "lime" | "emerald";
export interface CalendarParticipant {
name: string;
email: string;
initials?: string;
color?: AvatarProps["color"];
}
export interface CalendarEvent {
id: string;
/** Local calendar date in YYYY-MM-DD format. */
date: string;
title: string;
time?: string;
endTime?: string;
color?: CalendarEventColor;
duration?: string;
meetingCode?: string;
meetingLabel?: string;
timezoneOffset?: string;
timezone?: string;
reminder?: string;
participants?: CalendarParticipant[];
/** Optional consumer-owned image shown above the event details. */
imageSrc?: string;
}
export interface CalendarInboxAccount {
email: string;
feeds: Array<{
id: string;
label: string;
color: "blue" | "red" | "lime" | "purple" | "teal" | "pink";
}>;
}
export interface BoardCalendarProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
month?: Date;
defaultMonth?: Date;
selectedDate?: Date | null;
defaultSelectedDate?: Date | null;
events: CalendarEvent[];
inboxAccounts: CalendarInboxAccount[];
notifications: NotificationCenterItem[];
notificationCount?: number;
onMonthChange?: (month: Date) => void;
onDateSelect?: (date: Date) => void;
onNewEvent?: () => void;
onEventSelect?: (event: CalendarEvent) => void;
onJoinEvent?: (event: CalendarEvent) => void;
onEditTimezone?: (event: CalendarEvent) => void;
onEditParticipants?: (event: CalendarEvent) => void;
onEditReminders?: (event: CalendarEvent) => void;
onInboxFeedSelect?: (account: CalendarInboxAccount, feedId: string) => void;
onAddAccount?: () => void;
ref?: Ref<HTMLDivElement>;
}
const COLOR_CLASSES: Record<CalendarEventColor, { background: string; title: string; time: string }> = {
blue: { background: "bg-calendar-event-blue-background", title: "text-calendar-event-blue-title", time: "text-calendar-event-blue-time" },
pink: { background: "bg-calendar-event-pink-background", title: "text-calendar-event-pink-title", time: "text-calendar-event-pink-time" },
purple: { background: "bg-calendar-event-purple-background", title: "text-calendar-event-purple-title", time: "text-calendar-event-purple-time" },
lime: { background: "bg-calendar-event-lime-background", title: "text-calendar-event-lime-title", time: "text-calendar-event-lime-time" },
emerald: { background: "bg-calendar-event-emerald-background", title: "text-calendar-event-emerald-title", time: "text-calendar-event-emerald-time" },
};
const FEED_COLORS = {
blue: "bg-blue-200 text-blue-900",
red: "bg-red-200 text-red-700",
lime: "bg-lime-200 text-lime-700",
purple: "bg-purple-200 text-purple-700",
teal: "bg-teal-200 text-teal-700",
pink: "bg-pink-200 text-pink-700",
};
function dateKey(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function monthTitle(date: Date, short = false) {
return new Intl.DateTimeFormat(undefined, { month: short ? "short" : "long", year: short ? undefined : "numeric" }).format(date);
}
function fullDateLabel(date: Date) {
return new Intl.DateTimeFormat("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function displayedDays(month: Date) {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const start = new Date(month.getFullYear(), month.getMonth(), 1 - first.getDay());
return Array.from({ length: 42 }, (_, index) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + index));
}
function eventDateLabel(event: CalendarEvent) {
const [year, month, day] = event.date.split("-").map(Number);
return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", month: "short" }).format(new Date(year, month - 1, day));
}
function addHour(time?: string) {
if (!time) return undefined;
const [hour, minute] = time.split(":").map(Number);
return `${String((hour + 1) % 24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
function SmallChevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className={cx("size-4", direction === "left" ? "rotate-90" : "-rotate-90")}>
<path d="M4 7L7.29289 10.2929C7.68342 10.6834 8.31658 10.6834 8.70711 10.2929L12 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function GoogleMeetMark() {
return (
<svg viewBox="0 0 24 24" className="size-5 shrink-0" aria-hidden>
<path fill="#00832d" d="M3 7.2A2.2 2.2 0 0 1 5.2 5H14v14H5.2A2.2 2.2 0 0 1 3 16.8z" />
<path fill="#00ac47" d="M14 8.2 18.2 5.8c.8-.5 1.8.1 1.8 1v10.4c0 .9-1 1.5-1.8 1L14 15.8z" />
<path fill="#ffba00" d="M3 7.2A2.2 2.2 0 0 1 5.2 5H8l6 6V5H5.2A2.2 2.2 0 0 0 3 7.2z" />
<path fill="#0066da" d="M3 16.8A2.2 2.2 0 0 0 5.2 19H8l6-6H8L3 8z" />
</svg>
);
}
function EditButton({ label, icon: Icon, onClick }: { label: string; icon: typeof RiGlobalLine; onClick?: () => void }) {
return <Button size="xs" variant="secondary" iconOnly leadingIcon={Icon} aria-label={label} onClick={onClick} className="text-foreground-icon-secondary" />;
}
function EventDetails({
event,
onJoin,
onEditTimezone,
onEditParticipants,
onEditReminders,
}: {
event: CalendarEvent;
onJoin?: (event: CalendarEvent) => void;
onEditTimezone?: (event: CalendarEvent) => void;
onEditParticipants?: (event: CalendarEvent) => void;
onEditReminders?: (event: CalendarEvent) => void;
}) {
const participants = event.participants ?? [];
const endTime = event.endTime ?? addHour(event.time);
return (
<section aria-label="Event details" className="flex w-full flex-col gap-2.5 outline-none">
<div className="flex w-full flex-col gap-px rounded-2lg bg-background-secondary-default px-2.5 py-2">
<p className="text-headline-medium whitespace-nowrap text-text-primary">{event.title}</p>
<p className="text-body-medium text-text-secondary">{eventDateLabel(event)}</p>
</div>
{event.imageSrc ? (
<div className="relative h-[99px] w-full shrink-0 overflow-hidden rounded-[10px]"><img src={event.imageSrc} alt="" className="size-full object-cover" /></div>
) : event.id === "birthday" ? (
<div aria-hidden className="h-[99px] w-full shrink-0 overflow-hidden rounded-[10px] bg-[radial-gradient(circle_at_18%_35%,#ffc98b_0_6%,transparent_7%),radial-gradient(circle_at_72%_38%,#ff81a7_0_8%,transparent_9%),linear-gradient(130deg,#19154a,#7a2f73_48%,#ef9b6c)]" />
) : null}
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><GoogleMeetMark /><span className="text-body-2-medium whitespace-nowrap text-text-primary">{event.meetingLabel ?? "Google Meet"}</span></div>
<div className="flex shrink-0 items-center gap-1.5"><span className="shrink-0 rounded-sm bg-background-tertiary-default px-1 py-1 text-caption-1-medium text-text-secondary">{event.meetingCode ?? "fii-exdj-aqg"}</span><Button size="xs" onClick={() => onJoin?.(event)}>Join</Button></div>
</div>
{event.time && (
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiTimeLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1.5 text-body-2-medium whitespace-nowrap text-text-primary">{event.time}<RiArrowRightLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />{endTime}</span></div>
<span className="shrink-0 rounded-sm bg-background-tertiary-default px-1 py-1 text-caption-1-medium text-text-secondary">{event.duration ?? "1h"}</span>
</div>
)}
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiGlobalLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1 text-body-2-medium whitespace-nowrap"><span className="text-text-secondary">{event.timezoneOffset ?? "GMT+5.5"}</span><span className="text-text-primary">{event.timezone ?? "Amsterdam"}</span></span></div>
<EditButton label="Edit timezone" icon={RiArrowRightLine} onClick={() => onEditTimezone?.(event)} />
</div>
<div className="flex w-full flex-col gap-0.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex w-full items-center gap-2.5"><div className="flex min-w-0 flex-1 items-center gap-1.5"><RiGlobalLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="text-body-2-medium whitespace-nowrap text-text-secondary">Participants</span></div><EditButton label="Edit participants" icon={RiUserAddLine} onClick={() => onEditParticipants?.(event)} /></div>
<div className="flex w-full flex-col">{participants.map((participant) => <div key={participant.email} className="flex w-full items-center gap-2 rounded-2lg py-1.5"><Avatar size="xs" color={participant.color} initials={participant.initials ?? participant.name.charAt(0)} /><span className="truncate text-body-2-medium text-text-primary">{participant.email}</span></div>)}</div>
</div>
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiNotification3Line className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1 text-body-2-medium whitespace-nowrap"><span className="text-text-secondary">Reminders</span><span className="text-text-primary">{event.reminder ?? "2h before"}</span></span></div>
<EditButton label="Edit reminders" icon={RiArrowRightLine} onClick={() => onEditReminders?.(event)} />
</div>
</section>
);
}
function EventChip({ event, onClick }: { event: CalendarEvent; onClick: (event: CalendarEvent, trigger: HTMLButtonElement) => void }) {
const color = COLOR_CLASSES[event.color ?? "blue"];
return (
<button type="button" aria-label={event.title} onClick={(clickEvent) => onClick(event, clickEvent.currentTarget)} className={cx("flex min-w-0 cursor-pointer items-center justify-between gap-0.5 rounded-sm px-1 py-0.5 outline-none sm:gap-1 sm:rounded-md sm:px-1.5 transition-[filter] duration-150 ease hover:brightness-95 focus-visible:ring-2 focus-visible:ring-border-focus-ring", color.background)}>
<span className={cx("truncate text-[10px] leading-3 sm:text-body-2-medium", color.title)}>{event.title}</span>
{event.time && <span className={cx("hidden shrink-0 opacity-70 sm:inline sm:text-caption-1-medium", color.time)}>{event.time}</span>}
</button>
);
}
export function BoardCalendar({
month,
defaultMonth = new Date(2026, 7, 1),
selectedDate,
defaultSelectedDate = null,
events,
inboxAccounts,
notifications,
notificationCount = 5,
onMonthChange,
onDateSelect,
onNewEvent,
onEventSelect,
onJoinEvent,
onEditTimezone,
onEditParticipants,
onEditReminders,
onInboxFeedSelect,
onAddAccount,
className,
ref,
...props
}: BoardCalendarProps) {
const [internalMonth, setInternalMonth] = useState(() => new Date(defaultMonth.getFullYear(), defaultMonth.getMonth(), 1));
const [internalSelectedDate, setInternalSelectedDate] = useState<Date | null>(defaultSelectedDate);
const resolvedSelectedDate = selectedDate === undefined ? internalSelectedDate : selectedDate;
const visibleMonth = useMemo(
() => (month ? new Date(month.getFullYear(), month.getMonth(), 1) : internalMonth),
[month, internalMonth],
);
const [monthPickerOpen, setMonthPickerOpen] = useState(false);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [activeEvent, setActiveEvent] = useState<CalendarEvent | null>(null);
const notificationTrigger = useRef<HTMLButtonElement>(null);
const eventTrigger = useRef<HTMLButtonElement>(null);
const dayButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const focusRequested = useRef(false);
const days = useMemo(() => displayedDays(visibleMonth), [visibleMonth]);
const [focusedDayKey, setFocusedDayKey] = useState(() => dateKey(defaultSelectedDate ?? new Date(defaultMonth.getFullYear(), defaultMonth.getMonth(), 1)));
const visibleFocusedDayKey = days.some((day) => dateKey(day) === focusedDayKey)
? focusedDayKey
: dateKey(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1));
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of events) map.set(event.date, [...(map.get(event.date) ?? []), event]);
return map;
}, [events]);
const changeMonth = (next: Date) => {
const normalized = new Date(next.getFullYear(), next.getMonth(), 1);
if (month === undefined) setInternalMonth(normalized);
onMonthChange?.(normalized);
};
const moveMonth = (delta: number) => changeMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + delta, 1));
const pickerValue = new CalendarDate(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1);
useEffect(() => {
if (!focusRequested.current) return;
const button = dayButtonRefs.current.get(focusedDayKey);
if (!button) return;
button.focus();
focusRequested.current = false;
}, [days, focusedDayKey]);
const selectDay = (day: Date) => {
const normalized = new Date(day.getFullYear(), day.getMonth(), day.getDate());
if (selectedDate === undefined) setInternalSelectedDate(normalized);
onDateSelect?.(normalized);
};
const moveDayFocus = (day: Date, delta: number) => {
const target = new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta);
const targetKey = dateKey(target);
focusRequested.current = true;
setFocusedDayKey(targetKey);
if (!days.some((candidate) => dateKey(candidate) === targetKey)) changeMonth(target);
};
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, day: Date) => {
const deltas: Partial<Record<KeyboardEvent<HTMLButtonElement>["key"], number>> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -7,
ArrowDown: 7,
Home: -day.getDay(),
End: 6 - day.getDay(),
};
const delta = deltas[event.key];
if (delta === undefined) return;
event.preventDefault();
moveDayFocus(day, delta);
};
return (
<div ref={ref} className={cx("flex w-full flex-col gap-2.5", className)} {...props}>
<div className="px-3 pt-3 sm:contents">
<header className="flex w-full flex-col gap-1">
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5"><h1 className="px-1 text-title-2-medium whitespace-nowrap text-text-primary">{monthTitle(visibleMonth)}</h1></div>
<div className="flex w-full flex-nowrap items-start justify-end gap-2.5 sm:w-auto">
<span className="group relative inline-flex">
<IconButton ref={notificationTrigger} icon={RiNotification3Line} aria-label="Notifications" aria-expanded={notificationsOpen} onClick={() => setNotificationsOpen((open) => !open)} />
{notificationCount > 0 && <span className="pointer-events-none absolute top-0.5 left-[18px] flex size-4 items-center justify-center rounded-full border-[1.5px] border-background-primary-default bg-red-600 group-hover:border-0 group-active:border-0"><span className="w-4 text-center text-[10px] leading-4 font-bold text-white">{notificationCount}</span></span>}
</span>
<Dropdown>
<DropdownTrigger aria-label="Inbox" className="relative inline-flex size-9 shrink-0 items-center justify-center overflow-visible rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs transition-colors hover:bg-background-primary-hover"><RiInboxLine className="size-5" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label="Inbox menu" placement="bottom end" dialogClassName="gap-4" className="max-h-[322px] overflow-y-auto">
{inboxAccounts.map((account, accountIndex) => <div key={account.email} className="contents">{accountIndex > 0 && <DropdownDivider className="-my-1" />}<DropdownGroup label={account.email} className="pt-[5px]">{account.feeds.map((feed) => <DropdownItem key={feed.id} className="px-2 py-1.5" onSelect={() => onInboxFeedSelect?.(account, feed.id)}><span className={cx("flex size-5 shrink-0 items-center justify-center rounded-md", FEED_COLORS[feed.color])}><RiRssFill className="size-3" aria-hidden /></span><span className="truncate text-body-medium text-text-primary">{feed.label}</span></DropdownItem>)}</DropdownGroup></div>)}
<Button variant="secondary" size="small" leadingIcon={RiAddLine} className="w-full" onClick={onAddAccount}>Add new account</Button>
</DropdownPopover>
</Dropdown>
<div className="flex min-w-0 flex-1 items-start gap-2.5 sm:w-auto sm:flex-none sm:shrink-0">
<div className="relative h-9 min-w-0 flex-1 sm:w-[var(--month-switcher-width)] sm:flex-none" style={{ "--month-switcher-width": "320px" } as React.CSSProperties}>
<div className={cx("absolute top-0 right-0 left-0 z-10 flex w-full flex-col overflow-hidden rounded-2lg border border-border-button-default bg-background-primary-default shadow-dropdown transition-[width] duration-300 ease-in-out sm:right-auto sm:w-[var(--month-switcher-width)]", monthPickerOpen && "!w-[min(320px,calc(100vw-24px))]")}>
<div className="flex w-full shrink-0 items-center justify-between p-2">
<button type="button" aria-label="Previous month" onClick={() => moveMonth(-1)} className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[3px] text-foreground-icon-primary outline-none transition-colors duration-150 ease hover:bg-background-secondary-hover"><SmallChevron direction="left" /></button>
<button type="button" aria-expanded={monthPickerOpen} onClick={() => setMonthPickerOpen((open) => !open)} className="flex-1 cursor-default truncate text-center text-body-medium text-text-primary outline-none sm:cursor-pointer"><span className="sm:hidden">{monthTitle(visibleMonth, true)}</span><span className="hidden sm:inline">{monthTitle(visibleMonth)}</span></button>
<button type="button" aria-label="Next month" onClick={() => moveMonth(1)} className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[3px] text-foreground-icon-primary outline-none transition-colors duration-150 ease hover:bg-background-secondary-hover"><SmallChevron direction="right" /></button>
</div>
<AnimatePresence initial={false}>{monthPickerOpen && <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2, ease: "easeInOut" }}><AriaCalendar aria-label="Jump to date" value={pickerValue} onChange={(date) => { changeMonth(new Date(date.year, date.month - 1, 1)); setMonthPickerOpen(false); }}><div className="px-3 pb-3"><MonthPanel offset={0} bare hideHeader /></div></AriaCalendar></motion.div>}</AnimatePresence>
</div>
</div>
<Button leadingIcon={RiAddLine} onClick={onNewEvent}>New event</Button>
</div>
</div>
</div>
</header>
</div>
<div className="w-full overflow-hidden rounded-b-3xl bg-background-secondary-default p-0 sm:overflow-visible sm:rounded-3xl sm:p-3">
<div className="flex h-full min-h-0 w-full flex-col gap-0 overflow-hidden border-y border-separator-border-strong dark:border-separator-border sm:h-auto sm:gap-2 sm:overflow-visible sm:rounded-none sm:border-0">
<div className="grid grid-cols-7 gap-0 border-b border-separator-border-strong dark:border-separator-border sm:gap-2 sm:border-0">{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => <div key={day} className="flex items-center justify-center border-r border-separator-border-strong bg-background-secondary-default px-0.5 py-1 text-center text-[11px] leading-4 text-text-secondary last:border-r-0 dark:border-separator-border sm:rounded-xl sm:border-0 sm:px-2.5 sm:py-[5px] sm:text-body-2-regular">{day}</div>)}</div>
<div className="grid min-h-0 flex-1 grid-cols-7 grid-rows-[repeat(6,minmax(0,1fr))] gap-0 sm:flex-none sm:grid-rows-[repeat(6,minmax(0,auto))] sm:gap-2">
{days.map((day) => {
const key = dateKey(day);
const dayEvents = eventsByDate.get(key) ?? [];
const outside = day.getMonth() !== visibleMonth.getMonth();
const selected = resolvedSelectedDate ? dateKey(resolvedSelectedDate) === key : false;
return <div key={key} className="relative h-full border-r border-b border-separator-border-strong last:border-b-0 nth-[7n]:border-r-0 nth-[n+36]:border-b-0 dark:border-separator-border sm:border-0"><div className={cx("relative flex h-full min-h-[72px] flex-col overflow-hidden sm:min-h-[94px] sm:rounded-xl lg:min-h-[105px] xl:min-h-[128px] 2xl:min-h-[164px]", outside ? "bg-background-secondary-default sm:bg-background-tertiary-default" : "bg-background-primary-default max-sm:dark:bg-background-secondary-default sm:shadow-card")}><button ref={(node) => { if (node) dayButtonRefs.current.set(key, node); else dayButtonRefs.current.delete(key); }} type="button" aria-label={fullDateLabel(day)} aria-pressed={selected} data-selected={selected || undefined} tabIndex={key === visibleFocusedDayKey ? 0 : -1} onFocus={() => setFocusedDayKey(key)} onKeyDown={(event) => handleDayKeyDown(event, day)} onClick={() => selectDay(day)} className="absolute inset-0 flex cursor-pointer items-start rounded-[inherit] text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring"><span aria-hidden className={cx("pt-1.5 pl-1.5 text-[11px] leading-4 font-medium sm:pt-2 sm:pl-2.5 sm:text-body-2-medium", outside ? "text-text-secondary" : "text-text-primary")}>{day.getDate()}</span></button>{dayEvents.length > 0 && <div className="relative z-[1] mt-auto flex flex-col gap-0.5 px-1 pb-1 sm:gap-[5px] sm:px-2 sm:pb-2">{dayEvents.length > 2 && <span className="text-[10px] leading-3 font-medium text-text-secondary sm:text-body-2-medium">+{dayEvents.length - 2} more</span>}{dayEvents.map((event) => <EventChip key={event.id} event={event} onClick={(selected, trigger) => { eventTrigger.current = trigger; setActiveEvent(selected); onEventSelect?.(selected); }} />)}</div>}</div></div>;
})}
</div>
</div>
</div>
<Popover triggerRef={notificationTrigger} isOpen={notificationsOpen} onOpenChange={setNotificationsOpen} placement="bottom end" offset={8} isNonModal className="w-[430px] max-w-[calc(100vw-32px)] rounded-3xl outline-none transition duration-150 ease-out data-[entering]:scale-95 data-[entering]:opacity-0 data-[entering]:blur-[2px] data-[exiting]:scale-95 data-[exiting]:opacity-0 data-[exiting]:blur-[2px]"><Dialog aria-label="Notifications" className="outline-none"><NotificationCenter notifications={notifications} /></Dialog></Popover>
<Popover triggerRef={eventTrigger} isOpen={activeEvent !== null} onOpenChange={(open) => { if (!open) setActiveEvent(null); }} placement="right" offset={8} isNonModal className="w-[302px] max-w-[calc(100vw-32px)] rounded-[20px] border border-border-button-default bg-background-primary-default p-2.5 outline-none shadow-[0px_1px_2px_0px_rgba(0,0,0,0.04),0px_7px_8px_0px_rgba(0,0,0,0.04)] transition duration-150 ease-out sm:data-[entering]:scale-90 sm:data-[entering]:opacity-0 sm:data-[entering]:blur-[4px] sm:data-[exiting]:scale-90 sm:data-[exiting]:opacity-0 sm:data-[exiting]:blur-[4px] max-sm:!fixed max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!m-0 max-sm:!max-h-[85dvh] max-sm:w-full max-sm:max-w-none max-sm:overflow-y-auto max-sm:rounded-[24px] max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0 max-sm:pb-[calc(10px+env(safe-area-inset-bottom))] max-sm:duration-300 max-sm:data-[entering]:translate-y-full max-sm:data-[exiting]:translate-y-full"><Dialog aria-label="Event details" className="outline-none">{activeEvent && <EventDetails event={activeEvent} onJoin={onJoinEvent} onEditTimezone={onEditTimezone} onEditParticipants={onEditParticipants} onEditReminders={onEditReminders} />}</Dialog></Popover>
</div>
);
}
/** Alias matching BoardCN's public component name. */
export const Calendar = BoardCalendar;"use client";
import type { HTMLAttributes, KeyboardEvent, Ref } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Calendar as AriaCalendar, Dialog, Popover } from "react-aria-components";
import { CalendarDate } from "@internationalized/date";
import {
RiAddLine,
RiArrowRightLine,
RiInboxLine,
RiNotification3Line,
RiRssFill,
RiTimeLine,
RiUserAddLine,
RiGlobalLine,
} from "@remixicon/react";
import { AnimatePresence, motion } from "motion/react";
import { Avatar, type AvatarProps } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import { MonthPanel } from "@/components/base/date-picker/shared";
import {
Dropdown,
DropdownDivider,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import {
NotificationCenter,
type NotificationCenterItem,
} from "@/components/blocks/notification-center/notification-center";
import { cx } from "@/utils/cx";
export type CalendarEventColor = "blue" | "pink" | "purple" | "lime" | "emerald";
export interface CalendarParticipant {
name: string;
email: string;
initials?: string;
color?: AvatarProps["color"];
}
export interface CalendarEvent {
id: string;
/** Local calendar date in YYYY-MM-DD format. */
date: string;
title: string;
time?: string;
endTime?: string;
color?: CalendarEventColor;
duration?: string;
meetingCode?: string;
meetingLabel?: string;
timezoneOffset?: string;
timezone?: string;
reminder?: string;
participants?: CalendarParticipant[];
/** Optional consumer-owned image shown above the event details. */
imageSrc?: string;
}
export interface CalendarInboxAccount {
email: string;
feeds: Array<{
id: string;
label: string;
color: "blue" | "red" | "lime" | "purple" | "teal" | "pink";
}>;
}
export interface BoardCalendarProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
month?: Date;
defaultMonth?: Date;
selectedDate?: Date | null;
defaultSelectedDate?: Date | null;
events: CalendarEvent[];
inboxAccounts: CalendarInboxAccount[];
notifications: NotificationCenterItem[];
notificationCount?: number;
onMonthChange?: (month: Date) => void;
onDateSelect?: (date: Date) => void;
onNewEvent?: () => void;
onEventSelect?: (event: CalendarEvent) => void;
onJoinEvent?: (event: CalendarEvent) => void;
onEditTimezone?: (event: CalendarEvent) => void;
onEditParticipants?: (event: CalendarEvent) => void;
onEditReminders?: (event: CalendarEvent) => void;
onInboxFeedSelect?: (account: CalendarInboxAccount, feedId: string) => void;
onAddAccount?: () => void;
ref?: Ref<HTMLDivElement>;
}
const COLOR_CLASSES: Record<CalendarEventColor, { background: string; title: string; time: string }> = {
blue: { background: "bg-calendar-event-blue-background", title: "text-calendar-event-blue-title", time: "text-calendar-event-blue-time" },
pink: { background: "bg-calendar-event-pink-background", title: "text-calendar-event-pink-title", time: "text-calendar-event-pink-time" },
purple: { background: "bg-calendar-event-purple-background", title: "text-calendar-event-purple-title", time: "text-calendar-event-purple-time" },
lime: { background: "bg-calendar-event-lime-background", title: "text-calendar-event-lime-title", time: "text-calendar-event-lime-time" },
emerald: { background: "bg-calendar-event-emerald-background", title: "text-calendar-event-emerald-title", time: "text-calendar-event-emerald-time" },
};
const FEED_COLORS = {
blue: "bg-blue-200 text-blue-900",
red: "bg-red-200 text-red-700",
lime: "bg-lime-200 text-lime-700",
purple: "bg-purple-200 text-purple-700",
teal: "bg-teal-200 text-teal-700",
pink: "bg-pink-200 text-pink-700",
};
function dateKey(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function monthTitle(date: Date, short = false) {
return new Intl.DateTimeFormat(undefined, { month: short ? "short" : "long", year: short ? undefined : "numeric" }).format(date);
}
function fullDateLabel(date: Date) {
return new Intl.DateTimeFormat("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
function displayedDays(month: Date) {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const start = new Date(month.getFullYear(), month.getMonth(), 1 - first.getDay());
return Array.from({ length: 42 }, (_, index) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + index));
}
function eventDateLabel(event: CalendarEvent) {
const [year, month, day] = event.date.split("-").map(Number);
return new Intl.DateTimeFormat(undefined, { weekday: "short", day: "numeric", month: "short" }).format(new Date(year, month - 1, day));
}
function addHour(time?: string) {
if (!time) return undefined;
const [hour, minute] = time.split(":").map(Number);
return `${String((hour + 1) % 24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
function SmallChevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className={cx("size-4", direction === "left" ? "rotate-90" : "-rotate-90")}>
<path d="M4 7L7.29289 10.2929C7.68342 10.6834 8.31658 10.6834 8.70711 10.2929L12 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function GoogleMeetMark() {
return (
<svg viewBox="0 0 24 24" className="size-5 shrink-0" aria-hidden>
<path fill="#00832d" d="M3 7.2A2.2 2.2 0 0 1 5.2 5H14v14H5.2A2.2 2.2 0 0 1 3 16.8z" />
<path fill="#00ac47" d="M14 8.2 18.2 5.8c.8-.5 1.8.1 1.8 1v10.4c0 .9-1 1.5-1.8 1L14 15.8z" />
<path fill="#ffba00" d="M3 7.2A2.2 2.2 0 0 1 5.2 5H8l6 6V5H5.2A2.2 2.2 0 0 0 3 7.2z" />
<path fill="#0066da" d="M3 16.8A2.2 2.2 0 0 0 5.2 19H8l6-6H8L3 8z" />
</svg>
);
}
function EditButton({ label, icon: Icon, onClick }: { label: string; icon: typeof RiGlobalLine; onClick?: () => void }) {
return <Button size="xs" variant="secondary" iconOnly leadingIcon={Icon} aria-label={label} onClick={onClick} className="text-foreground-icon-secondary" />;
}
function EventDetails({
event,
onJoin,
onEditTimezone,
onEditParticipants,
onEditReminders,
}: {
event: CalendarEvent;
onJoin?: (event: CalendarEvent) => void;
onEditTimezone?: (event: CalendarEvent) => void;
onEditParticipants?: (event: CalendarEvent) => void;
onEditReminders?: (event: CalendarEvent) => void;
}) {
const participants = event.participants ?? [];
const endTime = event.endTime ?? addHour(event.time);
return (
<section aria-label="Event details" className="flex w-full flex-col gap-2.5 outline-none">
<div className="flex w-full flex-col gap-px rounded-2lg bg-background-secondary-default px-2.5 py-2">
<p className="text-headline-medium whitespace-nowrap text-text-primary">{event.title}</p>
<p className="text-body-medium text-text-secondary">{eventDateLabel(event)}</p>
</div>
{event.imageSrc ? (
<div className="relative h-[99px] w-full shrink-0 overflow-hidden rounded-[10px]"><img src={event.imageSrc} alt="" className="size-full object-cover" /></div>
) : event.id === "birthday" ? (
<div aria-hidden className="h-[99px] w-full shrink-0 overflow-hidden rounded-[10px] bg-[radial-gradient(circle_at_18%_35%,#ffc98b_0_6%,transparent_7%),radial-gradient(circle_at_72%_38%,#ff81a7_0_8%,transparent_9%),linear-gradient(130deg,#19154a,#7a2f73_48%,#ef9b6c)]" />
) : null}
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><GoogleMeetMark /><span className="text-body-2-medium whitespace-nowrap text-text-primary">{event.meetingLabel ?? "Google Meet"}</span></div>
<div className="flex shrink-0 items-center gap-1.5"><span className="shrink-0 rounded-sm bg-background-tertiary-default px-1 py-1 text-caption-1-medium text-text-secondary">{event.meetingCode ?? "fii-exdj-aqg"}</span><Button size="xs" onClick={() => onJoin?.(event)}>Join</Button></div>
</div>
{event.time && (
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiTimeLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1.5 text-body-2-medium whitespace-nowrap text-text-primary">{event.time}<RiArrowRightLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />{endTime}</span></div>
<span className="shrink-0 rounded-sm bg-background-tertiary-default px-1 py-1 text-caption-1-medium text-text-secondary">{event.duration ?? "1h"}</span>
</div>
)}
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiGlobalLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1 text-body-2-medium whitespace-nowrap"><span className="text-text-secondary">{event.timezoneOffset ?? "GMT+5.5"}</span><span className="text-text-primary">{event.timezone ?? "Amsterdam"}</span></span></div>
<EditButton label="Edit timezone" icon={RiArrowRightLine} onClick={() => onEditTimezone?.(event)} />
</div>
<div className="flex w-full flex-col gap-0.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex w-full items-center gap-2.5"><div className="flex min-w-0 flex-1 items-center gap-1.5"><RiGlobalLine className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="text-body-2-medium whitespace-nowrap text-text-secondary">Participants</span></div><EditButton label="Edit participants" icon={RiUserAddLine} onClick={() => onEditParticipants?.(event)} /></div>
<div className="flex w-full flex-col">{participants.map((participant) => <div key={participant.email} className="flex w-full items-center gap-2 rounded-2lg py-1.5"><Avatar size="xs" color={participant.color} initials={participant.initials ?? participant.name.charAt(0)} /><span className="truncate text-body-2-medium text-text-primary">{participant.email}</span></div>)}</div>
</div>
<div className="flex h-9 w-full shrink-0 items-center gap-2.5 rounded-2lg bg-background-secondary-default py-2 pr-1.5 pl-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5"><RiNotification3Line className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden /><span className="flex items-center gap-1 text-body-2-medium whitespace-nowrap"><span className="text-text-secondary">Reminders</span><span className="text-text-primary">{event.reminder ?? "2h before"}</span></span></div>
<EditButton label="Edit reminders" icon={RiArrowRightLine} onClick={() => onEditReminders?.(event)} />
</div>
</section>
);
}
function EventChip({ event, onClick }: { event: CalendarEvent; onClick: (event: CalendarEvent, trigger: HTMLButtonElement) => void }) {
const color = COLOR_CLASSES[event.color ?? "blue"];
return (
<button type="button" aria-label={event.title} onClick={(clickEvent) => onClick(event, clickEvent.currentTarget)} className={cx("flex min-w-0 cursor-pointer items-center justify-between gap-0.5 rounded-sm px-1 py-0.5 outline-none sm:gap-1 sm:rounded-md sm:px-1.5 transition-[filter] duration-150 ease hover:brightness-95 focus-visible:ring-2 focus-visible:ring-border-focus-ring", color.background)}>
<span className={cx("truncate text-[10px] leading-3 sm:text-body-2-medium", color.title)}>{event.title}</span>
{event.time && <span className={cx("hidden shrink-0 opacity-70 sm:inline sm:text-caption-1-medium", color.time)}>{event.time}</span>}
</button>
);
}
export function BoardCalendar({
month,
defaultMonth = new Date(2026, 7, 1),
selectedDate,
defaultSelectedDate = null,
events,
inboxAccounts,
notifications,
notificationCount = 5,
onMonthChange,
onDateSelect,
onNewEvent,
onEventSelect,
onJoinEvent,
onEditTimezone,
onEditParticipants,
onEditReminders,
onInboxFeedSelect,
onAddAccount,
className,
ref,
...props
}: BoardCalendarProps) {
const [internalMonth, setInternalMonth] = useState(() => new Date(defaultMonth.getFullYear(), defaultMonth.getMonth(), 1));
const [internalSelectedDate, setInternalSelectedDate] = useState<Date | null>(defaultSelectedDate);
const resolvedSelectedDate = selectedDate === undefined ? internalSelectedDate : selectedDate;
const visibleMonth = useMemo(
() => (month ? new Date(month.getFullYear(), month.getMonth(), 1) : internalMonth),
[month, internalMonth],
);
const [monthPickerOpen, setMonthPickerOpen] = useState(false);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [activeEvent, setActiveEvent] = useState<CalendarEvent | null>(null);
const notificationTrigger = useRef<HTMLButtonElement>(null);
const eventTrigger = useRef<HTMLButtonElement>(null);
const dayButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const focusRequested = useRef(false);
const days = useMemo(() => displayedDays(visibleMonth), [visibleMonth]);
const [focusedDayKey, setFocusedDayKey] = useState(() => dateKey(defaultSelectedDate ?? new Date(defaultMonth.getFullYear(), defaultMonth.getMonth(), 1)));
const visibleFocusedDayKey = days.some((day) => dateKey(day) === focusedDayKey)
? focusedDayKey
: dateKey(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1));
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of events) map.set(event.date, [...(map.get(event.date) ?? []), event]);
return map;
}, [events]);
const changeMonth = (next: Date) => {
const normalized = new Date(next.getFullYear(), next.getMonth(), 1);
if (month === undefined) setInternalMonth(normalized);
onMonthChange?.(normalized);
};
const moveMonth = (delta: number) => changeMonth(new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + delta, 1));
const pickerValue = new CalendarDate(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1);
useEffect(() => {
if (!focusRequested.current) return;
const button = dayButtonRefs.current.get(focusedDayKey);
if (!button) return;
button.focus();
focusRequested.current = false;
}, [days, focusedDayKey]);
const selectDay = (day: Date) => {
const normalized = new Date(day.getFullYear(), day.getMonth(), day.getDate());
if (selectedDate === undefined) setInternalSelectedDate(normalized);
onDateSelect?.(normalized);
};
const moveDayFocus = (day: Date, delta: number) => {
const target = new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta);
const targetKey = dateKey(target);
focusRequested.current = true;
setFocusedDayKey(targetKey);
if (!days.some((candidate) => dateKey(candidate) === targetKey)) changeMonth(target);
};
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, day: Date) => {
const deltas: Partial<Record<KeyboardEvent<HTMLButtonElement>["key"], number>> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -7,
ArrowDown: 7,
Home: -day.getDay(),
End: 6 - day.getDay(),
};
const delta = deltas[event.key];
if (delta === undefined) return;
event.preventDefault();
moveDayFocus(day, delta);
};
return (
<div ref={ref} className={cx("flex w-full flex-col gap-2.5", className)} {...props}>
<div className="px-3 pt-3 sm:contents">
<header className="flex w-full flex-col gap-1">
<div className="flex w-full flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5"><h1 className="px-1 text-title-2-medium whitespace-nowrap text-text-primary">{monthTitle(visibleMonth)}</h1></div>
<div className="flex w-full flex-nowrap items-start justify-end gap-2.5 sm:w-auto">
<span className="group relative inline-flex">
<IconButton ref={notificationTrigger} icon={RiNotification3Line} aria-label="Notifications" aria-expanded={notificationsOpen} onClick={() => setNotificationsOpen((open) => !open)} />
{notificationCount > 0 && <span className="pointer-events-none absolute top-0.5 left-[18px] flex size-4 items-center justify-center rounded-full border-[1.5px] border-background-primary-default bg-red-600 group-hover:border-0 group-active:border-0"><span className="w-4 text-center text-[10px] leading-4 font-bold text-white">{notificationCount}</span></span>}
</span>
<Dropdown>
<DropdownTrigger aria-label="Inbox" className="relative inline-flex size-9 shrink-0 items-center justify-center overflow-visible rounded-2lg border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs transition-colors hover:bg-background-primary-hover"><RiInboxLine className="size-5" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label="Inbox menu" placement="bottom end" dialogClassName="gap-4" className="max-h-[322px] overflow-y-auto">
{inboxAccounts.map((account, accountIndex) => <div key={account.email} className="contents">{accountIndex > 0 && <DropdownDivider className="-my-1" />}<DropdownGroup label={account.email} className="pt-[5px]">{account.feeds.map((feed) => <DropdownItem key={feed.id} className="px-2 py-1.5" onSelect={() => onInboxFeedSelect?.(account, feed.id)}><span className={cx("flex size-5 shrink-0 items-center justify-center rounded-md", FEED_COLORS[feed.color])}><RiRssFill className="size-3" aria-hidden /></span><span className="truncate text-body-medium text-text-primary">{feed.label}</span></DropdownItem>)}</DropdownGroup></div>)}
<Button variant="secondary" size="small" leadingIcon={RiAddLine} className="w-full" onClick={onAddAccount}>Add new account</Button>
</DropdownPopover>
</Dropdown>
<div className="flex min-w-0 flex-1 items-start gap-2.5 sm:w-auto sm:flex-none sm:shrink-0">
<div className="relative h-9 min-w-0 flex-1 sm:w-[var(--month-switcher-width)] sm:flex-none" style={{ "--month-switcher-width": "320px" } as React.CSSProperties}>
<div className={cx("absolute top-0 right-0 left-0 z-10 flex w-full flex-col overflow-hidden rounded-2lg border border-border-button-default bg-background-primary-default shadow-dropdown transition-[width] duration-300 ease-in-out sm:right-auto sm:w-[var(--month-switcher-width)]", monthPickerOpen && "!w-[min(320px,calc(100vw-24px))]")}>
<div className="flex w-full shrink-0 items-center justify-between p-2">
<button type="button" aria-label="Previous month" onClick={() => moveMonth(-1)} className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[3px] text-foreground-icon-primary outline-none transition-colors duration-150 ease hover:bg-background-secondary-hover"><SmallChevron direction="left" /></button>
<button type="button" aria-expanded={monthPickerOpen} onClick={() => setMonthPickerOpen((open) => !open)} className="flex-1 cursor-default truncate text-center text-body-medium text-text-primary outline-none sm:cursor-pointer"><span className="sm:hidden">{monthTitle(visibleMonth, true)}</span><span className="hidden sm:inline">{monthTitle(visibleMonth)}</span></button>
<button type="button" aria-label="Next month" onClick={() => moveMonth(1)} className="flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[3px] text-foreground-icon-primary outline-none transition-colors duration-150 ease hover:bg-background-secondary-hover"><SmallChevron direction="right" /></button>
</div>
<AnimatePresence initial={false}>{monthPickerOpen && <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2, ease: "easeInOut" }}><AriaCalendar aria-label="Jump to date" value={pickerValue} onChange={(date) => { changeMonth(new Date(date.year, date.month - 1, 1)); setMonthPickerOpen(false); }}><div className="px-3 pb-3"><MonthPanel offset={0} bare hideHeader /></div></AriaCalendar></motion.div>}</AnimatePresence>
</div>
</div>
<Button leadingIcon={RiAddLine} onClick={onNewEvent}>New event</Button>
</div>
</div>
</div>
</header>
</div>
<div className="w-full overflow-hidden rounded-b-3xl bg-background-secondary-default p-0 sm:overflow-visible sm:rounded-3xl sm:p-3">
<div className="flex h-full min-h-0 w-full flex-col gap-0 overflow-hidden border-y border-separator-border-strong dark:border-separator-border sm:h-auto sm:gap-2 sm:overflow-visible sm:rounded-none sm:border-0">
<div className="grid grid-cols-7 gap-0 border-b border-separator-border-strong dark:border-separator-border sm:gap-2 sm:border-0">{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => <div key={day} className="flex items-center justify-center border-r border-separator-border-strong bg-background-secondary-default px-0.5 py-1 text-center text-[11px] leading-4 text-text-secondary last:border-r-0 dark:border-separator-border sm:rounded-xl sm:border-0 sm:px-2.5 sm:py-[5px] sm:text-body-2-regular">{day}</div>)}</div>
<div className="grid min-h-0 flex-1 grid-cols-7 grid-rows-[repeat(6,minmax(0,1fr))] gap-0 sm:flex-none sm:grid-rows-[repeat(6,minmax(0,auto))] sm:gap-2">
{days.map((day) => {
const key = dateKey(day);
const dayEvents = eventsByDate.get(key) ?? [];
const outside = day.getMonth() !== visibleMonth.getMonth();
const selected = resolvedSelectedDate ? dateKey(resolvedSelectedDate) === key : false;
return <div key={key} className="relative h-full border-r border-b border-separator-border-strong last:border-b-0 nth-[7n]:border-r-0 nth-[n+36]:border-b-0 dark:border-separator-border sm:border-0"><div className={cx("relative flex h-full min-h-[72px] flex-col overflow-hidden sm:min-h-[94px] sm:rounded-xl lg:min-h-[105px] xl:min-h-[128px] 2xl:min-h-[164px]", outside ? "bg-background-secondary-default sm:bg-background-tertiary-default" : "bg-background-primary-default max-sm:dark:bg-background-secondary-default sm:shadow-card")}><button ref={(node) => { if (node) dayButtonRefs.current.set(key, node); else dayButtonRefs.current.delete(key); }} type="button" aria-label={fullDateLabel(day)} aria-pressed={selected} data-selected={selected || undefined} tabIndex={key === visibleFocusedDayKey ? 0 : -1} onFocus={() => setFocusedDayKey(key)} onKeyDown={(event) => handleDayKeyDown(event, day)} onClick={() => selectDay(day)} className="absolute inset-0 flex cursor-pointer items-start rounded-[inherit] text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring"><span aria-hidden className={cx("pt-1.5 pl-1.5 text-[11px] leading-4 font-medium sm:pt-2 sm:pl-2.5 sm:text-body-2-medium", outside ? "text-text-secondary" : "text-text-primary")}>{day.getDate()}</span></button>{dayEvents.length > 0 && <div className="relative z-[1] mt-auto flex flex-col gap-0.5 px-1 pb-1 sm:gap-[5px] sm:px-2 sm:pb-2">{dayEvents.length > 2 && <span className="text-[10px] leading-3 font-medium text-text-secondary sm:text-body-2-medium">+{dayEvents.length - 2} more</span>}{dayEvents.map((event) => <EventChip key={event.id} event={event} onClick={(selected, trigger) => { eventTrigger.current = trigger; setActiveEvent(selected); onEventSelect?.(selected); }} />)}</div>}</div></div>;
})}
</div>
</div>
</div>
<Popover triggerRef={notificationTrigger} isOpen={notificationsOpen} onOpenChange={setNotificationsOpen} placement="bottom end" offset={8} isNonModal className="w-[430px] max-w-[calc(100vw-32px)] rounded-3xl outline-none transition duration-150 ease-out data-[entering]:scale-95 data-[entering]:opacity-0 data-[entering]:blur-[2px] data-[exiting]:scale-95 data-[exiting]:opacity-0 data-[exiting]:blur-[2px]"><Dialog aria-label="Notifications" className="outline-none"><NotificationCenter notifications={notifications} /></Dialog></Popover>
<Popover triggerRef={eventTrigger} isOpen={activeEvent !== null} onOpenChange={(open) => { if (!open) setActiveEvent(null); }} placement="right" offset={8} isNonModal className="w-[302px] max-w-[calc(100vw-32px)] rounded-[20px] border border-border-button-default bg-background-primary-default p-2.5 outline-none shadow-[0px_1px_2px_0px_rgba(0,0,0,0.04),0px_7px_8px_0px_rgba(0,0,0,0.04)] transition duration-150 ease-out sm:data-[entering]:scale-90 sm:data-[entering]:opacity-0 sm:data-[entering]:blur-[4px] sm:data-[exiting]:scale-90 sm:data-[exiting]:opacity-0 sm:data-[exiting]:blur-[4px] max-sm:!fixed max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!m-0 max-sm:!max-h-[85dvh] max-sm:w-full max-sm:max-w-none max-sm:overflow-y-auto max-sm:rounded-[24px] max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0 max-sm:pb-[calc(10px+env(safe-area-inset-bottom))] max-sm:duration-300 max-sm:data-[entering]:translate-y-full max-sm:data-[exiting]:translate-y-full"><Dialog aria-label="Event details" className="outline-none">{activeEvent && <EventDetails event={activeEvent} onJoin={onJoinEvent} onEditTimezone={onEditTimezone} onEditParticipants={onEditParticipants} onEditReminders={onEditReminders} />}</Dialog></Popover>
</div>
);
}
/** Alias matching BoardCN's public component name. */
export const Calendar = BoardCalendar;Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
BoardCalendar
| Prop | Type | Default | Description |
|---|---|---|---|
| eventsrequired | CalendarEvent[] | — | — |
| inboxAccountsrequired | CalendarInboxAccount[] | — | — |
| notificationsrequired | NotificationCenterItem[] | — | — |
| defaultMonth | Date | new Date(2026, 7, 1) | — |
| defaultSelectedDate | Date | null | — |
| month | Date | — | — |
| notificationCount | number | 5 | — |
| onAddAccount | () => void | — | — |
| onDateSelect | (date: Date) => void | — | — |
| onEditParticipants | (event: CalendarEvent) => void | — | — |
| onEditReminders | (event: CalendarEvent) => void | — | — |
| onEditTimezone | (event: CalendarEvent) => void | — | — |
| onEventSelect | (event: CalendarEvent) => void | — | — |
| onInboxFeedSelect | (account: CalendarInboxAccount, feedId: string) => void | — | — |
| onJoinEvent | (event: CalendarEvent) => void | — | — |
| onMonthChange | (month: Date) => void | — | — |
| onNewEvent | () => void | — | — |
| selectedDate | Date | — | — |
Calendar
Alias matching BoardCN's public component name.
| Prop | Type | Default | Description |
|---|---|---|---|
| eventsrequired | CalendarEvent[] | — | — |
| inboxAccountsrequired | CalendarInboxAccount[] | — | — |
| notificationsrequired | NotificationCenterItem[] | — | — |
| defaultMonth | Date | — | — |
| defaultSelectedDate | Date | — | — |
| month | Date | — | — |
| notificationCount | number | — | — |
| onAddAccount | () => void | — | — |
| onDateSelect | (date: Date) => void | — | — |
| onEditParticipants | (event: CalendarEvent) => void | — | — |
| onEditReminders | (event: CalendarEvent) => void | — | — |
| onEditTimezone | (event: CalendarEvent) => void | — | — |
| onEventSelect | (event: CalendarEvent) => void | — | — |
| onInboxFeedSelect | (account: CalendarInboxAccount, feedId: string) => void | — | — |
| onJoinEvent | (event: CalendarEvent) => void | — | — |
| onMonthChange | (month: Date) => void | — | — |
| onNewEvent | () => void | — | — |
| selectedDate | Date | — | — |