{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"week-calendar","type":"registry:component","title":"Week calendar","description":"A scheduling week you hold in the hand: drag a slot into an event while its times roll, and anything it lands on eases aside.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/week-calendar.tsx","type":"registry:component","content":"// Hairline UI · week-calendar · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\nimport { addDays, atMinutes, layoutDay, minutesOf, sameDay, startOfDay, startOfWeek, useTimeGrid, type GridDraft, type GridItem } from \"./use-time-grid\";\n\n/* ─────────────────────────────────────────────────────────\n * WEEK CALENDAR: the scheduling week, held in the hand\n *\n *   create    drag down an empty slot and a block grows from\n *             where you pressed, snapping to the step while\n *             \"9:15 – 10:30\" rolls; let go and you name it in\n *             the block itself\n *   move      drag an event between slots and days: it glides\n *             step to step, its times roll, and anything it\n *             lands on eases aside into side-by-side columns\n *             while you're still holding\n *   resize    drag its top or bottom edge\n *   week      ‹ › slide the days in from the side you went\n *             toward; dates roll, the range morphs\n *   now       a hairline across today that moves with the\n *             clock\n *   fail      a save that throws puts the event back with a\n *             shake and says so\n *\n * Seven days wide, three in a narrow container, one on a\n * phone. Hold to pick up on touch, so a swipe still scrolls.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CalendarEvent {\n  id: string;\n  title: string;\n  start: Date;\n  end: Date;\n  /** Which of the theme's five chart colours. */\n  tone?: 1 | 2 | 3 | 4 | 5;\n  /** Time taken by something else (another calendar): drawn as busy, can't be moved, and you can still book over it. */\n  busy?: boolean;\n}\n\nexport interface WeekCalendarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  events?: CalendarEvent[];\n  defaultEvents?: CalendarEvent[];\n  onEventsChange?: (events: CalendarEvent[]) => void;\n  /** A new event, once it's named. Resolve with the stored event (to take its id) or nothing; throw to take it back. */\n  onCreate?: (event: CalendarEvent) => Promise<CalendarEvent | void> | CalendarEvent | void;\n  /** An event moved or resized. Throw to put it back. */\n  onUpdate?: (event: CalendarEvent, previous: CalendarEvent) => Promise<void> | void;\n  /** An event deleted (Delete or Backspace on it). Throw to bring it back. */\n  onDelete?: (event: CalendarEvent) => Promise<void> | void;\n  /** Any day in the range to show. */\n  date?: Date;\n  defaultDate?: Date;\n  onDateChange?: (firstDay: Date) => void;\n  /** Days side by side; \"auto\" shows 7, 3 or 1 to fit the width. 5 is Monday to Friday. */\n  days?: 1 | 3 | 5 | 7 | \"auto\";\n  weekStartsOn?: 0 | 1;\n  startHour?: number;\n  endHour?: number;\n  /** Snap, in minutes. */\n  step?: number;\n  /** Length of an event made by a click or a hold, in minutes. */\n  defaultDuration?: number;\n  hourHeight?: number;\n  /** Height of the scrolling hours, px. */\n  height?: number;\n  locale?: string;\n  label?: string;\n}\n\ntype Shown = GridItem & { ev: CalendarEvent; draft?: boolean };\ntype Gone = { key: number; ev: CalendarEvent; style: React.CSSProperties };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst EMPTY: CalendarEvent[] = [];\n/** Short events still get a line of text: drawn at least this tall (the overlap maths uses real times). */\nconst MIN_HEIGHT = 20;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst SHEEN =\n  \"[mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]\";\nlet seq = 0;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst tint = (tone: number, amount: number) => `color-mix(in oklab, var(--chart-${tone}) ${amount}%, var(--background))`;\nconst ring = (tone: number, amount: number, width = 1) => `inset 0 0 0 ${width}px color-mix(in oklab, var(--chart-${tone}) ${amount}%, transparent)`;\n\n/** A time whose figures roll when it changes (while an event is being moved), or plain text otherwise. */\nconst Clock = React.memo(function Clock({ minutes, hour12, live }: { minutes: number; hour12: boolean; live: boolean }) {\n  const h = Math.floor(minutes / 60) % 24;\n  const m = minutes % 60;\n  const hh = hour12 ? ((h + 11) % 12) + 1 : h;\n  const period = h < 12 ? \"am\" : \"pm\";\n  if (!live) return <span>{`${hh}:${String(m).padStart(2, \"0\")}${hour12 ? period : \"\"}`}</span>;\n  return (\n    <span className=\"inline-flex items-baseline\">\n      <NumberRoll value={hh} duration={520} />:\n      <NumberRoll value={m} format={{ minimumIntegerDigits: 2 }} duration={520} />\n      {hour12 && <TextMorph>{period}</TextMorph>}\n    </span>\n  );\n});\n\n/** A sentence that changes crossfades whole. */\nfunction Line({ text, className = \"\" }: { text: string; className?: string }) {\n  const [shown, setShown] = React.useState({ text, key: 0 });\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  if (text !== shown.text) {\n    if (shown.text) setLeaving((l) => [...l, shown]);\n    setShown({ text, key: shown.key + 1 });\n  }\n  return (\n    <span className={`relative block ${className}`}>\n      <span\n        key={shown.key}\n        className=\"block truncate\"\n        ref={(el) => {\n          if (!el || shown.key === 0 || el.dataset.in) return;\n          el.dataset.in = \"1\";\n          el.animate(\n            [\n              { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n              { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            ],\n            { duration: 420, delay: 60, easing: EASE, fill: \"backwards\" },\n          );\n        }}\n      >\n        {shown.text || \" \"}\n      </span>\n      {leaving.map((l) => (\n        <span\n          key={l.key}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 block truncate\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                { opacity: 0, transform: \"translateY(-0.35em)\", filter: \"blur(4px)\" },\n              ],\n              { duration: 200, easing: EASE, fill: \"forwards\" },\n            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));\n          }}\n        >\n          {l.text}\n        </span>\n      ))}\n    </span>\n  );\n}\n\ntype BlockActions = {\n  key: (e: React.KeyboardEvent, id: string) => void;\n  focus: (id: string) => void;\n  blur: (id: string) => void;\n  name: (id: string, value: string | null) => void;\n};\ntype BlockProps = {\n  id: string;\n  title: string;\n  tone: number;\n  draft: boolean;\n  top: number;\n  height: number;\n  left: string;\n  width: string;\n  zIndex: number;\n  lifted: boolean;\n  selected: boolean;\n  tall: boolean;\n  /** Tall enough to say what it lands on. */\n  roomy: boolean;\n  /** What it overlaps while held, or \"\". */\n  over: string;\n  live: boolean;\n  naming: boolean;\n  saving: boolean;\n  start: number;\n  end: number;\n  hour12: boolean;\n  reduced: boolean;\n  label: string;\n  keysId: string;\n  actions: React.RefObject<BlockActions>;\n};\n\n/** One event. Memoised on plain values, so a drag step re-renders only the events that moved. */\nconst EventBlock = React.memo(function EventBlock(p: BlockProps) {\n  const a = p.actions;\n  const ref = React.useRef<HTMLDivElement>(null);\n  const last = React.useRef<{ top: number; left: number } | null>(null);\n  // While held, a step moves the block once in layout and the eye follows a transform glide (additive, so steps that\n  // come quicker than the glide just stack): no layout work per frame.\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const now = { top: el.offsetTop, left: el.offsetLeft };\n    const was = last.current;\n    last.current = now;\n    if (!was || !p.lifted || p.reduced) return;\n    const dx = was.left - now.left;\n    const dy = was.top - now.top;\n    if (dx || dy) el.animate([{ translate: `${dx}px ${dy}px` }, { translate: \"0px 0px\" }], { duration: 220, easing: EASE, composite: \"add\" });\n  });\n  return (\n    <div\n      ref={ref}\n      data-event-id={p.id}\n      role=\"button\"\n      tabIndex={p.draft ? -1 : 0}\n      aria-label={p.label}\n      aria-describedby={p.keysId}\n      aria-pressed={p.selected}\n      onKeyDown={(e) => a.current.key(e, p.id)}\n      onFocus={() => a.current.focus(p.id)}\n      onBlur={() => a.current.blur(p.id)}\n      className={`group/event absolute flex flex-col overflow-hidden rounded-[8px] px-1.5 text-left outline-none ${p.tall ? \"py-1\" : \"justify-center\"} ${\n        p.lifted ? \"cursor-grabbing\" : \"cursor-grab\"\n      } ${p.saving ? SHEEN : \"\"}`}\n      style={{\n        top: p.top,\n        height: p.height,\n        left: p.left,\n        width: p.width,\n        zIndex: p.lifted ? 40 : p.selected ? 30 : p.zIndex,\n        background: tint(p.tone, p.lifted ? 20 : 13),\n        boxShadow: ring(p.tone, p.lifted || p.selected ? 85 : 42, p.selected ? 1.5 : 1),\n        transform: p.lifted ? \"scale(1.015)\" : \"none\",\n        transition: p.reduced\n          ? \"none\"\n          : `${p.lifted ? \"\" : `top 200ms ${EASE}, left 260ms ${EASE}, `}height 200ms ${EASE}, width 260ms ${EASE}, transform 200ms ${EASE}, background-color 200ms, box-shadow 200ms`,\n        willChange: p.lifted ? \"translate\" : undefined,\n      }}\n    >\n      {p.naming ? (\n        <input\n          autoFocus\n          defaultValue={p.title}\n          placeholder=\"New event\"\n          aria-label=\"Event name\"\n          onKeyDown={(e) => {\n            e.stopPropagation();\n            if (e.key === \"Enter\") a.current.name(p.id, e.currentTarget.value);\n            if (e.key === \"Escape\") a.current.name(p.id, null);\n          }}\n          onBlur={(e) => a.current.name(p.id, e.currentTarget.value)}\n          className=\"w-full min-w-0 bg-transparent text-[12px] font-medium outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n        />\n      ) : (\n        <span className={`truncate font-medium leading-tight ${p.tall ? \"text-[12px]\" : \"text-[11px]\"} ${p.title ? \"\" : \"text-muted-foreground\"}`}>\n          {p.title || \"New event\"}\n          {!p.tall && (\n            <span className=\"ml-1 font-normal text-muted-foreground tabular-nums\">\n              <Clock minutes={p.start} hour12={p.hour12} live={p.live} />\n            </span>\n          )}\n        </span>\n      )}\n      {p.tall && (\n        <span className=\"mt-0.5 flex items-baseline gap-1 truncate text-[11px] leading-tight text-muted-foreground tabular-nums\">\n          <Clock minutes={p.start} hour12={p.hour12} live={p.live} />\n          <span>–</span>\n          <Clock minutes={p.end} hour12={p.hour12} live={p.live} />\n        </span>\n      )}\n      {/* What it lands on, said while it's held over it. */}\n      {p.roomy && (\n        <span className=\"grid transition-[grid-template-rows,opacity] duration-200\" style={{ gridTemplateRows: p.over ? \"1fr\" : \"0fr\", opacity: p.over ? 1 : 0, transitionTimingFunction: EASE }}>\n          <span className=\"min-w-0 overflow-hidden\">\n            <span className=\"mt-0.5 flex items-center gap-1 truncate text-[11px] leading-tight text-amber-700 dark:text-amber-400\">\n              <span className=\"size-1.5 shrink-0 rounded-full bg-current\" />\n              <span className=\"truncate\">Overlaps {p.over}</span>\n            </span>\n          </span>\n        </span>\n      )}\n      {!p.draft && !p.naming && (\n        <>\n          <span data-edge=\"start\" aria-hidden=\"true\" className=\"absolute inset-x-0 top-0 h-1.5 cursor-ns-resize\" />\n          <span data-edge=\"end\" aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 flex h-2 cursor-ns-resize items-end justify-center pb-[3px]\">\n            <span className=\"h-px w-4 rounded-full bg-current opacity-0 transition-opacity group-hover/event:opacity-30\" />\n          </span>\n        </>\n      )}\n    </div>\n  );\n});\n\nconst DayHead = React.memo(function DayHead({ weekday, date, today, dir }: { weekday: string; date: number; today: boolean; dir: \"up\" | \"down\" | undefined }) {\n  return (\n    <div className=\"flex flex-col items-center gap-0.5 border-l border-border py-2\" aria-hidden=\"true\">\n      <span className=\"text-[11px] uppercase tracking-[0.04em] text-muted-foreground\">\n        <TextMorph>{weekday}</TextMorph>\n      </span>\n      <span className={`text-[15px] tabular-nums leading-none ${today ? \"font-semibold text-primary\" : \"font-medium\"}`}>\n        <NumberRoll value={date} direction={dir} duration={700} />\n      </span>\n    </div>\n  );\n});\n\nexport function WeekCalendar({\n  events: controlled,\n  defaultEvents,\n  onEventsChange,\n  onCreate,\n  onUpdate,\n  onDelete,\n  date,\n  defaultDate,\n  onDateChange,\n  days: daysProp = \"auto\",\n  weekStartsOn = 1,\n  startHour = 7,\n  endHour = 21,\n  step = 15,\n  defaultDuration = 30,\n  hourHeight = 44,\n  height = 420,\n  locale,\n  label = \"Calendar\",\n  className = \"\",\n  ...props\n}: WeekCalendarProps) {\n  const reduced = useReducedMotion();\n  const uid = React.useId();\n  const [own, setOwn] = React.useState<CalendarEvent[]>(defaultEvents ?? EMPTY);\n  const events = controlled ?? own;\n  const eventsRef = React.useRef(events);\n  eventsRef.current = events;\n  const [anchorOwn, setAnchorOwn] = React.useState(() => startOfDay(defaultDate ?? new Date()));\n  const anchor = date ? startOfDay(date) : anchorOwn;\n  const [width, setWidth] = React.useState(0);\n  const [draft, setDraft] = React.useState<GridDraft | null>(null);\n  const [naming, setNaming] = React.useState<string | null>(null);\n  const [selected, setSelected] = React.useState<string | null>(null);\n  const [saving, setSaving] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [rolling, setRolling] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [gone, setGone] = React.useState<Gone[]>([]);\n  const [notice, setNotice] = React.useState<{ text: string; key: number } | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [cursor, setCursor] = React.useState<{ day: number; minute: number } | null>(null);\n  const [now, setNow] = React.useState<Date | null>(null);\n  const [slide, setSlide] = React.useState<{ dir: number; key: number } | null>(null);\n  /** The range we just left: its events slide out the other way while the new ones come in. */\n  const [outgoing, setOutgoing] = React.useState<{ key: number; dir: number; blocks: { id: string; style: React.CSSProperties; ev: CalendarEvent }[] } | null>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const columnsRef = React.useRef<HTMLDivElement>(null);\n  const goneKey = React.useRef(0);\n\n  // ── Range ──────────────────────────────────────────────\n  const span = daysProp === \"auto\" ? (width === 0 || width >= 560 ? 7 : width >= 300 ? 3 : 1) : daysProp;\n  const first = span === 7 ? startOfWeek(anchor, weekStartsOn) : span === 5 ? startOfWeek(anchor, 1) : anchor;\n  const days = React.useMemo(() => Array.from({ length: span }, (_, i) => addDays(first, i)), [first.getTime(), span]); // eslint-disable-line react-hooks/exhaustive-deps\n  const min = startHour * 60;\n  const max = endHour * 60;\n  const ppm = hourHeight / 60;\n  const hour12 = React.useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"numeric\" }).resolvedOptions().hour12 ?? false, [locale]);\n  const fmt = React.useMemo(\n    () => ({\n      weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n      long: new Intl.DateTimeFormat(locale, { weekday: \"long\", hour: \"numeric\", minute: \"2-digit\" }),\n      time: new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }),\n      hour: new Intl.DateTimeFormat(locale, { hour: \"numeric\" }),\n      range: new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\" }),\n      day: new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\" }),\n    }),\n    [locale],\n  );\n  const rangeLabel = span === 1 ? fmt.day.format(days[0]) : (fmt.range.formatRange?.(days[0], days[span - 1]) ?? `${fmt.range.format(days[0])} – ${fmt.range.format(days[span - 1])}`);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // The clock: on mount (so the server never renders a stale \"now\"), then once a minute, on the minute.\n  React.useEffect(() => {\n    let timer = 0;\n    const tick = () => {\n      const d = new Date();\n      setNow(d);\n      timer = window.setTimeout(tick, 60_000 - (d.getSeconds() * 1000 + d.getMilliseconds()) + 20);\n    };\n    tick();\n    return () => window.clearTimeout(timer);\n  }, []);\n\n  // Open on the working day: an hour and a half before now when today is in view, else at 8.\n  const scrolled = React.useRef(false);\n  React.useLayoutEffect(() => {\n    const sc = scrollerRef.current;\n    if (!sc || scrolled.current || !now) return;\n    scrolled.current = true;\n    const today = days.some((d) => sameDay(d, now));\n    const at = today ? minutesOf(now) - 90 : 8 * 60;\n    sc.scrollTop = Math.max(0, (at - min) * ppm);\n  }, [now, days, min, ppm]);\n\n  const go = (dir: number) => {\n    const by = span === 5 ? 7 : span;\n    const next = dir === 0 ? startOfDay(new Date()) : addDays(anchor, dir * by);\n    const nextFirst = span === 7 ? startOfWeek(next, weekStartsOn) : span === 5 ? startOfWeek(next, 1) : next;\n    const d = Math.sign(nextFirst.getTime() - first.getTime());\n    if (!d) return;\n    if (!date) setAnchorOwn(next);\n    onDateChange?.(nextFirst);\n    if (!reduced) setOutgoing({ key: Date.now(), dir: d, blocks: shown.map((it) => ({ id: it.id, style: geometry(it), ev: it.ev })) });\n    setSlide((s) => ({ dir: d, key: (s?.key ?? 0) + 1 }));\n    setSelected(null);\n    setCursor(null);\n    setAnnounce(span === 1 ? fmt.day.format(nextFirst) : `${fmt.range.formatRange?.(nextFirst, addDays(nextFirst, span - 1)) ?? fmt.range.format(nextFirst)}`);\n  };\n\n  // ── Events in view ─────────────────────────────────────\n  const commit = (next: CalendarEvent[]) => {\n    eventsRef.current = next;\n    if (controlled === undefined) setOwn(next);\n    onEventsChange?.(next);\n  };\n  const flash = (text: string) => {\n    const key = Date.now();\n    setNotice({ text, key });\n    window.setTimeout(() => setNotice((n) => (n?.key === key ? null : n)), 3200);\n  };\n\n  const items: Shown[] = React.useMemo(\n    () =>\n      events.flatMap((ev) => {\n        const s = new Date(ev.start);\n        const e = new Date(ev.end);\n        const day = days.findIndex((d) => sameDay(d, s));\n        if (day < 0) return [];\n        const end = sameDay(s, e) ? minutesOf(e) : 24 * 60;\n        return [{ id: ev.id, day, start: Math.max(min, minutesOf(s)), end: Math.min(max, end), busy: ev.busy, ev }];\n      }),\n    [events, days, min, max],\n  );\n\n  const shown: Shown[] = !draft\n    ? items\n    : draft.kind === \"create\"\n      ? [...items, { id: draft.id, day: draft.day, start: draft.start, end: draft.end, draft: true, ev: { id: draft.id, title: \"\", start: atMinutes(days[draft.day], draft.start), end: atMinutes(days[draft.day], draft.end), tone: 1 } }]\n      : items.map((it) => (it.id === draft.id ? { ...it, day: draft.day, start: draft.start, end: draft.end } : it));\n\n  const layout = new Map<string, { col: number; cols: number }>();\n  for (let d = 0; d < span; d++) layoutDay(shown.filter((it) => it.day === d && !it.busy)).forEach((v, k) => layout.set(k, v));\n\n  /**\n   * Where an event sits. Overlapping events fan out rather than splitting into slivers: each takes its column's start,\n   * reaches 1.6 columns across (never past the day), and later columns sit on top, so every title keeps some room.\n   */\n  const geometry = (it: { day: number; start: number; end: number; id: string; busy?: boolean }): React.CSSProperties & { zIndex: number } => {\n    const l = it.busy ? { col: 0, cols: 1 } : (layout.get(it.id) ?? { col: 0, cols: 1 });\n    const col = 100 / span;\n    const reach = Math.min(l.cols - l.col, l.col === l.cols - 1 ? 1 : 1.6);\n    return {\n      top: (it.start - min) * ppm + 1,\n      height: Math.max(MIN_HEIGHT, (it.end - it.start) * ppm - 2),\n      left: `calc(${it.day * col + (l.col * col) / l.cols}% + ${l.col ? 0 : 2}px)`,\n      width: `calc(${(col / l.cols) * reach}% - ${l.col ? 2 : 4}px)`,\n      zIndex: 10 + l.col,\n    };\n  };\n\n  const overlapsOf = (it: { id: string; day: number; start: number; end: number }) =>\n    shown.filter((o) => o.id !== it.id && o.day === it.day && o.start < it.end && o.end > it.start).map((o) => (o.busy ? \"busy time\" : o.ev.title || \"an event\"));\n\n  const describe = (ev: { title: string }, it: { day: number; start: number; end: number }) =>\n    `${ev.title || \"New event\"}, ${fmt.long.format(atMinutes(days[it.day], it.start))} to ${fmt.time.format(atMinutes(days[it.day], it.end))}`;\n\n  const roll = (id: string) => {\n    setRolling((s) => new Set(s).add(id));\n    window.setTimeout(() => setRolling((s) => {\n      const n = new Set(s);\n      n.delete(id);\n      return n;\n    }), 1200);\n  };\n\n  const shake = (id: string) => {\n    if (reduced) return;\n    rootRef.current?.querySelector<HTMLElement>(`[data-event-id=\"${CSS.escape(id)}\"]`)?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\", composite: \"add\" },\n    );\n  };\n\n  /** Leaves through a fold: a copy where it was shrinks a little and blurs out. */\n  const fold = (ev: CalendarEvent) => {\n    const it = items.find((x) => x.id === ev.id);\n    if (!it || reduced) return;\n    setGone((g) => [...g, { key: ++goneKey.current, ev, style: geometry(it) }]);\n  };\n\n  const update = async (next: CalendarEvent, prev: CalendarEvent, how: string) => {\n    commit(eventsRef.current.map((e) => (e.id === next.id ? next : e)));\n    roll(next.id);\n    const it = { day: days.findIndex((d) => sameDay(d, next.start)), start: minutesOf(next.start), end: minutesOf(next.end) };\n    const over = it.day >= 0 ? overlapsOf({ id: next.id, ...it }) : [];\n    if (it.day >= 0) setAnnounce(`${how} ${describe(next, it)}.${over.length ? ` Overlaps ${over[0]}.` : \"\"}`);\n    if (!onUpdate) return;\n    try {\n      await onUpdate(next, prev);\n    } catch {\n      commit(eventsRef.current.map((e) => (e.id === next.id ? prev : e)));\n      roll(next.id);\n      flash(`Couldn't move “${prev.title}”. It's back where it was.`);\n      setAnnounce(`Couldn't save. ${prev.title} is back where it was.`);\n      window.setTimeout(() => shake(next.id), 240);\n    }\n  };\n\n  const remove = async (ev: CalendarEvent) => {\n    fold(ev);\n    commit(eventsRef.current.filter((e) => e.id !== ev.id));\n    setSelected(null);\n    setAnnounce(`Deleted ${ev.title}.`);\n    columnsRef.current?.focus({ preventScroll: true });\n    if (!onDelete) return;\n    try {\n      await onDelete(ev);\n    } catch {\n      commit([...eventsRef.current, ev]);\n      flash(`Couldn't delete “${ev.title}”.`);\n      window.setTimeout(() => shake(ev.id), 60);\n    }\n  };\n\n  const create = (day: number, start: number, end: number, id = `new-${Date.now().toString(36)}-${seq++}`) => {\n    const ev: CalendarEvent = { id, title: \"\", start: atMinutes(days[day], start), end: atMinutes(days[day], end), tone: 1 };\n    commit([...eventsRef.current, ev]);\n    setNaming(id);\n    setSelected(id);\n    roll(id);\n  };\n\n  const finishNaming = async (id: string, title: string | null) => {\n    if (naming !== id) return;\n    setNaming(null);\n    const ev = eventsRef.current.find((e) => e.id === id);\n    if (!ev) return;\n    if (title === null) {\n      fold(ev);\n      commit(eventsRef.current.filter((e) => e.id !== id));\n      setAnnounce(\"Discarded the new event.\");\n      return;\n    }\n    const named = { ...ev, title: title.trim() || \"New event\" };\n    commit(eventsRef.current.map((e) => (e.id === id ? named : e)));\n    const it = { day: days.findIndex((d) => sameDay(d, named.start)), start: minutesOf(named.start), end: minutesOf(named.end) };\n    setAnnounce(`Added ${describe(named, it)}.`);\n    if (!onCreate) return;\n    setSaving((s) => new Set(s).add(id));\n    try {\n      const stored = await onCreate(named);\n      if (stored && stored.id !== id) commit(eventsRef.current.map((e) => (e.id === id ? { ...named, ...stored } : e)));\n    } catch {\n      shake(id);\n      window.setTimeout(() => {\n        const cur = eventsRef.current.find((e) => e.id === id);\n        if (cur) fold(cur);\n        commit(eventsRef.current.filter((e) => e.id !== id));\n      }, reduced ? 0 : 420);\n      flash(`Couldn't save “${named.title}”.`);\n      setAnnounce(`Couldn't save ${named.title}.`);\n    } finally {\n      setSaving((s) => {\n        const n = new Set(s);\n        n.delete(id);\n        return n;\n      });\n    }\n  };\n\n  // ── Gestures ───────────────────────────────────────────\n  const grid = useTimeGrid({\n    columns: columnsRef,\n    scroller: scrollerRef,\n    span,\n    min,\n    max,\n    pxPerMinute: ppm,\n    step,\n    holdDuration: defaultDuration,\n    items: items.filter((it) => !it.busy && it.id !== naming),\n    disabled: !!naming,\n    onDraft: setDraft,\n    onCommit: (d) => {\n      if (d.kind === \"create\") return create(d.day, d.start, d.end, d.id);\n      const prev = eventsRef.current.find((e) => e.id === d.id);\n      if (!prev) return;\n      const next = { ...prev, start: atMinutes(days[d.day], d.start), end: atMinutes(days[d.day], d.end) };\n      if (next.start.getTime() === new Date(prev.start).getTime() && next.end.getTime() === new Date(prev.end).getTime()) return;\n      setSelected(d.id);\n      update(next, prev, d.kind === \"move\" ? \"Moved to\" : \"Now\");\n    },\n    onTap: (t, pointer) => {\n      if (\"id\" in t) {\n        setSelected(t.id);\n        rootRef.current?.querySelector<HTMLElement>(`[data-event-id=\"${CSS.escape(t.id)}\"]`)?.focus({ preventScroll: true });\n        return;\n      }\n      setSelected(null);\n      // A click makes an event; a tap on a phone only scrolls (hold to make one).\n      if (pointer !== \"touch\") create(t.day, t.minute, Math.min(max, t.minute + defaultDuration));\n    },\n  });\n\n  // ── Keys ───────────────────────────────────────────────\n  const onEventKey = (e: React.KeyboardEvent, it: Shown) => {\n    if (naming === it.id || it.busy) return;\n    const ev = it.ev;\n    const move = (dDay: number, dStart: number, dEnd: number) => {\n      const day = it.day + dDay;\n      if (day < 0 || day >= span) return;\n      const start = it.start + dStart;\n      const end = it.end + dEnd;\n      if (start < min || end > max || end - start < step) return;\n      update({ ...ev, start: atMinutes(days[day], start), end: atMinutes(days[day], end) }, ev, dStart === dEnd ? \"Moved to\" : \"Now\");\n    };\n    const keys: Record<string, () => void> = {\n      ArrowUp: () => (e.shiftKey ? move(0, 0, -step) : move(0, -step, -step)),\n      ArrowDown: () => (e.shiftKey ? move(0, 0, step) : move(0, step, step)),\n      ArrowLeft: () => move(-1, 0, 0),\n      ArrowRight: () => move(1, 0, 0),\n      Enter: () => setNaming(it.id),\n      Delete: () => remove(ev),\n      Backspace: () => remove(ev),\n      Escape: () => columnsRef.current?.focus({ preventScroll: true }),\n    };\n    const run = keys[e.key];\n    if (!run) return;\n    e.preventDefault();\n    e.stopPropagation();\n    run();\n  };\n\n  const onGridKey = (e: React.KeyboardEvent) => {\n    if (e.target !== e.currentTarget) return;\n    const c = cursor ?? { day: Math.max(0, now ? days.findIndex((d) => sameDay(d, now)) : 0), minute: 9 * 60 };\n    const set = (day: number, minute: number) => {\n      const next = { day: Math.max(0, Math.min(span - 1, day)), minute: Math.max(min, Math.min(max - step, minute)) };\n      setCursor(next);\n      setAnnounce(`${fmt.long.format(atMinutes(days[next.day], next.minute))}`);\n      const sc = scrollerRef.current;\n      const y = (next.minute - min) * ppm;\n      if (sc && (y < sc.scrollTop || y > sc.scrollTop + sc.clientHeight - 40)) sc.scrollTop = y - sc.clientHeight / 3;\n    };\n    const keys: Record<string, () => void> = {\n      ArrowUp: () => set(c.day, c.minute - step),\n      ArrowDown: () => set(c.day, c.minute + step),\n      ArrowLeft: () => set(c.day - 1, c.minute),\n      ArrowRight: () => set(c.day + 1, c.minute),\n      PageUp: () => go(-1),\n      PageDown: () => go(1),\n      Enter: () => create(c.day, c.minute, Math.min(max, c.minute + defaultDuration)),\n    };\n    const run = keys[e.key];\n    if (!run) return;\n    e.preventDefault();\n    if (!cursor && e.key !== \"Enter\") setCursor(c);\n    run();\n  };\n\n  // Handlers the memoised blocks call through a stable ref, so they never re-render just because these were re-created.\n  const shownRef = React.useRef(shown);\n  shownRef.current = shown;\n  const actions = React.useRef<BlockActions>(null!);\n  actions.current = {\n    key: (e, id) => {\n      const it = shownRef.current.find((x) => x.id === id);\n      if (it) onEventKey(e, it);\n    },\n    focus: (id) => setSelected(id),\n    blur: (id) => setSelected((s) => (s === id ? null : s)),\n    name: (id, value) => finishNaming(id, value),\n  };\n\n  // ── Render ─────────────────────────────────────────────\n  const today = now ? days.findIndex((d) => sameDay(d, now)) : -1;\n  const hours = Array.from({ length: endHour - startHour }, (_, i) => startHour + i);\n  const gridHeight = (max - min) * ppm;\n\n  return (\n    <div ref={rootRef} className={`w-full text-foreground ${className}`} {...props}>\n      <div className=\"flex items-center justify-between gap-2\">\n        <p className=\"min-w-0 truncate text-[14px] font-medium tracking-[-0.01em]\">\n          <TextMorph>{rangeLabel}</TextMorph>\n        </p>\n        <div className=\"flex shrink-0 items-center gap-0.5\">\n          <button\n            type=\"button\"\n            onClick={() => go(0)}\n            className={`mr-1 inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n          >\n            Today\n          </button>\n          <button type=\"button\" aria-label=\"Previous\" onClick={() => go(-1)} className={`flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}>\n            <ChevronLeft className=\"size-4\" strokeWidth={1.75} />\n          </button>\n          <button type=\"button\" aria-label=\"Next\" onClick={() => go(1)} className={`flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}>\n            <ChevronRight className=\"size-4\" strokeWidth={1.75} />\n          </button>\n        </div>\n      </div>\n      <div className=\"grid transition-[grid-template-rows] duration-300\" style={{ gridTemplateRows: notice ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n        <div className=\"min-w-0 overflow-hidden\">\n          <Line text={notice?.text ?? \"\"} className=\"pt-1.5 text-[12.5px] text-red-600 dark:text-red-400\" />\n        </div>\n      </div>\n\n      <div className=\"mt-3 overflow-hidden rounded-[16px] border border-border\">\n        {/* Day heads */}\n        <div className=\"flex border-b border-border\">\n          <div className=\"w-11 shrink-0\" />\n          <div className=\"grid flex-1\" style={{ gridTemplateColumns: `repeat(${span}, minmax(0, 1fr))` }}>\n            {days.map((d, i) => (\n              <DayHead key={i} weekday={fmt.weekday.format(d)} date={d.getDate()} today={i === today} dir={slide ? (slide.dir > 0 ? \"up\" : \"down\") : undefined} />\n            ))}\n          </div>\n        </div>\n\n        <div ref={scrollerRef} className=\"relative overflow-y-auto overscroll-contain\" style={{ height }}>\n          <div className=\"relative flex\" style={{ height: gridHeight }}>\n            {/* Hours */}\n            <div className=\"relative w-11 shrink-0\" aria-hidden=\"true\">\n              {hours.slice(1).map((h) => (\n                <span key={h} className=\"absolute right-2 -translate-y-1/2 text-[10.5px] tabular-nums text-muted-foreground\" style={{ top: (h * 60 - min) * ppm }}>\n                  {fmt.hour.format(atMinutes(days[0], h * 60))}\n                </span>\n              ))}\n            </div>\n\n            <div\n              ref={columnsRef}\n              tabIndex={0}\n              role=\"group\"\n              aria-label={`${label}, ${rangeLabel}`}\n              aria-describedby={`${uid}-grid-keys`}\n              onKeyDown={onGridKey}\n              onBlur={(e) => {\n                if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCursor(null);\n              }}\n              {...grid}\n              className=\"relative flex-1 cursor-cell touch-pan-y select-none outline-none\"\n              style={{\n                backgroundImage: `linear-gradient(to bottom, var(--border) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--border) 45%, transparent) 1px, transparent 1px)`,\n                backgroundSize: `100% ${hourHeight}px, 100% ${hourHeight}px`,\n                backgroundPosition: `0 ${hourHeight - 0.5}px, 0 ${hourHeight / 2 - 0.5}px`,\n              }}\n            >\n              {/* Day separators */}\n              <div className=\"pointer-events-none absolute inset-0 grid\" style={{ gridTemplateColumns: `repeat(${span}, minmax(0, 1fr))` }} aria-hidden=\"true\">\n                {days.map((_, i) => (\n                  <div key={i} className=\"border-l border-border\" />\n                ))}\n              </div>\n\n              {/* The keyboard's slot */}\n              {cursor && (\n                <div\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute rounded-[8px] shadow-[inset_0_0_0_1.5px_var(--ring)]\"\n                  style={{ ...geometry({ id: \"cursor\", busy: true, day: cursor.day, start: cursor.minute, end: cursor.minute + defaultDuration }), transition: reduced ? \"none\" : `top 180ms ${EASE}, left 220ms ${EASE}` }}\n                />\n              )}\n\n              {outgoing && (\n                <div\n                  key={outgoing.key}\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-0\"\n                  ref={(el) => {\n                    if (!el || el.dataset.out) return;\n                    el.dataset.out = \"1\";\n                    el.animate(\n                      [\n                        { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n                        { transform: `translateX(${-outgoing.dir * 32}px)`, opacity: 0, filter: \"blur(4px)\" },\n                      ],\n                      { duration: 260, easing: EASE, fill: \"forwards\" },\n                    ).onfinish = () => setOutgoing((o) => (o?.key === outgoing.key ? null : o));\n                  }}\n                >\n                  {outgoing.blocks.map((b) => (\n                    <div\n                      key={b.id}\n                      className=\"absolute overflow-hidden rounded-[8px] px-1.5 py-1 text-[12px] font-medium\"\n                      style={\n                        b.ev.busy\n                          ? { ...b.style, backgroundImage: \"repeating-linear-gradient(135deg, color-mix(in oklab, var(--foreground) 9%, transparent) 0 1px, transparent 1px 6px)\", boxShadow: \"inset 0 0 0 1px var(--border)\" }\n                          : { ...b.style, background: tint(b.ev.tone ?? 1, 13), boxShadow: ring(b.ev.tone ?? 1, 42) }\n                      }\n                    >\n                      <span className=\"block truncate\">{b.ev.busy ? \"\" : b.ev.title || \"New event\"}</span>\n                    </div>\n                  ))}\n                </div>\n              )}\n\n              {/* Events: one layer for the whole range, so an event dragged across days glides there. */}\n              <div\n                key={slide?.key ?? 0}\n                className=\"absolute inset-0\"\n                ref={(el) => {\n                  if (!el || !slide || reduced || el.dataset.in) return;\n                  el.dataset.in = \"1\";\n                  el.animate(\n                    [\n                      { transform: `translateX(${slide.dir * 32}px)`, opacity: 0, filter: \"blur(4px)\" },\n                      { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n                    ],\n                    { duration: 460, easing: EASE },\n                  );\n                }}\n              >\n                {shown.map((it) => {\n                  const ev = it.ev;\n                  const lifted = draft?.id === it.id;\n                  const g = geometry(it);\n                  if (it.busy)\n                    return (\n                      <div\n                        key={it.id}\n                        data-event-id={it.id}\n                        data-busy=\"1\"\n                        aria-label={`Busy, ${fmt.long.format(atMinutes(days[it.day], it.start))} to ${fmt.time.format(atMinutes(days[it.day], it.end))}`}\n                        role=\"note\"\n                        className=\"pointer-events-none absolute overflow-hidden rounded-[8px] px-1.5 py-1 text-[11px] text-muted-foreground\"\n                        style={{\n                          ...g,\n                          backgroundImage: \"repeating-linear-gradient(135deg, color-mix(in oklab, var(--foreground) 9%, transparent) 0 1px, transparent 1px 6px)\",\n                          boxShadow: \"inset 0 0 0 1px var(--border)\",\n                        }}\n                      >\n                        {ev.title || \"Busy\"}\n                      </div>\n                    );\n                  const over = lifted ? (overlapsOf(it)[0] ?? \"\") : \"\";\n                  const tall = (it.end - it.start) * ppm - 2 >= 34;\n                  return (\n                    <EventBlock\n                      key={it.id}\n                      id={it.id}\n                      title={ev.title}\n                      tone={ev.tone ?? 1}\n                      draft={!!it.draft}\n                      top={g.top as number}\n                      height={g.height as number}\n                      left={g.left as string}\n                      width={g.width as string}\n                      zIndex={g.zIndex}\n                      lifted={lifted}\n                      selected={selected === it.id && !lifted}\n                      tall={tall}\n                      roomy={tall && (it.end - it.start) * ppm >= 52}\n                      over={over}\n                      live={lifted || rolling.has(it.id)}\n                      naming={naming === it.id}\n                      saving={saving.has(it.id)}\n                      start={it.start}\n                      end={it.end}\n                      hour12={hour12}\n                      reduced={reduced}\n                      label={describe(ev, it) + (over ? `. Overlaps ${over}.` : \"\")}\n                      keysId={`${uid}-event-keys`}\n                      actions={actions}\n                    />\n                  );\n                })}\n\n                {gone.map((g) => (\n                  <div\n                    key={g.key}\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute overflow-hidden rounded-[8px] px-1.5 py-1 text-[12px] font-medium\"\n                    style={{ ...g.style, background: tint(g.ev.tone ?? 1, 13), boxShadow: ring(g.ev.tone ?? 1, 42) }}\n                    ref={(el) => {\n                      if (!el || el.dataset.out) return;\n                      el.dataset.out = \"1\";\n                      el.animate(\n                        [\n                          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                          { opacity: 0, transform: \"scale(0.94)\", filter: \"blur(4px)\" },\n                        ],\n                        { duration: 280, easing: EASE, fill: \"forwards\" },\n                      ).onfinish = () => setGone((all) => all.filter((x) => x.key !== g.key));\n                    }}\n                  >\n                    {g.ev.title || \"New event\"}\n                  </div>\n                ))}\n              </div>\n\n              {/* Now */}\n              {today >= 0 && now && minutesOf(now) >= min && minutesOf(now) <= max && (\n                <div\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute z-50 h-px bg-primary\"\n                  style={{\n                    top: (minutesOf(now) - min) * ppm,\n                    left: `${(today / span) * 100}%`,\n                    width: `${100 / span}%`,\n                    transition: reduced ? \"none\" : `top 1s ${EASE}`,\n                  }}\n                >\n                  <span className=\"absolute -left-[3.5px] -top-[3px] size-[7px] rounded-full bg-primary\" />\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <p id={`${uid}-grid-keys`} className=\"sr-only\">\n        Arrow keys pick a time, Enter adds an event there, Page Up and Page Down change the {span === 1 ? \"day\" : \"week\"}. Tab moves to the events.\n      </p>\n      <p id={`${uid}-event-keys`} className=\"sr-only\">\n        Arrow keys move the event, Shift with up or down changes its length, Enter renames it, Delete removes it.\n      </p>\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/use-time-grid.ts","type":"registry:component","content":"// Hairline UI · week-calendar · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TIME GRID: the gestures of a calendar week\n *\n *   create    press on an empty slot and drag: a block grows\n *             from where you pressed to where you are, snapped\n *             to the step; on touch, hold for a moment first,\n *             so a swipe still scrolls\n *   move      drag an event: it keeps the spot you grabbed it\n *             by, snaps in time and jumps day to day\n *   resize    drag its top or bottom edge\n *   scroll    near the top or bottom of the scroller the grid\n *             scrolls itself, faster the closer you get\n *\n * The hook only turns pointers into snapped drafts\n * ({ day, start, end } in minutes) and reports a change of\n * step, not every move; the component lays drafts out like\n * any other event, so the overlap columns re-flow live.\n * Plus the date and overlap helpers it needs.\n * ───────────────────────────────────────────────────────── */\n\nexport type GridItem = { id: string; day: number; start: number; end: number; busy?: boolean };\nexport type GridDraftKind = \"create\" | \"move\" | \"start\" | \"end\";\nexport type GridDraft = { kind: GridDraftKind; id: string; day: number; start: number; end: number };\n\n// ── Dates ────────────────────────────────────────────────\nexport const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\nexport const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nexport const startOfWeek = (d: Date, weekStartsOn: number) => {\n  const day = startOfDay(d);\n  return addDays(day, -((day.getDay() - weekStartsOn + 7) % 7));\n};\nexport const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\nexport const minutesOf = (d: Date) => d.getHours() * 60 + d.getMinutes();\nexport const atMinutes = (day: Date, minutes: number) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, minutes);\n\n// ── Overlaps ─────────────────────────────────────────────\n/**\n * Side-by-side columns for one day: items that overlap (directly or through a chain) form a cluster, each takes the\n * first column that's free when it starts, and the whole cluster shares the cluster's column count.\n */\nexport function layoutDay(items: { id: string; start: number; end: number }[]) {\n  const out = new Map<string, { col: number; cols: number }>();\n  const sorted = [...items].sort((a, b) => a.start - b.start || b.end - a.end);\n  let cluster: string[] = [];\n  let ends: number[] = [];\n  let clusterEnd = -Infinity;\n  const flush = () => {\n    for (const id of cluster) out.get(id)!.cols = ends.length;\n    cluster = [];\n    ends = [];\n  };\n  for (const it of sorted) {\n    if (it.start >= clusterEnd && cluster.length) flush();\n    let col = ends.findIndex((end) => end <= it.start);\n    if (col === -1) {\n      col = ends.length;\n      ends.push(it.end);\n    } else ends[col] = it.end;\n    out.set(it.id, { col, cols: 1 });\n    cluster.push(it.id);\n    clusterEnd = cluster.length === 1 ? it.end : Math.max(clusterEnd, it.end);\n  }\n  flush();\n  return out;\n}\n\n// ── Gestures ─────────────────────────────────────────────\ntype Options = {\n  /** The day columns (inside the scroller): pointers are read against it. */\n  columns: React.RefObject<HTMLElement | null>;\n  scroller: React.RefObject<HTMLElement | null>;\n  span: number;\n  /** Visible range in minutes from midnight. */\n  min: number;\n  max: number;\n  pxPerMinute: number;\n  step: number;\n  /** Length of an event made by holding (touch) before dragging. */\n  holdDuration: number;\n  items: GridItem[];\n  /** A new snapped draft, or null when the gesture ends. Called per step, not per move. */\n  onDraft: (draft: GridDraft | null) => void;\n  /** The gesture ended on a draft. */\n  onCommit: (draft: GridDraft) => void;\n  /** A press that didn't become a drag: on an event (id) or an empty slot. */\n  onTap: (target: { id: string } | { day: number; minute: number }, pointerType: string) => void;\n  disabled?: boolean;\n};\n\ntype Gesture = {\n  pointerId: number;\n  pointerType: string;\n  kind: GridDraftKind;\n  id: string;\n  x: number;\n  y: number;\n  downDay: number;\n  downMinute: number;\n  /** Minutes between the grab point and the event's start. */\n  offset: number;\n  item: { day: number; start: number; end: number } | null;\n  active: boolean;\n  held: boolean;\n  timer: number;\n  draft: GridDraft | null;\n};\n\nconst HOLD = 360;\nconst EDGE = 40;\nlet seq = 0;\n\nexport function useTimeGrid(o: Options) {\n  const opts = React.useRef(o);\n  opts.current = o;\n  const g = React.useRef<Gesture | null>(null);\n  const scrollLoop = React.useRef(0);\n\n  const snap = (m: number) => Math.round(m / opts.current.step) * opts.current.step;\n  const floorStep = (m: number) => Math.floor(m / opts.current.step) * opts.current.step;\n\n  /** The grid's box, measured when a gesture starts and adjusted for scrolling, so a drag never forces a layout read. */\n  const box = React.useRef<{ left: number; top: number; width: number; scroll: number } | null>(null);\n  /** The scroller's offset, kept from its scroll events: reading scrollTop mid-transition would force a style flush. */\n  const scrollY = React.useRef(0);\n  const measure = () => {\n    const r = opts.current.columns.current!.getBoundingClientRect();\n    scrollY.current = opts.current.scroller.current?.scrollTop ?? 0;\n    return { left: r.left, top: r.top, width: r.width, scroll: scrollY.current };\n  };\n\n  /** Pointer position to a day and a minute (unsnapped). */\n  const locate = (x: number, y: number) => {\n    const { span, min, max, pxPerMinute } = opts.current;\n    const r = box.current ?? measure();\n    const top = r.top - (scrollY.current - r.scroll);\n    const day = Math.min(span - 1, Math.max(0, Math.floor(((x - r.left) / r.width) * span)));\n    const minute = Math.min(max, Math.max(min, min + (y - top) / pxPerMinute));\n    return { day, minute };\n  };\n\n  const compute = () => {\n    const s = g.current;\n    if (!s?.active) return;\n    const { min, max, step, holdDuration } = opts.current;\n    const { day, minute } = locate(s.x, s.y);\n    let next: GridDraft;\n    if (s.kind === \"create\") {\n      const a = floorStep(s.downMinute);\n      const b = snap(minute);\n      // Held without moving yet (touch): a block of the default length; then it follows the finger from the anchor.\n      if (s.held && Math.abs(minute - s.downMinute) < step / 2) next = { kind: \"create\", id: s.id, day: s.downDay, start: a, end: Math.min(max, a + holdDuration) };\n      else if (b > a) next = { kind: \"create\", id: s.id, day: s.downDay, start: a, end: Math.max(a + step, b) };\n      else next = { kind: \"create\", id: s.id, day: s.downDay, start: Math.max(min, Math.min(b, a)), end: a + step };\n    } else {\n      const it = s.item!;\n      const length = it.end - it.start;\n      if (s.kind === \"move\") {\n        const start = Math.min(max - length, Math.max(min, snap(minute - s.offset)));\n        next = { kind: \"move\", id: s.id, day, start, end: start + length };\n      } else if (s.kind === \"start\") next = { kind: \"start\", id: s.id, day: it.day, start: Math.min(it.end - step, Math.max(min, snap(minute))), end: it.end };\n      else next = { kind: \"end\", id: s.id, day: it.day, start: it.start, end: Math.max(it.start + step, Math.min(max, snap(minute))) };\n    }\n    const prev = s.draft;\n    if (prev && prev.day === next.day && prev.start === next.start && prev.end === next.end) return;\n    s.draft = next;\n    opts.current.onDraft(next);\n  };\n\n  /** Near the scroller's top or bottom edge, scroll it (faster the closer) and re-read the pointer against the moved grid. */\n  const autoScroll = () => {\n    cancelAnimationFrame(scrollLoop.current);\n    const tick = () => {\n      const s = g.current;\n      const sc = opts.current.scroller.current;\n      if (!s?.active || !sc) return;\n      const r = sc.getBoundingClientRect();\n      const up = s.y < r.top + EDGE ? (r.top + EDGE - s.y) / EDGE : 0;\n      const down = s.y > r.bottom - EDGE ? (s.y - (r.bottom - EDGE)) / EDGE : 0;\n      const speed = (down - up) * 14;\n      if (!speed) return;\n      const before = sc.scrollTop;\n      sc.scrollTop += speed;\n      scrollY.current = sc.scrollTop;\n      if (scrollY.current !== before) compute();\n      scrollLoop.current = requestAnimationFrame(tick);\n    };\n    scrollLoop.current = requestAnimationFrame(tick);\n  };\n\n  const activate = () => {\n    const s = g.current;\n    if (!s || s.active) return;\n    s.active = true;\n    if (s.pointerType === \"touch\") navigator.vibrate?.(8);\n    compute();\n  };\n\n  const end = (commit: boolean) => {\n    const s = g.current;\n    if (!s) return;\n    window.clearTimeout(s.timer);\n    cancelAnimationFrame(scrollLoop.current);\n    g.current = null;\n    box.current = null;\n    try {\n      opts.current.columns.current?.releasePointerCapture(s.pointerId);\n    } catch {}\n    if (s.active) {\n      if (commit && s.draft) opts.current.onCommit(s.draft);\n      opts.current.onDraft(null);\n    } else if (commit) {\n      opts.current.onTap(s.item ? { id: s.id } : { day: s.downDay, minute: floorStep(s.downMinute) }, s.pointerType);\n    }\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLElement>) => {\n    if (opts.current.disabled || g.current || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const target = e.target as HTMLElement;\n    if (target.closest(\"input, textarea, [data-grid-ignore]\")) return;\n    const el = target.closest<HTMLElement>(\"[data-event-id]\");\n    const edge = target.closest<HTMLElement>(\"[data-edge]\")?.dataset.edge as \"start\" | \"end\" | undefined;\n    box.current = measure();\n    const { day, minute } = locate(e.clientX, e.clientY);\n    const item = el && !el.dataset.busy ? opts.current.items.find((it) => it.id === el.dataset.eventId) ?? null : null;\n    g.current = {\n      pointerId: e.pointerId,\n      pointerType: e.pointerType,\n      kind: item ? (edge ?? \"move\") : \"create\",\n      id: item ? item.id : `new-${Date.now().toString(36)}-${seq++}`,\n      x: e.clientX,\n      y: e.clientY,\n      downDay: day,\n      downMinute: minute,\n      offset: item ? minute - item.start : 0,\n      item: item && { day: item.day, start: item.start, end: item.end },\n      active: false,\n      held: false,\n      timer: 0,\n      draft: null,\n    };\n    if (e.pointerType === \"touch\") {\n      // Hold to pick up; moving first is a scroll.\n      g.current.timer = window.setTimeout(() => {\n        if (!g.current) return;\n        g.current.held = true;\n        activate();\n      }, HOLD);\n    } else {\n      e.preventDefault();\n      e.currentTarget.setPointerCapture(e.pointerId);\n    }\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLElement>) => {\n    const s = g.current;\n    if (!s || s.pointerId !== e.pointerId) return;\n    const moved = Math.hypot(e.clientX - s.x, e.clientY - s.y);\n    if (!s.active) {\n      if (s.pointerType === \"touch\") {\n        if (moved > 8) end(false);\n        return;\n      }\n      if (moved < 4) return;\n      s.x = e.clientX;\n      s.y = e.clientY;\n      activate();\n    }\n    s.x = e.clientX;\n    s.y = e.clientY;\n    compute();\n    autoScroll();\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLElement>) => {\n    if (g.current?.pointerId === e.pointerId) end(true);\n  };\n  const onPointerCancel = (e: React.PointerEvent<HTMLElement>) => {\n    if (g.current?.pointerId === e.pointerId) end(false);\n  };\n\n  // Once a held finger has picked something up, the page mustn't scroll under it (and no context menu).\n  React.useEffect(() => {\n    const el = o.columns.current;\n    if (!el) return;\n    const touchMove = (e: TouchEvent) => {\n      if (g.current?.active && e.cancelable) e.preventDefault();\n    };\n    const menu = (e: Event) => {\n      if (g.current) e.preventDefault();\n    };\n    el.addEventListener(\"touchmove\", touchMove, { passive: false });\n    el.addEventListener(\"contextmenu\", menu);\n    return () => {\n      el.removeEventListener(\"touchmove\", touchMove);\n      el.removeEventListener(\"contextmenu\", menu);\n    };\n  }, [o.columns]);\n\n  React.useEffect(() => {\n    const sc = o.scroller.current;\n    if (!sc) return;\n    const onScroll = () => {\n      scrollY.current = sc.scrollTop;\n    };\n    sc.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => sc.removeEventListener(\"scroll\", onScroll);\n  }, [o.scroller]);\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(scrollLoop.current);\n      if (g.current) window.clearTimeout(g.current.timer);\n    },\n    [],\n  );\n\n  return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture: onPointerCancel };\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/week-calendar. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}