Skip to main content

ns-ui

Seatmap ASCII Pick

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.

Usage

registry/core/seatmap-ascii-pick/demo.tsx
"use client";

import { SeatmapAsciiPick } from "./component";

export default function SeatmapAsciiPickDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">ns-ui / seatmap-ascii-pick</p>
      <SeatmapAsciiPick />
      <p className="max-w-md text-center text-xs text-ns-muted">
        Drag a marquee across the plan — taken seats and the aisle break the block, and the selection
        snaps to the longest unbroken run.
      </p>
    </div>
  );
}

Component

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[][];
  /** label for each row, left of the grid */
  rowLabels?: string[];
  /** label for each column, above the grid */
  colLabels?: string[];
  /** heading above the seatmap */
  sectionLabel?: string;
  /** extra classes merged onto the rendered root element */
  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-ns-muted">{sectionLabel}</span>
        <span className="text-[10px] uppercase tracking-widest text-ns-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-ns-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-ns-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-ns-accent ${
                      status === "taken"
                        ? "cursor-not-allowed text-ns-muted/50"
                        : selected
                          ? "bg-ns-accent/[0.16] text-foreground hover:bg-ns-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-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/seatmap-ascii-pick.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/seatmap-ascii-pick.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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-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.

Props

PropTypeDefaultDescription
seats?SeatStatus[][]DEFAULT_SEATSrows x cols seat status grid
rowLabels?string[]DEFAULT_ROW_LABELSlabel for each row, left of the grid
colLabels?string[]DEFAULT_COL_LABELSlabel for each column, above the grid
sectionLabel?string"SECTION C"heading above the seatmap
className?string—extra classes merged onto the rendered root element