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.
npx shadcn add https://design.helpmarq.com /r/seatmap-ascii-pick.jsonregistry/core/seatmap-ascii-pick/component.tsx"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// SeatmapAsciiPick — an ASCII floor plan where a drag marquee selects a
// CONTIGUOUS block of seats, not one seat at a time. Taken seats and the
// aisle gap are both "unavailable" for the purpose of contiguity: dragging a
// marquee across a row that contains one breaks the run right there, and the
// selection snaps to the single longest unbroken run of available seats
// inside the marquee's row span — never the raw rectangle, which is what
// makes "unavailable cells break the block" visible rather than assumed.
// Distinct from grid-magnetic-lattice (ambient cursor field, nothing is
// selected) and filter-facet-mesh (toggling a SET of facet chips, not a
// contiguous run of grid cells). No canvas: every glyph is a real DOM
// button/span, colored from tokens via Tailwind classes only.
// ---------------------------------------------------------------------------
type SeatStatus = "available" | "taken" | "aisle";
type Cell = { r: number; c: number };
export interface SeatmapAsciiPickProps {
/** rows x cols seat status grid */
seats?: SeatStatus[][];
rowLabels?: string[];
colLabels?: string[];
sectionLabel?: string;
className?: string;
}
const A: SeatStatus = "available";
const T: SeatStatus = "taken";
const G: SeatStatus = "aisle";
const DEFAULT_SEATS: SeatStatus[][] = [
[A, A, T, A, A, G, A, A, A, T, A],
[A, A, A, A, T, G, A, A, T, A, A],
[T, A, A, A, A, G, A, A, A, A, A],
[A, A, A, T, A, G, A, T, A, A, A],
[A, T, A, A, A, G, A, A, A, A, T],
[A, A, A, A, A, G, T, A, A, A, A],
];
const DEFAULT_ROW_LABELS = ["ROW 8", "ROW 9", "ROW 10", "ROW 11", "ROW 12", "ROW 13"];
const DEFAULT_COL_LABELS = ["A", "B", "C", "D", "E", "", "F", "G", "H", "I", "J"];
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
/** longest unbroken run of available seats, within [left,right], across every row in [top,bottom] */
function bestRun(seats: SeatStatus[][], top: number, bottom: number, left: number, right: number) {
let best: { r: number; start: number; end: number } | null = null;
for (let r = top; r <= bottom; r++) {
let runStart = -1;
for (let c = left; c <= right + 1; c++) {
const status = c <= right ? seats[r]?.[c] : "taken"; // sentinel to flush a trailing run
if (status === "available") {
if (runStart === -1) runStart = c;
} else {
if (runStart !== -1) {
const len = c - runStart;
if (!best || len > best.end - best.start + 1) {
best = { r, start: runStart, end: c - 1 };
}
}
runStart = -1;
}
}
}
return best;
}
export function SeatmapAsciiPick({
seats = DEFAULT_SEATS,
rowLabels = DEFAULT_ROW_LABELS,
colLabels = DEFAULT_COL_LABELS,
sectionLabel = "SECTION C",
className = "",
}: SeatmapAsciiPickProps) {
const rows = seats.length;
const cols = seats[0]?.length ?? 0;
const [anchor, setAnchor] = useState<Cell | null>(null);
const [focus, setFocus] = useState<Cell | null>(null);
const draggingRef = useRef(false);
const gridRef = useRef<HTMLDivElement>(null);
const run = useMemo(() => {
if (!anchor || !focus) return null;
const top = Math.min(anchor.r, focus.r);
const bottom = Math.max(anchor.r, focus.r);
const left = Math.min(anchor.c, focus.c);
const right = Math.max(anchor.c, focus.c);
return bestRun(seats, top, bottom, left, right);
}, [anchor, focus, seats]);
const startDrag = (cell: Cell) => {
if (seats[cell.r]?.[cell.c] === "aisle") return;
draggingRef.current = true;
setAnchor(cell);
setFocus(cell);
};
const dragTo = (cell: Cell) => {
if (draggingRef.current) setFocus(cell);
};
useEffect(() => {
const end = () => {
draggingRef.current = false;
};
window.addEventListener("pointerup", end);
return () => window.removeEventListener("pointerup", end);
}, []);
const focusSeatButton = (r: number, c: number) => {
gridRef.current?.querySelector<HTMLButtonElement>(`[data-r="${r}"][data-c="${c}"]`)?.focus();
};
const nextAvailable = useCallback(
(r: number, c: number, dc: number): number => {
let nc = c;
for (let i = 0; i < cols; i++) {
nc = clamp(nc + dc, 0, cols - 1);
if (seats[r]?.[nc] !== "aisle") return nc;
if (nc === 0 || nc === cols - 1) return nc;
}
return c;
},
[cols, seats]
);
const onKeyDown = (e: React.KeyboardEvent, cell: Cell) => {
const map: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, -1],
ArrowRight: [0, 1],
};
const delta = map[e.key];
if (!delta) return;
e.preventDefault();
let nr = clamp(cell.r + delta[0], 0, rows - 1);
let nc = clamp(cell.c + delta[1], 0, cols - 1);
if (delta[1] !== 0) nc = nextAvailable(cell.r, cell.c, delta[1]);
if (seats[nr]?.[nc] === "aisle") return;
if (e.shiftKey) {
setAnchor((a) => a ?? cell);
setFocus({ r: nr, c: nc });
} else {
setAnchor({ r: nr, c: nc });
setFocus({ r: nr, c: nc });
}
focusSeatButton(nr, nc);
};
const inRun = (r: number, c: number) => !!run && run.r === r && c >= run.start && c <= run.end;
const [activeR, activeC] = focus ? [focus.r, focus.c] : [0, 0];
return (
<div className={`inline-flex flex-col gap-2 font-mono ${className}`}>
<div className="flex items-center justify-between gap-3">
<span className="text-xs uppercase tracking-widest text-muted">{sectionLabel}</span>
<span className="text-[10px] uppercase tracking-widest text-muted">drag to select a block</span>
</div>
<div className="flex flex-col items-center gap-1 rounded-sm border border-border bg-surface px-4 py-3">
<span className="mb-1 select-none text-[10px] tracking-[0.4em] text-muted">— STAGE —</span>
<div
ref={gridRef}
className="grid gap-y-1"
style={{ gridTemplateColumns: `2.5em repeat(${cols}, 1.6em)` }}
>
{seats.map((seatRow, r) => (
<div key={r} className="contents">
<div className="flex items-center text-[10px] text-muted">{rowLabels[r]}</div>
{seatRow.map((status, c) => {
if (status === "aisle") {
return (
<div
key={c}
aria-hidden
className="flex h-6 items-center justify-center text-border"
onPointerEnter={() => dragTo({ r, c })}
>
{" "}
</div>
);
}
const selected = inRun(r, c);
const isActive = activeR === r && activeC === c;
return (
<button
key={c}
type="button"
data-r={r}
data-c={c}
disabled={status === "taken"}
aria-pressed={selected}
aria-label={`${rowLabels[r]} seat ${colLabels[c]}${status === "taken" ? ", taken" : selected ? ", selected" : ", available"}`}
tabIndex={isActive ? 0 : -1}
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-accent ${
status === "taken"
? "cursor-not-allowed text-muted/50"
: selected
? "bg-accent/[0.16] text-foreground hover:bg-accent/[0.24]"
: "text-foreground hover:bg-foreground/[0.08]"
}`}
onPointerDown={() => startDrag({ r, c })}
onPointerEnter={() => dragTo({ r, c })}
onFocus={() => setFocus({ r, c })}
onKeyDown={(e) => onKeyDown(e, { r, c })}
>
{status === "taken" ? "×" : selected ? "●" : "○"}
</button>
);
})}
</div>
))}
</div>
</div>
<div
data-count-readout
aria-live="polite"
className="rounded-sm border border-border bg-background px-3 py-1.5 text-[11px] tabular-nums text-muted"
>
{run ? (
<>
<span className="text-foreground">{run.end - run.start + 1}</span>
{" seats selected — "}
<span className="text-foreground">
{rowLabels[run.r]} {colLabels[run.start]}–{colLabels[run.end]}
</span>
</>
) : (
"no seats selected — drag across an available block"
)}
</div>
</div>
);
}
picking a contiguous block of seats (or any grid of discrete slots that must stay together) where taken slots need to visibly break a run rather than being silently skipped — pick this over grid-magnetic-lattice (ambient cursor field, nothing is actually selected) or filter-facet-mesh (toggling a set of independent facet chips, not a contiguous run of grid cells). Not a fit for a click-one-seat-at-a-time picker; the mechanic here is specifically the marquee-to-block snap.
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-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/--muted/--border/--background/--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.