PasteableGrid
Preview
Basic
Loading…
Preview
Code
ts
import PasteableGrid from '@/components/table/PasteableGrid';src/components/table/PasteableGrid.tsx
AI prompt
text
Build a spreadsheet-style editable grid component in React + TypeScript + Tailwind CSS: every cell is an input; paste a block copied from Excel / Google Sheets, select ranges, copy them as TSV, fill-drag like Excel, undo / redo, resize columns, and optionally freeze the header and leading columns.
## Look
- Wrapper: `isolate`; by default a card `border border-slate-200 dark:border-slate-700 rounded-md shadow-sm` (`bordered={false}` drops it). Default `overflow-x-auto` with no vertical scroll of its own; in pinned mode `overflow-auto max-h-full` (`h-full` with `fillHeight`).
- Table: `select-none`, `table-layout: fixed`, `border-collapse` with a 1px `border` on every cell in `border-slate-300 dark:border-slate-600`. Pinned mode instead uses `border-separate border-spacing-0` and one-sided `border-b border-r` cells (the wrapper gives top / left) — collapsed borders belong to the table and stay behind when a sticky cell moves.
- Columns, left to right: a "#" column (3rem) of row numbers — centred, slate-400 / dark slate-500 on `bg-slate-50 dark:bg-slate-700/40`; an optional `rowHeader` column (default 8rem, label `rowHeaderLabel`) with non-editable slate-600 / dark slate-300 text, nowrap, truncating; the data columns; an optional 2rem clear-row column.
- Header cells: `bg-slate-100 dark:bg-slate-700`, 15px semibold slate-600 / dark slate-300, centred, `px-2 pt-1.5 pb-1`, label truncates `leading-tight`. A column with `summary` shows it on a second line under a divider: `mt-1.5 pt-1.5 border-t`, right-aligned, 14px semibold indigo-600 / dark indigo-400 — e.g. a running total. Not pinned, the thead is sticky `top-0 z-10`.
- Row height 40px (set as line-height on the cell). The cell is `relative p-0`; the input fills it (`absolute inset-0 w-full h-full box-border px-1.5`), text right-aligned `tabular-nums`, no outline, a 1px transparent border that turns `border-indigo-500` on focus (plus `z-10`). That 1px border is the ONLY indicator for a single active cell.
- Read-only column: `bg-slate-50 dark:bg-slate-700/30 text-slate-500 dark:text-slate-400 cursor-default`, focus border slate-400 / dark slate-500.
- Invalid cell (`cellError` returns a message): `bg-rose-50 dark:bg-rose-900/25 text-rose-700 dark:text-rose-300 border-rose-400 dark:border-rose-500/70`, message as the input's `title`. Presentational only — input is never blocked.
- Multi-cell selection: `bg-indigo-100/50 dark:bg-indigo-900/30` on each cell plus a 2px indigo-500 border on the outer edges only (top row gets `border-t-2`, bottom row `border-b-2`, etc.) — one Excel-style rectangle.
- Fill handle: a 7×7px `bg-indigo-600` square with a 1px white / dark slate-800 border, at the selection's bottom-right cell offset `-right-[3px] -bottom-[3px]`, `cursor-crosshair z-20`, title "Drag to fill". While dragging, cells about to be filled get `border-2 border-dashed border-slate-500 dark:border-slate-300`.
- Resize handle: 6px strip at each header's right edge, `cursor-col-resize hover:bg-indigo-400/50 active:bg-indigo-500/60`.
- Clear-row button: 12px Trash2, slate-300 → hover rose-600, invisible until the row is hovered.
## Behaviour
- Values are opaque strings; the grid never computes anything (summaries are consumer text). It never adds rows.
- Paste (multi-cell text): strip `\r`, drop trailing empty lines, split on tab if any line has one else on comma, trim each cell. If the first row looks like this grid's own headers — at least min(2, non-empty cells) of its cells match a column's `aliases` (default: its label), compared lowercase with non-alphanumerics removed — drop it. Write starting at the selection's top-left (or the pasted cell), ignoring rows / columns past the end and skipping read-only columns.
- Paste a single value (no tab / newline) onto a multi-cell selection: fill every selected cell with it. Onto one cell: native paste.
- Copy with a range selected: write the range as TSV. Single cell: native copy. Delete / Backspace on a range clears it; on one cell, native (focusing a cell selects its text).
- Mouse: mousedown sets anchor + focus, dragging extends, Shift+click extends from the anchor.
- Keys: arrows move focus (clamped), Shift+arrows extend the selection, Enter moves down, Escape reverts the cell's in-progress edit (no history step) or else collapses the selection to the anchor.
- Undo / redo: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z or Cmd/Ctrl+Y, 100 steps. Typing coalesces into ONE step per cell, committed on blur or navigation, not per keystroke; paste, clear and fill are each one step. History is discarded when `rows` changes from outside (anything other than the grid's own emit).
- Fill drag: locks to one axis — whichever the pointer is furthest past the selection on — and TILES the source values (repeats them as a pattern, never extrapolates a series), skipping read-only columns. Afterwards the selection covers the filled area; releasing inside the source does nothing. Listen for mouseup on `window`.
- Column widths: by default the table is 100% wide, columns with `width` keep it and the rest share the remainder. Dragging a border snapshots every column's rendered px width, then grows the dragged column by taking space from its siblings in proportion to how much each has above a 40px minimum (shrinking hands width back in proportion to size), and renders widths as PERCENTAGES so the grid still scales with its container — a resize never causes horizontal scroll. Exception: if EVERY column has a px / rem width, the table's width is their sum (+3rem #, row header, 32px clear column), it scrolls horizontally, and a resize may grow the total once siblings are out of slack.
- Pinned mode: header row sticky top; # and row-header columns sticky left, each with an opaque `bg-slate-50 dark:bg-slate-900`. The row header's `left` must be the # column's MEASURED width (ResizeObserver) — rem widths shift with root font size and leave a seam. Z tiers: corner header cells 50 > data headers 40 > pinned body cells 30 > fill handle 20 > focused input 10; `isolate` keeps them from out-stacking page popovers. The parent must bound the height.
## API
- `PasteableGridColumn<K>`: `key: K`, `label`, `width?` ('16rem' / '120px'), `aliases?: string[]`, `summary?: string`, `readOnly?`.
- Props: `columns`, `rows: Record<K, string>[]`, `onRowsChange(next)` (fully controlled), `rowHeader?(rowIndex): ReactNode`, `rowHeaderWidth` ('8rem'), `rowHeaderLabel`, `onClearRow?(rowIndex)`, `cellError?(rowIndex, key): string | null`, `pinned`, `fillHeight`, `bordered` (true).
- Ref handle (forwardRef, generic over K): `pasteAt(rowIdx, colIdx, text)` runs the same paste logic from outside, e.g. a "Paste from clipboard" button.
## Demo
Bonus adjustments in a 22rem-tall box, pinned + fillHeight: Person (16rem, aliases "name", "user"), Amount (10rem, summary = the column's live total), Note. Rows: Ada Lovelace 1200 "Q3 bonus", Grace Hopper 850, Alan Turing 2400 "Relocation", Katherine J. 640, plus one blank row. Paste "Name⇥Amount⇥Note" plus rows to show the header being dropped.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: marketing-stats (96S1), verbatim. */
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Trash2 } from 'lucide-react';
// A spreadsheet-style editable grid:
// - Click any cell and paste (Cmd/Ctrl+V) a tab/comma-separated range copied
// from Excel or Google Sheets — it fills starting exactly from that cell
// (or the top-left of the current selection), spanning however many
// rows/columns the clipboard holds. A pasted first row that looks like one
// of this grid's own column headers (matched via each column's `aliases`)
// is dropped automatically.
// - Copy a single cell (Cmd/Ctrl+C with no range selected) then paste it while
// a multi-cell range is selected to fill every marked cell with that one
// value — same "copy one, paste to all marked" behavior as Excel/Sheets,
// distinct from the multi-cell paste above (which maps each clipboard cell
// to its own target cell instead of repeating a single value).
// - Click-drag or Shift+click/arrow selects a cell range, shown with an
// Excel-style blue outline + light fill.
// - Cmd/Ctrl+C copies the selected range as TSV; Delete/Backspace clears it.
// - Arrow keys/Enter move the active cell; Shift+Arrow extends the selection;
// Escape reverts an in-progress edit on the active cell.
// - Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z or Cmd/Ctrl+Y redoes. Typing into a
// cell coalesces into one undo step per cell (committed on blur/navigate,
// not per keystroke); paste and clear/delete are each their own step.
// - A small fill handle sits at the bottom-right corner of the current
// selection — drag it up/down/left/right to repeat (tile) the selection's
// values into the cells passed over, Excel-style. One axis at a time
// (whichever direction you've dragged furthest past the selection), shown
// live via a dashed preview outline, and undoable as one step.
// - Columns fill the available width by default (data columns share the
// remaining space evenly); dragging a header's right edge resizes that
// column by taking space from its siblings (proportional to how much each
// can spare above MIN_COL_WIDTH) rather than growing the table's total
// width — so a resize never forces horizontal scrolling on its own, and the
// grid keeps scaling with its container (sidebar collapse, window resize)
// same as before any resize happened. The one exception is a grid where
// EVERY column already declares its own pixel `width` (e.g. a wide grid
// with columns too numerous to share space evenly without becoming
// unreadable) — there, growing a column past what siblings can spare grows
// the table's total width as a last resort, same "scroll rather than
// squeeze" contract that mode already had. No vertical scroll of its own —
// the page/panel it sits in handles that — unless `pinned` is set (see the
// prop's comment), which makes the grid its own two-axis scroll container
// with the header row and row-number/rowHeader columns frozen in place.
export interface PasteableGridColumn<K extends string> {
key: K;
label: string;
width?: string;
// Header text (case/punctuation-insensitive) that identifies this column
// when a pasted header row is being auto-detected. Defaults to the label.
aliases?: string[];
// Optional small line rendered under the column label in the header — e.g.
// a running total for that column. The grid doesn't compute this itself
// (it treats every cell value as an opaque string); the consumer supplies
// whatever text it wants shown.
summary?: string;
// Derived/calculated column (e.g. a running total) — still selectable and
// copyable like any other cell, but typing, pasting, and clear/delete
// never modify its value; the consumer recomputes it instead.
readOnly?: boolean;
}
export interface PasteableGridHandle {
pasteAt: (rowIdx: number, colIdx: number, text: string) => void;
}
interface PasteableGridProps<K extends string> {
columns: PasteableGridColumn<K>[];
rows: Record<K, string>[];
onRowsChange: (next: Record<K, string>[]) => void;
// Rendered as a non-editable leading column, after the built-in row number
// — e.g. a date label. Omit it entirely to skip that column.
rowHeader?: (rowIndex: number) => React.ReactNode;
rowHeaderWidth?: string;
rowHeaderLabel?: string;
onClearRow?: (rowIndex: number) => void;
// Per-cell validation: return an error message to tint that cell red (the
// message doubles as the input's title tooltip), or null/undefined for a
// clean cell. Purely presentational — the grid never blocks input on it.
cellError?: (rowIndex: number, colKey: K) => string | null | undefined;
// Frozen-pane mode (see conventions.md): pins the header row to the top and
// the row-number + rowHeader columns to the left while the grid scrolls.
// The wrapper becomes the scroll container for BOTH axes (`overflow-auto` +
// `max-h-full`), so the consumer must bound its height (e.g. a
// `flex-1 min-h-0` parent) — without a bound the header pin is inert (the
// column pin still works, since horizontal overflow is the grid's own).
// Uses `border-separate` + one-sided cell borders instead of the default
// `border-collapse`, because collapsed borders belong to the table and stay
// behind when a sticky cell is offset — the gridlines would scroll away
// from the pinned cells.
pinned?: boolean;
// Pinned-only. Default pinned sizing is `max-h-full` — the wrapper only
// ever shrinks to the row content's own height, capped by the parent, so a
// short grid (few rows) inside a tall bounded parent leaves visible blank
// space beneath it. Set this when the consumer wants the grid to always
// stretch to fill that parent instead (`h-full`) — e.g. several grids
// sharing a flex row/column where each should claim its full allotted
// share of the space regardless of row count.
fillHeight?: boolean;
// The wrapper's `border` + `shadow-sm` + `rounded-md` give the grid a
// "card" look by default. Set to `false` to omit it (e.g. several grids
// already sitting inside their own sectioned area, where an outer card
// border around each one is redundant chrome) — every existing caller
// keeps the bordered look unless it opts out.
bordered?: boolean;
}
type CellPos = { row: number; col: number };
type Bounds = { minRow: number; maxRow: number; minCol: number; maxCol: number };
const MIN_COL_WIDTH = 40;
const ROW_NUMBER_COL_WIDTH = '3rem';
const MAX_HISTORY = 100;
function normalizeHeaderText(h: string): string {
return h.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function splitClipboardText(text: string): string[][] {
const lines = text.replace(/\r/g, '').split('\n');
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
if (lines.length === 0) return [];
const delimiter = lines.some(l => l.includes('\t')) ? '\t' : ',';
return lines.map(l => l.split(delimiter).map(c => c.trim()));
}
function getBounds(a: CellPos | null, f: CellPos | null): Bounds | null {
if (!a || !f) return null;
return {
minRow: Math.min(a.row, f.row), maxRow: Math.max(a.row, f.row),
minCol: Math.min(a.col, f.col), maxCol: Math.max(a.col, f.col),
};
}
type FillDirection = 'up' | 'down' | 'left' | 'right';
// Excel's fill handle locks to a single axis per drag — whichever axis the
// pointer has moved furthest past the source selection along, extending only
// that side. Returns null while the pointer is still inside (or exactly on)
// the source bounds, meaning "no-op if released now".
function computeFillExtension(source: Bounds, target: CellPos): { bounds: Bounds; direction: FillDirection } | null {
const rowOut = target.row > source.maxRow ? target.row - source.maxRow
: target.row < source.minRow ? source.minRow - target.row : 0;
const colOut = target.col > source.maxCol ? target.col - source.maxCol
: target.col < source.minCol ? source.minCol - target.col : 0;
if (rowOut === 0 && colOut === 0) return null;
if (rowOut >= colOut) {
return target.row > source.maxRow
? { bounds: { ...source, maxRow: target.row }, direction: 'down' }
: { bounds: { ...source, minRow: target.row }, direction: 'up' };
}
return target.col > source.maxCol
? { bounds: { ...source, maxCol: target.col }, direction: 'right' }
: { bounds: { ...source, minCol: target.col }, direction: 'left' };
}
// Tiles the source block's values into the extension area (the part of
// `extended` outside `source`) — repeating, not extrapolating a series, per
// the fill handle's job of "repeat content into neighboring cells". Wraps
// around the source block's own height/width so a multi-row/column source
// repeats as a pattern rather than just smearing its last row/column.
function applyFillToRows<K extends string>(
rows: Record<K, string>[],
columns: PasteableGridColumn<K>[],
source: Bounds,
extended: Bounds,
direction: FillDirection
): Record<K, string>[] {
const next = [...rows];
const srcHeight = source.maxRow - source.minRow + 1;
const srcWidth = source.maxCol - source.minCol + 1;
const mod = (n: number, m: number) => ((n % m) + m) % m;
if (direction === 'up' || direction === 'down') {
for (let r = extended.minRow; r <= extended.maxRow; r++) {
if (r >= source.minRow && r <= source.maxRow) continue; // source rows stay put
if (r < 0 || r >= next.length) continue;
const srcRow = source.minRow + mod(r - source.minRow, srcHeight);
const updated = { ...next[r] };
for (let c = source.minCol; c <= source.maxCol; c++) {
if (c < 0 || c >= columns.length || columns[c].readOnly) continue;
updated[columns[c].key] = next[srcRow]?.[columns[c].key] ?? '';
}
next[r] = updated;
}
} else {
for (let c = extended.minCol; c <= extended.maxCol; c++) {
if (c >= source.minCol && c <= source.maxCol) continue; // source columns stay put
if (c < 0 || c >= columns.length || columns[c].readOnly) continue;
const srcCol = source.minCol + mod(c - source.minCol, srcWidth);
for (let r = source.minRow; r <= source.maxRow; r++) {
if (r < 0 || r >= next.length) continue;
next[r] = { ...next[r], [columns[c].key]: next[r]?.[columns[srcCol].key] ?? '' };
}
}
}
return next;
}
// Only understands plain 'Npx'/'Nrem' — the two units this grid's own
// defaults (ROW_NUMBER_COL_WIDTH, rowHeaderWidth) and every consumer's column
// `width` actually use. Returns null for anything else (a '%' width, or no
// width at all), which is the signal that the columns in question don't have
// a fully-specified pixel size to sum.
function parseCssLengthToPx(value: string): number | null {
const remMatch = /^(\d+(?:\.\d+)?)rem$/.exec(value.trim());
if (remMatch) return parseFloat(remMatch[1]) * 16;
const pxMatch = /^(\d+(?:\.\d+)?)px$/.exec(value.trim());
return pxMatch ? parseFloat(pxMatch[1]) : null;
}
function PasteableGridInner<K extends string>(
{ columns, rows, onRowsChange, rowHeader, rowHeaderWidth = '8rem', rowHeaderLabel, onClearRow, cellError, pinned, fillHeight, bordered = true }: PasteableGridProps<K>,
ref: React.Ref<PasteableGridHandle>
) {
// Pinned mode: the rowHeader column's sticky `left` must equal the
// row-number column's RENDERED width — which can't be derived from
// ROW_NUMBER_COL_WIDTH ('3rem'), because the app's density setting scales
// the root font-size (13/14/16px — see ThemeContext), so 3rem is 39-48px
// depending on the user's setting (and fixed table layout then also
// redistributes any declared-vs-actual difference across every column).
// Assuming 1rem=16px left a visible transparent seam between the two
// pinned columns at the other densities, so measure the real width
// instead — same measured-sticky-offset technique as BrandsRoiGrid.
const rowNumberThRef = useRef<HTMLTableCellElement>(null);
const [measuredRowNumberWidth, setMeasuredRowNumberWidth] = useState<number | null>(null);
useEffect(() => {
if (!pinned || !rowNumberThRef.current) return;
const el = rowNumberThRef.current;
const update = () => setMeasuredRowNumberWidth(el.getBoundingClientRect().width);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [pinned]);
// Data columns start after the built-in row-number column and the optional
// rowHeader column — this offset keeps the <colgroup>/resize-handle
// indexes aligned whichever way the grid is configured.
const hasRowHeader = !!rowHeader;
const dataColOffset = hasRowHeader ? 2 : 1;
// When EVERY column carries an explicit pixel width (e.g. a wide grid like
// DepositRetentionSyncModal's 26-column one, where the default "share 100%
// evenly" layout would squeeze each column unreadably thin), sum them up
// front as the table's own width — same mechanism the column-resize path
// below already uses once the user drags a border, just computed from the
// columns' declared widths instead of their measured rendered ones. A grid
// with no (or only some) columns carrying a `width` keeps the existing
// "fill 100%, columns share the remainder" default untouched.
const explicitTotalWidthPx = useMemo(() => {
let total = parseCssLengthToPx(ROW_NUMBER_COL_WIDTH) ?? 0;
if (hasRowHeader) total += parseCssLengthToPx(rowHeaderWidth) ?? 0;
for (const col of columns) {
const px = col.width ? parseCssLengthToPx(col.width) : null;
if (px === null) return null;
total += px;
}
if (onClearRow) total += 32;
return total;
}, [columns, hasRowHeader, rowHeaderWidth, onClearRow]);
// Only a grid where every column already declares its own pixel width gets
// to grow its total width past the container (see the header comment) —
// every other grid stays "fluid": resizing redistributes among siblings
// and the rendered widths are expressed as percentages (see `colWidth`
// below) so the whole thing keeps scaling with the container afterward.
const fluidMode = explicitTotalWidthPx === null;
const inputRefs = useRef<(HTMLInputElement | null)[][]>([]);
const tableRef = useRef<HTMLTableElement>(null);
const isDragging = useRef(false);
const suppressNextFocusSync = useRef(false);
const [anchor, setAnchor] = useState<CellPos | null>(null);
const [focus, setFocus] = useState<CellPos | null>(null);
// Fill handle drag: `fillSourceRef` is the selection the drag started from
// (fixed for the whole drag), `fillTarget` is the cell currently under the
// pointer (drives the live preview). Kept separate from anchor/focus so an
// in-progress fill drag never disturbs the normal cell selection.
const isFillDragging = useRef(false);
const fillSourceRef = useRef<Bounds | null>(null);
const [fillTarget, setFillTarget] = useState<CellPos | null>(null);
// Reassigned every render (not in an effect) so it always closes over the
// latest `rows`/`columns`/`commitChange`, while the window mouseup listener
// that invokes it is registered once and never goes stale.
const finishFillDragRef = useRef<() => void>(() => {});
// null = default layout (row-number/date fixed, data columns share the rest
// of the width evenly). Once the user drags a column border, every
// column's rendered width is snapshotted here in px (drag math needs px),
// then rendered back out as percentages in fluid mode (or as absolute px,
// same as before, in the all-columns-declare-a-width mode) — see `colWidth`.
const [colWidthsPx, setColWidthsPx] = useState<number[] | null>(null);
// Undo/redo: refs (not state) since they don't need to trigger renders —
// only the `rows` they eventually emit does.
const undoStackRef = useRef<Record<K, string>[][]>([]);
const redoStackRef = useRef<Record<K, string>[][]>([]);
const lastEmittedRef = useRef<Record<K, string>[] | null>(null);
// A cell mid-typing-session: snapshot taken on its first keystroke, pushed
// to the undo stack only once the session ends (blur/navigate/Cmd+Z) — so
// undo reverts a whole edit, not one character at a time.
const pendingEditSnapshotRef = useRef<Record<K, string>[] | null>(null);
const pendingEditCellRef = useRef<{ row: number; col: K } | null>(null);
// If `rows` changed via something other than our own emit (e.g. the parent
// reset it for a different agent/month), the history no longer applies.
useEffect(() => {
if (rows !== lastEmittedRef.current) {
undoStackRef.current = [];
redoStackRef.current = [];
pendingEditSnapshotRef.current = null;
pendingEditCellRef.current = null;
}
}, [rows]);
useEffect(() => {
const handleMouseUp = () => {
isDragging.current = false;
finishFillDragRef.current();
};
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}, []);
const aliasMapRef = useRef<Record<string, number>>({});
const aliasMap: Record<string, number> = {};
columns.forEach((col, idx) => {
(col.aliases ?? [col.label]).forEach(a => { aliasMap[normalizeHeaderText(a)] = idx; });
});
aliasMapRef.current = aliasMap;
const looksLikeHeaderRow = (cells: string[]): boolean => {
const nonEmpty = cells.filter(c => c.trim() !== '');
if (nonEmpty.length === 0) return false;
const recognized = nonEmpty.filter(c => aliasMapRef.current[normalizeHeaderText(c)] !== undefined).length;
return recognized >= Math.min(2, nonEmpty.length);
};
const setInputRef = (rowIdx: number, colIdx: number) => (el: HTMLInputElement | null) => {
if (!inputRefs.current[rowIdx]) inputRefs.current[rowIdx] = [];
inputRefs.current[rowIdx][colIdx] = el;
};
const emit = (next: Record<K, string>[]) => {
lastEmittedRef.current = next;
onRowsChange(next);
};
// Ends the current typing session (if any), pushing its pre-edit snapshot
// as one undo step. Called on blur, navigation, and before any other
// atomic edit so history stays in the right order.
const commitPendingEdit = () => {
if (pendingEditSnapshotRef.current) {
undoStackRef.current.push(pendingEditSnapshotRef.current);
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift();
redoStackRef.current = [];
}
pendingEditSnapshotRef.current = null;
pendingEditCellRef.current = null;
};
// Paste/clear are atomic — one undo step each, taken immediately.
const commitChange = (next: Record<K, string>[]) => {
commitPendingEdit();
undoStackRef.current.push(rows);
if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift();
redoStackRef.current = [];
emit(next);
};
const handleUndo = () => {
commitPendingEdit();
if (undoStackRef.current.length === 0) return;
const prev = undoStackRef.current.pop()!;
redoStackRef.current.push(rows);
emit(prev);
};
const handleRedo = () => {
if (redoStackRef.current.length === 0) return;
const next = redoStackRef.current.pop()!;
undoStackRef.current.push(rows);
emit(next);
};
const applyPastedBlock = (startRow: number, startCol: number, text: string) => {
let block = splitClipboardText(text);
if (block.length === 0) return;
if (looksLikeHeaderRow(block[0])) {
block = block.slice(1);
}
if (block.length === 0) return;
const next = [...rows];
block.forEach((cellsRow, ri) => {
const targetRow = startRow + ri;
if (targetRow >= next.length) return; // never extend past the grid's fixed row count
const updated = { ...next[targetRow] };
cellsRow.forEach((val, ci) => {
const targetCol = startCol + ci;
if (targetCol >= columns.length) return; // ignore columns past the last one we support
if (columns[targetCol].readOnly) return;
updated[columns[targetCol].key] = val;
});
next[targetRow] = updated;
});
commitChange(next);
};
useImperativeHandle(ref, () => ({ pasteAt: applyPastedBlock }));
const bounds = getBounds(anchor, focus);
const handleCellPaste = (rowIdx: number, colIdx: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const text = e.clipboardData.getData('text/plain');
if (!text) return;
if (!text.includes('\t') && !text.includes('\n')) {
// A single copied value pasted onto an active multi-cell selection fills
// every marked cell with it, rather than only the focused/active one.
if (bounds && (bounds.maxRow > bounds.minRow || bounds.maxCol > bounds.minCol)) {
e.preventDefault();
fillRangeWithValue(bounds, text);
}
return; // single cell, no range selected: let the plain paste behave natively
}
e.preventDefault();
const startRow = bounds ? bounds.minRow : rowIdx;
const startCol = bounds ? bounds.minCol : colIdx;
applyPastedBlock(startRow, startCol, text);
};
const handleCopy = async (rowIdx: number, colIdx: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const b = bounds ?? { minRow: rowIdx, maxRow: rowIdx, minCol: colIdx, maxCol: colIdx };
if (b.maxRow === b.minRow && b.maxCol === b.minCol) return; // single cell: let native copy of the selected text handle it
e.preventDefault();
const lines: string[] = [];
for (let r = b.minRow; r <= b.maxRow; r++) {
const cells: string[] = [];
for (let c = b.minCol; c <= b.maxCol; c++) {
cells.push(rows[r]?.[columns[c].key] ?? '');
}
lines.push(cells.join('\t'));
}
e.clipboardData.setData('text/plain', lines.join('\n'));
};
const fillRangeWithValue = (b: Bounds, value: string) => {
const next = [...rows];
for (let r = b.minRow; r <= b.maxRow; r++) {
if (r < 0 || r >= next.length) continue;
const updated = { ...next[r] };
for (let c = b.minCol; c <= b.maxCol; c++) {
if (c < 0 || c >= columns.length || columns[c].readOnly) continue;
updated[columns[c].key] = value;
}
next[r] = updated;
}
commitChange(next);
};
const clearRange = (b: Bounds) => fillRangeWithValue(b, '');
const updateCell = (rowIdx: number, col: K, value: string) => {
const sameSession = pendingEditCellRef.current?.row === rowIdx && pendingEditCellRef.current?.col === col;
if (!sameSession) {
commitPendingEdit(); // flush a session left on a different cell first
pendingEditSnapshotRef.current = rows;
pendingEditCellRef.current = { row: rowIdx, col };
}
emit(rows.map((r, i) => i === rowIdx ? { ...r, [col]: value } : r));
};
const moveFocusTo = (row: number, col: number) => {
inputRefs.current[row]?.[col]?.focus();
};
const handleKeyDown = (rowIdx: number, colIdx: number, e: React.KeyboardEvent<HTMLInputElement>) => {
const isMeta = e.metaKey || e.ctrlKey;
if (isMeta && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) handleRedo(); else handleUndo();
return;
}
if (isMeta && !e.shiftKey && e.key.toLowerCase() === 'y') {
e.preventDefault();
handleRedo();
return;
}
const withinRows = (r: number) => r >= 0 && r < rows.length;
const withinCols = (c: number) => c >= 0 && c < columns.length;
const arrowDelta: Record<string, [number, number]> = {
ArrowDown: [1, 0], ArrowUp: [-1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1],
};
if (e.key in arrowDelta) {
const [dr, dc] = arrowDelta[e.key];
e.preventDefault();
if (e.shiftKey) {
const base = focus ?? { row: rowIdx, col: colIdx };
const nr = base.row + dr, nc = base.col + dc;
if (withinRows(nr) && withinCols(nc)) setFocus({ row: nr, col: nc });
} else {
const nr = rowIdx + dr, nc = colIdx + dc;
if (withinRows(nr) && withinCols(nc)) moveFocusTo(nr, nc);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (withinRows(rowIdx + 1)) moveFocusTo(rowIdx + 1, colIdx);
return;
}
if (e.key === 'Escape') {
if (pendingEditSnapshotRef.current) {
// Cancel the in-progress edit, reverting without adding a history step.
const reverted = pendingEditSnapshotRef.current;
pendingEditSnapshotRef.current = null;
pendingEditCellRef.current = null;
emit(reverted);
} else {
setFocus(anchor);
}
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
const b = bounds;
if (b && (b.maxRow > b.minRow || b.maxCol > b.minCol)) {
e.preventDefault();
clearRange(b);
}
// single cell: text is pre-selected on focus, so native Delete/Backspace already clears it
}
};
const handleMouseDown = (rowIdx: number, colIdx: number, e: React.MouseEvent) => {
isDragging.current = true;
if (e.shiftKey && anchor) {
suppressNextFocusSync.current = true;
setFocus({ row: rowIdx, col: colIdx });
} else {
setAnchor({ row: rowIdx, col: colIdx });
setFocus({ row: rowIdx, col: colIdx });
}
};
const handleMouseEnter = (rowIdx: number, colIdx: number) => {
if (isFillDragging.current) {
setFillTarget({ row: rowIdx, col: colIdx });
return;
}
if (isDragging.current) {
suppressNextFocusSync.current = true;
setFocus({ row: rowIdx, col: colIdx });
}
};
// Starts a fill drag from the handle at the current selection's
// bottom-right corner. stopPropagation keeps this from also firing the
// cell's own onMouseDown (which would reset anchor/focus to this corner).
const handleFillMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!bounds) return;
isFillDragging.current = true;
fillSourceRef.current = bounds;
setFillTarget({ row: bounds.maxRow, col: bounds.maxCol });
};
const fillExtension = useMemo(() => {
if (!fillTarget || !fillSourceRef.current) return null;
return computeFillExtension(fillSourceRef.current, fillTarget);
}, [fillTarget]);
finishFillDragRef.current = () => {
if (!isFillDragging.current) return;
isFillDragging.current = false;
const source = fillSourceRef.current;
const target = fillTarget;
fillSourceRef.current = null;
setFillTarget(null);
if (!source || !target) return;
const ext = computeFillExtension(source, target);
if (!ext) return; // released back inside the source selection: no-op
commitChange(applyFillToRows(rows, columns, source, ext.bounds, ext.direction));
setAnchor({ row: ext.bounds.minRow, col: ext.bounds.minCol });
setFocus({ row: ext.bounds.maxRow, col: ext.bounds.maxCol });
};
const handleCellFocus = (rowIdx: number, colIdx: number, e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
if (suppressNextFocusSync.current) {
suppressNextFocusSync.current = false;
return;
}
setAnchor({ row: rowIdx, col: colIdx });
setFocus({ row: rowIdx, col: colIdx });
};
const isSelected = (r: number, c: number) => !!bounds && r >= bounds.minRow && r <= bounds.maxRow && c >= bounds.minCol && c <= bounds.maxCol;
// A single active cell (no drag/shift range) gets its focus indicator from
// the input's own border below — same 1px weight as the grid lines. The
// thicker blue rectangle here is only for an actual multi-cell selection.
const selectionCellClass = (r: number, c: number): string => {
if (!bounds || !isSelected(r, c)) return '';
if (bounds.minRow === bounds.maxRow && bounds.minCol === bounds.maxCol) return '';
const cls = ['bg-indigo-100/50', 'dark:bg-indigo-900/30'];
if (r === bounds.minRow) cls.push('border-t-2 border-t-indigo-500');
if (r === bounds.maxRow) cls.push('border-b-2 border-b-indigo-500');
if (c === bounds.minCol) cls.push('border-l-2 border-l-indigo-500');
if (c === bounds.maxCol) cls.push('border-r-2 border-r-indigo-500');
return cls.join(' ');
};
// Dashed outline over the region a fill drag would write into if released
// now — i.e. inside `fillExtension.bounds` but outside the source
// selection, which already has its own solid highlight from above.
const fillPreviewCellClass = (r: number, c: number): string => {
if (!fillExtension) return '';
const { bounds: ext } = fillExtension;
if (r < ext.minRow || r > ext.maxRow || c < ext.minCol || c > ext.maxCol) return '';
const source = fillSourceRef.current;
if (source && r >= source.minRow && r <= source.maxRow && c >= source.minCol && c <= source.maxCol) return '';
return 'border-2 border-dashed border-slate-500 dark:border-slate-300';
};
// Dragging a header's right edge: snapshot every column's current rendered
// width, then grow the dragged column by taking space from its siblings —
// proportional to how much each can spare above MIN_COL_WIDTH — instead of
// just growing the table's total width. Only once every sibling is already
// at MIN_COL_WIDTH does the dragged column's growth spill over into growing
// the total, and even then only for a grid where every column already
// declares its own pixel width (`!fluidMode`) — a fluid grid's dragged
// column simply stops growing once its siblings are out of slack, so the
// table can never overflow its container from a resize alone. Shrinking the
// dragged column is symmetric: freed width is handed back to siblings
// proportional to their current width, never to the total.
const startColumnResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const ths = tableRef.current?.querySelectorAll('thead th');
if (!ths) return;
const resizableCount = dataColOffset + columns.length;
const startWidths = Array.from(ths).map(th => th.getBoundingClientRect().width);
const startX = e.clientX;
const startWidth = startWidths[colIndex];
const otherIndices = startWidths.map((_, i) => i).filter(i => i !== colIndex && i < resizableCount);
const totalOthersWidth = otherIndices.reduce((sum, i) => sum + startWidths[i], 0);
const totalShrinkable = otherIndices.reduce((sum, i) => sum + Math.max(0, startWidths[i] - MIN_COL_WIDTH), 0);
const onMouseMove = (ev: MouseEvent) => {
const desired = Math.max(MIN_COL_WIDTH, startWidth + (ev.clientX - startX));
const rawGrow = desired - startWidth;
// Amount actually reclaimed from siblings — capped at what they can
// spare so none of them ever drops below MIN_COL_WIDTH.
const takeFromOthers = Math.max(0, Math.min(rawGrow, totalShrinkable));
// Fluid grids never grow the total; explicit-width grids may, but only
// for the leftover a resize's siblings couldn't supply.
const extraGrowth = fluidMode ? 0 : Math.max(0, rawGrow - totalShrinkable);
const appliedGrow = rawGrow >= 0 ? takeFromOthers + extraGrowth : rawGrow;
const next = [...startWidths];
next[colIndex] = startWidth + appliedGrow;
if (rawGrow > 0 && totalShrinkable > 0) {
otherIndices.forEach(i => {
const shrinkable = Math.max(0, startWidths[i] - MIN_COL_WIDTH);
next[i] = startWidths[i] - (shrinkable / totalShrinkable) * takeFromOthers;
});
} else if (rawGrow < 0 && totalOthersWidth > 0) {
otherIndices.forEach(i => {
next[i] = startWidths[i] + (startWidths[i] / totalOthersWidth) * (-rawGrow);
});
}
setColWidthsPx(next);
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const ResizeHandle = ({ colIndex }: { colIndex: number }) => (
<div
onMouseDown={(e) => startColumnResize(colIndex, e)}
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-indigo-400/50 active:bg-indigo-500/60"
/>
);
// Pinned mode's frozen-pane pieces (see the `pinned` prop comment and
// conventions.md §4):
// - One-sided borders (`border-b border-r`, the wrapper supplies top/left)
// so each gridline belongs to a cell and moves with it when sticky.
// - The rowHeader column pins at a `left` equal to the row-number column's
// MEASURED width (see rowNumberThRef above) — declared widths don't
// survive the density root-font-size scaling or fixed-layout
// redistribution, and a wrong offset shows as a transparent seam.
// - z tiers: corner header cells (sticky on both axes) > data header cells
// > pinned body cells — all above the fill handle's z-20 and a focused
// input's z-10, so scrolled content passes UNDER every pinned cell.
// The wrapper is `isolate` so these tiers stay INTERNAL — without it the
// sticky headers out-stack the app's absolutely-positioned popovers
// (MonthPicker/TreeMultiSelectDropdown panels are z-30) and the grid
// paints over an open dropdown.
// - Sticky cells get their own opaque backgrounds (bg-slate-50/
// dark:bg-slate-900, same pair as agent-score's pinned cells) — the
// default translucent dark:bg-slate-700/40 would let cells scrolling
// underneath show through.
const cellBorder = pinned ? 'border-b border-r' : 'border';
const rowNumberWidthPx = measuredRowNumberWidth
?? (colWidthsPx ? colWidthsPx[0] : parseCssLengthToPx(ROW_NUMBER_COL_WIDTH) ?? 48);
const cornerThClass = pinned ? 'sticky top-0 z-50' : 'relative';
const dataThClass = pinned ? 'sticky top-0 z-40' : 'relative';
const pinnedBodyCellClass = pinned
? 'sticky z-30 bg-slate-50 dark:bg-slate-900'
: 'bg-slate-50 dark:bg-slate-700/40';
// Sum of every resized column's snapshot, i.e. everything BUT the fixed
// clear-row column — the denominator `colWidth` below turns each entry
// into a percentage of. In fluid mode this stays constant across a resize
// (see startColumnResize), so expressing widths as a share of it, rather
// than as their own absolute px, is what keeps the table scaling with its
// container instead of freezing at whatever size it happened to be dragged
// to.
const totalColWidthsPx = colWidthsPx ? colWidthsPx.reduce((sum, w) => sum + w, 0) : null;
const totalBaselinePx = totalColWidthsPx !== null ? totalColWidthsPx + (onClearRow ? 32 : 0) : null;
const colWidth = (idx: number): string =>
fluidMode ? `${(colWidthsPx![idx] / totalBaselinePx!) * 100}%` : `${colWidthsPx![idx]}px`;
return (
<div className={`isolate ${bordered ? 'border border-slate-200 dark:border-slate-700 rounded-md shadow-sm' : ''} ${pinned ? `overflow-auto ${fillHeight ? 'h-full' : 'max-h-full'}` : 'overflow-x-auto'}`}>
<table
ref={tableRef}
className={`select-none ${pinned ? 'border-separate border-spacing-0' : 'border-collapse'}`}
style={{
tableLayout: 'fixed',
// A fluid grid (see the header comment) always stays at 100%, resized
// or not — its columns are rendered as percentages of each other
// (`colWidth`), so the table scales with its container rather than
// freezing at whatever size a drag happened to leave it at. Only a
// grid where every column already declares its own pixel `width`
// (`!fluidMode`) gets an explicit total-px width: table-layout:fixed +
// width:auto doesn't reliably grow past the container to fit wider
// <col>s in every browser, so that's what makes the wrapper's
// overflow-x-auto kick in for that mode, resized or not
// (explicitTotalWidthPx before any resize, the resized total after).
width: fluidMode
? '100%'
: colWidthsPx
? `${totalBaselinePx}px`
: explicitTotalWidthPx !== null ? `${explicitTotalWidthPx}px` : '100%',
}}
>
<colgroup>
<col style={{ width: colWidthsPx ? colWidth(0) : ROW_NUMBER_COL_WIDTH }} />
{hasRowHeader && <col style={{ width: colWidthsPx ? colWidth(1) : rowHeaderWidth }} />}
{columns.map((col, i) => (
// In the default layout a column's optional `width` pins it while
// the unsized columns share the remaining space; once the user
// drags a border, the snapshot widths take over for everything.
<col key={col.key} style={colWidthsPx ? { width: colWidth(i + dataColOffset) } : col.width ? { width: col.width } : undefined} />
))}
{onClearRow && <col style={{ width: colWidthsPx && fluidMode ? `${(32 / totalBaselinePx!) * 100}%` : '2rem' }} />}
</colgroup>
<thead className={pinned ? undefined : 'sticky top-0 z-10'}>
<tr>
{/* Header font-size is bumped via an inline style — the app's global
table-density CSS (see globals.css) unconditionally forces th
font-size regardless of Tailwind classes, so only an inline
style (or !important) can override it for this grid specifically. */}
<th ref={rowNumberThRef} className={`${cornerThClass} ${cellBorder} border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 px-2 text-center font-semibold text-slate-600 dark:text-slate-300`} style={{ fontSize: '0.9375rem', ...(pinned ? { left: 0 } : undefined) }}>
#
<ResizeHandle colIndex={0} />
</th>
{hasRowHeader && (
<th className={`${cornerThClass} ${cellBorder} border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 px-2 text-center font-semibold text-slate-600 dark:text-slate-300`} style={{ fontSize: '0.9375rem', ...(pinned ? { left: rowNumberWidthPx } : undefined) }}>
{rowHeaderLabel}
<ResizeHandle colIndex={1} />
</th>
)}
{columns.map((col, colIdx) => (
<th key={col.key} className={`${dataThClass} ${cellBorder} border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 px-2 pt-1.5 pb-1 text-center font-semibold text-slate-600 dark:text-slate-300 truncate`} style={{ fontSize: '0.9375rem' }}>
<div className="truncate leading-tight">{col.label}</div>
{col.summary !== undefined && (
<div
className="mt-1.5 pt-1.5 border-t border-slate-300 dark:border-slate-600 text-right font-semibold text-indigo-600 dark:text-indigo-400 truncate leading-tight"
style={{ fontSize: '0.875rem' }}
>
{col.summary}
</div>
)}
<ResizeHandle colIndex={colIdx + dataColOffset} />
</th>
))}
{onClearRow && <th className={`${pinned ? 'sticky top-0 z-40' : ''} ${cellBorder} border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700`} />}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr key={rowIdx} className="group">
<td className={`${pinnedBodyCellClass} ${cellBorder} border-slate-300 dark:border-slate-600 px-2 text-center text-slate-400 dark:text-slate-500`} style={pinned ? { left: 0 } : undefined}>
{rowIdx + 1}
</td>
{hasRowHeader && (
<td className={`${pinnedBodyCellClass} ${cellBorder} border-slate-300 dark:border-slate-600 px-2 text-slate-600 dark:text-slate-300 whitespace-nowrap truncate`} style={pinned ? { left: rowNumberWidthPx } : undefined}>
{rowHeader(rowIdx)}
</td>
)}
{columns.map((col, colIdx) => {
const error = cellError?.(rowIdx, col.key);
return (
<td
key={col.key}
onMouseDown={(e) => handleMouseDown(rowIdx, colIdx, e)}
onMouseEnter={() => handleMouseEnter(rowIdx, colIdx)}
className={`relative ${cellBorder} border-slate-300 dark:border-slate-600 p-0 ${selectionCellClass(rowIdx, colIdx)} ${fillPreviewCellClass(rowIdx, colIdx)}`}
>
<input
ref={setInputRef(rowIdx, colIdx)}
value={row[col.key]}
readOnly={col.readOnly}
title={error || undefined}
onChange={(e) => { if (!col.readOnly) updateCell(rowIdx, col.key, e.target.value); }}
onPaste={(e) => handleCellPaste(rowIdx, colIdx, e)}
onCopy={(e) => handleCopy(rowIdx, colIdx, e)}
onKeyDown={(e) => handleKeyDown(rowIdx, colIdx, e)}
onFocus={(e) => handleCellFocus(rowIdx, colIdx, e)}
onBlur={commitPendingEdit}
onDragStart={(e) => e.preventDefault()}
className={`absolute inset-0 block w-full h-full box-border px-1.5 text-right tabular-nums outline-none focus:z-10 ${
col.readOnly
? 'bg-slate-50 dark:bg-slate-700/30 text-slate-500 dark:text-slate-400 cursor-default border border-transparent focus:border-slate-400 dark:focus:border-slate-500'
: error
? 'bg-rose-50 dark:bg-rose-900/25 text-rose-700 dark:text-rose-300 border border-rose-400 dark:border-rose-500/70 focus:border-rose-500'
: 'bg-transparent dark:text-slate-100 border border-transparent focus:border-indigo-500'
}`}
/>
{bounds && rowIdx === bounds.maxRow && colIdx === bounds.maxCol && (
<div
onMouseDown={handleFillMouseDown}
title="Drag to fill"
className="absolute -right-[3px] -bottom-[3px] w-[7px] h-[7px] bg-indigo-600 border border-white dark:border-slate-800 cursor-crosshair z-20"
/>
)}
</td>
);
})}
{onClearRow && (
<td className={`${cellBorder} border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/40 text-center`}>
<button
type="button"
onClick={() => onClearRow(rowIdx)}
className="text-slate-300 hover:text-rose-600 opacity-0 group-hover:opacity-100 transition-opacity dark:text-slate-500"
title="Clear row"
>
<Trash2 size={12} className="mx-auto" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
const PasteableGrid = forwardRef(PasteableGridInner) as <K extends string>(
props: PasteableGridProps<K> & { ref?: React.Ref<PasteableGridHandle> }
) => ReturnType<typeof PasteableGridInner>;
export default PasteableGrid;
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | PasteableGridColumn<K>[] | — | |
rows* | Record<K, string>[] | — | |
onRowsChange* | (next: Record<K, string>[]) => void | — | |
rowHeader | (rowIndex: number) => React.ReactNode | — | Rendered as a non-editable leading column, after the built-in row number — e.g. a date label. Omit it entirely to skip that column. |
rowHeaderWidth | string | — | |
rowHeaderLabel | string | — | |
onClearRow | (rowIndex: number) => void | — | |
cellError | (rowIndex: number, colKey: K) => string | null | undefined | — | Per-cell validation: return an error message to tint that cell red (the message doubles as the input's title tooltip), or null/undefined for a clean cell. Purely presentational — the grid never blocks input on it. |
pinned | boolean | — | Frozen-pane mode (see conventions.md): pins the header row to the top and the row-number + rowHeader columns to the left while the grid scrolls. The wrapper becomes the scroll container for BOTH axes (`overflow-auto` + `max-h-full`), so the consumer must bound its height (e.g. a `flex-1 min-h-0` parent) — without a bound the header pin is inert (the column pin still works, since horizontal overflow is the grid's own). Uses `border-separate` + one-sided cell borders instead of the default `border-collapse`, because collapsed borders belong to the table and stay behind when a sticky cell is offset — the gridlines would scroll away from the pinned cells. |
fillHeight | boolean | — | Pinned-only. Default pinned sizing is `max-h-full` — the wrapper only ever shrinks to the row content's own height, capped by the parent, so a short grid (few rows) inside a tall bounded parent leaves visible blank space beneath it. Set this when the consumer wants the grid to always stretch to fill that parent instead (`h-full`) — e.g. several grids sharing a flex row/column where each should claim its full allotted share of the space regardless of row count. |
bordered | boolean | — | The wrapper's `border` + `shadow-sm` + `rounded-md` give the grid a "card" look by default. Set to `false` to omit it (e.g. several grids already sitting inside their own sectioned area, where an outer card border around each one is redundant chrome) — every existing caller keeps the bordered look unless it opts out. |