A spreadsheet-style rectangular cell range select where the border draws itself in real box-drawing glyphs (┌─┐│└┘) and a live ASCII status bar prints the aggregate — count, sum, mean, min, max — as the range grows by drag or Shift+Arrow.
npx shadcn add https://design.helpmarq.com /r/sheet-ascii-range.jsonregistry/core/sheet-ascii-range/component.tsx"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// SheetAsciiRange — a spreadsheet-style rectangular range select. Dragging
// (or Shift+Arrow from a roving-tabindex active cell) grows a rectangle of
// cells; the rectangle's own perimeter is drawn from real box-drawing glyphs
// (┌─┐│└┘) positioned on the seams between cells, and a live ASCII status
// bar prints the aggregate (n / sum / mean / min / max) of whatever is
// currently inside it. The aggregate recomputes on every cell the rectangle
// gains or loses, whether that came from a pointer drag or a keystroke — the
// mechanic is the rectangle-of-cells selection itself, not a cell's visual
// style (table-heat-shimmer is a fixed table whose HOT rows shimmer; nothing
// here is selectable). No canvas: every glyph is a real DOM span positioned
// on the cell grid, colored from tokens via Tailwind classes only.
// ---------------------------------------------------------------------------
type Cell = { r: number; c: number };
export interface SheetAsciiRangeProps {
/** rows x cols numeric grid */
data?: number[][];
rowLabels?: string[];
colLabels?: string[];
/** unit suffix printed after aggregate numbers, e.g. "units" */
unit?: string;
title?: string;
className?: string;
}
const DEFAULT_DATA: number[][] = [
[42, 58, 61, 33, 70, 45, 52, 39],
[55, 63, 47, 71, 38, 60, 44, 57],
[31, 49, 66, 52, 59, 41, 68, 36],
[64, 40, 53, 62, 46, 73, 35, 50],
[48, 57, 34, 65, 51, 43, 69, 32],
[37, 61, 56, 48, 67, 54, 42, 60],
];
const DEFAULT_ROW_LABELS = ["MON", "TUE", "WED", "THU", "FRI", "SAT"];
const DEFAULT_COL_LABELS = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
export function SheetAsciiRange({
data = DEFAULT_DATA,
rowLabels = DEFAULT_ROW_LABELS,
colLabels = DEFAULT_COL_LABELS,
unit = "units",
title = "Shipment log",
className = "",
}: SheetAsciiRangeProps) {
const rows = data.length;
const cols = data[0]?.length ?? 0;
const [anchor, setAnchor] = useState<Cell | null>(null);
const [focus, setFocus] = useState<Cell>({ r: 0, c: 0 });
const draggingRef = useRef(false);
const gridRef = useRef<HTMLDivElement>(null);
const rect = useMemo(() => {
if (!anchor) return null;
return {
top: Math.min(anchor.r, focus.r),
bottom: Math.max(anchor.r, focus.r),
left: Math.min(anchor.c, focus.c),
right: Math.max(anchor.c, focus.c),
};
}, [anchor, focus]);
const stats = useMemo(() => {
if (!rect) return { n: 0, sum: 0, mean: 0, min: 0, max: 0 };
let n = 0;
let sum = 0;
let min = Infinity;
let max = -Infinity;
for (let r = rect.top; r <= rect.bottom; r++) {
for (let c = rect.left; c <= rect.right; c++) {
const v = data[r]?.[c] ?? 0;
n++;
sum += v;
if (v < min) min = v;
if (v > max) max = v;
}
}
return { n, sum, mean: n ? sum / n : 0, min: n ? min : 0, max: n ? max : 0 };
}, [rect, data]);
const inRect = useCallback(
(r: number, c: number) => !!rect && r >= rect.top && r <= rect.bottom && c >= rect.left && c <= rect.right,
[rect]
);
const startDrag = (cell: Cell) => {
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 focusCellButton = (r: number, c: number) => {
gridRef.current?.querySelector<HTMLButtonElement>(`[data-r="${r}"][data-c="${c}"]`)?.focus();
};
const moveActive = (dr: number, dc: number, extend: boolean) => {
const nr = clamp(focus.r + dr, 0, rows - 1);
const nc = clamp(focus.c + dc, 0, cols - 1);
if (extend) {
if (!anchor) setAnchor(focus);
setFocus({ r: nr, c: nc });
} else {
setAnchor({ r: nr, c: nc });
setFocus({ r: nr, c: nc });
}
focusCellButton(nr, nc);
};
const onKeyDown = (e: React.KeyboardEvent) => {
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();
moveActive(delta[0], delta[1], e.shiftKey);
};
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">{title}</span>
<span className="text-[10px] uppercase tracking-widest text-muted">drag or shift+arrow to select</span>
</div>
<div
ref={gridRef}
role="grid"
aria-label={`${title} cell grid`}
className="relative inline-grid select-none rounded-sm border border-border bg-surface p-2"
style={{
gridTemplateColumns: `3.5em repeat(${cols}, 3em)`,
}}
>
<div />
{colLabels.map((label) => (
<div key={label} className="px-1 py-1 text-center text-[10px] text-muted">
{label}
</div>
))}
{rowLabels.map((rowLabel, r) => (
<div key={rowLabel} className="contents">
<div className="flex items-center px-1 text-[10px] text-muted">{rowLabel}</div>
{Array.from({ length: cols }, (_, c) => {
const value = data[r]?.[c] ?? 0;
const selected = inRect(r, c);
const isActive = focus.r === r && focus.c === c;
const isTop = selected && r === rect!.top;
const isBottom = selected && r === rect!.bottom;
const isLeft = selected && c === rect!.left;
const isRight = selected && c === rect!.right;
let corner: string | null = null;
if (isTop && isLeft) corner = "corner-tl";
else if (isTop && isRight) corner = "corner-tr";
else if (isBottom && isLeft) corner = "corner-bl";
else if (isBottom && isRight) corner = "corner-br";
const glyph =
corner === "corner-tl"
? "┌"
: corner === "corner-tr"
? "┐"
: corner === "corner-bl"
? "└"
: corner === "corner-br"
? "┘"
: isTop || isBottom
? "─"
: isLeft || isRight
? "│"
: null;
return (
<button
key={`${r}-${c}`}
type="button"
data-r={r}
data-c={c}
role="gridcell"
aria-selected={selected}
aria-label={`${rowLabel} ${colLabels[c]}: ${fmt(value)} ${unit}`}
tabIndex={isActive ? 0 : -1}
className={`relative flex h-8 items-center justify-end px-1.5 text-xs tabular-nums text-foreground outline-none transition-colors duration-100 motion-reduce:transition-none hover:bg-foreground/[0.06] focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
selected ? "bg-accent/[0.09]" : ""
}`}
onPointerDown={() => startDrag({ r, c })}
onPointerEnter={() => dragTo({ r, c })}
onFocus={() => setFocus({ r, c })}
onKeyDown={onKeyDown}
>
{fmt(value)}
{glyph && (
<span
aria-hidden
data-glyph={corner ?? (isTop ? "top" : isBottom ? "bottom" : isLeft ? "left" : "right")}
className={`absolute select-none text-[11px] leading-none text-foreground ${
corner
? corner === "corner-tl"
? "-left-px -top-px -translate-x-1/2 -translate-y-1/2"
: corner === "corner-tr"
? "-right-px -top-px translate-x-1/2 -translate-y-1/2"
: corner === "corner-bl"
? "-bottom-px -left-px -translate-x-1/2 translate-y-1/2"
: "-bottom-px -right-px translate-x-1/2 translate-y-1/2"
: isTop
? "inset-x-0 -top-px -translate-y-1/2 text-center"
: isBottom
? "inset-x-0 -bottom-px translate-y-1/2 text-center"
: isLeft
? "inset-y-0 -left-px -translate-x-1/2 flex items-center"
: "inset-y-0 -right-px translate-x-1/2 flex items-center"
}`}
>
{glyph}
</span>
)}
</button>
);
})}
</div>
))}
</div>
<div
data-status-bar
aria-live="polite"
className="rounded-sm border border-border bg-background px-3 py-1.5 text-[11px] tabular-nums text-muted"
>
{stats.n > 0 ? (
<>
<span className="text-foreground">n={stats.n}</span>
{" Σ="}
<span className="text-foreground">{fmt(stats.sum)}</span>
{" x̄="}
<span className="text-foreground">{fmt(stats.mean)}</span>
{" min="}
<span className="text-foreground">{fmt(stats.min)}</span>
{" max="}
<span className="text-foreground">{fmt(stats.max)}</span>
{` ${unit}`}
</>
) : (
"n=0 — click a cell or drag a range"
)}
</div>
</div>
);
}
a rectangular multi-cell range selection over tabular numeric data where the aggregate of the current selection should read live, spreadsheet-style — pick this over table-heat-shimmer when the selection itself is the point, not a fixed row's status. table-heat-shimmer's rows aren't selectable at all; its mechanic is which rows shimmer, not which cells are chosen.
Build <SheetAsciiRange data rowLabels colLabels unit title className> over a rows x cols numeric grid (default a 6x8 synthetic shipment log). MECHANIC: pointerdown on a cell sets both the drag anchor and the live focus cell to it; while the pointer is down (no setPointerCapture — this deliberately relies on native hover/enter events firing on whichever cell the pointer is currently over, exactly like a real spreadsheet drag, and a global window 'pointerup' listener ends the drag regardless of what element the pointer released over), each subsequent cell's onPointerEnter updates only the focus corner, and the selection rectangle is the min/max of anchor and focus on both axes — recomputed on every cell gained or lost, not just on release. A bare pointerdown+pointerup on one cell with no movement commits a 1x1 rectangle immediately, so the mechanic has a resting, gate-visible non-drag path too. KEYBOARD: one roving-tabindex button per cell (tabIndex 0 only on the current focus cell); plain ArrowKeys move the active cell and collapse the selection to it (spreadsheet cursor behavior); Shift+ArrowKeys (preventDefault, so the page never scrolls under the extend) keep the anchor fixed and move only the focus corner, extending or shrinking the rectangle exactly like a mouse drag would, and the aggregate recomputes on every keystroke. BORDER: every cell on the rectangle's perimeter renders one absolutely-positioned, aria-hidden box-drawing glyph seated on the seam at its own edge/corner of the CELL (not a separate overlay grid) — ┌┐└┘ at the four corners (priority: a cell that is simultaneously top+left is the top-left corner, etc.), ─ centered on the outer edge of top/bottom perimeter cells, │ centered on the outer edge of left/right perimeter cells — so the whole rectangle's boundary reads as a real drawn box even though every glyph belongs to its own cell's DOM. Selected cells (including interior ones) get a flat --accent-derived tint (bg-accent/[0.09], via Tailwind's arbitrary-opacity utility over the token, never a literal hex) with no glyph. STATUS BAR: a visible (not sr-only — it doubles as its own accessible live region via aria-live=polite) monospace line beneath the grid prints 'n=<count> Σ=<sum> x̄=<mean> min=<min> max=<max> <unit>' whenever the rectangle is non-empty, or a plain 'n=0 — click a cell or drag a range' placeholder at rest. A11Y: role=grid on the container, role=gridcell + aria-selected on every cell, and each cell's aria-label states its row label, column label and raw value/unit so a screen reader can navigate the sheet cell-by-cell independent of the visual rectangle. Colors are token-only (--foreground/--muted/--border/--background/--accent via Tailwind utility classes) — no canvas, no hex, so there's nothing to re-derive on theme change. 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.