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