{
  "name": "seat-map",
  "type": "registry:component",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Seat Map\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { Accessibility, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  useEffect,\n  useMemo,\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\nexport interface SeatTier {\n  name: string;\n  price: number;\n  /** Row letters in this tier, front to back. */\n  rows: string[];\n}\n\nexport interface SeatMapProps {\n  title?: string;\n  subtitle?: string;\n  tiers?: SeatTier[];\n  /** Seats per block, left to right, with an aisle between blocks. */\n  blocks?: number[];\n  /** Seat ids already sold, like \"D7\". */\n  taken?: string[];\n  /** Seats with wheelchair space. */\n  accessible?: string[];\n  defaultSelected?: string[];\n  /** Most seats one booking can hold. */\n  maxSeats?: number;\n  currency?: string;\n  onChange?: (seats: string[]) => void;\n  onContinue?: (seats: string[], total: number) => void;\n  className?: string;\n}\n\nconst DEFAULT_TIERS: SeatTier[] = [\n  { name: \"Front\", price: 11, rows: [\"A\", \"B\"] },\n  { name: \"Standard\", price: 15, rows: [\"C\", \"D\", \"E\", \"F\", \"G\"] },\n  { name: \"Premium\", price: 21, rows: [\"H\", \"I\"] },\n];\n\nconst DEFAULT_TAKEN = [\n  \"A5\",\n  \"A6\",\n  \"B4\",\n  \"B5\",\n  \"B6\",\n  \"B7\",\n  \"C2\",\n  \"C3\",\n  \"C8\",\n  \"D5\",\n  \"D6\",\n  \"D7\",\n  \"D8\",\n  \"E1\",\n  \"E2\",\n  \"E10\",\n  \"F6\",\n  \"F7\",\n  \"G4\",\n  \"G5\",\n  \"G9\",\n  \"G10\",\n  \"H3\",\n  \"H4\",\n  \"H5\",\n  \"H6\",\n  \"I7\",\n  \"I8\",\n  \"I11\",\n  \"I12\",\n];\n\ninterface Seat {\n  id: string;\n  row: string;\n  /** 1-based seat number across the row. */\n  number: number;\n  /** Index of the block (between aisles) the seat is in. */\n  block: number;\n  tier: SeatTier;\n}\n\ninterface Paint {\n  row: string;\n  from: number;\n  mode: \"add\" | \"remove\";\n  base: string[];\n}\n\nconst seatOrder = (a: Seat, b: Seat) =>\n  a.row === b.row ? a.number - b.number : a.row.localeCompare(b.row);\n\nexport default function SeatMap({\n  title = \"Dune: Part Three\",\n  subtitle = \"Today · 19:30 · Screen 4\",\n  tiers = DEFAULT_TIERS,\n  blocks = [3, 6, 3],\n  taken = DEFAULT_TAKEN,\n  accessible = [\"A1\", \"A12\"],\n  defaultSelected = [],\n  maxSeats = 8,\n  currency = \"$\",\n  onChange,\n  onContinue,\n  className,\n}: SeatMapProps) {\n  const [selected, setSelected] = useState<string[]>(defaultSelected);\n  const [focused, setFocused] = useState<string | null>(null);\n  const [limitHit, setLimitHit] = useState(false);\n  const limitTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const reduceMotion = useReducedMotion();\n  const paintRef = useRef<Paint | null>(null);\n\n  useEffect(() => () => clearTimeout(limitTimer.current), []);\n  const gridRef = useRef<HTMLFieldSetElement>(null);\n\n  const perRow = blocks.reduce((a, b) => a + b, 0);\n  const takenSet = useMemo(() => new Set(taken), [taken]);\n  const accessibleSet = useMemo(() => new Set(accessible), [accessible]);\n\n  const seats = useMemo(() => {\n    const map = new Map<string, Seat>();\n    for (const tier of tiers) {\n      for (const row of tier.rows) {\n        let number = 1;\n        blocks.forEach((size, block) => {\n          for (let i = 0; i < size; i++) {\n            map.set(`${row}${number}`, {\n              id: `${row}${number}`,\n              row,\n              number,\n              block,\n              tier,\n            });\n            number += 1;\n          }\n        });\n      }\n    }\n    return map;\n  }, [tiers, blocks]);\n\n  const rows = tiers.flatMap((tier) => tier.rows);\n  const selectedSet = useMemo(() => new Set(selected), [selected]);\n  const picked = selected\n    .map((id) => seats.get(id))\n    .filter((seat): seat is Seat => Boolean(seat))\n    .sort(seatOrder);\n  const total = picked.reduce((sum, seat) => sum + seat.tier.price, 0);\n\n  // A free seat boxed in by the booking on one side and a sold seat, the\n  // booking or the aisle on the other is one nobody will buy.\n  const stranded = useMemo(() => {\n    const blocked = (id: string, block: number) => {\n      const seat = seats.get(id);\n      return (\n        !seat || seat.block !== block || takenSet.has(id) || selectedSet.has(id)\n      );\n    };\n    const result: string[] = [];\n    for (const seat of seats.values()) {\n      if (takenSet.has(seat.id) || selectedSet.has(seat.id)) {\n        continue;\n      }\n      const left = `${seat.row}${seat.number - 1}`;\n      const right = `${seat.row}${seat.number + 1}`;\n      const touchesBooking = selectedSet.has(left) || selectedSet.has(right);\n      if (\n        touchesBooking &&\n        blocked(left, seat.block) &&\n        blocked(right, seat.block)\n      ) {\n        result.push(seat.id);\n      }\n    }\n    return result;\n  }, [seats, takenSet, selectedSet]);\n  const strandedSet = useMemo(() => new Set(stranded), [stranded]);\n\n  const update = (next: string[]) => {\n    setSelected(next);\n    onChange?.(next);\n  };\n\n  const paintRange = (paint: Paint, to: number) => {\n    const low = Math.min(paint.from, to);\n    const high = Math.max(paint.from, to);\n    const ids: string[] = [];\n    for (let n = low; n <= high; n++) {\n      const id = `${paint.row}${n}`;\n      if (!takenSet.has(id)) {\n        ids.push(id);\n      }\n    }\n    if (paint.mode === \"remove\") {\n      update(paint.base.filter((id) => !ids.includes(id)));\n      return;\n    }\n    const next = [...new Set([...paint.base, ...ids])];\n    if (next.length > maxSeats) {\n      setLimitHit(true);\n      clearTimeout(limitTimer.current);\n      limitTimer.current = setTimeout(() => setLimitHit(false), 2000);\n      return;\n    }\n    update(next);\n  };\n\n  const seatFrom = (target: EventTarget | null) => {\n    const element =\n      target instanceof Element\n        ? target.closest<HTMLElement>(\"[data-seat]\")\n        : null;\n    return element ? seats.get(element.dataset.seat ?? \"\") : undefined;\n  };\n\n  // The grid holds the pointer, not the seat, so the drag survives crossing\n  // from one seat to the next.\n  const onPointerDown = (event: PointerEvent<HTMLFieldSetElement>) => {\n    const seat = seatFrom(event.target);\n    if (!seat || takenSet.has(seat.id) || event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const paint: Paint = {\n      row: seat.row,\n      from: seat.number,\n      mode: selectedSet.has(seat.id) ? \"remove\" : \"add\",\n      base: selected,\n    };\n    paintRef.current = paint;\n    paintRange(paint, seat.number);\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLFieldSetElement>) => {\n    const paint = paintRef.current;\n    if (!paint) {\n      return;\n    }\n    const seat = seatFrom(\n      document.elementFromPoint(event.clientX, event.clientY)\n    );\n    if (seat && seat.row === paint.row) {\n      paintRange(paint, seat.number);\n    }\n  };\n\n  const endPaint = () => {\n    paintRef.current = null;\n  };\n\n  // Clicks from a pointer are handled by the paint above; this is Enter and\n  // Space on a focused seat.\n  const onSeatClick = (event: MouseEvent<HTMLButtonElement>, seat: Seat) => {\n    if (event.detail !== 0 || takenSet.has(seat.id)) {\n      return;\n    }\n    const mode = selectedSet.has(seat.id) ? \"remove\" : \"add\";\n    paintRange(\n      { row: seat.row, from: seat.number, mode, base: selected },\n      seat.number\n    );\n  };\n\n  const onGridKeyDown = (event: KeyboardEvent<HTMLFieldSetElement>) => {\n    const seat = seatFrom(event.target);\n    if (!seat) {\n      return;\n    }\n    const rowIndex = rows.indexOf(seat.row);\n    const moves: Record<string, [number, number]> = {\n      ArrowLeft: [0, -1],\n      ArrowRight: [0, 1],\n      ArrowUp: [-1, 0],\n      ArrowDown: [1, 0],\n    };\n    const move = moves[event.key];\n    if (!move) {\n      return;\n    }\n    event.preventDefault();\n    const row =\n      rows[Math.min(rows.length - 1, Math.max(0, rowIndex + move[0]))];\n    const number = Math.min(perRow, Math.max(1, seat.number + move[1]));\n    const id = `${row}${number}`;\n    setFocused(id);\n    gridRef.current?.querySelector<HTMLElement>(`[data-seat=\"${id}\"]`)?.focus();\n  };\n\n  const tabStop =\n    focused ?? picked[0]?.id ?? `${rows.at(-1)}${Math.ceil(perRow / 2)}`;\n\n  const columns = blocks\n    .map((size) => `repeat(${size}, minmax(0, 1fr))`)\n    .join(\" 0.75rem \");\n\n  const money = (value: number) =>\n    `${currency}${value.toLocaleString(\"en-US\")}`;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-md 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-baseline justify-between gap-3\">\n        <div className=\"min-w-0\">\n          <p className=\"truncate font-semibold text-zinc-900 dark:text-zinc-50\">\n            {title}\n          </p>\n          <p className=\"text-sm text-zinc-500 dark:text-zinc-400\">{subtitle}</p>\n        </div>\n        <p className=\"shrink-0 text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {perRow * rows.length - taken.length - selected.length} left\n        </p>\n      </div>\n\n      {/* The screen, curved toward the seats. */}\n      <div className=\"relative mx-auto mt-6 w-[86%]\">\n        <svg\n          aria-hidden\n          className=\"h-4 w-full overflow-visible\"\n          preserveAspectRatio=\"none\"\n          viewBox=\"0 0 100 10\"\n        >\n          <title>Screen</title>\n          <path\n            className=\"stroke-zinc-300 dark:stroke-zinc-700\"\n            d=\"M0 9 Q50 0 100 9\"\n            fill=\"none\"\n            strokeLinecap=\"round\"\n            strokeWidth=\"2\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        </svg>\n        <p className=\"text-center text-[10px] text-zinc-400 tracking-[0.2em] dark:text-zinc-500\">\n          SCREEN\n        </p>\n      </div>\n\n      {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: the grid only routes drags and arrow keys to the seat buttons inside it. */}\n      <fieldset\n        className=\"mt-4 touch-pan-y select-none\"\n        onKeyDown={onGridKeyDown}\n        onLostPointerCapture={endPaint}\n        onPointerCancel={endPaint}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endPaint}\n        ref={gridRef}\n      >\n        <legend className=\"sr-only\">Seats</legend>\n        {tiers.map((tier, tierIndex) => (\n          <div className={cn(tierIndex > 0 && \"mt-3\")} key={tier.name}>\n            <p className=\"mb-1.5 flex items-center gap-2 text-[11px] text-zinc-400 dark:text-zinc-500\">\n              <span className=\"h-px flex-1 bg-zinc-100 dark:bg-zinc-900\" />\n              {tier.name} · {money(tier.price)}\n              <span className=\"h-px flex-1 bg-zinc-100 dark:bg-zinc-900\" />\n            </p>\n            <div className=\"space-y-1\">\n              {tier.rows.map((row) => (\n                <div className=\"flex items-center gap-2\" key={row}>\n                  <span className=\"w-3 text-center font-medium text-[10px] text-zinc-400 dark:text-zinc-500\">\n                    {row}\n                  </span>\n                  <div\n                    className=\"grid flex-1 gap-1\"\n                    style={{ gridTemplateColumns: columns }}\n                  >\n                    {Array.from({ length: perRow }, (_, i) => {\n                      const id = `${row}${i + 1}`;\n                      const seat = seats.get(id);\n                      if (!seat) {\n                        return null;\n                      }\n                      // Aisles are empty grid tracks between blocks.\n                      const column = i + 1 + seat.block;\n                      return (\n                        <SeatButton\n                          column={column}\n                          isAccessible={accessibleSet.has(id)}\n                          isSelected={selectedSet.has(id)}\n                          isStranded={strandedSet.has(id)}\n                          isTaken={takenSet.has(id)}\n                          key={id}\n                          label={`Row ${row}, seat ${seat.number}, ${tier.name}, ${money(tier.price)}${accessibleSet.has(id) ? \", wheelchair space\" : \"\"}`}\n                          onClick={(event) => onSeatClick(event, seat)}\n                          onFocus={() => setFocused(id)}\n                          seat={seat}\n                          tabIndex={tabStop === id ? 0 : -1}\n                        />\n                      );\n                    })}\n                  </div>\n                  <span className=\"w-3 text-center font-medium text-[10px] text-zinc-400 dark:text-zinc-500\">\n                    {row}\n                  </span>\n                </div>\n              ))}\n            </div>\n          </div>\n        ))}\n      </fieldset>\n\n      <div className=\"mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-zinc-500 dark:text-zinc-400\">\n        <Legend\n          className=\"bg-zinc-100 ring-1 ring-zinc-200 ring-inset dark:bg-zinc-900 dark:ring-zinc-800\"\n          label=\"Free\"\n        />\n        <Legend className=\"bg-zinc-900 dark:bg-zinc-100\" label=\"Yours\" />\n        <Legend\n          className=\"bg-[repeating-linear-gradient(135deg,var(--tw-gradient-from)_0_2px,transparent_2px_4px)] from-zinc-300 dark:from-zinc-700\"\n          label=\"Sold\"\n        />\n        <span className=\"ml-auto\">Drag along a row to pick several</span>\n      </div>\n\n      <div className=\"mt-4 border-zinc-200 border-t pt-4 dark:border-zinc-800\">\n        <div className=\"flex min-h-7 flex-wrap gap-1.5\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {picked.length === 0 && (\n              <motion.p\n                animate={{ opacity: 1 }}\n                className=\"text-sm text-zinc-400 leading-7 dark:text-zinc-500\"\n                exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                initial={{ opacity: 0 }}\n                key=\"empty\"\n              >\n                No seats yet\n              </motion.p>\n            )}\n            {picked.map((seat) => (\n              <motion.button\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                aria-label={`Remove ${seat.id}`}\n                className=\"group/chip inline-flex h-7 items-center gap-1 rounded-full bg-zinc-100 pr-1.5 pl-2.5 font-medium text-xs text-zinc-900 tabular-nums outline-none transition-colors duration-150 hover:bg-zinc-200 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800\"\n                exit={{\n                  opacity: 0,\n                  y: 6,\n                  filter: \"blur(2px)\",\n                  transition: { duration: 0.12 },\n                }}\n                initial={{ opacity: 0, y: -8, filter: \"blur(2px)\" }}\n                key={seat.id}\n                layout={reduceMotion ? false : \"position\"}\n                onClick={() => update(selected.filter((id) => id !== seat.id))}\n                transition={{ duration: 0.22, ease: EASE_OUT }}\n                type=\"button\"\n              >\n                {seat.id}\n                <X\n                  aria-hidden\n                  className=\"size-3 text-zinc-400 group-hover/chip:text-zinc-600 dark:group-hover/chip:text-zinc-300\"\n                />\n              </motion.button>\n            ))}\n          </AnimatePresence>\n        </div>\n\n        <Notice limitHit={limitHit} maxSeats={maxSeats} stranded={stranded} />\n\n        <button\n          className={cn(\n            \"mt-3 flex h-11 w-full items-center justify-between rounded-2xl px-4 font-medium text-sm outline-none transition-colors duration-150\",\n            \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n            picked.length > 0\n              ? \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white\"\n              : \"bg-zinc-100 text-zinc-400 dark:bg-zinc-900 dark:text-zinc-600\"\n          )}\n          disabled={picked.length === 0}\n          onClick={() =>\n            onContinue?.(\n              picked.map((seat) => seat.id),\n              total\n            )\n          }\n          type=\"button\"\n        >\n          <span>\n            {picked.length > 0\n              ? `${picked.length} ${picked.length === 1 ? \"seat\" : \"seats\"}`\n              : \"Pick your seats\"}\n          </span>\n          <span className=\"tabular-nums\">\n            {picked.length > 0 ? `Continue · ${money(total)}` : \"\"}\n          </span>\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction SeatButton({\n  seat,\n  column,\n  label,\n  isTaken,\n  isSelected,\n  isStranded,\n  isAccessible,\n  tabIndex,\n  onClick,\n  onFocus,\n}: {\n  seat: Seat;\n  column: number;\n  label: string;\n  isTaken: boolean;\n  isSelected: boolean;\n  isStranded: boolean;\n  isAccessible: boolean;\n  tabIndex: number;\n  onClick: (event: MouseEvent<HTMLButtonElement>) => void;\n  onFocus: () => void;\n}) {\n  return (\n    <button\n      aria-disabled={isTaken}\n      aria-label={isTaken ? `${label}, sold` : label}\n      aria-pressed={isSelected}\n      className={cn(\n        \"group/seat relative flex aspect-[1/0.92] items-center justify-center rounded-t-[7px] rounded-b-[4px] outline-none\",\n        \"transition-[background-color,box-shadow,color] duration-150 ease-out\",\n        \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-zinc-950\",\n        isTaken &&\n          \"cursor-not-allowed bg-[repeating-linear-gradient(135deg,var(--tw-gradient-from)_0_2px,transparent_2px_4px)] from-zinc-200 dark:from-zinc-800\",\n        !(isTaken || isSelected) &&\n          \"cursor-pointer bg-zinc-100 text-zinc-400 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06)] hover:bg-zinc-200 dark:bg-zinc-900 dark:text-zinc-500 dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.06)] dark:hover:bg-zinc-800\",\n        isSelected &&\n          \"cursor-pointer bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\",\n        isStranded &&\n          \"shadow-[inset_0_0_0_1.5px_rgb(234_88_12)] dark:shadow-[inset_0_0_0_1.5px_rgb(251_146_60)]\"\n      )}\n      data-seat={seat.id}\n      onClick={onClick}\n      onFocus={onFocus}\n      style={{ gridColumn: column }}\n      tabIndex={tabIndex}\n      type=\"button\"\n    >\n      {isAccessible && !isTaken && (\n        <Accessibility aria-hidden className=\"size-[60%]\" strokeWidth={2} />\n      )}\n      {/* The seat number shows on yours, rising in as it is picked. */}\n      {!isAccessible && (\n        <span\n          className={cn(\n            \"font-medium text-[9px] tabular-nums leading-none transition-[opacity,translate] ease-out\",\n            \"duration-200 motion-reduce:duration-0\",\n            isSelected\n              ? \"translate-y-0 opacity-100\"\n              : \"translate-y-0.5 opacity-0\"\n          )}\n        >\n          {seat.number}\n        </span>\n      )}\n    </button>\n  );\n}\n\nfunction Legend({ className, label }: { className: string; label: string }) {\n  return (\n    <span className=\"inline-flex items-center gap-1.5\">\n      <span\n        className={cn(\"size-3 rounded-t-[4px] rounded-b-[2px]\", className)}\n      />\n      {label}\n    </span>\n  );\n}\n\nfunction Notice({\n  stranded,\n  limitHit,\n  maxSeats,\n}: {\n  stranded: string[];\n  limitHit: boolean;\n  maxSeats: number;\n}) {\n  let message: string | null = null;\n  if (limitHit) {\n    message = `Up to ${maxSeats} seats per booking`;\n  }\n  if (stranded.length > 0) {\n    message = `${stranded.join(\", \")} would be left on its own. Try moving over one.`;\n    if (stranded.length > 1) {\n      message = `${stranded.join(\", \")} would be left on their own. Try moving over one.`;\n    }\n  }\n  return (\n    <div aria-live=\"polite\" className=\"relative mt-2 min-h-4\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {message && (\n          <motion.p\n            animate={{ opacity: 1, y: 0 }}\n            className=\"text-orange-600 text-xs dark:text-orange-400\"\n            exit={{ opacity: 0, y: -4 }}\n            initial={{ opacity: 0, y: 4 }}\n            key={message}\n            transition={{ duration: 0.18, ease: EASE_OUT }}\n          >\n            {message}\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/seat-map.tsx",
      "target": "components/kokonutui/seat-map.tsx"
    }
  ]
}