{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "event-calendar",
  "title": "Event Calendar",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A full calendar with month, week, day and agenda views: overlapping events side by side, multi-day bars, drag to move, resize or create, keyboard moves, a live now line and locale-aware weeks.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/popover.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/calendar-dates.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/event-calendar.tsx",
      "content": "// Ballmac UI: Event Calendar. https://ui.ballmac.com/components/event-calendar\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarDays, ChevronLeft, ChevronRight, MapPin, Plus } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ballmac/popover\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport {\n  addDays,\n  addMinutes,\n  addMonths,\n  daysBetween,\n  format,\n  formatRange,\n  isSameDay,\n  isSameMonth,\n  layoutDay,\n  layoutWeek,\n  minutesOfDay,\n  monthGrid,\n  setMinutesOfDay,\n  snap,\n  spanOf,\n  startOfDay,\n  toDate,\n  touchesDay,\n  weekDays,\n  weekStartFor,\n  type DateInput,\n  type Span,\n  type WeekStart,\n} from \"@/lib/ballmac/calendar-dates\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useLocale, useMessages } from \"@/lib/ballmac/i18n\"\nimport { cn } from \"@/lib/utils\"\n\ntype EventTone = \"chart-1\" | \"chart-2\" | \"chart-3\" | \"chart-4\" | \"chart-5\" | \"muted\"\ntype EventCalendarView = \"month\" | \"week\" | \"day\" | \"agenda\"\n\ntype CalendarEvent = {\n  id: string\n  title: string\n  start: DateInput\n  /** For a timed event, when it ends (an hour after `start` when left out). For an all-day event, its last day. */\n  end?: DateInput\n  allDay?: boolean\n  tone?: EventTone\n  location?: string\n  description?: string\n  /** Stays where it is even when the calendar is editable. */\n  readOnly?: boolean\n}\n\ntype EventChange = { start: Date; end: Date; allDay: boolean }\ntype EventRange = { start: Date; end: Date; allDay: boolean }\n\ntype EventCalendarProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> &\n  StateProps & {\n    /** What the calendar is, read by screen readers: \"Team calendar\". */\n    label: string\n    events: CalendarEvent[]\n    view?: EventCalendarView\n    defaultView?: EventCalendarView\n    onViewChange?: (view: EventCalendarView) => void\n    /** Which views people can switch between. */\n    views?: EventCalendarView[]\n    /** Any day in the period on show. */\n    date?: Date\n    defaultDate?: Date\n    onDateChange?: (date: Date) => void\n    /** The day treated as today. It is read from the clock after the page loads when you leave it out, so a server render never shows the wrong day. */\n    today?: Date\n    locale?: string\n    /** 0 is Sunday, 1 is Monday. Defaults to what the locale uses. */\n    weekStartsOn?: WeekStart\n    /** Let people drag events to move them and drag their bottom edge to change the length. */\n    editable?: boolean\n    /** Let people drag across empty time to make an event. */\n    selectable?: boolean\n    onEventClick?: (event: CalendarEvent) => void\n    onEventChange?: (event: CalendarEvent, change: EventChange) => void\n    onRangeSelect?: (range: EventRange) => void\n    /** Called when a day is chosen in the month view. Default: open that day. */\n    onDateClick?: (date: Date) => void\n    /** Minutes in one step when dragging. */\n    slotMinutes?: 15 | 30 | 60\n    /** Pixels in an hour of the week and day views. */\n    hourHeight?: number\n    /** The first hour of the day on show, so the grid opens on working hours. */\n    scrollToHour?: number\n    /** Height of the scrolling week and day views. */\n    height?: number\n    /** How many bars a month cell shows before \"+n more\". */\n    maxLanes?: number\n    /** Replace what an event shows inside its block. */\n    renderEvent?: (event: CalendarEvent, info: { view: EventCalendarView; compact: boolean }) => React.ReactNode\n  }\n\nconst TONES: Record<EventTone, { block: string; accent: string; dot: string }> = {\n  \"chart-1\": { block: \"border-chart-1/40 bg-chart-1/15 hover:bg-chart-1/25\", accent: \"bg-chart-1\", dot: \"bg-chart-1\" },\n  \"chart-2\": { block: \"border-chart-2/40 bg-chart-2/15 hover:bg-chart-2/25\", accent: \"bg-chart-2\", dot: \"bg-chart-2\" },\n  \"chart-3\": { block: \"border-chart-3/40 bg-chart-3/15 hover:bg-chart-3/25\", accent: \"bg-chart-3\", dot: \"bg-chart-3\" },\n  \"chart-4\": { block: \"border-chart-4/40 bg-chart-4/15 hover:bg-chart-4/25\", accent: \"bg-chart-4\", dot: \"bg-chart-4\" },\n  \"chart-5\": { block: \"border-chart-5/40 bg-chart-5/15 hover:bg-chart-5/25\", accent: \"bg-chart-5\", dot: \"bg-chart-5\" },\n  muted: { block: \"border-border bg-muted hover:bg-muted/70\", accent: \"bg-muted-foreground\", dot: \"bg-muted-foreground\" },\n}\n\nconst ALL_VIEWS: EventCalendarView[] = [\"month\", \"week\", \"day\", \"agenda\"]\nconst GUTTER = 56 // width of the hour labels, in pixels\nconst ALL_DAY_LANE = 24\nconst MONTH_CELL = 124\n\nconst clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))\n\n/** The current time, read after the page loads and every minute, never during render. */\nfunction useNow(override?: Date) {\n  const fixed = override?.getTime()\n  const [now, setNow] = React.useState<Date | null>(fixed === undefined ? null : new Date(fixed))\n  React.useEffect(() => {\n    if (fixed !== undefined) {\n      setNow(new Date(fixed))\n      return\n    }\n    setNow(new Date())\n    const t = window.setInterval(() => setNow(new Date()), 60_000)\n    return () => window.clearInterval(t)\n  }, [fixed])\n  return now\n}\n\ntype Ctx = {\n  locale: string\n  rtl: boolean\n  today: Date | null\n  /** The current moment, for the now line. */\n  now: Date | null\n  editable: boolean\n  selectable: boolean\n  slot: number\n  hourHeight: number\n  onEventClick?: (e: CalendarEvent) => void\n  onEventChange?: (e: CalendarEvent, c: EventChange) => void\n  onRangeSelect?: (r: EventRange) => void\n  announce: (text: string) => void\n  renderEvent?: EventCalendarProps[\"renderEvent\"]\n  view: EventCalendarView\n}\nconst CalendarCtx = React.createContext<Ctx | null>(null)\nconst useCal = () => {\n  const c = React.useContext(CalendarCtx)\n  if (!c) throw new Error(\"EventCalendar parts must be used inside <EventCalendar>.\")\n  return c\n}\n\n// ------------------------------------------------------------------------------------------------- words\n\nfunction useWords() {\n  const msg = useMessages()\n  const { locale } = useCal()\n  const time = (d: Date) => format(d, locale, { hour: \"numeric\", minute: \"2-digit\" })\n  /** \"9 AM\" on the hour, \"9:30 AM\" otherwise: shorter, for narrow cells. */\n  const shortTime = (d: Date) => format(d, locale, d.getMinutes() === 0 ? { hour: \"numeric\" } : { hour: \"numeric\", minute: \"2-digit\" })\n  const dayLong = (d: Date) => format(d, locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\" })\n  /** \"Design review, Tuesday, October 20, 2026, 10:00 AM to 11:00 AM, Room 4\" */\n  const describe = (event: CalendarEvent) => {\n    const s = spanOf(event)\n    const where = event.location ? `, ${event.location}` : \"\"\n    if (s.allDay) {\n      const last = addDays(s.end, -1)\n      return isSameDay(s.start, last)\n        ? msg(\"event-calendar.describeAllDay\", \"{title}, {date}, all day{where}\", { title: event.title, date: dayLong(s.start), where })\n        : msg(\"event-calendar.describeAllDayRange\", \"{title}, {from} to {to}, all day{where}\", { title: event.title, from: dayLong(s.start), to: dayLong(last), where })\n    }\n    return isSameDay(s.start, addMinutes(s.end, -1))\n      ? msg(\"event-calendar.describeTimed\", \"{title}, {date}, {from} to {to}{where}\", { title: event.title, date: dayLong(s.start), from: time(s.start), to: time(s.end), where })\n      : msg(\"event-calendar.describeTimedRange\", \"{title}, {from} to {to}{where}\", { title: event.title, from: `${dayLong(s.start)} ${time(s.start)}`, to: `${dayLong(s.end)} ${time(s.end)}`, where })\n  }\n  return { msg, time, shortTime, dayLong, describe }\n}\n\n// ------------------------------------------------------------------------------------------------- an event, as a button\n\ntype ChipProps = {\n  event: CalendarEvent\n  /** `block` is a tinted block with an accent edge. `inline` is a dot, the time and the title, for a short event in a month cell. */\n  variant: \"block\" | \"inline\"\n  className?: string\n  style?: React.CSSProperties\n  /** Where the event continues, for a bar that is cut by the edge of a week. */\n  continues?: { before: boolean; after: boolean }\n  /** Which keys move it, and what they do. Set by the view. */\n  onKeyMove?: (event: CalendarEvent, key: string, shift: boolean) => boolean\n  onPointerDown?: (e: React.PointerEvent<HTMLButtonElement>) => void\n  dragging?: boolean\n  ghost?: boolean\n  showTime?: boolean\n  children?: React.ReactNode\n}\n\nfunction EventChip({ event, variant, className, style, continues, onKeyMove, onPointerDown, dragging, ghost, showTime = true, children }: ChipProps) {\n  const cal = useCal()\n  const { time, shortTime, describe } = useWords()\n  const tone = TONES[event.tone ?? \"chart-1\"]\n  const s = spanOf(event)\n  const compact = variant === \"inline\"\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"event-calendar-event\"\n      data-event-id={event.id}\n      data-dragging={dragging || undefined}\n      aria-label={describe(event)}\n      aria-roledescription={cal.editable && !event.readOnly ? \"movable event\" : undefined}\n      tabIndex={ghost ? -1 : 0}\n      onPointerDown={onPointerDown}\n      onClick={(e) => {\n        if (ghost) return\n        e.stopPropagation()\n        cal.onEventClick?.(event)\n      }}\n      onKeyDown={(e) => {\n        if (e.altKey && onKeyMove?.(event, e.key, e.shiftKey)) {\n          e.preventDefault()\n          e.stopPropagation()\n        }\n      }}\n      style={style}\n      className={cn(\n        \"group/event relative flex min-w-0 items-start gap-1.5 overflow-hidden text-start text-xs leading-tight outline-none transition-colors focus-visible:z-20 focus-visible:ring-[3px] focus-visible:ring-ring/60 motion-reduce:transition-none\",\n        compact ? \"rounded px-1 py-0.5 hover:bg-accent\" : cn(\"rounded-md border px-2 py-1\", tone.block),\n        !compact && continues?.before && \"rounded-s-none border-s-0\",\n        !compact && continues?.after && \"rounded-e-none border-e-0\",\n        dragging && \"z-30 cursor-grabbing shadow-lg\",\n        ghost && \"pointer-events-none opacity-90 shadow-xl ring-1 ring-ring/40\",\n        className\n      )}\n    >\n      {compact ? <span aria-hidden=\"true\" className={cn(\"mt-[3px] size-1.5 shrink-0 rounded-full\", tone.dot)} /> : <span aria-hidden=\"true\" className={cn(\"absolute inset-y-0 start-0 w-1\", tone.accent, continues?.before && \"hidden\")} />}\n      {children ?? (\n        <span className={cn(\"min-w-0 flex-1\", !compact && !continues?.before && \"ps-1\")}>\n          {cal.renderEvent ? (\n            cal.renderEvent(event, { view: cal.view, compact })\n          ) : (\n            <>\n              <span className=\"block truncate font-medium text-foreground\">\n                {compact && showTime && !s.allDay ? <span className=\"me-1 font-normal tabular-nums text-muted-foreground\">{shortTime(s.start)}</span> : null}\n                {event.title}\n              </span>\n              {!compact && showTime && !s.allDay ? <bdi className=\"block truncate tabular-nums text-muted-foreground\">{time(s.start)} – {time(s.end)}</bdi> : null}\n            </>\n          )}\n        </span>\n      )}\n    </button>\n  )\n}\n\n// ------------------------------------------------------------------------------------------------- the calendar\n\n/**\n * A full calendar with month, week, day and agenda views. Events that overlap sit side by side, multi-day events run as bars across the month,\n * and when `editable` is on people drag events to move them and drag across empty time to make new ones. Everything has a keyboard path:\n * arrow keys move between days, Alt+arrow moves an event, and the toolbar makes a new one.\n */\nfunction EventCalendar({\n  label,\n  events,\n  view: viewProp,\n  defaultView = \"month\",\n  onViewChange,\n  views = ALL_VIEWS,\n  date: dateProp,\n  defaultDate,\n  onDateChange,\n  today: todayProp,\n  locale: localeProp,\n  weekStartsOn,\n  editable = false,\n  selectable = false,\n  onEventClick,\n  onEventChange,\n  onRangeSelect,\n  onDateClick,\n  slotMinutes = 30,\n  hourHeight = 56,\n  scrollToHour = 7,\n  height = 560,\n  maxLanes = 3,\n  renderEvent,\n  state,\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: EventCalendarProps) {\n  const msg = useMessages()\n  const contextLocale = useLocale()\n  const locale = localeProp ?? contextLocale\n  const dir = useDirection()\n  const rtl = dir === \"rtl\"\n  const now = useNow(todayProp)\n  const weekStart = weekStartsOn ?? weekStartFor(locale)\n\n  const [innerView, setInnerView] = React.useState<EventCalendarView>(defaultView)\n  const view = viewProp ?? innerView\n  const setView = (v: EventCalendarView) => {\n    if (viewProp === undefined) setInnerView(v)\n    onViewChange?.(v)\n  }\n  const [innerDate, setInnerDate] = React.useState<Date | null>(defaultDate ? startOfDay(defaultDate) : null)\n  // with no date given, the calendar opens on today once it knows what today is\n  const date = dateProp ?? innerDate ?? (now ? startOfDay(now) : null)\n  const setDate = (d: Date) => {\n    if (dateProp === undefined) setInnerDate(d)\n    onDateChange?.(d)\n  }\n\n  const [spoken, setSpoken] = React.useState(\"\")\n  const spans = React.useMemo(() => events.map((e) => spanOf(e)), [events])\n\n  const ctx: Ctx = {\n    locale,\n    rtl,\n    today: now ? startOfDay(now) : null,\n    now,\n    editable,\n    selectable,\n    slot: slotMinutes,\n    hourHeight,\n    onEventClick,\n    onEventChange,\n    onRangeSelect,\n    announce: setSpoken,\n    renderEvent,\n    view,\n  }\n\n  const step = (n: -1 | 1) => {\n    if (!date) return\n    setDate(view === \"month\" || view === \"agenda\" ? addMonths(date, n) : view === \"week\" ? addDays(date, 7 * n) : addDays(date, n))\n  }\n  const goToday = () => now && setDate(startOfDay(now))\n\n  const title = !date\n    ? \"\"\n    : view === \"month\" || view === \"agenda\"\n      ? format(date, locale, { month: \"long\", year: \"numeric\" })\n      : view === \"day\"\n        ? format(date, locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\" })\n        : (() => {\n            const days = weekDays(date, weekStart)\n            return formatRange(days[0]!, days[6]!, locale, { month: \"short\", day: \"numeric\", year: \"numeric\" })\n          })()\n\n  const periodWord = view === \"month\" || view === \"agenda\" ? \"month\" : view\n  const prevLabel = msg(`event-calendar.previous.${periodWord}`, periodWord === \"month\" ? \"Previous month\" : periodWord === \"week\" ? \"Previous week\" : \"Previous day\")\n  const nextLabel = msg(`event-calendar.next.${periodWord}`, periodWord === \"month\" ? \"Next month\" : periodWord === \"week\" ? \"Next week\" : \"Next day\")\n  const viewName = (v: EventCalendarView) => msg(`event-calendar.view.${v}`, v === \"month\" ? \"Month\" : v === \"week\" ? \"Week\" : v === \"day\" ? \"Day\" : \"Agenda\")\n\n  const openDay = (d: Date) => {\n    setDate(startOfDay(d))\n    if (views.includes(\"day\")) setView(\"day\")\n  }\n\n  const toolbar = (\n    <div data-slot=\"event-calendar-toolbar\" className=\"flex flex-wrap items-center gap-2 border-b px-3 py-2.5\">\n      <div className=\"flex items-center gap-1\">\n        <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={goToday} disabled={!now}>\n          {msg(\"event-calendar.today\", \"Today\")}\n        </Button>\n        <Button type=\"button\" variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label={prevLabel} onClick={() => step(-1)} disabled={!date}>\n          <ChevronLeft className=\"rtl:rotate-180\" aria-hidden=\"true\" />\n        </Button>\n        <Button type=\"button\" variant=\"ghost\" size=\"icon\" className=\"size-8\" aria-label={nextLabel} onClick={() => step(1)} disabled={!date}>\n          <ChevronRight className=\"rtl:rotate-180\" aria-hidden=\"true\" />\n        </Button>\n      </div>\n      <h2 aria-live=\"polite\" className=\"min-w-0 flex-1 truncate text-base font-semibold tracking-tight tabular-nums\">\n        {title}\n      </h2>\n      {selectable && date ? (\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          size=\"sm\"\n          onClick={() => {\n            const start = setMinutesOfDay(date, 9 * 60)\n            onRangeSelect?.({ start, end: addMinutes(start, 60), allDay: false })\n          }}\n        >\n          <Plus aria-hidden=\"true\" />\n          {msg(\"event-calendar.add\", \"Add event\")}\n        </Button>\n      ) : null}\n      {views.length > 1 ? (\n        <div role=\"group\" aria-label={msg(\"event-calendar.viewSwitch\", \"Calendar view\")} className=\"inline-flex rounded-lg bg-muted p-0.5\">\n          {views.map((v) => (\n            <button\n              key={v}\n              type=\"button\"\n              aria-pressed={view === v}\n              onClick={() => setView(v)}\n              className={cn(\n                \"rounded-md px-2.5 py-1 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                view === v ? \"bg-background text-foreground shadow-xs\" : \"text-muted-foreground hover:text-foreground\"\n              )}\n            >\n              {viewName(v)}\n            </button>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  )\n\n  const skeleton = (\n    <div className=\"overflow-hidden rounded-xl border bg-card\" aria-hidden=\"true\">\n      <div className=\"flex items-center gap-2 border-b px-3 py-2.5\">\n        <Skeleton className=\"h-8 w-16\" />\n        <Skeleton className=\"h-8 w-8\" />\n        <Skeleton className=\"h-8 w-8\" />\n        <Skeleton className=\"h-5 w-40\" />\n      </div>\n      <div className=\"grid grid-cols-7\">\n        {Array.from({ length: 35 }, (_, i) => (\n          <div key={i} className=\"h-20 border-e border-b p-2\">\n            <Skeleton className=\"h-3 w-5\" />\n            {i % 4 === 1 ? <Skeleton className=\"mt-3 h-4 w-full\" /> : null}\n            {i % 6 === 2 ? <Skeleton className=\"mt-2 h-4 w-3/4\" /> : null}\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n\n  const effective: StateProps[\"state\"] = state ?? \"ready\"\n\n  return (\n    <CalendarCtx.Provider value={ctx}>\n      <div data-slot=\"event-calendar\" data-view={view} dir={dir} className={cn(\"min-w-0\", className)} {...props}>\n        <StateView state={effective} loading={skeleton} error={errorState ?? {}} empty={emptyState ?? {}} onRetry={onRetry} label={msg(\"event-calendar.loading\", \"Loading the calendar\")} bare className=\"rounded-xl\">\n          <section aria-label={label} className=\"overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs\">\n            {toolbar}\n            {date ? (\n              view === \"month\" ? (\n                <MonthView date={date} spans={spans} events={events} weekStart={weekStart} maxLanes={maxLanes} label={label} onOpenDay={onDateClick ?? openDay} setDate={setDate} />\n              ) : view === \"agenda\" ? (\n                <AgendaView date={date} spans={spans} events={events} emptyState={emptyState} />\n              ) : (\n                <TimeGridView date={date} days={view === \"day\" ? [date] : weekDays(date, weekStart)} spans={spans} events={events} height={height} scrollToHour={scrollToHour} onOpenDay={openDay} label={label} />\n              )\n            ) : (\n              <div className=\"h-64\" aria-hidden=\"true\" />\n            )}\n          </section>\n        </StateView>\n        <span data-slot=\"event-calendar-status\" role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {spoken}\n        </span>\n      </div>\n    </CalendarCtx.Provider>\n  )\n}\n\nconst eventById = (events: CalendarEvent[], id: string) => events.find((e) => e.id === id)\n\n/** The result of moving an event with the keyboard: Alt+arrows move it, Alt+Shift+arrows change its length. */\nfunction keyChange(event: CalendarEvent, key: string, shift: boolean, opts: { dayStep: number; weekStep: number; slot: number; timed: boolean; rtl: boolean }): EventChange | null {\n  const s = spanOf(event)\n  const start = key === \"ArrowLeft\" ? -1 : key === \"ArrowRight\" ? 1 : 0\n  const horizontal = opts.rtl ? -start : start\n  const vertical = key === \"ArrowUp\" ? -1 : key === \"ArrowDown\" ? 1 : 0\n  if (!horizontal && !vertical) return null\n  if (shift) {\n    // length: Down and Right make it longer\n    const delta = (vertical || horizontal) * (opts.timed ? opts.slot : 1440)\n    const end = addMinutes(s.end, delta)\n    if (end <= addMinutes(s.start, opts.timed ? opts.slot : 1440)) return null\n    return { start: s.start, end, allDay: s.allDay }\n  }\n  if (opts.timed) {\n    const days = horizontal\n    const minutes = vertical * opts.slot\n    const start2 = addMinutes(addDays(s.start, days), minutes)\n    const end2 = addMinutes(addDays(s.end, days), minutes)\n    return { start: start2, end: end2, allDay: false }\n  }\n  const days = horizontal * opts.dayStep + vertical * opts.weekStep\n  return { start: addDays(s.start, days), end: addDays(s.end, days), allDay: s.allDay }\n}\n\n/** Gives a change back in the shape callers store: an all-day event's end is its last day. */\nfunction forCaller(event: CalendarEvent, c: EventChange): EventChange {\n  return c.allDay ? { ...c, end: addDays(c.end, -1) } : c\n}\n\n// ------------------------------------------------------------------------------------------------- month\n\nfunction MonthView({\n  date,\n  spans,\n  events,\n  weekStart,\n  maxLanes,\n  label,\n  onOpenDay,\n  setDate,\n}: {\n  date: Date\n  spans: Span<CalendarEvent>[]\n  events: CalendarEvent[]\n  weekStart: WeekStart\n  maxLanes: number\n  label: string\n  onOpenDay: (d: Date) => void\n  setDate: (d: Date) => void\n}) {\n  const cal = useCal()\n  const { msg, dayLong } = useWords()\n  const weeks = React.useMemo(() => monthGrid(date, weekStart), [date, weekStart])\n  const heads = weeks[0]!\n  const [focus, setFocus] = React.useState<Date>(date)\n  const root = React.useRef<HTMLDivElement>(null)\n  const wantFocus = React.useRef(false)\n  React.useEffect(() => setFocus(date), [date])\n\n  const moveFocus = (d: Date) => {\n    wantFocus.current = true\n    if (!isSameMonth(d, date)) setDate(d)\n    setFocus(d)\n  }\n  React.useLayoutEffect(() => {\n    if (!wantFocus.current) return\n    wantFocus.current = false\n    root.current?.querySelector<HTMLElement>(`[data-date=\"${keyOfDate(focus)}\"]`)?.focus()\n  }, [focus, date])\n\n  const onGridKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const target = (e.target as HTMLElement).closest<HTMLElement>(\"[data-date]\")\n    if (!target || target !== e.target) return\n    const d = parseKey(target.dataset.date!)\n    const back = cal.rtl ? \"ArrowRight\" : \"ArrowLeft\"\n    const fwd = cal.rtl ? \"ArrowLeft\" : \"ArrowRight\"\n    let next: Date | null = null\n    if (e.key === back) next = addDays(d, -1)\n    else if (e.key === fwd) next = addDays(d, 1)\n    else if (e.key === \"ArrowUp\") next = addDays(d, -7)\n    else if (e.key === \"ArrowDown\") next = addDays(d, 7)\n    else if (e.key === \"PageUp\") next = addMonths(d, -1)\n    else if (e.key === \"PageDown\") next = addMonths(d, 1)\n    else if (e.key === \"Home\") next = addDays(d, -((d.getDay() - weekStart + 7) % 7))\n    else if (e.key === \"End\") next = addDays(d, 6 - ((d.getDay() - weekStart + 7) % 7))\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault()\n      onOpenDay(d)\n      return\n    }\n    if (next) {\n      e.preventDefault()\n      moveFocus(next)\n    }\n  }\n\n  // ----- dragging an event to another day\n  const [drag, setDrag] = React.useState<{ id: string; x: number; y: number; over: Date | null } | null>(null)\n  const startDrag = (event: CalendarEvent) => (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (!cal.editable || event.readOnly || e.button !== 0) return\n    const el = e.currentTarget\n    const x0 = e.clientX\n    const y0 = e.clientY\n    let moved = false\n    const dayAt = (x: number, y: number) => {\n      const hit = document.elementsFromPoint(x, y).find((n) => (n as HTMLElement).dataset?.dateCell !== undefined) as HTMLElement | undefined\n      return hit ? parseKey(hit.dataset.dateCell!) : null\n    }\n    const mv = (ev: PointerEvent) => {\n      if (!moved && Math.hypot(ev.clientX - x0, ev.clientY - y0) < 5) return\n      moved = true\n      setDrag({ id: event.id, x: ev.clientX, y: ev.clientY, over: dayAt(ev.clientX, ev.clientY) })\n    }\n    const finish = (ev: PointerEvent, commit: boolean) => {\n      window.removeEventListener(\"pointermove\", mv)\n      window.removeEventListener(\"pointerup\", up)\n      window.removeEventListener(\"pointercancel\", cancel)\n      setDrag(null)\n      if (!moved) return\n      // the click that follows a drag must not open the event\n      el.addEventListener(\"click\", (c) => c.stopPropagation(), { capture: true, once: true })\n      const target = commit ? dayAt(ev.clientX, ev.clientY) : null\n      if (!target) return\n      const s = spanOf(event)\n      const delta = daysBetween(s.start, target)\n      if (!delta) return\n      const change: EventChange = { start: addDays(s.start, delta), end: addDays(s.end, delta), allDay: s.allDay }\n      cal.onEventChange?.(event, forCaller(event, change))\n      cal.announce(msg(\"event-calendar.movedTo\", \"Moved {title} to {date}.\", { title: event.title, date: dayLong(change.start) }))\n    }\n    const up = (ev: PointerEvent) => finish(ev, true)\n    const cancel = (ev: PointerEvent) => finish(ev, false)\n    window.addEventListener(\"pointermove\", mv)\n    window.addEventListener(\"pointerup\", up)\n    window.addEventListener(\"pointercancel\", cancel)\n  }\n\n  const onKeyMove = (event: CalendarEvent, key: string, shift: boolean) => {\n    if (!cal.editable || event.readOnly) return false\n    const c = keyChange(event, key, shift, { dayStep: 1, weekStep: 7, slot: cal.slot, timed: false, rtl: cal.rtl })\n    if (!c) return false\n    const out = spanOf(event).allDay ? c : { ...c, allDay: false }\n    cal.onEventChange?.(event, forCaller(event, out))\n    cal.announce(msg(\"event-calendar.movedTo\", \"Moved {title} to {date}.\", { title: event.title, date: dayLong(out.start) }))\n    return true\n  }\n\n  const dragged = drag ? eventById(events, drag.id) : null\n\n  return (\n    <div ref={root} role=\"grid\" aria-label={format(date, cal.locale, { month: \"long\", year: \"numeric\" })} aria-colcount={7} aria-rowcount={weeks.length + 1} onKeyDown={onGridKey} data-slot=\"event-calendar-month\" className=\"select-none\">\n      <div role=\"row\" className=\"grid grid-cols-7 border-b bg-muted/40\">\n        {heads.map((d) => (\n          <div key={d.getDay()} role=\"columnheader\" aria-label={format(d, cal.locale, { weekday: \"long\" })} className=\"px-2 py-1.5 text-center text-xs font-medium text-muted-foreground\">\n            {format(d, cal.locale, { weekday: \"short\" })}\n          </div>\n        ))}\n      </div>\n      {weeks.map((week, wi) => {\n        const segs = layoutWeek(spans, week)\n        const overflow = week.map((day, di) => segs.filter((s) => s.from <= di && s.to >= di && s.lane >= maxLanes).length)\n        return (\n          <div key={wi} role=\"row\" aria-rowindex={wi + 2} className=\"relative grid grid-cols-7 border-b last:border-b-0\" style={{ minHeight: MONTH_CELL }}>\n            {week.map((day, di) => {\n              const inMonth = isSameMonth(day, date)\n              const isToday = !!cal.today && isSameDay(day, cal.today)\n              const count = spans.filter((s) => touchesDay(s, day)).length\n              const hover = drag?.over && isSameDay(drag.over, day)\n              return (\n                <div\n                  key={di}\n                  role=\"gridcell\"\n                  tabIndex={isSameDay(day, focus) ? 0 : -1}\n                  data-date={keyOfDate(day)}\n                  data-date-cell={keyOfDate(day)}\n                  data-today={isToday || undefined}\n                  data-outside={!inMonth || undefined}\n                  aria-label={count ? msg(\"event-calendar.dayWithEvents\", { one: \"{date}, {count} event\", other: \"{date}, {count} events\" }, { date: dayLong(day), count }) : dayLong(day)}\n                  aria-current={isToday ? \"date\" : undefined}\n                  onFocus={(e) => e.target === e.currentTarget && setFocus(day)}\n                  onClick={(e) => e.target === e.currentTarget && cal.selectable && cal.onRangeSelect?.({ start: day, end: addDays(day, 1), allDay: true })}\n                  onDoubleClick={() => onOpenDay(day)}\n                  className={cn(\n                    \"relative min-w-0 border-e p-1 outline-none transition-colors last:border-e-0 focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/60 motion-reduce:transition-none\",\n                    !inMonth && \"bg-muted/30\",\n                    hover && \"bg-accent\"\n                  )}\n                >\n                  <button\n                    type=\"button\"\n                    tabIndex={-1}\n                    onClick={() => onOpenDay(day)}\n                    aria-label={msg(\"event-calendar.openDay\", \"Open {date}\", { date: dayLong(day) })}\n                    className={cn(\n                      \"ms-auto flex size-6 items-center justify-center rounded-full text-xs tabular-nums outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                      !inMonth && \"text-muted-foreground\",\n                      isToday && \"bg-primary font-semibold text-primary-foreground hover:bg-primary/90\"\n                    )}\n                  >\n                    {day.getDate()}\n                  </button>\n                  {overflow[di]! > 0 ? (\n                    <MoreButton\n                      day={day}\n                      spans={spans}\n                      count={overflow[di]!}\n                      className=\"absolute inset-x-1\"\n                      style={{ top: 28 + maxLanes * ALL_DAY_LANE }}\n                    />\n                  ) : null}\n                  {/* a bar starts in the cell of its first day and runs across the cells after it, so the row holds only cells */}\n                  {segs\n                    .filter((seg) => seg.from === di && seg.lane < maxLanes)\n                    .map((seg) => {\n                      const e = seg.span.event\n                      const multi = seg.span.allDay || seg.to > seg.from || !isSameDay(seg.span.start, addMinutes(seg.span.end, -1))\n                      return (\n                        <EventChip\n                          key={`${e.id}-${wi}`}\n                          event={e}\n                          variant={multi ? \"block\" : \"inline\"}\n                          continues={{ before: !seg.startsHere, after: !seg.endsHere }}\n                          dragging={drag?.id === e.id}\n                          onPointerDown={startDrag(e)}\n                          onKeyMove={onKeyMove}\n                          className={cn(\"absolute z-10\", cal.editable && !e.readOnly && \"cursor-grab\")}\n                          style={{\n                            insetInlineStart: 3,\n                            width: `calc(${(seg.to - seg.from + 1) * 100}% - 6px)`,\n                            top: 30 + seg.lane * ALL_DAY_LANE,\n                            height: ALL_DAY_LANE - 2,\n                          }}\n                        />\n                      )\n                    })}\n                </div>\n              )\n            })}\n          </div>\n        )\n      })}\n      {drag && dragged ? (\n        <div className=\"pointer-events-none fixed z-50\" style={{ left: drag.x + 8, top: drag.y + 8, width: 180 }}>\n          <EventChip event={dragged} variant=\"block\" ghost style={{ height: ALL_DAY_LANE + 4 }} />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nfunction MoreButton({ day, spans, count, className, style }: { day: Date; spans: Span<CalendarEvent>[]; count: number; className?: string; style?: React.CSSProperties }) {\n  const { msg, dayLong } = useWords()\n  const { locale } = useCal()\n  const list = spans.filter((s) => touchesDay(s, day)).sort((a, b) => a.start.getTime() - b.start.getTime())\n  return (\n    <Popover>\n      <PopoverTrigger\n        className={cn(\"rounded px-1 text-start text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\", className)}\n        style={style}\n        onClick={(e) => e.stopPropagation()}\n        aria-label={msg(\"event-calendar.moreOn\", \"{count} more events on {date}\", { count, date: dayLong(day) })}\n      >\n        {msg(\"event-calendar.more\", \"+{count} more\", { count })}\n      </PopoverTrigger>\n      <PopoverContent label={msg(\"event-calendar.eventsOn\", \"Events on {date}\", { date: dayLong(day) })} align=\"start\" className=\"w-64 p-2\">\n        <p className=\"mb-1.5 px-1 text-sm font-semibold\">{format(day, locale, { weekday: \"long\", month: \"long\", day: \"numeric\" })}</p>\n        <div className=\"grid gap-1\">\n          {list.map((s) => (\n            <EventChip key={s.event.id} event={s.event} variant=\"block\" className=\"h-8 items-center\" />\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nconst keyOfDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`\nconst parseKey = (k: string) => toDate(k)\n\n// ------------------------------------------------------------------------------------------------- week and day\n\ntype TimeDrag =\n  | { kind: \"move\"; event: CalendarEvent; day: number; start: Date; end: Date; grab: number }\n  | { kind: \"resize-end\" | \"resize-start\"; event: CalendarEvent; day: number; start: Date; end: Date; grab: number }\n  | { kind: \"create\"; day: number; anchor: number; start: Date; end: Date; grab: number }\n\nfunction TimeGridView({\n  date,\n  days,\n  spans,\n  events,\n  height,\n  scrollToHour,\n  onOpenDay,\n  label,\n}: {\n  date: Date\n  days: Date[]\n  spans: Span<CalendarEvent>[]\n  events: CalendarEvent[]\n  height: number\n  scrollToHour: number\n  onOpenDay: (d: Date) => void\n  label: string\n}) {\n  const cal = useCal()\n  const { msg, time, dayLong } = useWords()\n  const { hourHeight: hh, slot } = cal\n  const total = hh * 24\n  const body = React.useRef<HTMLDivElement>(null)\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const columns = React.useRef<HTMLDivElement>(null)\n  const [drag, setDrag] = React.useState<TimeDrag | null>(null)\n  const dragRef = React.useRef<TimeDrag | null>(null)\n  dragRef.current = drag\n\n  React.useEffect(() => {\n    if (scroller.current) scroller.current.scrollTop = Math.max(0, scrollToHour * hh - 8)\n    // opens on working hours once, and again when the view changes length\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [days.length])\n\n  const allDay = spans.filter((s) => s.allDay || s.end.getTime() - s.start.getTime() >= 86_400_000)\n  const timedSpans = spans.filter((s) => !allDay.includes(s))\n  const segs = layoutWeek(allDay, days)\n  const lanes = segs.reduce((m, s) => Math.max(m, s.lane + 1), 0)\n\n  /** The column and minute the pointer is over. */\n  const locate = (x: number, y: number) => {\n    const box = columns.current!.getBoundingClientRect()\n    const w = box.width / days.length\n    const raw = cal.rtl ? (box.right - x) / w : (x - box.left) / w\n    const day = clamp(Math.floor(raw), 0, days.length - 1)\n    const minutes = clamp(((y - box.top) / hh) * 60, 0, 1440)\n    return { day, minutes }\n  }\n\n  const rangeFor = (day: number, a: number, b: number) => {\n    const lo = Math.min(a, b)\n    const hi = Math.max(a, b)\n    const start = setMinutesOfDay(days[day]!, clamp(snap(lo, slot), 0, 1440 - slot))\n    const end = setMinutesOfDay(days[day]!, clamp(Math.max(snap(hi, slot), snap(lo, slot) + slot), slot, 1440))\n    return { start, end }\n  }\n\n  const begin = (kind: \"move\" | \"resize-end\" | \"resize-start\", event: CalendarEvent, dayIndex: number) => (e: React.PointerEvent<HTMLElement>) => {\n    if (!cal.editable || event.readOnly || e.button !== 0) return\n    e.stopPropagation()\n    const s = spanOf(event)\n    const x0 = e.clientX\n    const y0 = e.clientY\n    const at = locate(x0, y0)\n    const grab = at.minutes - minutesOfDay(s.start)\n    const el = e.currentTarget\n    let moved = false\n    const dur = (s.end.getTime() - s.start.getTime()) / 60_000\n    const mv = (ev: PointerEvent) => {\n      if (!moved && Math.hypot(ev.clientX - x0, ev.clientY - y0) < 5) return\n      moved = true\n      const p = locate(ev.clientX, ev.clientY)\n      if (kind === \"move\") {\n        const startMin = clamp(snap(p.minutes - grab, slot), 0, Math.max(0, 1440 - Math.min(dur, 1440)))\n        const start = setMinutesOfDay(days[p.day]!, startMin)\n        setDrag({ kind, event, day: p.day, start, end: addMinutes(start, dur), grab })\n      } else if (kind === \"resize-end\") {\n        const end = clamp(Math.max(snap(p.minutes, slot), minutesOfDay(s.start) + slot), 0, 1440)\n        setDrag({ kind, event, day: dayIndex, start: s.start, end: setMinutesOfDay(days[dayIndex]!, end), grab })\n      } else {\n        const startMin = clamp(Math.min(snap(p.minutes, slot), minutesOfDay(s.end) - slot), 0, 1440)\n        setDrag({ kind, event, day: dayIndex, start: setMinutesOfDay(days[dayIndex]!, startMin), end: s.end, grab })\n      }\n    }\n    const done = (commit: boolean) => {\n      window.removeEventListener(\"pointermove\", mv)\n      window.removeEventListener(\"pointerup\", up)\n      window.removeEventListener(\"pointercancel\", cancel)\n      window.removeEventListener(\"keydown\", key, true)\n      const d = dragRef.current\n      setDrag(null)\n      if (!moved) return\n      el.addEventListener(\"click\", (c) => c.stopPropagation(), { capture: true, once: true })\n      if (!commit || !d || d.kind === \"create\") return\n      if (d.start.getTime() === s.start.getTime() && d.end.getTime() === s.end.getTime()) return\n      cal.onEventChange?.(event, { start: d.start, end: d.end, allDay: false })\n      cal.announce(msg(\"event-calendar.movedTimed\", \"Moved {title} to {date}, {from} to {to}.\", { title: event.title, date: dayLong(d.start), from: time(d.start), to: time(d.end) }))\n    }\n    const up = () => done(true)\n    const cancel = () => done(false)\n    const key = (ev: KeyboardEvent) => {\n      if (ev.key === \"Escape\") {\n        ev.stopPropagation()\n        done(false)\n      }\n    }\n    window.addEventListener(\"pointermove\", mv)\n    window.addEventListener(\"pointerup\", up)\n    window.addEventListener(\"pointercancel\", cancel)\n    window.addEventListener(\"keydown\", key, true)\n  }\n\n  const beginCreate = (e: React.PointerEvent<HTMLDivElement>) => {\n    // only empty time starts one: the surface itself or a day column, not an event on it\n    const onEmpty = e.target === e.currentTarget || (e.target as HTMLElement).dataset?.day !== undefined\n    if (!cal.selectable || e.button !== 0 || !onEmpty) return\n    const x0 = e.clientX\n    const y0 = e.clientY\n    const at = locate(x0, y0)\n    const anchor = snap(at.minutes, slot)\n    const dayIndex = at.day\n    let moved = false\n    const mv = (ev: PointerEvent) => {\n      if (!moved && Math.hypot(ev.clientX - x0, ev.clientY - y0) < 5) return\n      moved = true\n      const p = locate(ev.clientX, ev.clientY)\n      const r = rangeFor(dayIndex, anchor, p.minutes)\n      setDrag({ kind: \"create\", day: dayIndex, anchor, ...r, grab: 0 })\n    }\n    const done = (commit: boolean) => {\n      window.removeEventListener(\"pointermove\", mv)\n      window.removeEventListener(\"pointerup\", up)\n      window.removeEventListener(\"pointercancel\", cancel)\n      const d = dragRef.current\n      setDrag(null)\n      if (!commit) return\n      // a click without a drag makes one slot\n      const range = d && d.kind === \"create\" ? { start: d.start, end: d.end } : rangeFor(dayIndex, anchor, anchor)\n      cal.onRangeSelect?.({ ...range, allDay: false })\n    }\n    const up = () => done(true)\n    const cancel = () => done(false)\n    window.addEventListener(\"pointermove\", mv)\n    window.addEventListener(\"pointerup\", up)\n    window.addEventListener(\"pointercancel\", cancel)\n  }\n\n  const onKeyMove = (event: CalendarEvent, key: string, shift: boolean) => {\n    if (!cal.editable || event.readOnly) return false\n    const s = spanOf(event)\n    const timed = !s.allDay\n    const c = keyChange(event, key, shift, { dayStep: 1, weekStep: 7, slot, timed, rtl: cal.rtl })\n    if (!c) return false\n    const out = timed ? c : c\n    cal.onEventChange?.(event, forCaller(event, out))\n    cal.announce(\n      timed\n        ? msg(\"event-calendar.movedTimed\", \"Moved {title} to {date}, {from} to {to}.\", { title: event.title, date: dayLong(out.start), from: time(out.start), to: time(out.end) })\n        : msg(\"event-calendar.movedTo\", \"Moved {title} to {date}.\", { title: event.title, date: dayLong(out.start) })\n    )\n    return true\n  }\n\n  const hours = Array.from({ length: 24 }, (_, h) => h)\n  const gridCols = { gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }\n\n  return (\n    <div data-slot=\"event-calendar-timegrid\" className=\"flex min-w-0 select-none flex-col\">\n      {/* day headings and the all-day row stay in view while the hours scroll */}\n      <div className=\"border-b bg-card\">\n        <div className=\"flex\" style={{ paddingInlineStart: GUTTER }}>\n          <div className=\"grid flex-1\" style={gridCols}>\n            {days.map((d) => {\n              const isToday = !!cal.today && isSameDay(d, cal.today)\n              return (\n                <div key={keyOfDate(d)} className=\"flex min-w-0 flex-col items-center gap-0.5 border-s px-1 py-2 first:border-s-0\">\n                  <span className=\"text-xs font-medium text-muted-foreground\">{format(d, cal.locale, { weekday: \"short\" })}</span>\n                  <button\n                    type=\"button\"\n                    onClick={() => onOpenDay(d)}\n                    aria-label={msg(\"event-calendar.openDay\", \"Open {date}\", { date: dayLong(d) })}\n                    aria-current={isToday ? \"date\" : undefined}\n                    className={cn(\"flex size-8 items-center justify-center rounded-full text-sm font-medium tabular-nums outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\", isToday && \"bg-primary text-primary-foreground hover:bg-primary/90\")}\n                  >\n                    {d.getDate()}\n                  </button>\n                </div>\n              )\n            })}\n          </div>\n        </div>\n        {lanes > 0 ? (\n          <div className=\"flex border-t\" style={{ paddingInlineStart: GUTTER }}>\n            <div className=\"relative flex-1\" style={{ height: lanes * ALL_DAY_LANE + 8 }}>\n              <span className=\"absolute -start-14 top-1.5 w-14 pe-2 text-end text-[11px] text-muted-foreground\">{msg(\"event-calendar.allDay\", \"All day\")}</span>\n              {segs.map((s) => (\n                <EventChip\n                  key={`${s.span.event.id}-all`}\n                  event={s.span.event}\n                  variant=\"block\"\n                  showTime={false}\n                  continues={{ before: !s.startsHere, after: !s.endsHere }}\n                  onKeyMove={onKeyMove}\n                  className=\"absolute\"\n                  style={{ insetInlineStart: `calc(${(s.from / days.length) * 100}% + 2px)`, width: `calc(${((s.to - s.from + 1) / days.length) * 100}% - 4px)`, top: 4 + s.lane * ALL_DAY_LANE, height: ALL_DAY_LANE - 3 }}\n                />\n              ))}\n            </div>\n          </div>\n        ) : null}\n      </div>\n\n      <div ref={scroller} className=\"relative overflow-y-auto overscroll-contain\" style={{ height }}>\n        <div ref={body} className=\"relative flex\" style={{ height: total }}>\n          <div aria-hidden=\"true\" className=\"sticky start-0 z-20 shrink-0 border-e bg-card\" style={{ width: GUTTER }}>\n            {hours.map((h) => (\n              <div key={h} className=\"relative text-end text-[11px] tabular-nums text-muted-foreground\" style={{ height: hh }}>\n                {h > 0 ? <span className=\"absolute -top-2 end-2\">{format(new Date(2000, 0, 1, h), cal.locale, { hour: \"numeric\" })}</span> : null}\n              </div>\n            ))}\n          </div>\n          <div ref={columns} className=\"relative grid flex-1\" style={gridCols} onPointerDown={beginCreate} role=\"presentation\">\n            {/* the hour and half-hour lines */}\n            <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n              {hours.map((h) => (\n                <div key={h} className=\"border-t border-border/70\" style={{ height: hh }}>\n                  <div className=\"border-t border-dashed border-border/40\" style={{ marginTop: hh / 2 - 1 }} />\n                </div>\n              ))}\n            </div>\n            {days.map((d, di) => {\n              const placed = layoutDay(timedSpans, d, Math.max(18, (hh / 60) * slot))\n              const isToday = !!cal.today && isSameDay(d, cal.today)\n              return (\n                <div key={keyOfDate(d)} role=\"group\" aria-label={dayLong(d)} data-day={keyOfDate(d)} className={cn(\"relative border-s first:border-s-0\", isToday && \"bg-primary/[0.03]\")}>\n                  {placed.map((p) => {\n                    const e = p.span.event\n                    const beingDragged = drag && drag.kind !== \"create\" && drag.event.id === e.id\n                    const width = 100 / p.columns\n                    return (\n                      <EventChip\n                        key={e.id}\n                        event={e}\n                        variant=\"block\"\n                        dragging={!!beingDragged}\n                        onPointerDown={begin(\"move\", e, di)}\n                        onKeyMove={onKeyMove}\n                        className={cn(\"absolute z-10 items-start\", cal.editable && !e.readOnly && \"cursor-grab\", beingDragged && \"opacity-40\")}\n                        style={{\n                          top: (p.top / 60) * hh,\n                          height: Math.max((p.height / 60) * hh - 2, 18),\n                          insetInlineStart: `calc(${p.column * width}% + 2px)`,\n                          width: `calc(${width}% - 4px)`,\n                        }}\n                      >\n                        <span className=\"min-w-0 flex-1 ps-1\">\n                          {cal.renderEvent ? (\n                            cal.renderEvent(e, { view: cal.view, compact: p.height < 45 })\n                          ) : (\n                            <>\n                              <span className=\"block truncate font-medium text-foreground\">{e.title}</span>\n                              {p.height >= 45 ? <bdi className=\"block truncate tabular-nums text-muted-foreground\">{time(p.span.start)} – {time(p.span.end)}</bdi> : null}\n                              {p.height >= 75 && e.location ? (\n                                <span className=\"mt-0.5 flex items-center gap-1 truncate text-muted-foreground\">\n                                  <MapPin className=\"size-3 shrink-0\" aria-hidden=\"true\" />\n                                  <span className=\"truncate\">{e.location}</span>\n                                </span>\n                              ) : null}\n                            </>\n                          )}\n                        </span>\n                        {cal.editable && !e.readOnly ? (\n                          <span\n                            aria-hidden=\"true\"\n                            onPointerDown={begin(\"resize-end\", e, di)}\n                            className=\"absolute inset-x-0 bottom-0 flex h-2 cursor-ns-resize touch-none items-end justify-center after:mb-0.5 after:h-0.5 after:w-6 after:rounded-full after:bg-foreground/0 group-hover/event:after:bg-foreground/30\"\n                          />\n                        ) : null}\n                      </EventChip>\n                    )\n                  })}\n                  {/* what is being made or moved, drawn where it will land */}\n                  {drag && drag.day === di && drag.kind !== \"resize-end\" && drag.kind !== \"resize-start\" ? <Ghost drag={drag} hh={hh} /> : null}\n                  {drag && drag.kind !== \"create\" && (drag.kind === \"resize-end\" || drag.kind === \"resize-start\") && drag.day === di ? <Ghost drag={drag} hh={hh} /> : null}\n                  {isToday && cal.today ? <NowLine hh={hh} /> : null}\n                </div>\n              )\n            })}\n          </div>\n        </div>\n      </div>\n      <span className=\"sr-only\">{label}</span>\n    </div>\n  )\n}\n\nfunction Ghost({ drag, hh }: { drag: TimeDrag; hh: number }) {\n  const { time } = useWords()\n  const top = (minutesOfDay(drag.start) / 60) * hh\n  const end = drag.end.getTime() - startOfDay(drag.start).getTime() >= 86_400_000 ? 1440 : minutesOfDay(drag.end)\n  const h = Math.max(((end - minutesOfDay(drag.start)) / 60) * hh - 2, 18)\n  const event = drag.kind === \"create\" ? null : drag.event\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0.5 z-30\" style={{ top, height: h }}>\n      {event ? (\n        <EventChip event={{ ...event, start: drag.start, end: drag.end, allDay: false }} variant=\"block\" ghost className=\"h-full items-start\" />\n      ) : (\n        <div className=\"h-full rounded-md border border-primary/50 bg-primary/10 px-2 py-1 text-xs tabular-nums text-primary\">\n          {time(drag.start)} – {time(drag.end)}\n        </div>\n      )}\n    </div>\n  )\n}\n\n/** The line for the current time. The clock is read in an effect, so the server never draws it in the wrong place. */\nfunction NowLine({ hh }: { hh: number }) {\n  const { now } = useCal()\n  if (!now) return null\n  return (\n    <div aria-hidden=\"true\" data-slot=\"event-calendar-now\" className=\"pointer-events-none absolute inset-x-0 z-20 flex items-center\" style={{ top: (minutesOfDay(now) / 60) * hh }}>\n      <span className=\"-ms-1 size-2 rounded-full bg-destructive\" />\n      <span className=\"h-px flex-1 bg-destructive\" />\n    </div>\n  )\n}\n\n// ------------------------------------------------------------------------------------------------- agenda\n\nfunction AgendaView({ date, spans, events, emptyState }: { date: Date; spans: Span<CalendarEvent>[]; events: CalendarEvent[]; emptyState: EventCalendarProps[\"emptyState\"] }) {\n  const cal = useCal()\n  const { msg, time, dayLong } = useWords()\n  const first = new Date(date.getFullYear(), date.getMonth(), 1)\n  const last = new Date(date.getFullYear(), date.getMonth() + 1, 0)\n  const days = Array.from({ length: last.getDate() }, (_, i) => addDays(first, i))\n  const groups = days\n    .map((d) => ({ day: d, items: spans.filter((s) => touchesDay(s, d)).sort((a, b) => Number(b.allDay) - Number(a.allDay) || a.start.getTime() - b.start.getTime()) }))\n    .filter((g) => g.items.length)\n  if (!groups.length) {\n    return (\n      <div className=\"flex min-h-48 flex-col items-center justify-center gap-2 p-8 text-center\" data-slot=\"event-calendar-empty\">\n        <CalendarDays className=\"size-8 text-muted-foreground\" aria-hidden=\"true\" />\n        <p className=\"font-medium\">{emptyState?.title ?? msg(\"event-calendar.emptyTitle\", \"No events this month\")}</p>\n        <p className=\"max-w-sm text-sm text-muted-foreground\">{emptyState?.description ?? msg(\"event-calendar.emptyDescription\", \"Events you add will be listed here by day.\")}</p>\n        {emptyState?.action}\n      </div>\n    )\n  }\n  return (\n    <ol data-slot=\"event-calendar-agenda\" className=\"divide-y\" aria-label={format(date, cal.locale, { month: \"long\", year: \"numeric\" })}>\n      {groups.map(({ day, items }) => {\n        const isToday = !!cal.today && isSameDay(day, cal.today)\n        return (\n          <li key={keyOfDate(day)} className=\"grid gap-x-4 gap-y-2 p-3 sm:grid-cols-[8rem_1fr]\">\n            <h3 className={cn(\"flex items-baseline gap-2 text-sm font-semibold sm:flex-col sm:gap-0\", isToday && \"text-primary\")}>\n              <span className=\"text-2xl tabular-nums sm:leading-none\">{day.getDate()}</span>\n              <span className=\"text-xs font-medium text-muted-foreground\">{format(day, cal.locale, { weekday: \"long\", month: \"short\" })}</span>\n            </h3>\n            <ul className=\"grid min-w-0 gap-1.5\">\n              {items.map((s) => (\n                <li key={s.event.id} className=\"min-w-0\">\n                  <EventChip event={s.event} variant=\"block\" className=\"w-full items-center gap-3 py-1.5\">\n                    <span className=\"w-24 shrink-0 ps-1 text-xs tabular-nums text-muted-foreground\">{s.allDay ? msg(\"event-calendar.allDay\", \"All day\") : `${time(s.start)}`}</span>\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"block truncate text-sm font-medium text-foreground\">{s.event.title}</span>\n                      {s.event.location ? <span className=\"block truncate text-xs text-muted-foreground\">{s.event.location}</span> : null}\n                    </span>\n                  </EventChip>\n                </li>\n              ))}\n            </ul>\n          </li>\n        )\n      })}\n    </ol>\n  )\n}\n\nexport {\n  EventCalendar,\n  type CalendarEvent,\n  type EventCalendarProps,\n  type EventCalendarView,\n  type EventChange,\n  type EventRange,\n  type EventTone,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/event-calendar.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "calendar",
      "events",
      "schedule",
      "month",
      "week",
      "agenda",
      "drag",
      "timeline"
    ],
    "version": "1.0.0",
    "updated": "2026-10-19",
    "tests": true,
    "ai": {
      "summary": "Pass events [{ id, title, start, end?, allDay?, tone?, location? }] and a label. Hold view and date in state if you need them. For editing set editable and selectable and handle onEventChange(event, { start, end, allDay }) and onRangeSelect.",
      "whenToUse": [
        "Team, booking, room and delivery calendars with several views",
        "Schedulers where people drag events to reschedule or drag across time to add one",
        "A month overview with multi-day events and an agenda list for phones"
      ],
      "whenNotToUse": [
        "Picking a date in a form; use calendar or date-picker",
        "A resource timeline with rows per person or room",
        "Recurring-event rules or time zone conversion; compute those yourself and pass plain events"
      ],
      "composesWith": [
        "calendar",
        "popover",
        "badge",
        "dialog"
      ],
      "a11y": [
        {
          "keys": "Arrow keys (month)",
          "action": "Move between days; Page Up and Page Down change month; Home and End go to the week's ends"
        },
        {
          "keys": "Enter / Space (month)",
          "action": "Open the focused day"
        },
        {
          "keys": "Enter on an event",
          "action": "Calls onEventClick"
        },
        {
          "keys": "Alt + arrow keys on an event",
          "action": "Move it by a day or a slot; Alt+Shift+arrows change its length. The move is announced"
        },
        {
          "keys": "Add event button",
          "action": "Starts an event without dragging, for keyboard and switch users"
        },
        {
          "keys": "Screen readers",
          "action": "The month is a grid with each day named and counted; every event button reads its title, date, time and place"
        }
      ],
      "customization": [
        "views: month | week | day | agenda, defaultView, defaultDate",
        "weekStartsOn (defaults to the locale), slotMinutes, hourHeight, scrollToHour, maxLanes",
        "tone per event: chart-1 to chart-5 or muted",
        "renderEvent to draw your own event content",
        "today to pin the current day in tests and screenshots"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "event-calendar-loading",
        "trigger": "state=\"loading\"",
        "note": "A month-shaped skeleton, announced as loading; nothing can be clicked."
      },
      {
        "id": "empty",
        "title": "No events",
        "example": "event-calendar-empty",
        "trigger": "events={[]} in the agenda view",
        "note": "The agenda says there is nothing this month and emptyState can add an action; the month and week grids stay as plain calendars."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "event-calendar-error",
        "trigger": "state=\"error\"",
        "note": "An error with a Try again button (onRetry), announced as an alert."
      }
    ],
    "examples": [
      "event-calendar-demo",
      "event-calendar-week",
      "event-calendar-day",
      "event-calendar-editable",
      "event-calendar-agenda",
      "event-calendar-loading",
      "event-calendar-empty",
      "event-calendar-error"
    ],
    "url": "https://ui.ballmac.com/components/event-calendar"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}