{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "appointment-scheduler",
  "title": "Appointment Scheduler",
  "description": "Appointment booking for React and shadcn: pick a provider, day and time, and the chosen slot lifts into the confirmation card.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-indicator.json",
    "https://eased.dev/r/eased-text-swap.json",
    "https://eased.dev/r/eased-health-core.json",
    "https://eased.dev/r/avatar.json",
    "https://eased.dev/r/button.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/appointment-scheduler/appointment-scheduler.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { Avatar, initialsOf } from \"../avatar/avatar\";\nimport { Button } from \"../button/button\";\nimport { ease, fade, reducedFade, smooth, useSafeReducedMotion } from \"../lib/motion\";\nimport { HEALTH_LOCALE, HEALTH_TIME_ZONE, formatClock, toMinutes } from \"../_primitives/health-core/health-core\";\nimport { Indicator, IndicatorGroup } from \"../_primitives/indicator/indicator\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\n\nexport type SchedulerProvider = { id: string; name: string; short: string; role?: string };\nexport type BusyInterval = { start: number; end: number };\nexport type Booking = { providerId: string; start: number; end: number };\n\nexport type AppointmentSchedulerProps = {\n  providers: readonly SchedulerProvider[];\n  /** Booked time per provider id. A slot is open when the visit fits without touching any of it. */\n  busy: Readonly<Record<string, readonly BusyInterval[]>>;\n  /** Bookable days as midnight timestamps (epoch ms, in `timeZone`), e.g. the next five weekdays. */\n  days: readonly number[];\n  /** Clinic hours as \"HH:MM\": slots start every `slotMinutes` from `start`, skipping `lunch`. */\n  hours: { start: string; end: string; lunch?: readonly [string, string]; slotMinutes: number };\n  /** The visit being booked. */\n  visit: { label: string; minutes: number };\n  /** Called when Book is pressed; resolve to confirm. The button shows a spinner until it settles. */\n  onBook?: (booking: Booking) => void | Promise<void>;\n  defaultProvider?: string;\n  /** Leading side of the toolbar. */\n  title?: ReactNode;\n  locale?: string;\n  timeZone?: string;\n  className?: string;\n};\n\ntype Flight = { key: number; label: string; from: DOMRect; to: DOMRect };\nconst MIN = 60_000;\n\n/**\n * Booking a visit in three choices. The provider and the day each carry a highlight that\n * glides; the day's slots re-tint in a ripple as availability changes; and picking a time\n * lifts that slot out of the grid and flies it into the confirmation card, where the rest of\n * the booking fades in around it. Book shows a spinner, then the label morphs to Booked.\n */\nexport function AppointmentScheduler({\n  providers,\n  busy,\n  days,\n  hours,\n  visit,\n  onBook,\n  defaultProvider,\n  title,\n  locale = HEALTH_LOCALE,\n  timeZone = HEALTH_TIME_ZONE,\n  className = \"\",\n}: AppointmentSchedulerProps) {\n  const reduced = useSafeReducedMotion();\n  const root = useRef<HTMLDivElement>(null);\n  const target = useRef<HTMLSpanElement>(null);\n  const [providerId, setProviderId] = useState(defaultProvider ?? providers[0]?.id);\n  const [booked, setBooked] = useState<Booking[]>([]);\n  const [pick, setPick] = useState<number | null>(null);\n  const [status, setStatus] = useState<\"idle\" | \"booking\" | \"booked\">(\"idle\");\n  const [flight, setFlight] = useState<Flight | null>(null);\n  const flights = useRef(0);\n\n  // Every slot start of a day, in minutes after midnight.\n  const starts: number[] = [];\n  const [lunchA, lunchB] = hours.lunch ? hours.lunch.map(toMinutes) : [-1, -1];\n  for (let m = toMinutes(hours.start); m + visit.minutes <= toMinutes(hours.end); m += hours.slotMinutes) {\n    if (m < lunchB && m + visit.minutes > lunchA) continue;\n    starts.push(m);\n  }\n  const taken = (pid: string) => [...(busy[pid] ?? []), ...booked.filter((b) => b.providerId === pid)];\n  const isOpen = (pid: string, start: number) => {\n    const end = start + visit.minutes * MIN;\n    return !taken(pid).some((b) => b.start < end && b.end > start);\n  };\n  const openCount = (pid: string, day: number) => starts.filter((m) => isOpen(pid, day + m * MIN)).length;\n  const firstDay = Math.max(0, days.findIndex((d) => openCount(providerId, d) > 0));\n  const [dayIndex, setDayIndex] = useState(firstDay);\n  const day = days[dayIndex];\n\n  const provider = providers.find((p) => p.id === providerId) ?? providers[0];\n  const weekday = new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone });\n  const dateNum = new Intl.DateTimeFormat(locale, { day: \"numeric\", timeZone });\n  const longDate = new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", timeZone });\n  const fullDate = new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", timeZone });\n  const clock = (t: number) => formatClock(t, { locale, timeZone });\n\n  const choose = (fn: () => void) => {\n    fn();\n    setPick(null);\n    setFlight(null);\n    setStatus(\"idle\");\n  };\n  const pickSlot = (start: number, el: HTMLElement) => {\n    setPick(start);\n    setStatus(\"idle\");\n    const box = root.current?.getBoundingClientRect();\n    const to = target.current?.getBoundingClientRect();\n    if (reduced || !box || !to) return;\n    const rel = (r: DOMRect) => new DOMRect(r.x - box.x, r.y - box.y, r.width, r.height);\n    setFlight({ key: ++flights.current, label: clock(start), from: rel(el.getBoundingClientRect()), to: rel(to) });\n  };\n  const book = async () => {\n    if (pick === null || status !== \"idle\") return;\n    const booking = { providerId, start: pick, end: pick + visit.minutes * MIN };\n    setStatus(\"booking\");\n    await Promise.resolve(onBook?.(booking));\n    setBooked((b) => [...b, booking]);\n    setStatus(\"booked\");\n  };\n\n  const rovingKeys = (e: KeyboardEvent, i: number, count: number, go: (i: number) => void, list: (HTMLElement | null)[]) => {\n    const next = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? (i + 1) % count : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? (i - 1 + count) % count : e.key === \"Home\" ? 0 : e.key === \"End\" ? count - 1 : -1;\n    if (next < 0) return;\n    e.preventDefault();\n    go(next);\n    list[next]?.focus();\n  };\n  const providerButtons = useRef<(HTMLButtonElement | null)[]>([]);\n  const dayButtons = useRef<(HTMLButtonElement | null)[]>([]);\n  const landed = !flight;\n  const mine = pick !== null && status === \"booked\";\n\n  return (\n    <div ref={root} className={`@container relative flex min-h-0 min-w-0 flex-col ${className}`}>\n      <div className=\"flex h-12 shrink-0 items-center justify-between gap-3 border-b border-line-subtle px-(--pad,16px)\">\n        <div className=\"flex min-w-0 items-center gap-2 text-ui font-medium text-text\">{typeof title === \"string\" ? <p className=\"truncate\">{title}</p> : title}</div>\n        <p className=\"shrink-0 text-ui-sm text-text-muted\">\n          {visit.label} · {visit.minutes} min\n        </p>\n      </div>\n\n      <div className=\"flex min-h-0 flex-1 flex-col gap-4 p-(--pad,16px) @max-[26rem]:gap-3\">\n        <IndicatorGroup>\n          <div role=\"radiogroup\" aria-label=\"Provider\" className=\"grid gap-1.5\" style={{ gridTemplateColumns: `repeat(${providers.length}, minmax(0, 1fr))` }}>\n            {providers.map((p, i) => {\n              const active = p.id === providerId;\n              return (\n                <button\n                  key={p.id}\n                  ref={(el) => void (providerButtons.current[i] = el)}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={active}\n                  aria-label={`${p.name}${p.role ? `, ${p.role}` : \"\"}`}\n                  tabIndex={active ? 0 : -1}\n                  onClick={() => choose(() => setProviderId(p.id))}\n                  onKeyDown={(e) => rovingKeys(e, i, providers.length, (n) => choose(() => setProviderId(providers[n].id)), providerButtons.current)}\n                  className={`relative flex h-control-md min-w-0 items-center gap-2 rounded-control border px-2 text-start @max-[26rem]:justify-center text-ui-sm transition-colors duration-150 ease-standard focus-ring ${\n                    active ? \"cursor-default border-line text-text\" : \"border-line-subtle text-text-secondary hover-fine:bg-surface-hover\"\n                  }`}\n                >\n                  {active && <Indicator name=\"provider\" className=\"-inset-px rounded-control border border-line-strong bg-surface-active\" />}\n                  <Avatar name={p.name} initials={initialsOf(p.name.replace(/^Dr\\.\\s+/, \"\").replace(/,.*$/, \"\"))} size=\"sm\" className=\"relative z-1 -ms-0.5 @max-[26rem]:hidden\" />\n                  <span className=\"relative z-1 truncate\">{p.short}</span>\n                </button>\n              );\n            })}\n          </div>\n        </IndicatorGroup>\n\n        <IndicatorGroup>\n          <div role=\"radiogroup\" aria-label=\"Day\" className=\"grid gap-1.5\" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}>\n            {days.map((d, i) => {\n              const active = i === dayIndex;\n              const open = openCount(providerId, d);\n              return (\n                <button\n                  key={d}\n                  ref={(el) => void (dayButtons.current[i] = el)}\n                  type=\"button\"\n                  role=\"radio\"\n                  aria-checked={active}\n                  aria-label={`${fullDate.format(d)}, ${open ? `${open} open` : \"no times\"}`}\n                  tabIndex={active ? 0 : -1}\n                  onClick={() => choose(() => setDayIndex(i))}\n                  onKeyDown={(e) => rovingKeys(e, i, days.length, (n) => choose(() => setDayIndex(n)), dayButtons.current)}\n                  className={`relative flex min-w-0 flex-col items-center gap-0.5 rounded-control py-1.5 transition-colors duration-150 ease-standard focus-ring ${\n                    active ? \"cursor-default\" : \"hover-fine:bg-surface-hover\"\n                  }`}\n                >\n                  {active && <Indicator name=\"day\" className=\"inset-0 rounded-control bg-surface-active\" />}\n                  <span className={`relative z-1 text-ui-xs transition-colors duration-150 ${active ? \"text-text-secondary\" : \"text-text-muted\"}`}>{weekday.format(d)}</span>\n                  <span className=\"relative z-1 text-ui-md font-medium text-text tabular-nums\">{dateNum.format(d)}</span>\n                  <span className={`relative z-1 text-ui-2xs tabular-nums @max-[26rem]:hidden ${open ? \"text-text-muted\" : \"text-text-disabled\"}`}>\n                    <TextSwap>{open ? `${open} open` : \"Full\"}</TextSwap>\n                  </span>\n                </button>\n              );\n            })}\n          </div>\n        </IndicatorGroup>\n\n        <div role=\"group\" aria-label={`Times on ${fullDate.format(day)}`} className=\"grid min-h-0 grid-cols-4 content-start gap-1.5 @min-[26rem]:grid-cols-5\">\n          {starts.map((m, i) => {\n            const start = day + m * MIN;\n            const yours = booked.some((b) => b.providerId === providerId && b.start === start);\n            const open = isOpen(providerId, start);\n            const selected = pick === start;\n            return (\n              <button\n                key={m}\n                type=\"button\"\n                aria-pressed={selected}\n                disabled={!open && !yours}\n                aria-label={`${clock(start)}${yours ? \", booked for you\" : open ? \"\" : \", taken\"}`}\n                onClick={(e) => open && pickSlot(start, e.currentTarget)}\n                style={{ transitionDelay: reduced ? undefined : `${i * 12}ms` }}\n                className={`h-control-sm rounded-control-sm border text-ui-sm tabular-nums transition-[background-color,color,border-color,box-shadow] duration-150 ease-standard focus-ring disabled:cursor-not-allowed ${\n                  selected || yours\n                    ? \"border-brand-line bg-brand-subtle text-brand-text\"\n                    : open\n                      ? \"border-transparent bg-surface-hover text-text hover-fine:bg-surface-active\"\n                      : \"border-transparent text-text-disabled\"\n                }`}\n              >\n                {clock(start)}\n              </button>\n            );\n          })}\n        </div>\n\n        <div className=\"mt-auto flex min-w-0 items-center gap-3 rounded-panel border border-line-subtle bg-surface-raised p-3 ps-4 [--radius-outer:var(--eased-radius-panel)]\">\n          <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n            <p className=\"truncate text-ui-xs text-text-muted\">\n              <TextSwap>{pick === null ? \"No time picked\" : `${longDate.format(pick)} · ${provider.short}`}</TextSwap>\n            </p>\n            <p className=\"flex min-w-0 items-baseline text-ui-md font-medium text-text tabular-nums\">\n              <span ref={target} className={`whitespace-nowrap transition-opacity duration-100 ${pick !== null && !landed ? \"opacity-0\" : \"\"}`}>\n                {pick === null ? <span className=\"font-normal text-text-secondary\">Pick a time</span> : clock(pick)}\n              </span>\n              <AnimatePresence initial={false}>\n                {pick !== null && landed && (\n                  <motion.span\n                    key={pick}\n                    className=\"truncate whitespace-pre font-normal text-text-secondary\"\n                    initial={{ opacity: 0, x: -4 }}\n                    animate={{ opacity: 1, x: 0 }}\n                    exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                    transition={reduced ? reducedFade : fade}\n                  >\n                    {` – ${clock(pick + visit.minutes * MIN)}`}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </p>\n          </div>\n          <Button\n            variant={mine ? \"secondary\" : \"primary\"}\n            className={mine ? \"text-success-text\" : \"\"}\n            disabled={pick === null}\n            aria-disabled={mine || pick === null || undefined}\n            focusableWhenDisabled\n            loading={status === \"booking\"}\n            onClick={book}\n            swapKey={mine ? \"booked\" : \"book\"}\n          >\n            {mine ? (\n              <>\n                <CheckGlyph reduced={reduced} />\n                Booked\n              </>\n            ) : (\n              \"Book\"\n            )}\n          </Button>\n        </div>\n      </div>\n\n      <AnimatePresence>\n        {flight && (\n          <motion.span\n            key={flight.key}\n            aria-hidden\n            className=\"pointer-events-none absolute z-10 flex items-center justify-center rounded-control-sm border border-brand-line bg-brand-subtle text-ui-sm text-brand-text tabular-nums shadow-raised\"\n            style={{ left: flight.from.x, top: flight.from.y, width: flight.from.width, height: flight.from.height }}\n            initial={{ x: 0, y: 0, scale: 1 }}\n            animate={{\n              x: flight.to.x - flight.from.x + (flight.to.width - flight.from.width) / 2,\n              y: flight.to.y - flight.from.y + (flight.to.height - flight.from.height) / 2,\n              scale: [1, 1.06, 1],\n            }}\n            exit={{ opacity: 0, transition: { duration: 0.08 } }}\n            transition={{ ...smooth, scale: { duration: 0.4, times: [0, 0.4, 1] } }}\n            onAnimationComplete={() => setFlight((f) => (f?.key === flight.key ? null : f))}\n          >\n            {flight.label}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction CheckGlyph({ reduced }: { reduced: boolean }) {\n  return (\n    <svg aria-hidden viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\">\n      <motion.path\n        d=\"M3.5 8.5l3 3 6-7\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={{ pathLength: reduced ? 1 : 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: reduced ? 0 : 0.3, ease: ease.inOut, delay: reduced ? 0 : 0.08 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/appointment-scheduler/appointment-scheduler.tsx"
    }
  ],
  "meta": {
    "details": "Book a visit in three choices: provider and day highlights glide, the day's slots re-tint in a ripple, and the picked time lifts out of the grid into the confirmation card before Book morphs to Booked.",
    "industries": [
      "health"
    ],
    "primaryIndustry": "health",
    "kind": "block",
    "releaseAt": "2026-10-23"
  },
  "docs": "Import AppointmentScheduler from components/eased/appointment-scheduler/appointment-scheduler. Pass providers, busy intervals per provider, days (midnights), hours ({ start, end, lunch, slotMinutes }), visit and onBook.",
  "type": "registry:ui"
}