{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "seatmap-ascii-pick",
  "title": "Seatmap ASCII Pick",
  "description": "An ASCII venue floor plan where dragging a marquee selects a contiguous block of seats: taken seats and the centre aisle break the run, and the selection visibly snaps to the longest unbroken block inside the drag, with a live seat count.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/seatmap-ascii-pick/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SeatmapAsciiPick — an ASCII floor plan where a drag marquee selects a\n// CONTIGUOUS block of seats, not one seat at a time. Taken seats and the\n// aisle gap are both \"unavailable\" for the purpose of contiguity: dragging a\n// marquee across a row that contains one breaks the run right there, and the\n// selection snaps to the single longest unbroken run of available seats\n// inside the marquee's row span — never the raw rectangle, which is what\n// makes \"unavailable cells break the block\" visible rather than assumed.\n// Distinct from grid-magnetic-lattice (ambient cursor field, nothing is\n// selected) and filter-facet-mesh (toggling a SET of facet chips, not a\n// contiguous run of grid cells). No canvas: every glyph is a real DOM\n// button/span, colored from tokens via Tailwind classes only.\n// ---------------------------------------------------------------------------\n\ntype SeatStatus = \"available\" | \"taken\" | \"aisle\";\ntype Cell = { r: number; c: number };\n\nexport interface SeatmapAsciiPickProps {\n  /** rows x cols seat status grid */\n  seats?: SeatStatus[][];\n  /** label for each row, left of the grid */\n  rowLabels?: string[];\n  /** label for each column, above the grid */\n  colLabels?: string[];\n  /** heading above the seatmap */\n  sectionLabel?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst A: SeatStatus = \"available\";\nconst T: SeatStatus = \"taken\";\nconst G: SeatStatus = \"aisle\";\n\nconst DEFAULT_SEATS: SeatStatus[][] = [\n  [A, A, T, A, A, G, A, A, A, T, A],\n  [A, A, A, A, T, G, A, A, T, A, A],\n  [T, A, A, A, A, G, A, A, A, A, A],\n  [A, A, A, T, A, G, A, T, A, A, A],\n  [A, T, A, A, A, G, A, A, A, A, T],\n  [A, A, A, A, A, G, T, A, A, A, A],\n];\n\nconst DEFAULT_ROW_LABELS = [\"ROW 8\", \"ROW 9\", \"ROW 10\", \"ROW 11\", \"ROW 12\", \"ROW 13\"];\nconst DEFAULT_COL_LABELS = [\"A\", \"B\", \"C\", \"D\", \"E\", \"\", \"F\", \"G\", \"H\", \"I\", \"J\"];\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\n\n/** longest unbroken run of available seats, within [left,right], across every row in [top,bottom] */\nfunction bestRun(seats: SeatStatus[][], top: number, bottom: number, left: number, right: number) {\n  let best: { r: number; start: number; end: number } | null = null;\n  for (let r = top; r <= bottom; r++) {\n    let runStart = -1;\n    for (let c = left; c <= right + 1; c++) {\n      const status = c <= right ? seats[r]?.[c] : \"taken\"; // sentinel to flush a trailing run\n      if (status === \"available\") {\n        if (runStart === -1) runStart = c;\n      } else {\n        if (runStart !== -1) {\n          const len = c - runStart;\n          if (!best || len > best.end - best.start + 1) {\n            best = { r, start: runStart, end: c - 1 };\n          }\n        }\n        runStart = -1;\n      }\n    }\n  }\n  return best;\n}\n\nexport function SeatmapAsciiPick({\n  seats = DEFAULT_SEATS,\n  rowLabels = DEFAULT_ROW_LABELS,\n  colLabels = DEFAULT_COL_LABELS,\n  sectionLabel = \"SECTION C\",\n  className = \"\",\n}: SeatmapAsciiPickProps) {\n  const rows = seats.length;\n  const cols = seats[0]?.length ?? 0;\n\n  const [anchor, setAnchor] = useState<Cell | null>(null);\n  const [focus, setFocus] = useState<Cell | null>(null);\n  const draggingRef = useRef(false);\n  const gridRef = useRef<HTMLDivElement>(null);\n\n  const run = useMemo(() => {\n    if (!anchor || !focus) return null;\n    const top = Math.min(anchor.r, focus.r);\n    const bottom = Math.max(anchor.r, focus.r);\n    const left = Math.min(anchor.c, focus.c);\n    const right = Math.max(anchor.c, focus.c);\n    return bestRun(seats, top, bottom, left, right);\n  }, [anchor, focus, seats]);\n\n  const startDrag = (cell: Cell) => {\n    if (seats[cell.r]?.[cell.c] === \"aisle\") return;\n    draggingRef.current = true;\n    setAnchor(cell);\n    setFocus(cell);\n  };\n\n  const dragTo = (cell: Cell) => {\n    if (draggingRef.current) setFocus(cell);\n  };\n\n  useEffect(() => {\n    const end = () => {\n      draggingRef.current = false;\n    };\n    window.addEventListener(\"pointerup\", end);\n    return () => window.removeEventListener(\"pointerup\", end);\n  }, []);\n\n  const focusSeatButton = (r: number, c: number) => {\n    gridRef.current?.querySelector<HTMLButtonElement>(`[data-r=\"${r}\"][data-c=\"${c}\"]`)?.focus();\n  };\n\n  const nextAvailable = useCallback(\n    (r: number, c: number, dc: number): number => {\n      let nc = c;\n      for (let i = 0; i < cols; i++) {\n        nc = clamp(nc + dc, 0, cols - 1);\n        if (seats[r]?.[nc] !== \"aisle\") return nc;\n        if (nc === 0 || nc === cols - 1) return nc;\n      }\n      return c;\n    },\n    [cols, seats]\n  );\n\n  const onKeyDown = (e: React.KeyboardEvent, cell: Cell) => {\n    const map: Record<string, [number, number]> = {\n      ArrowUp: [-1, 0],\n      ArrowDown: [1, 0],\n      ArrowLeft: [0, -1],\n      ArrowRight: [0, 1],\n    };\n    const delta = map[e.key];\n    if (!delta) return;\n    e.preventDefault();\n    let nr = clamp(cell.r + delta[0], 0, rows - 1);\n    let nc = clamp(cell.c + delta[1], 0, cols - 1);\n    if (delta[1] !== 0) nc = nextAvailable(cell.r, cell.c, delta[1]);\n    if (seats[nr]?.[nc] === \"aisle\") return;\n    if (e.shiftKey) {\n      setAnchor((a) => a ?? cell);\n      setFocus({ r: nr, c: nc });\n    } else {\n      setAnchor({ r: nr, c: nc });\n      setFocus({ r: nr, c: nc });\n    }\n    focusSeatButton(nr, nc);\n  };\n\n  const inRun = (r: number, c: number) => !!run && run.r === r && c >= run.start && c <= run.end;\n\n  const [activeR, activeC] = focus ? [focus.r, focus.c] : [0, 0];\n\n  return (\n    <div className={`inline-flex flex-col gap-2 font-mono ${className}`}>\n      <div className=\"flex items-center justify-between gap-3\">\n        <span className=\"text-xs uppercase tracking-widest text-ns-muted\">{sectionLabel}</span>\n        <span className=\"text-[10px] uppercase tracking-widest text-ns-muted\">drag to select a block</span>\n      </div>\n\n      <div className=\"flex flex-col items-center gap-1 rounded-sm border border-border bg-surface px-4 py-3\">\n        <span className=\"mb-1 select-none text-[10px] tracking-[0.4em] text-ns-muted\">— STAGE —</span>\n        <div\n          ref={gridRef}\n          className=\"grid gap-y-1\"\n          style={{ gridTemplateColumns: `2.5em repeat(${cols}, 1.6em)` }}\n        >\n          {seats.map((seatRow, r) => (\n            <div key={r} className=\"contents\">\n              <div className=\"flex items-center text-[10px] text-ns-muted\">{rowLabels[r]}</div>\n              {seatRow.map((status, c) => {\n                if (status === \"aisle\") {\n                  return (\n                    <div\n                      key={c}\n                      aria-hidden\n                      className=\"flex h-6 items-center justify-center text-border\"\n                      onPointerEnter={() => dragTo({ r, c })}\n                    >\n                      {\" \"}\n                    </div>\n                  );\n                }\n                const selected = inRun(r, c);\n                const isActive = activeR === r && activeC === c;\n                return (\n                  <button\n                    key={c}\n                    type=\"button\"\n                    data-r={r}\n                    data-c={c}\n                    disabled={status === \"taken\"}\n                    aria-pressed={selected}\n                    aria-label={`${rowLabels[r]} seat ${colLabels[c]}${status === \"taken\" ? \", taken\" : selected ? \", selected\" : \", available\"}`}\n                    tabIndex={isActive ? 0 : -1}\n                    className={`flex h-6 w-6 items-center justify-center rounded-[2px] text-xs outline-none transition-colors duration-100 motion-reduce:transition-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ns-accent ${\n                      status === \"taken\"\n                        ? \"cursor-not-allowed text-ns-muted/50\"\n                        : selected\n                          ? \"bg-ns-accent/[0.16] text-foreground hover:bg-ns-accent/[0.24]\"\n                          : \"text-foreground hover:bg-foreground/[0.08]\"\n                    }`}\n                    onPointerDown={() => startDrag({ r, c })}\n                    onPointerEnter={() => dragTo({ r, c })}\n                    onFocus={() => setFocus({ r, c })}\n                    onKeyDown={(e) => onKeyDown(e, { r, c })}\n                  >\n                    {status === \"taken\" ? \"×\" : selected ? \"●\" : \"○\"}\n                  </button>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div\n        data-count-readout\n        aria-live=\"polite\"\n        className=\"rounded-sm border border-border bg-background px-3 py-1.5 text-[11px] tabular-nums text-ns-muted\"\n      >\n        {run ? (\n          <>\n            <span className=\"text-foreground\">{run.end - run.start + 1}</span>\n            {\" seats selected — \"}\n            <span className=\"text-foreground\">\n              {rowLabels[run.r]} {colLabels[run.start]}–{colLabels[run.end]}\n            </span>\n          </>\n        ) : (\n          \"no seats selected — drag across an available block\"\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/seatmap-ascii-pick.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "seatmap",
      "selection",
      "grid",
      "ascii",
      "mono",
      "drag",
      "keyboard-navigation",
      "booking"
    ],
    "instruction": "Build <SeatmapAsciiPick seats rowLabels colLabels sectionLabel className> over a rows x cols SeatStatus grid (each cell 'available' | 'taken' | 'aisle'; default a 6-row x 11-col plan with a fixed aisle column down the centre and scattered taken seats). MECHANIC: pointerdown on an available seat sets a drag anchor and live focus cell to it (aisle cells cannot start a drag); while the pointer is down (no setPointerCapture, so native pointerenter fires on whatever seat or aisle cell the pointer is actually over, and a global window pointerup ends the drag regardless of release target) each hovered cell's onPointerEnter updates only the focus corner, and the raw marquee rectangle is the min/max of anchor and focus on both axes. THE SNAP: the rectangle itself is never the selection. On every anchor/focus change, scan every row inside the rectangle's row span for the longest unbroken horizontal run of 'available' seats whose columns lie fully inside the rectangle's column span — a 'taken' seat or the aisle column breaks a run at that exact point — and take the single longest run found across all scanned rows as the actual selection; ties keep the first (topmost) row found. This is what makes 'unavailable cells break the block, selection snaps to the largest valid run' a real, observable behavior rather than an assumption: dragging a rectangle that straddles a taken seat or the aisle visibly shrinks the highlighted block to whichever side has the longer clear run. A bare pointerdown+pointerup on one seat with no movement commits a 1x1 rectangle, so a single click on an available seat is itself a valid (length-1) run and the gate can reach a real non-resting state without a drag. KEYBOARD: one roving-tabindex button per seat (aisle cells are plain non-interactive spans, never focusable); plain ArrowKeys move the active seat and collapse the selection to it, skipping straight over the aisle column so Left/Right never lands on a gap; Shift+ArrowKeys extend the marquee from a fixed anchor exactly like a drag would, re-running the same snap-to-longest-run logic per keystroke. RENDERING: available seats render ○, taken seats render × (rendered disabled, not merely styled — a real disabled attribute, so it is excluded from the tab order and cannot start a drag), seats inside the current run render ● with an --accent-derived tint (bg-ns-accent/[0.16], never a literal hex), and the aisle renders as blank whitespace with no seat glyph at all. A live readout beneath the plan (aria-live=polite, doubling as its own accessible announcement — no separate sr-only region needed) prints '<n> seats selected — <row label> <first seat>–<last seat>' or a 'no seats selected' placeholder at rest. A11Y: every seat button has an aria-label stating its row, its seat letter, and its status (taken / selected / available); disabled seats are real disabled buttons, satisfying the 'exposed, non-disabled interactive control needs an accessible name' rule by not being exposed as an actionable control at all. Colors are token-only (--foreground/--ns-muted/--border/--background/--ns-accent via Tailwind classes) — no canvas, no hex. No motion beyond a 100ms background-color transition on hover/selection, skipped under prefers-reduced-motion via motion-reduce:transition-none; there is no rAF loop anywhere in this component."
  },
  "type": "registry:ui"
}