Grid Paste
Paste
Cmd+V paste with Excel anchor/tile/clip geometry, typed coercion, per-cell gating, and one bulk onPaste callback.
Cmd/Ctrl+V pastes clipboard TSV into the grid. Like editing, paste is controlled: the grid never mutates your rows. It parses the clipboard, works out which cells the block lands on, coerces and gates every one of them, and then fires one onPaste(payload) — you apply payload.cells to your own state in a single update.
Paste is opt-in. Without an onPaste prop the surface leaves paste events entirely alone.
Copy the 2x2 block below, then paste it into the grid. A single cell writes it once; select Symbol + Qty across the first 4 rows — an exact multiple of the block — to see it tile; anchor on the last row to see the overflow clipped, reported in the status line, and appended as a new row through the onPasteCapture + parseTsv recipe under Overflow clips and reports:
A 2x2 clipboard block writes once at a single cell, tiles across an exact-multiple selection, and clips with a reported, appended overflow past the last row.
The grid above wires a single onPaste callback — the shape every paste integration starts from:
<PretableSurface
ariaLabel="Positions"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
onPaste={({ cells }) => {
const byRow = new Map<string, Record<string, unknown>>();
for (const cell of cells) {
byRow.set(cell.rowId, {
...byRow.get(cell.rowId),
[cell.columnId]: cell.value,
});
}
setRows((prev) =>
prev.map((r) => (byRow.has(r.id) ? { ...r, ...byRow.get(r.id) } : r)),
);
}}
/>The trigger
A DOM paste listener on the surface root reads event.clipboardData.getData("text/plain"). It is not navigator.clipboard.readText(): the paste event carries the data with no permission prompt at all, and the listener lives on the grid root rather than the document, so two grids on one page never handle each other's paste.
The surface stays inert — no preventDefault, no callback, the browser's default paste applies — in four cases:
| Situation | Why |
|---|---|
no onPaste prop | paste is opt-in, exactly like onRowChange |
an input / textarea inside the grid is focused or targeted | that input owns its own paste |
the clipboard's text/plain is empty | nothing to parse |
| nothing is selected and nothing is focused | there is no anchor, so the block has nowhere to land |
Everything else is handled: the surface calls event.preventDefault() and fires onPaste exactly once. That includes a paste that produces zero applicable cells — a block that lands entirely past the last row still calls preventDefault and still fires onPaste, so the overflow is reportable rather than silent.
Paste never opens an editor and never fires onRowChange. One paste is one callback, whether it wrote 1 cell or 5,000.
Where the block lands
The anchor is the top-left of the target area:
- With a selection, the anchor is the top-left of the range that contains the focused cell; if the focus is outside every range, the first range wins.
- With no selection at all, the anchor is the focused cell and the selection counts as 1 × 1.
- A range bound on the synthetic row-select column (a full-row selection) expands to the full data-column span, mirroring how copy translates that bound.
Rows are addressed in the grid's current filtered and sorted order, not your source array's order. A row filtered out is not a target; the block walks the rows the user is actually looking at, in the order they see them. The synthetic row-select column is never a target.
Shape mismatch
The block's size and the selection's size decide the target area, per dimension, independently:
| Selection | Clipboard block | Result |
|---|---|---|
| a single cell (or just a focused cell) | any | anchored at that cell; the block writes down and to the right |
| 4 rows × 2 cols | 2 rows × 2 cols | tiles — the block repeats twice down, filling the selection |
| 2 rows × 4 cols | 2 rows × 2 cols | tiles across |
| 4 rows × 4 cols | 2 rows × 2 cols | tiles in both dimensions (4 copies) |
| 3 rows × 2 cols | 2 rows × 2 cols | written once from the top-left; the third row is left untouched |
| any | overflows the last row / column | the excess is clipped and counted in clipped |
The rule in one line: a dimension tiles only when the selection is larger than the block and an exact integer multiple of it; otherwise the block is written exactly once from the top-left. This is Excel's rule, and ag-grid's.
A ragged block — rows of differing width, which a hand-edited TSV easily produces — writes no cell where the source row has no field. A short row leaves those cells alone rather than clearing them.
The payload
onPaste receives one PastePayload<TRow> and may return a promise:
| Field | Type | Notes |
|---|---|---|
cells | PastedCell<TRow>[] | cells to apply, in row-major order |
rejected | RejectedPasteCell[] | cells the grid refused, with the reason |
source | { rows, columns } | shape of the parsed clipboard block (columns = the widest row) |
clipped | { rows, columns } | target-area rows/columns dropped past the grid's last row/column — not cells |
PastedCell<TRow>:
| Field | Type | Notes |
|---|---|---|
rowId | string | the getRowId of the target row |
columnId | string | column.id of the target cell |
value | unknown | the coerced value (see Coercion) |
raw | string | the clipboard text this cell came from, before coercion |
row | TRow | the row as it was when the paste was gated |
RejectedPasteCell carries rowId, columnId, raw, a reason of "not-editable" | "invalid", and an optional message.
The gate
Every target runs the same three steps a committed edit does, in this order, and all targets run in parallel:
editabledecides whether the cell can be written at all.- Coercion turns the clipboard string into a typed value.
validatejudges that typed value.
editable comes first on purpose. A cell nobody could ever write is rejected as "not-editable" and never coerced, so a read-only number column receiving "abc" reports the reason that is actually true rather than complaining about a value that was never going to land. Coercion comes before validate so validate sees the typed value, not the raw text.
Coercion
Coercion uses the same rules a committed edit gets — so typed columns hand you typed values, not clipboard strings:
parseEditValuewins. When the column supplies it, it runs (parseEditValue(raw, input)) and its result is the value. If it throws, the cell is rejected as"invalid"with the thrown message.- Otherwise the built-in per-type parse runs.
number→ a number (empty text →null; unparseable → rejected"invalid", message"Not a number").enumwithoptions→ the matched option'svalue(empty →null; matching nothing →"Pick an option").date→ a strictYYYY-MM-DDstring (empty →null; anything else →"Use YYYY-MM-DD"). - Everything else passes through as the clipboard string —
textcolumns,enumcolumns withoutoptions, andbooleancolumns. Abooleancolumn has no built-in text parse, so a pasted"true"arrives as the string"true". SupplyparseEditValueon boolean columns you intend people to paste into.
Rejections
Both hooks are awaited, so async permission checks and async validation work here exactly as they do for an inline edit:
editable—falseby default. A column that never opted into editing rejects every pasted cell withreason: "not-editable", and that cell is never coerced.validate— runs only on cells that passededitable, against the coerced value. Returning a string rejects the cell withreason: "invalid"and that string asmessage.
Because rejections are reported rather than swallowed, "N of M applied" is a one-liner:
<PretableSurface
onPaste={({ cells, rejected }) => {
applyCells(cells); // your own state update
const total = cells.length + rejected.length;
if (rejected.length > 0) {
const why = rejected
.filter((r) => r.message)
.map((r) => r.message)
.slice(0, 3);
setStatus(
`Pasted ${cells.length} of ${total} cells. ` +
`${rejected.length} rejected${why.length ? `: ${why.join("; ")}` : ""}.`,
);
} else {
setStatus(`Pasted ${cells.length} cells.`);
}
}}
/* ... */
/>The homepage hero does exactly this: its Qty column's validate enforces a 7% single-name guardrail, so pasting a too-large block comes back part-applied with the desk's reason attached.
Errors
A throw is contained to the cell that caused it. If editable, parseEditValue, or validate throws (or rejects) for one cell, that cell lands in rejected with reason: "invalid" and the error's message, and every other cell in the block is gated and applied as normal — one flaky async predicate does not cost you the paste.
A failure outside a single cell's gate — most commonly onPaste itself throwing — is caught, logged with console.warn("[pretable] paste failed", err), and announced as "Paste failed". It is not otherwise surfaced: the grid has no idea what your onPaste was trying to do, so showing the user a recovery path is yours.
Prefer validate's string return to a thrown error when refusing a cell: it is the documented channel, and it lets you supply a message the user can act on.
Staleness
The async gate is guarded by a monotonic token: if a second paste starts, or the grid unmounts, while the first one's editable / validate calls are still in flight, the older result is discarded instead of firing a stale onPaste. Row and column changes underneath a pending paste do not invalidate it — the payload is addressed by row id, so a streaming grid can replace its rows mid-gate and you still apply the result against your current state, exactly as you would an onRowChange.
That means the payload is a snapshot: PastedCell.row is the row as it was when the paste started, and editable / validate judged those pre-tick values. Apply cells against your current state and no-op on row ids that have since disappeared — the same contract onRowChange has.
Overflow clips and reports
The grid cannot invent row ids under a controlled data model, so a block that runs past the last row is clipped, and the dropped rows and columns are counted in payload.clipped. Excel grows the sheet; ag-grid clips silently. Pretable clips and tells you, which is enough to grow the data yourself.
clipped counts rows and columns, not cells — and it does not carry their text. It counts the target area, i.e. the block after tiling, so when a block tiled into a larger selection clipped.rows can be larger than the block itself has rows.
The two halves are not equally recoverable. clipped.rows is a to-do list: rows are yours to append, and the recipe below does exactly that. clipped.columns is a notice — a grid's columns are its schema, so there is nowhere for those values to go and nothing to append them to. Surface it to the user ("3 columns were wider than this grid") rather than dropping it on the floor; a paste that reports clipped columns usually means the clipboard came from a different sheet than the one they think they are in.
To append the overflow you need the clipboard text, which you can stash from your own capture-phase listener. The capture phase is load-bearing: the surface listens for paste in the bubble phase, so a plain onPaste on the same wrapper would run after the grid's handler and the ref would still be empty when your onPaste callback reads it. onPasteCapture runs before the grid sees the event.
import { PretableSurface, parseTsv } from "@pretable/react";
function Sheet() {
const [rows, setRows] = useState<Row[]>(initialRows);
const clipboardText = useRef("");
return (
<div
onPasteCapture={(event) => {
clipboardText.current = event.clipboardData.getData("text/plain");
}}
>
<PretableSurface<Row>
ariaLabel="Sheet"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
onPaste={({ cells, clipped }) => {
applyCells(cells);
if (clipped.rows === 0) return;
// Anchored (not tiled) case: the rows that fell off the end are the
// last `clipped.rows` rows of the parsed matrix. The clamp matters —
// a tiled block can report MORE clipped rows than the block has, and
// an unclamped negative start would take the whole matrix.
const matrix = parseTsv(clipboardText.current);
const overflow = matrix.slice(
Math.max(0, matrix.length - clipped.rows),
);
setRows((prev) => [
...prev,
...overflow.map((fields, i) => ({
id: `new-${Date.now()}-${i}`,
name: fields[0] ?? "",
qty: Number(fields[1] ?? 0),
})),
]);
}}
/>
</div>
);
}Two things to know about that slice. It is scoped to an anchored paste — the common case, where the block wrote once from the top-left and clipped.rows is therefore at most the block's row count. When the block tiled into a larger selection, source rows repeat, clipped.rows counts target rows (possibly more than the block has), and the last clipped.rows source rows are not what fell off the end; reconstruct the tiling yourself if you need that case. And appended rows land wherever your sort puts them, not necessarily at the bottom of the view.
This is exactly the recipe the demo at the top of this page runs: paste onto its last row and the appended row is this code, live.
Announcements
A paste is the one clipboard operation that can be partly applied, which makes it the one that most needs a voice: a sighted user sees which cells changed, and a screen-reader user sees nothing. So the surface announces every paste into the same off-screen aria-live="polite" region copy uses, debounced the same ~500ms.
| Outcome | Default announcement |
|---|---|
| Applied cleanly | 12 cells pasted / 1 cell pasted |
| Partly refused | 9 cells pasted, 3 rejected |
| Wholly refused | No cells pasted, 3 rejected |
| Clipped (any of the above) | …, clipped to fit |
onPaste threw or rejected | Paste failed |
The wholly-refused row is the reason this exists at all: without it, a paste where editable or validate said no to everything is indistinguishable from the keystroke never having been noticed.
Two overrides cover all of it:
<PretableSurface
messages={{
pasteAnnouncement: ({ cellCount, rejectedCount, clipped }) => {
if (cellCount === 0) return `Nothing pasted — ${rejectedCount} refused`;
const tail = clipped.rows > 0 ? ` (${clipped.rows} rows didn't fit)` : "";
return `${cellCount} of ${cellCount + rejectedCount} applied${tail}`;
},
pasteFailedAnnouncement: () => "Couldn't paste — try again",
}}
onPaste={applyCells}
/>pasteAnnouncement is one function rather than one per outcome because the first three rows above are the same sentence at different counts, and clipping is orthogonal — it can co-occur with any of them. Splitting them would hand a localizer the cross-product and make them repeat pluralization in every branch. pasteFailedAnnouncement is separate for the reason copyFailedAnnouncement is: nothing was applied, so there are no counts to report.
Per-cell rejected[].message text is deliberately not passed to pasteAnnouncement. A live region is read start to finish and cannot be re-read or skimmed, so a list of validation messages is the wrong payload for it. Render those from onPaste into something the user can navigate, as the rejections example does.
The TSV format
The parser is the exact inverse of the escaping copy applies:
- Quote iff needed. A field is quoted only when it contains a TAB, CR, LF, or
"; embedded quotes are doubled. So"say ""hi"""parses back tosay "hi", and a quoted field may contain tabs and newlines without splitting a cell. Unlike ag-grid — which never quotes on copy and leaves quote characters in the value on paste — a wrapped, multi-line cell survives a Pretable copy → paste round trip intact. - A
"that is not the first character of a field is literal.a"bparses asa"b, because a correctly escaped field never emits one there. \r\n,\nand\rall end a row, so Excel-on-Windows, Excel-on-Mac, and Sheets all parse.- Exactly one trailing blank line is trimmed (Excel-on-Windows appends one). A second trailing blank line survives as an empty row.
- Ragged rows are preserved — rows keep whatever field count they had.
- Empty text parses to
[], i.e. no content, and the paste is inert.
The clipboard is treated as one matrix. A multi-range copy — whose blocks are separated by a blank line, and whose headers are their own row under copyWithHeaders — flattens: the blank separator parses as a one-field empty row and the header row parses as data. Pasting that back writes an empty string into the anchor column at the separator's position. If your app copies multi-range or with headers and needs the paste to round-trip, pre-process the text yourself.
parseTsv(text: string): string[][] is exported from @pretable/react (alongside serializeRanges and defaultCoerceForCopy), so pre-processing means reusing the same parser rather than writing a second one:
import { parseTsv } from "@pretable/react";
const matrix = parseTsv(text).filter((row) => row.some((f) => f !== ""));Out of scope
Row creation on overflow (reported, not performed), multi-range reconstruction, cut (Cmd/Ctrl+X), reading the HTML clipboard flavor (copy writes it; paste only ever reads text/plain), and undo. mapPasteToTargets — the pure geometry function behind the anchor/tile/clip rules — is internal; open an issue if you want it public.