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.json
npx shadcn@latest add https://boardcn.dev/r/calendar.json

npm 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.

components/blocks/calendar/calendar.tsx
"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

PropTypeDefaultDescription
eventsrequiredCalendarEvent[]
inboxAccountsrequiredCalendarInboxAccount[]
notificationsrequiredNotificationCenterItem[]
defaultMonthDatenew Date(2026, 7, 1)
defaultSelectedDateDatenull
monthDate
notificationCountnumber5
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
selectedDateDate

Calendar

Alias matching BoardCN's public component name.

PropTypeDefaultDescription
eventsrequiredCalendarEvent[]
inboxAccountsrequiredCalendarInboxAccount[]
notificationsrequiredNotificationCenterItem[]
defaultMonthDate
defaultSelectedDateDate
monthDate
notificationCountnumber
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
selectedDateDate