{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar-1",
  "title": "Calendar 1: week view with mini month",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A week calendar: a time grid with overlapping events placed side by side, an all-day row, a now line, a mini month and calendar toggles, event popovers and a new-event dialog. One day at a time on phones.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/calendar.json",
    "https://ui.ballmac.com/r/dialog.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/popover.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/calendar-1/calendar-1.tsx",
      "content": "// Ballmac UI: Calendar 1. https://ui.ballmac.com/blocks/calendar-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, MapPin, Plus } from \"lucide-react\"\n\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { Calendar } from \"@/components/ballmac/calendar\"\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ballmac/dialog\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from \"@/components/ballmac/popover\"\nimport { cn } from \"@/lib/utils\"\n\ntype Calendar1Event = {\n  id: string\n  title: string\n  /** Start, as local date and time: YYYY-MM-DDTHH:mm. */\n  start: string\n  /** End, as local date and time: YYYY-MM-DDTHH:mm. Ignored for all-day events. */\n  end: string\n  /** Which calendar it belongs to; sets the colour and the checkbox that shows or hides it. */\n  calendar: string\n  location?: string\n  /** Show it in the all-day row instead of the time grid. */\n  allDay?: boolean\n}\n\ntype Calendar1Props = Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> & {\n  /** Events to show. */\n  defaultEvents?: Calendar1Event[]\n  /** Calendars and their colours (chart tokens 1 to 5). */\n  calendars?: { id: string; label: string; color: 1 | 2 | 3 | 4 | 5 }[]\n  /** Today, as YYYY-MM-DD. Passed in so the page is the same on the server and in the browser. */\n  today?: string\n  /** The time of day for the \"now\" line, as HH:mm. Pass null to hide it. */\n  now?: string | null\n  /** First hour shown. */\n  startHour?: number\n  /** Last hour shown. */\n  endHour?: number\n  /** Called when an event is added. */\n  onAdd?: (event: Calendar1Event) => void\n  /** Height of the frame. */\n  height?: string\n}\n\nconst ROW = 56\nconst DAY_MS = 86400000\n\nconst palette: Record<number, { bg: string; bar: string }> = {\n  1: { bg: \"bg-chart-1/15\", bar: \"bg-chart-1\" },\n  2: { bg: \"bg-chart-2/15\", bar: \"bg-chart-2\" },\n  3: { bg: \"bg-chart-3/20\", bar: \"bg-chart-3\" },\n  4: { bg: \"bg-chart-4/15\", bar: \"bg-chart-4\" },\n  5: { bg: \"bg-chart-5/15\", bar: \"bg-chart-5\" },\n}\n\nconst defaultCalendars: NonNullable<Calendar1Props[\"calendars\"]> = [\n  { id: \"work\", label: \"Work\", color: 1 },\n  { id: \"team\", label: \"Team\", color: 5 },\n  { id: \"personal\", label: \"Personal\", color: 2 },\n]\n\nconst defaultEvents: Calendar1Event[] = [\n  { id: \"e1\", title: \"Team standup\", start: \"2026-09-28T09:30\", end: \"2026-09-28T09:45\", calendar: \"team\" },\n  { id: \"e2\", title: \"Design review\", start: \"2026-09-29T11:00\", end: \"2026-09-29T12:00\", calendar: \"work\", location: \"Room 3\" },\n  { id: \"e3\", title: \"Team standup\", start: \"2026-09-30T09:30\", end: \"2026-09-30T09:45\", calendar: \"team\" },\n  { id: \"e4\", title: \"1:1 with Maya\", start: \"2026-09-30T14:00\", end: \"2026-09-30T14:30\", calendar: \"work\" },\n  { id: \"e5\", title: \"Team standup\", start: \"2026-10-01T09:30\", end: \"2026-10-01T09:45\", calendar: \"team\" },\n  { id: \"e6\", title: \"Pricing page working session\", start: \"2026-10-01T10:00\", end: \"2026-10-01T12:00\", calendar: \"work\", location: \"Zoom\" },\n  { id: \"e7\", title: \"Lunch with Amara\", start: \"2026-10-01T12:30\", end: \"2026-10-01T13:30\", calendar: \"personal\", location: \"Café Lisboa\" },\n  { id: \"e8\", title: \"Roadmap sync\", start: \"2026-10-01T14:00\", end: \"2026-10-01T15:00\", calendar: \"team\", location: \"Room 1\" },\n  { id: \"e9\", title: \"Customer call: Northwind\", start: \"2026-10-01T14:30\", end: \"2026-10-01T15:15\", calendar: \"work\" },\n  { id: \"e10\", title: \"Team standup\", start: \"2026-10-02T09:30\", end: \"2026-10-02T09:45\", calendar: \"team\" },\n  { id: \"e11\", title: \"Launch day\", start: \"2026-10-02T00:00\", end: \"2026-10-02T23:59\", calendar: \"work\", allDay: true },\n  { id: \"e12\", title: \"Quarterly planning\", start: \"2026-10-02T13:00\", end: \"2026-10-02T16:00\", calendar: \"team\", location: \"Main hall\" },\n  { id: \"e13\", title: \"Pottery class\", start: \"2026-10-03T10:00\", end: \"2026-10-03T12:00\", calendar: \"personal\" },\n]\n\nconst parseDay = (iso: string) => Date.parse(`${iso.slice(0, 10)}T00:00:00Z`)\nconst minutes = (iso: string) => Number(iso.slice(11, 13)) * 60 + Number(iso.slice(14, 16))\nconst iso = (ms: number) => new Date(ms).toISOString().slice(0, 10)\nconst mondayOf = (ms: number) => ms - ((new Date(ms).getUTCDay() + 6) % 7) * DAY_MS\nconst clock = (m: number) => {\n  const h = Math.floor(m / 60)\n  const mm = m % 60\n  return `${h % 12 === 0 ? 12 : h % 12}${mm ? `:${String(mm).padStart(2, \"0\")}` : \"\"} ${h < 12 ? \"AM\" : \"PM\"}`\n}\n\n/** Stack events that overlap in time side by side. */\nfunction lanes(list: Calendar1Event[]) {\n  const sorted = [...list].sort((a, b) => minutes(a.start) - minutes(b.start) || minutes(b.end) - minutes(a.end))\n  const placed: { e: Calendar1Event; lane: number; of: number }[] = []\n  let group: typeof placed = []\n  let groupEnd = -1\n  const flush = () => {\n    const of = Math.max(1, ...group.map((g) => g.lane + 1))\n    group.forEach((g) => (g.of = of))\n    placed.push(...group)\n    group = []\n  }\n  for (const e of sorted) {\n    if (minutes(e.start) >= groupEnd && group.length) flush()\n    const used = new Set(group.filter((g) => minutes(g.e.end) > minutes(e.start)).map((g) => g.lane))\n    let lane = 0\n    while (used.has(lane)) lane++\n    group.push({ e, lane, of: 1 })\n    groupEnd = Math.max(groupEnd, minutes(e.end))\n  }\n  if (group.length) flush()\n  return placed\n}\n\nfunction Calendar1({\n  defaultEvents: initial = defaultEvents,\n  calendars = defaultCalendars,\n  today = \"2026-10-01\",\n  now = \"10:24\",\n  startHour = 8,\n  endHour = 18,\n  onAdd,\n  height = \"44rem\",\n  className,\n  style,\n  ...props\n}: Calendar1Props) {\n  const [events, setEvents] = React.useState(initial)\n  const [selected, setSelected] = React.useState(today)\n  const [hidden, setHidden] = React.useState<string[]>([])\n  const [adding, setAdding] = React.useState(false)\n  const [draft, setDraft] = React.useState({ title: \"\", day: today, from: \"09:00\", to: \"10:00\", calendar: calendars[0]?.id ?? \"work\" })\n  const monday = mondayOf(parseDay(selected))\n  const week = Array.from({ length: 7 }, (_, i) => monday + i * DAY_MS)\n  const title = new Intl.DateTimeFormat(\"en-US\", { month: \"long\", year: \"numeric\", timeZone: \"UTC\" }).format(new Date(monday + 3 * DAY_MS))\n  const colorOf = (id: string) => palette[calendars.find((c) => c.id === id)?.color ?? 1]\n  const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i)\n  const visible = events.filter((e) => !hidden.includes(e.calendar))\n  const gridHeight = hours.length * ROW\n  const sel = new Date(Number(selected.slice(0, 4)), Number(selected.slice(5, 7)) - 1, Number(selected.slice(8, 10)))\n  const shift = (days: number) => setSelected(iso(parseDay(selected) + days * DAY_MS))\n  const dayFmt = new Intl.DateTimeFormat(\"en-US\", { weekday: \"short\", timeZone: \"UTC\" })\n  const longFmt = new Intl.DateTimeFormat(\"en-US\", { weekday: \"long\", month: \"long\", day: \"numeric\", timeZone: \"UTC\" })\n  const nowMin = now ? minutes(`0000-00-00T${now}`) : null\n\n  function addEvent(e: React.FormEvent) {\n    e.preventDefault()\n    if (!draft.title.trim() || draft.to <= draft.from) return\n    const ev: Calendar1Event = { id: `new-${events.length + 1}`, title: draft.title.trim(), start: `${draft.day}T${draft.from}`, end: `${draft.day}T${draft.to}`, calendar: draft.calendar }\n    setEvents((all) => [...all, ev])\n    onAdd?.(ev)\n    setSelected(draft.day)\n    setAdding(false)\n    setDraft({ ...draft, title: \"\" })\n  }\n\n  return (\n    <div data-slot=\"calendar-1\" className={cn(\"bg-background flex overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]\", className)} style={{ height, ...style }} {...props}>\n      <aside aria-label=\"Calendars\" className=\"bg-muted/30 hidden w-64 shrink-0 flex-col gap-5 overflow-y-auto border-e p-4 lg:flex\">\n        <Dialog open={adding} onOpenChange={setAdding}>\n          <DialogTrigger className={buttonVariants({ className: \"w-full\" })}><Plus /> New event</DialogTrigger>\n          <DialogContent>\n            <form onSubmit={addEvent}>\n              <DialogHeader>\n                <DialogTitle>New event</DialogTitle>\n                <DialogDescription>It appears on your calendar straight away.</DialogDescription>\n              </DialogHeader>\n              <div className=\"my-5 grid gap-3\">\n                <Input aria-label=\"Title\" placeholder=\"Event title\" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} required />\n                <Input aria-label=\"Day\" type=\"date\" value={draft.day} onChange={(e) => setDraft({ ...draft, day: e.target.value })} />\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <label className=\"grid gap-1.5 text-sm font-medium\">Starts<Input type=\"time\" value={draft.from} onChange={(e) => setDraft({ ...draft, from: e.target.value })} /></label>\n                  <label className=\"grid gap-1.5 text-sm font-medium\">Ends<Input type=\"time\" value={draft.to} onChange={(e) => setDraft({ ...draft, to: e.target.value })} aria-invalid={draft.to <= draft.from || undefined} /></label>\n                </div>\n                {draft.to <= draft.from && <p role=\"alert\" className=\"text-destructive text-sm\">The end time must be after the start time.</p>}\n              </div>\n              <DialogFooter>\n                <Button type=\"button\" variant=\"ghost\" onClick={() => setAdding(false)}>Cancel</Button>\n                <Button type=\"submit\" disabled={!draft.title.trim() || draft.to <= draft.from}>Add event</Button>\n              </DialogFooter>\n            </form>\n          </DialogContent>\n        </Dialog>\n        <Calendar mode=\"single\" selected={sel} month={sel} onMonthChange={(m) => setSelected(iso(Date.UTC(m.getFullYear(), m.getMonth(), 1)))} onSelect={(d) => d && setSelected(iso(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())))} className=\"-mx-2 p-0\" />\n        <fieldset>\n          <legend className=\"text-muted-foreground mb-2 text-xs font-medium\">My calendars</legend>\n          <ul className=\"space-y-1\">\n            {calendars.map((c) => (\n              <li key={c.id}>\n                <label className=\"hover:bg-accent/60 flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm\">\n                  <input type=\"checkbox\" checked={!hidden.includes(c.id)} onChange={() => setHidden((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} className=\"peer sr-only\" />\n                  <span aria-hidden=\"true\" className={cn(\"peer-focus-visible:ring-ring/50 flex size-4 items-center justify-center rounded border-2 transition-colors peer-focus-visible:ring-[3px]\", hidden.includes(c.id) ? \"border-border\" : cn(palette[c.color].bar, \"border-transparent\"))}>{!hidden.includes(c.id) && <svg viewBox=\"0 0 12 12\" className=\"text-background size-3\"><path d=\"M2.5 6.5l2.5 2.5 4.5-5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" /></svg>}</span>\n                  {c.label}\n                </label>\n              </li>\n            ))}\n          </ul>\n        </fieldset>\n      </aside>\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <header className=\"flex flex-wrap items-center gap-2 border-b p-3\">\n          <h2 className=\"me-2 text-lg font-semibold tracking-[-0.02em]\">{title}</h2>\n          <Button variant=\"outline\" size=\"sm\" onClick={() => setSelected(today)}>Today</Button>\n          <div className=\"hidden md:flex\">\n            <Button variant=\"ghost\" size=\"icon\" aria-label=\"Previous week\" onClick={() => shift(-7)}><ChevronLeft  className=\"rtl:rotate-180\"/></Button>\n            <Button variant=\"ghost\" size=\"icon\" aria-label=\"Next week\" onClick={() => shift(7)}><ChevronRight  className=\"rtl:rotate-180\"/></Button>\n          </div>\n          <Button variant=\"outline\" size=\"icon\" className=\"ms-auto lg:hidden\" aria-label=\"New event\" onClick={() => setAdding(true)}><Plus /></Button>\n          <div className=\"flex items-center gap-1 md:hidden\">\n            <Button variant=\"ghost\" size=\"icon\" aria-label=\"Previous day\" onClick={() => shift(-1)}><ChevronLeft  className=\"rtl:rotate-180\"/></Button>\n            <Button variant=\"ghost\" size=\"icon\" aria-label=\"Next day\" onClick={() => shift(1)}><ChevronRight  className=\"rtl:rotate-180\"/></Button>\n          </div>\n        </header>\n\n        <div className=\"min-h-0 flex-1 overflow-auto\" tabIndex={0} role=\"region\" aria-label={`Week of ${longFmt.format(new Date(monday))}`}>\n          <div className=\"min-w-0\" style={{ minWidth: \"0\" }}>\n            <div className=\"bg-background/95 sticky top-0 z-20 border-b backdrop-blur\">\n              <div className=\"grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] max-md:grid-cols-[3.5rem_minmax(0,1fr)]\">\n                <div />\n                {week.map((d) => {\n                  const day = iso(d)\n                  const isToday = day === today\n                  const isSel = day === selected\n                  return (\n                    <button key={day} type=\"button\" onClick={() => setSelected(day)} aria-current={isToday ? \"date\" : undefined} aria-label={longFmt.format(new Date(d))} className={cn(\"focus-visible:ring-ring/50 flex-col items-center gap-0.5 py-2 text-center outline-none focus-visible:ring-[3px] focus-visible:ring-inset\", isSel ? \"flex\" : \"hidden md:flex\")}>\n                      <span className=\"text-muted-foreground text-xs font-medium\">{dayFmt.format(new Date(d))}</span>\n                      <span className={cn(\"flex size-8 items-center justify-center rounded-full text-sm font-semibold tabular-nums\", isToday && \"bg-foreground text-background\")}>{new Date(d).getUTCDate()}</span>\n                    </button>\n                  )\n                })}\n              </div>\n              {visible.some((e) => e.allDay) && (\n                <div className=\"grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] border-t max-md:grid-cols-[3.5rem_minmax(0,1fr)]\">\n                  <span className=\"text-muted-foreground py-1.5 pe-2 text-end text-[10px]\">all-day</span>\n                  {week.map((d) => (\n                    <div key={d} className={cn(\"min-h-7 space-y-0.5 border-s p-0.5\", iso(d) !== selected && \"max-md:hidden\")}>\n                      {visible.filter((e) => e.allDay && parseDay(e.start) === d).map((e) => (\n                        <div key={e.id} className={cn(\"truncate rounded px-1.5 py-0.5 text-xs font-medium\", colorOf(e.calendar).bg)}>{e.title}</div>\n                      ))}\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n\n            <div className=\"relative grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))] max-md:grid-cols-[3.5rem_minmax(0,1fr)]\" style={{ height: gridHeight }}>\n              <div aria-hidden=\"true\">\n                {hours.map((h) => (\n                  <div key={h} className=\"text-muted-foreground relative pe-2 text-end text-[10px]\" style={{ height: ROW }}><span className=\"absolute -top-2 end-2\">{h === startHour ? \"\" : clock(h * 60)}</span></div>\n                ))}\n              </div>\n              {week.map((d) => {\n                const day = iso(d)\n                const items = lanes(visible.filter((e) => !e.allDay && parseDay(e.start) === d))\n                return (\n                  <div key={day} className={cn(\"relative border-s\", day !== selected && \"max-md:hidden\", day === today && \"bg-accent/20\")}>\n                    {hours.map((h) => <div key={h} className=\"border-b\" style={{ height: ROW }} />)}\n                    {day === today && nowMin !== null && nowMin >= startHour * 60 && nowMin <= endHour * 60 && (\n                      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 z-10 flex items-center\" style={{ top: ((nowMin - startHour * 60) / 60) * ROW }}>\n                        <span className=\"bg-destructive -ms-1 size-2 rounded-full\" />\n                        <span className=\"bg-destructive h-px flex-1\" />\n                      </div>\n                    )}\n                    {items.map(({ e, lane, of }) => {\n                      const top = ((Math.max(minutes(e.start), startHour * 60) - startHour * 60) / 60) * ROW\n                      const h = Math.max(((Math.min(minutes(e.end), endHour * 60) - Math.max(minutes(e.start), startHour * 60)) / 60) * ROW, 22)\n                      const c = colorOf(e.calendar)\n                      return (\n                        <Popover key={e.id}>\n                          <PopoverTrigger\n                            className={cn(\"focus-visible:ring-ring/50 absolute z-[5] overflow-hidden rounded-md border-s-[3px] px-1.5 py-1 text-start text-xs leading-tight outline-none transition-[filter] hover:brightness-95 focus-visible:z-20 focus-visible:ring-[3px]\", c.bg, c.bar.replace(\"bg-\", \"border-\"))}\n                            style={{ top, height: h - 2, left: `calc(${(lane / of) * 100}% + 2px)`, width: `calc(${100 / of}% - 4px)` }}\n                            aria-label={`${e.title}, ${clock(minutes(e.start))} to ${clock(minutes(e.end))}${e.location ? `, ${e.location}` : \"\"}`}\n                          >\n                            <span className=\"block truncate font-semibold\">{e.title}</span>\n                            {h > 36 && <span className=\"block truncate opacity-90\">{clock(minutes(e.start))}</span>}\n                          </PopoverTrigger>\n                          <PopoverContent className=\"w-64\" label={e.title}>\n                            <PopoverTitle>{e.title}</PopoverTitle>\n                            <PopoverDescription>{longFmt.format(new Date(d))}<br />{clock(minutes(e.start))} to {clock(minutes(e.end))}</PopoverDescription>\n                            {e.location && <p className=\"mt-2 flex items-center gap-1.5 text-sm\"><MapPin className=\"size-4\" aria-hidden=\"true\" />{e.location}</p>}\n                            <p className=\"text-muted-foreground mt-2 text-xs\">{calendars.find((x) => x.id === e.calendar)?.label} calendar</p>\n                          </PopoverContent>\n                        </Popover>\n                      )\n                    })}\n                  </div>\n                )\n              })}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport { Calendar1, type Calendar1Props, type Calendar1Event }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/calendar-1/calendar-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "calendar",
      "schedule",
      "week view",
      "events",
      "planner",
      "agenda"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "A scheduling view. Pass defaultEvents=[{ id, title, start, end, calendar, location?, allDay? }] with local 'YYYY-MM-DDTHH:mm' times, calendars, today (YYYY-MM-DD) and now (HH:mm) so the page is identical on the server and in the browser.",
      "whenToUse": [
        "Scheduling, booking and planning screens",
        "A realistic product screenshot"
      ],
      "whenNotToUse": [
        "Picking a date in a form (use calendar or date-picker)",
        "An agenda list (use calendar-agenda)"
      ],
      "composesWith": [
        "app-shell-1",
        "kanban-1",
        "mail-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Events are buttons in time order; Enter opens a popover with the details"
        },
        {
          "keys": "Mini month arrow keys",
          "action": "Move between days; PageUp and PageDown change month"
        },
        {
          "keys": "Calendar checkboxes",
          "action": "Show or hide a calendar; each event's name, time and place are in its accessible name"
        }
      ],
      "customization": [
        "defaultEvents, calendars, today, now (null hides the line)",
        "startHour and endHour",
        "onAdd(event), height"
      ]
    },
    "examples": [
      "calendar-1-demo",
      "calendar-1-compact"
    ],
    "url": "https://ui.ballmac.com/components/calendar-1"
  },
  "categories": [
    "blocks",
    "calendar"
  ],
  "type": "registry:block"
}