{
  "name": "slot-picker",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Slot Picker\n * @version: 2.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { ArrowRight, Check, Globe } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** Meetings start on the quarter hour. */\nconst STEP = 15;\n/** Height of one minute on the day rail, in px. */\nconst PX = 0.8;\nconst HATCH =\n  \"repeating-linear-gradient(135deg, currentColor 0 1px, transparent 1px 6px)\";\n\n/** Minutes from midnight. */\ntype Span = [number, number];\n\nexport interface SlotDay {\n  /** YYYY-MM-DD */\n  date: string;\n  /** Hours you can be booked, in minutes from midnight, in the guest's time. */\n  open: Span;\n  /** Meetings already in the calendar. */\n  busy: Span[];\n}\n\nexport interface SlotPickerProps {\n  /** Who you're booking. `offset` is their clock minus yours, in minutes. */\n  host?: { name: string; title?: string; city?: string; offset?: number };\n  days?: SlotDay[];\n  /** Lengths offered, in minutes. */\n  durations?: number[];\n  /** The guest's time zone, shown under the rail. */\n  timezone?: string;\n  onBook?: (slot: { date: string; start: number; minutes: number }) => void;\n  className?: string;\n}\n\nconst h = (hours: number, minutes = 0) => hours * 60 + minutes;\n\nconst DEMO_DAYS: SlotDay[] = [\n  {\n    date: \"2026-11-02\",\n    open: [h(9), h(17)],\n    busy: [\n      [h(9), h(10)],\n      [h(12), h(13, 30)],\n      [h(15), h(16)],\n    ],\n  },\n  {\n    date: \"2026-11-03\",\n    open: [h(9), h(17)],\n    busy: [\n      [h(9), h(9, 30)],\n      [h(10), h(10, 45)],\n      [h(11, 30), h(12)],\n      [h(13), h(14)],\n      [h(16), h(17)],\n    ],\n  },\n  {\n    date: \"2026-11-04\",\n    open: [h(9), h(13)],\n    busy: [\n      [h(9, 30), h(10)],\n      [h(10, 30), h(11)],\n      [h(11, 30), h(12)],\n      [h(12, 30), h(13)],\n    ],\n  },\n  {\n    date: \"2026-11-05\",\n    open: [h(10), h(17)],\n    busy: [\n      [h(11), h(12)],\n      [h(14), h(14, 30)],\n      [h(15, 30), h(17)],\n    ],\n  },\n  {\n    date: \"2026-11-06\",\n    open: [h(9), h(15)],\n    busy: [\n      [h(9), h(11)],\n      [h(12), h(12, 30)],\n    ],\n  },\n];\n\nconst WEEKDAY = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\nconst MONTH = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n];\n\nconst parse = (iso: string) => {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return new Date(Date.UTC(y, m - 1, d));\n};\n\nconst longDate = (iso: string) => {\n  const date = parse(iso);\n  return `${WEEKDAY[date.getUTCDay()]}, ${MONTH[date.getUTCMonth()]} ${date.getUTCDate()}`;\n};\n\nconst clock = (minutes: number, suffix = true) => {\n  const wrapped = ((minutes % 1440) + 1440) % 1440;\n  const hours = Math.floor(wrapped / 60);\n  const mins = wrapped % 60;\n  const twelve = hours % 12 || 12;\n  const time = mins\n    ? `${twelve}:${String(mins).padStart(2, \"0\")}`\n    : `${twelve}`;\n  return suffix ? `${time}${hours >= 12 ? \"pm\" : \"am\"}` : time;\n};\n\n/** \"10:30 – 11am\", dropping the first am/pm when both ends share it. */\nconst range = (start: number, minutes: number) => {\n  const end = start + minutes;\n  const same = start < h(12) === end <= h(12);\n  return `${clock(start, !same)} – ${clock(end)}`;\n};\n\nconst lengthLabel = (minutes: number) =>\n  minutes < 60 ? `${minutes} min` : `${minutes / 60} hr`;\n\n/** Every quarter hour where the whole meeting fits. */\nfunction startsFor(day: SlotDay, minutes: number) {\n  const out: number[] = [];\n  for (let t = day.open[0]; t + minutes <= day.open[1]; t += STEP) {\n    if (!day.busy.some(([a, b]) => t < b && t + minutes > a)) {\n      out.push(t);\n    }\n  }\n  return out;\n}\n\n/** The start that fits closest to where the hand is. */\nfunction nearest(starts: number[], target: number) {\n  let best: number | null = null;\n  for (const s of starts) {\n    if (best === null || Math.abs(s - target) < Math.abs(best - target)) {\n      best = s;\n    }\n  }\n  return best;\n}\n\nfunction freeMinutes(day: SlotDay) {\n  const busy = day.busy.reduce(\n    (sum, [a, b]) =>\n      sum + Math.max(0, Math.min(b, day.open[1]) - Math.max(a, day.open[0])),\n    0\n  );\n  return day.open[1] - day.open[0] - busy;\n}\n\nfunction DayStrip({\n  days,\n  active,\n  minutes,\n  onPick,\n  spring,\n  layoutId,\n}: {\n  days: SlotDay[];\n  active: number;\n  minutes: number;\n  onPick: (index: number) => void;\n  spring: Transition;\n  layoutId: string;\n}) {\n  return (\n    <div className=\"mt-4 grid grid-cols-5 gap-1\">\n      {days.map((d, i) => {\n        const date = parse(d.date);\n        const full = startsFor(d, minutes).length === 0;\n        // Whole and half hours read better than 4.25h.\n        const halves = Math.floor(freeMinutes(d) / 30);\n        const free = `${Math.floor(halves / 2) || \"\"}${halves % 2 ? \"½\" : \"\"}`;\n        return (\n          <button\n            aria-pressed={i === active}\n            className={cn(\n              \"relative flex flex-col items-center rounded-2xl py-2 outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:cursor-not-allowed\",\n              i !== active && \"hover:bg-zinc-50 dark:hover:bg-zinc-900\"\n            )}\n            disabled={full}\n            key={d.date}\n            onClick={() => onPick(i)}\n            type=\"button\"\n          >\n            {i === active && (\n              <motion.span\n                className=\"absolute inset-0 rounded-2xl border border-zinc-900 dark:border-zinc-100\"\n                layoutId={layoutId}\n                transition={spring}\n              />\n            )}\n            <span className=\"text-[11px] text-zinc-500 dark:text-zinc-400\">\n              {WEEKDAY[date.getUTCDay()]}\n            </span>\n            <span\n              className={cn(\n                \"font-semibold text-lg tabular-nums leading-tight\",\n                full\n                  ? \"text-zinc-300 dark:text-zinc-700\"\n                  : \"text-zinc-900 dark:text-zinc-100\"\n              )}\n            >\n              {date.getUTCDate()}\n            </span>\n            <span\n              className={cn(\n                \"text-[10px] tabular-nums leading-[14px]\",\n                full\n                  ? \"text-zinc-400 dark:text-zinc-600\"\n                  : \"text-emerald-600 dark:text-emerald-400\"\n              )}\n            >\n              {full ? \"Full\" : `${free || 0}h free`}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/** A meeting already in the calendar, or the hours outside the day. */\nfunction Blocked({\n  span,\n  from,\n  label,\n}: {\n  span: Span;\n  from: number;\n  label?: string;\n}) {\n  const height = (span[1] - span[0]) * PX;\n  return (\n    <div\n      className={cn(\n        \"absolute inset-x-0 overflow-hidden text-zinc-200 dark:text-zinc-800\",\n        label &&\n          \"rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900\"\n      )}\n      style={{\n        top: (span[0] - from) * PX,\n        height,\n        backgroundImage: HATCH,\n      }}\n    >\n      {label && height >= 20 && (\n        <span className=\"absolute top-1 left-2 font-medium text-[10px] text-zinc-500 leading-3 dark:text-zinc-400\">\n          {label}\n        </span>\n      )}\n    </div>\n  );\n}\n\nfunction ConfirmBar({\n  day,\n  start,\n  minutes,\n  host,\n  booked,\n  onConfirm,\n  fade,\n}: {\n  day: SlotDay | undefined;\n  start: number | null;\n  minutes: number;\n  host: NonNullable<SlotPickerProps[\"host\"]>;\n  booked: boolean;\n  onConfirm: () => void;\n  fade: Transition;\n}) {\n  const hostTime =\n    start !== null && host.offset\n      ? `${clock(start + host.offset)} for ${host.name.split(\" \")[0]}${host.city ? ` in ${host.city}` : \"\"}`\n      : null;\n  return (\n    <div className=\"mt-4 flex items-center gap-3 border-zinc-100 border-t pt-4 dark:border-zinc-900\">\n      <div className=\"relative h-9 min-w-0 flex-1 overflow-hidden\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            className=\"absolute inset-0 flex flex-col justify-center leading-tight\"\n            exit={{ opacity: 0, y: -8 }}\n            initial={{ opacity: 0, y: 8 }}\n            key={start === null ? \"none\" : \"some\"}\n            transition={fade}\n          >\n            {start === null || !day ? (\n              <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n                Click a free gap, then drag it to move.\n              </p>\n            ) : (\n              <>\n                <p className=\"truncate font-medium text-sm text-zinc-900 tabular-nums dark:text-zinc-100\">\n                  {longDate(day.date)} · {range(start, minutes)}\n                </p>\n                {hostTime && (\n                  <p className=\"truncate text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n                    {hostTime}\n                  </p>\n                )}\n              </>\n            )}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n      <button\n        className={cn(\n          \"flex h-9 shrink-0 items-center gap-1.5 rounded-xl px-3.5 font-medium text-xs outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-zinc-950\",\n          booked\n            ? \"bg-emerald-600 text-white dark:bg-emerald-500 dark:text-emerald-950\"\n            : \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n        )}\n        disabled={start === null}\n        onClick={onConfirm}\n        type=\"button\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"inline-flex items-center gap-1.5\"\n            exit={{ opacity: 0, y: -6 }}\n            initial={{ opacity: 0, y: 6 }}\n            key={booked ? \"booked\" : \"confirm\"}\n            transition={fade}\n          >\n            {booked ? (\n              <>\n                <Check aria-hidden className=\"size-3.5\" strokeWidth={2.5} />\n                Booked\n              </>\n            ) : (\n              <>\n                Confirm\n                <ArrowRight aria-hidden className=\"size-3.5\" />\n              </>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n    </div>\n  );\n}\n\n/**\n * Booking a meeting on a real day rail. Click a free gap and the meeting\n * lands there; drag it and it hops over what's already booked instead of\n * sliding under it. Change the length and it grows in place, moving only\n * if it no longer fits.\n */\nexport default function SlotPicker({\n  host = {\n    name: \"Lena Ortiz\",\n    title: \"Product walkthrough\",\n    city: \"Lisbon\",\n    offset: 300,\n  },\n  days = DEMO_DAYS,\n  durations = [30, 60, 90],\n  timezone = \"Eastern Time, New York\",\n  onBook,\n  className,\n}: SlotPickerProps) {\n  const [minutes, setMinutes] = useState(durations[0] ?? 30);\n  const [dayIndex, setDayIndex] = useState(0);\n  const [start, setStart] = useState<number | null>(null);\n  const [ghost, setGhost] = useState<number | null>(null);\n  const [dragging, setDragging] = useState(false);\n  const [booked, setBooked] = useState(false);\n  const railRef = useRef<HTMLDivElement>(null);\n  const meetingRef = useRef<HTMLButtonElement>(null);\n  // Scopes the sliding highlights, so two pickers on a page don't share them.\n  const layoutScope = useId();\n  const grabRef = useRef(0);\n  const reduceMotion = Boolean(useReducedMotion());\n\n  const day = days[dayIndex];\n  const starts = day ? startsFor(day, minutes) : [];\n  const from = Math.min(...days.map((d) => d.open[0]));\n  const to = Math.max(...days.map((d) => d.open[1]));\n  const hours: number[] = [];\n  for (let t = Math.ceil(from / 60) * 60; t <= to; t += 60) {\n    hours.push(t);\n  }\n\n  const spring = reduceMotion\n    ? { duration: 0 }\n    : { type: \"spring\" as const, duration: 0.35, bounce: 0 };\n  const fade = { duration: reduceMotion ? 0 : 0.18, ease: EASE_OUT };\n\n  /** Minutes on the rail under the pointer. */\n  const minuteAt = (event: PointerEvent) => {\n    const rect = railRef.current?.getBoundingClientRect();\n    return rect ? from + (event.clientY - rect.top) / PX : from;\n  };\n\n  const place = (next: number | null) => {\n    setStart(next);\n    setBooked(false);\n  };\n\n  const onDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) {\n      return;\n    }\n    const at = minuteAt(event);\n    const onBlock = start !== null && at >= start && at <= start + minutes;\n    // Grab the meeting where you held it; a click elsewhere drops it there.\n    grabRef.current = onBlock && start !== null ? at - start : minutes / 2;\n    if (!onBlock) {\n      place(nearest(starts, at - minutes / 2));\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDragging(true);\n    setGhost(null);\n  };\n\n  const onMove = (event: PointerEvent<HTMLDivElement>) => {\n    const at = minuteAt(event);\n    if (dragging) {\n      const next = nearest(starts, at - grabRef.current);\n      if (next !== start) {\n        place(next);\n      }\n      return;\n    }\n    const over = start !== null && at >= start && at <= start + minutes;\n    setGhost(\n      event.pointerType === \"mouse\" && !over && !booked\n        ? nearest(starts, at - minutes / 2)\n        : null\n    );\n  };\n\n  const onKey = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (start === null) {\n      return;\n    }\n    const index = starts.indexOf(start);\n    const moves: Record<string, number | undefined> = {\n      ArrowUp: starts[Math.max(0, index - 1)],\n      ArrowDown: starts[Math.min(starts.length - 1, index + 1)],\n      Home: starts[0],\n      End: starts.at(-1),\n    };\n    if (event.key in moves) {\n      event.preventDefault();\n      place(moves[event.key] ?? start);\n    }\n  };\n\n  const pickMinutes = (next: number) => {\n    setMinutes(next);\n    if (day && start !== null) {\n      // Grow in place when it fits; otherwise move to the closest gap.\n      const fits = startsFor(day, next);\n      place(fits.includes(start) ? start : nearest(fits, start));\n    }\n  };\n\n  // Keyboard way in before anything is placed: Enter or Space on the rail\n  // drops the meeting in the first free gap, then the arrows move it.\n  const placeFirst = (event: MouseEvent<HTMLButtonElement>) => {\n    if (event.detail !== 0 || starts.length === 0) {\n      return;\n    }\n    place(starts[0]);\n    requestAnimationFrame(() => meetingRef.current?.focus());\n  };\n\n  const pickDay = (index: number) => {\n    setDayIndex(index);\n    place(null);\n  };\n\n  const initials = host.name\n    .split(\" \")\n    .map((p) => p[0])\n    .join(\"\")\n    .slice(0, 2);\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-sm rounded-3xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-950\",\n        className\n      )}\n    >\n      <div className=\"flex items-center gap-3\">\n        <span className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-sky-100 font-medium text-sky-700 text-xs dark:bg-sky-500/15 dark:text-sky-300\">\n          {initials}\n        </span>\n        <div className=\"min-w-0 flex-1 leading-tight\">\n          <p className=\"truncate font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n            {host.title}\n          </p>\n          <p className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n            with {host.name}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"mt-4 flex rounded-full bg-zinc-100 p-0.5 dark:bg-zinc-900\">\n        {durations.map((d) => (\n          <button\n            aria-pressed={d === minutes}\n            className={cn(\n              \"relative z-10 h-8 flex-1 rounded-full font-medium text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400\",\n              d === minutes\n                ? \"text-zinc-900 dark:text-zinc-50\"\n                : \"text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200\"\n            )}\n            key={d}\n            onClick={() => pickMinutes(d)}\n            type=\"button\"\n          >\n            {d === minutes && (\n              <motion.span\n                className=\"absolute inset-0 -z-10 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.08)] dark:bg-zinc-700\"\n                layoutId={`${layoutScope}-length`}\n                transition={spring}\n              />\n            )}\n            {lengthLabel(d)}\n          </button>\n        ))}\n      </div>\n\n      <DayStrip\n        active={dayIndex}\n        days={days}\n        layoutId={`${layoutScope}-day`}\n        minutes={minutes}\n        onPick={pickDay}\n        spring={spring}\n      />\n\n      {/* The day: hour lines, what's taken, and the meeting you're placing. */}\n      <div className=\"mt-4 flex gap-2\">\n        <div\n          aria-hidden=\"true\"\n          className=\"relative w-9 shrink-0\"\n          style={{ height: (to - from) * PX }}\n        >\n          {hours.map((t) => (\n            <span\n              className=\"absolute right-0 -translate-y-1/2 text-[10px] text-zinc-400 tabular-nums dark:text-zinc-500\"\n              key={t}\n              style={{ top: (t - from) * PX }}\n            >\n              {clock(t)}\n            </span>\n          ))}\n        </div>\n        <div\n          className={cn(\n            \"relative flex-1 touch-none select-none\",\n            dragging ? \"cursor-grabbing\" : \"cursor-pointer\"\n          )}\n          onPointerCancel={() => setDragging(false)}\n          onPointerDown={onDown}\n          onPointerLeave={() => setGhost(null)}\n          onPointerMove={onMove}\n          onPointerUp={() => setDragging(false)}\n          ref={railRef}\n          style={{ height: (to - from) * PX }}\n        >\n          {start === null && (\n            <button\n              aria-label={`${day ? longDate(day.date) : \"Day\"}: no time picked. Press Enter to place a ${lengthLabel(minutes)} meeting in the first free gap.`}\n              className=\"pointer-events-none absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sky-400\"\n              onClick={placeFirst}\n              type=\"button\"\n            />\n          )}\n          {hours.map((t) => (\n            <span\n              className=\"absolute inset-x-0 border-zinc-100 border-t dark:border-zinc-900\"\n              key={t}\n              style={{ top: (t - from) * PX }}\n            />\n          ))}\n          {day && day.open[0] > from && (\n            <Blocked from={from} span={[from, day.open[0]]} />\n          )}\n          {day && day.open[1] < to && (\n            <Blocked from={from} span={[day.open[1], to]} />\n          )}\n          {day?.busy.map((span) => (\n            <Blocked\n              from={from}\n              key={`${day.date}-${span[0]}`}\n              label=\"Busy\"\n              span={span}\n            />\n          ))}\n\n          {ghost !== null && start !== ghost && (\n            <div\n              className=\"pointer-events-none absolute inset-x-0 rounded-lg border border-sky-400 border-dashed bg-sky-50/60 dark:border-sky-500/60 dark:bg-sky-500/10\"\n              style={{ top: (ghost - from) * PX, height: minutes * PX }}\n            >\n              <span className=\"absolute top-1 left-2 font-medium text-[10px] text-sky-700 tabular-nums leading-3 dark:text-sky-300\">\n                {range(ghost, minutes)}\n              </span>\n            </div>\n          )}\n\n          {start !== null && (\n            <motion.div\n              animate={{ y: (start - from) * PX, height: minutes * PX }}\n              className=\"absolute inset-x-0 top-0\"\n              initial={{ y: (start - from) * PX, height: minutes * PX }}\n              key={dayIndex}\n              transition={spring}\n            >\n              <button\n                aria-label={`${longDate(day?.date ?? \"\")}, ${range(start, minutes)}. Arrow keys move it.`}\n                className={cn(\n                  \"flex h-full w-full items-start gap-1 overflow-hidden rounded-lg px-2 py-1 text-left font-medium text-[11px] tabular-nums leading-4 outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n                  booked\n                    ? \"bg-emerald-600 text-white dark:bg-emerald-500 dark:text-emerald-950\"\n                    : \"bg-sky-600 text-white shadow-[0_1px_2px_rgb(2_132_199/0.3)] dark:bg-sky-500 dark:text-sky-950\",\n                  !(booked || dragging) && \"cursor-grab\"\n                )}\n                onKeyDown={onKey}\n                ref={meetingRef}\n                type=\"button\"\n              >\n                {booked && (\n                  <Check\n                    aria-hidden\n                    className=\"mt-0.5 size-3 shrink-0\"\n                    strokeWidth={3}\n                  />\n                )}\n                <span className=\"truncate\">{range(start, minutes)}</span>\n              </button>\n            </motion.div>\n          )}\n        </div>\n      </div>\n\n      <p className=\"mt-3 flex items-center gap-1.5 text-[11px] text-zinc-500 dark:text-zinc-400\">\n        <Globe aria-hidden className=\"size-3\" />\n        {timezone}\n      </p>\n\n      <ConfirmBar\n        booked={booked}\n        day={day}\n        fade={fade}\n        host={host}\n        minutes={minutes}\n        onConfirm={() => {\n          if (!day || start === null || booked) {\n            return;\n          }\n          onBook?.({ date: day.date, start, minutes });\n          setBooked(true);\n        }}\n        start={start}\n      />\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/slot-picker.tsx",
      "target": "components/kokonutui/slot-picker.tsx"
    }
  ]
}