Seatmap ASCII Pick

Seatmap

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.

Install
npx shadcn add https://design.helpmarq.com/r/seatmap-ascii-pick.json
Source
registry/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>
  );
}
Use when

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 spec

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.

Tags
seatmapselectiongridasciimonodragkeyboard-navigationbooking