Undo and Cancel Affordances for Inline Cell Edits

Permalink to "Undo and Cancel Affordances for Inline Cell Edits"

Inline editing puts a form control inside a data grid, and the two safety mechanisms it needs are frequently conflated. Escape cancels an edit that is still in progress; undo reverses one that has already committed. They apply at different moments, they need different announcements, and implementing only one leaves a specific group of users stranded — those who cannot see that the value changed and therefore commit before noticing a mistake.

This page covers both, and the announcement copy that makes each one legible by ear. It is the recovery detail beneath inline editing and form controls, and it works alongside inline form validation inside editable table cells.

Spec reference

Permalink to "Spec reference"

SC 3.3.4 Error Prevention (Level AA) requires, for pages that modify user-controllable data, that submissions are reversible, checked, or confirmed. An inline cell edit is exactly such a modification, and “reversible” is the cheapest of the three to provide.

SC 4.1.3 Status Messages (Level AA) covers both announcements — the cancel and the save-with-undo — because neither moves focus.

SC 2.1.1 Keyboard applies to the undo affordance itself. An Undo control rendered only inside a toast that disappears on a timer, with no keyboard route to it, is not operable.

There is no ARIA attribute for undo. The mechanism is an ordinary focusable control plus a status message that mentions it.

Cancel and undo are different affordances Comparison of cancelling an in-progress edit against undoing a committed one, showing why one cannot substitute for the other. Cancel and undo are different affordances✓ Cancel (Escape)Applies while the editor is openRestores the pre-edit value locallyNo server round tripAnnounce "Edit cancelled"✗ Undo (Ctrl+Z or a control)Applies after a commitReverses a persisted changeUsually a second requestAnnounce what was restored and from what
A user who has already pressed Enter cannot cancel — they need undo.

When to use — and when not to

Permalink to "When to use — and when not to"

Provide Escape-to-cancel on every inline editor without exception. It costs a few lines and it is the behaviour every user already expects from a spreadsheet.

Provide undo wherever a commit persists immediately. If edits are staged and saved in a batch, the batch save is the reversible unit and per-cell undo is unnecessary — but then the staged state must be visible and announced, or the user cannot tell what is pending.

Do not implement undo as an infinite history in a data grid unless the product genuinely calls for it. A single-step undo covering the most recent commit handles the overwhelming majority of real mistakes and is far easier to describe: “Undo” is unambiguous where “Undo (3 of 7)” is not.

Annotated code example

Permalink to "Annotated code example"
// SC 3.3.4: cancel restores the value; undo reverses a commit
let pristine = null;      // value at the moment editing started
let lastCommit = null;    // { rowId, field, from, to } for undo

function beginEdit(cell) {
  pristine = cell.dataset.value;
  renderEditor(cell, pristine);
  editorInput(cell).focus();
}

function cancelEdit(cell) {
  renderValue(cell, pristine);          // restore, do not keep the typed text
  cell.focus();                          // back to grid mode on the cell itself
  announce('Edit cancelled');            // silence here reads as a save
  pristine = null;
}

async function commitEdit(cell, value) {
  const from = pristine;
  const ok = await save(cell.dataset.rowId, cell.dataset.field, value);
  if (!ok) return showFieldError(cell);

  renderValue(cell, value);
  cell.focus();
  lastCommit = { rowId: cell.dataset.rowId, field: cell.dataset.field, from, to: value };
  showUndoControl();                     // a real button, in the DOM, next tab stop

  requestAnimationFrame(() => announce(
    `${labelOf(cell)} saved as ${value}. Undo available.`));
}

async function undoLastCommit() {
  if (!lastCommit) return;
  const { rowId, field, from, to } = lastCommit;
  await save(rowId, field, from);
  renderValue(cellFor(rowId, field), from);
  lastCommit = null;
  hideUndoControl();
  announce(`${labelOf(cellFor(rowId, field))} restored to ${from}, was ${to}`);
}
<!-- SC 2.1.1: the undo control is a real button, immediately after the message -->
<p id="grid-status" role="status" class="visually-hidden"></p>
<button type="button" id="undo-edit" hidden>Undo last edit</button>

Placing the undo button directly after the status region in the DOM matters more than it looks. A keyboard user who hears “Undo available” presses Tab, and the control they were told about is the very next stop. A control rendered in a corner of the page is technically reachable and practically undiscoverable.

The cancel and undo contract Keyboard contract for inline editing covering Escape to cancel, Ctrl plus Z to undo a committed edit, and the announcements each produces. The cancel and undo contractEscapeCancel the edit in progressRestore the original value and announce "Editcancelled"Ctrl + ZUndo the last committed editAnnounce the field, the restored value and what itreplacedEnterCommit the editAnnounce the saved value, or the validation errorTabCommit and move to the next editablecellAnnounce the new cell, not the commit
Escape cancels an edit in progress; undo reverses one that already committed.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Assistive technology On Escape On save-with-undo Deviation
NVDA + Firefox Reads the cancel message, then the restored cell Reads the save message including “Undo available” None significant
JAWS + Chrome Reads the cancel message Reads the save message May truncate a long message at a sentence boundary
VoiceOver + Safari Reads the cancel message after a short delay Reads the save message Drops the message if written in the same frame as the re-render
TalkBack + Chrome Reads both messages Reads both messages The undo control must be reachable by swipe, not only by a gesture
The undo window Timeline showing a committed edit, the undo affordance appearing in a status region, and the window closing after a set period. The undo windowEnter pressedvalue committedAnnounced"Saved. Undo available."Undo controlfocusable, in the DOMWindow closesannounced or silentlyexpiredtime after a commit →
An undo affordance that is only visual is unusable by keyboard.

Integration context

Permalink to "Integration context"

Cancel and validation interact: if a field is invalid and the user presses Escape, the cancel must also clear aria-invalid and the error description, or the cell keeps announcing an error for a value that is no longer there. Inline form validation inside editable table cells covers the error wiring.

Undo and bulk operations interact too. If a batch action modified many rows, a single undo covering the batch is far more useful than per-row undo — and its announcement should name the scope: “412 rows restored.”

Gotchas

Permalink to "Gotchas"

Escape closes but keeps the text. The most common implementation bug, and the most dangerous, because the cell now shows an uncommitted value that looks committed.

The undo control disappears on a timer. Ten seconds is a reasonable minimum, and the window should not close while the control has focus.

Undo without a message. A silent reversal leaves the user unsure whether it worked. Always announce what was restored.

Focus lost on cancel. Removing the editor without moving focus to the cell drops focus to the body. Focus the cell, then announce.

FAQ

Permalink to "FAQ"
Should Escape restore the value or just close the editor?

Restore it. A cancel that closes the editor while keeping the typed text is indistinguishable from a commit for anyone who cannot see the cell, and it is a genuine data-integrity risk if the row is saved later. Restore the original value, close the editor, return focus to the cell in grid mode, and announce that the edit was cancelled.

Does an undo affordance need to be keyboard reachable?

Yes. A toast with an Undo link that disappears before a keyboard user can reach it is not an affordance. Render the control in the DOM immediately after the status message so it is the next tab stop, keep it available for at least ten seconds, and announce that it exists in the same message that confirms the save.

How should an undo be announced?

By naming what was restored and what it replaced: “Amount for Nordics AB restored to 412.00, was 512.00.” A bare “Undone” leaves the user unsure which of several recent edits was reversed, which is exactly the uncertainty undo exists to remove.

Permalink to "Related"

Back to Inline Editing & Form Controls