Keyboard Copy and Paste in Data Grids

Permalink to "Keyboard Copy and Paste in Data Grids"

Copy and paste are the keyboard user’s bulk-editing tools. In a spreadsheet-style grid, Ctrl+C on a selected range should put the values on the clipboard in a form that pastes cleanly into another grid or a spreadsheet, and Ctrl+V should fill a range from the clipboard. The accessibility failures are all about feedback: a copy that happens silently, a paste that changes forty cells without a word, or a paste where six values were quietly rejected.

This page covers the clipboard mechanics and the messages around them. It belongs to cell selection & clipboard in data grids and depends on the range model from announcing rectangular cell range selections.

Spec reference

Permalink to "Spec reference"

The HTML clipboard events — copy, cut, paste — fire on the focused element and bubble. In a copy handler, event.clipboardData.setData('text/plain', …) with event.preventDefault() replaces what the browser would have copied. In a paste handler, event.clipboardData.getData('text/plain') reads the clipboard synchronously, without a permission prompt, because the user initiated it.

Tab-separated values (TSV), with rows separated by newlines, is the interchange format every spreadsheet understands on paste. Cells that contain tabs or newlines need quoting.

WCAG criteria: SC 2.1.1 Keyboard (copy and paste must work from the keyboard, not only a context menu), SC 4.1.3 Status Messages (confirmations and results), SC 3.3.1 Error Identification (rejected paste values must be identified), and SC 3.3.4 Error Prevention for data that matters — a paste should be reversible.

A paste, from clipboard to report Flow of a paste into a grid: read TSV from the clipboard, map it onto the target range, validate each cell, apply valid cells as one change, and report the result. A paste, from clipboard to reportReadtext/plain from thepaste eventMaprows and columnsfrom the focusedcellValidateeach value againstits columnApplyvalid cells, oneundo stepReport"18 pasted, 2rejected"
Validation happens per cell, but the result is reported once — with the count of rejects and where they are.

When to support clipboard — and when not to

Permalink to "When to support clipboard — and when not to"

Support it in any grid where users would otherwise re-type data: budgeting, inventory, scheduling, bulk configuration. For users who find precise pointer work difficult, pasting a column from a spreadsheet is often the only practical way to enter data at volume.

A read-only reporting grid needs copy but not paste. Copying a range should still work — users move data into their own tools — and it is the more commonly broken half, because custom grids intercept Ctrl+C for navigation reasons and never implement it.

The misapplication to name is relying on the browser’s native text selection for copy. In a grid that uses roving focus, Ctrl+C copies nothing (no text selection exists) or copies the whole page. The grid has to own the copy event.

Annotated code example

Permalink to "Annotated code example"
// COPY: the selected rectangle as TSV, raw values not display text
grid.addEventListener('copy', (e) => {
  const { r1, r2, c1, c2 } = rangeBounds();
  const rows = [];
  for (let r = r1; r <= r2; r++) {
    const cells = [];
    for (let c = c1; c <= c2; c++) cells.push(tsvQuote(rawValue(r, c)));
    rows.push(cells.join('\t'));
  }
  e.clipboardData.setData('text/plain', rows.join('\n'));
  e.preventDefault();
  const n = (r2 - r1 + 1) * (c2 - c1 + 1);
  status(`Copied ${n} cell${n > 1 ? 's' : ''}.`);            // SC 4.1.3
});

// PASTE: validate each cell, apply valid ones as a single undoable change
grid.addEventListener('paste', (e) => {
  e.preventDefault();
  const text = e.clipboardData.getData('text/plain');
  const matrix = parseTsv(text);                              // [[v, v], [v, v]]
  const origin = activeCell();                                // paste starts here
  const changes = [], rejects = [];
  matrix.forEach((row, i) => row.forEach((value, j) => {
    const r = origin.row + i, c = origin.col + j;
    if (!inBounds(r, c) || isReadonly(r, c)) { rejects.push({ r, c, why: 'read-only' }); return; }
    const err = validate(c, value);
    if (err) rejects.push({ r, c, why: err });
    else changes.push({ r, c, from: rawValue(r, c), to: value });
  }));

  applyChanges(changes);                 // SC 3.3.4: one entry on the undo stack
  rejects.forEach(({ r, c, why }) => markInvalid(r, c, why));  // SC 3.3.1

  if (!rejects.length) {
    status(`Pasted ${changes.length} cells. Press Control Z to undo.`);
  } else {
    // Focus the first reject so the user can fix it; say where the rest are
    focusCell(rejects[0].r, rejects[0].c);
    status(`Pasted ${changes.length} cells. ${rejects.length} rejected, starting at ${cellName(rejects[0])}: ${rejects[0].why}.`, 'assertive');
  }
});

Copy the raw value. If a cell displays “€1.2k”, the clipboard should carry 1200, so it lands as a number in a spreadsheet. This is also where the visually hidden text from formatting numbers and units in table cells would otherwise leak into the copied text.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT-specific deviations
Ctrl+C / Cmd+C on a range “Copied 12 cells.” NVDA may also say “copied to clipboard” from its own hook
Ctrl+C on one cell “Copied 1 cell.” Some teams stay silent for one cell; confirm anyway
Ctrl+V, all valid “Pasted 20 cells. Press Control Z to undo.” Polite
Ctrl+V, some rejected Focus to first reject; “…2 rejected, starting at South Q2: must be a number.” Assertive, so it is not lost behind the cell read
Ctrl+Z “Paste undone, 20 cells restored.” —
Where pasted values went, in one example paste Bar chart of a 24-cell paste showing how many cells were applied, rejected for validation and rejected as read-only. Where pasted values went, in one example pasteApplied20 cellsRejected: not a number2 cells — focus moves to the firstRejected: read-only column2 cells — named in the message
The report must carry all three numbers — the rejects are what the user has to act on.

Integration context

Permalink to "Integration context"

Every paste should be a single undo step, so one Ctrl+Z reverses all of it; see undo and cancel affordances for inline cell edits. Rejected cells use the same invalid marking — aria-invalid plus a description — as a failed inline edit in inline form validation inside editable table cells.

If the grid is inside a page with its own keyboard shortcuts, make sure none of them claim Ctrl+C or Ctrl+V; the collision rules are in keyboard shortcuts and command palettes.

What lands on the clipboard Comparison of copying a cell's display text against copying its raw value, using a formatted currency cell as the example. What lands on the clipboard✗ Copy the DOM text"€1.2k1,200" — visible and hidden text joinedCurrency symbols break numeric pastingArrows and "down" words from delta cellsDiffers by locale and theme✓ Copy the raw value"1200" — a number any spreadsheet acceptsSame value the sort usesNo hidden screen reader textTab-separated, newline between rows
Copy from the data model — the DOM contains presentation and hidden speech that do not belong in a spreadsheet.

Gotchas

Permalink to "Gotchas"

Copy events in browse mode. A screen reader in browse mode handles Ctrl+C itself if there is a virtual selection, and your handler may never fire. Grids with role="grid" put readers into focus mode, where your handler works.

Pasting a larger block than fits. Decide whether overflow is clipped or rejected, and say which in the result message. Silent clipping is data loss.

Multi-line cell values. A notes column containing newlines breaks naïve TSV parsing. Quote on copy, and use a parser that honours quotes on paste.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Should cut be supported as well as copy?

Only in editable grids, and with the same care as delete: cut clears data, so it should be undoable in one step and confirmed with a message that says how many cells were cut. In read-only grids, handle cut as copy or ignore it.

How do I make Ctrl+C copy cells from a custom data grid?

Handle the copy event on the grid, build tab-separated text from the selected range’s raw values, set it with clipboardData.setData(‘text/plain’), call preventDefault, and confirm with a status message such as “Copied 12 cells”.

How should a paste with invalid values be reported?

Apply the valid cells, mark each rejected cell with aria-invalid and a description, move focus to the first rejected cell, and announce the counts and the first problem assertively. Make the whole paste undoable in one step.

Should copied values be the formatted text or the raw data?

The raw data. Formatted display text — currency symbols, abbreviations, hidden screen reader text — pastes badly into spreadsheets. Keep formatting for display and copy the underlying values.

Permalink to "Related"

← Back to Cell Selection & Clipboard in Data Grids