Inline editing — click a name in a table, it becomes a text field, change it, press Enter — saves navigation to a separate form, but it breaks keyboard users in small ways: Escape does nothing (or closes the whole dialog around the table), blur saves a half-typed value the user wanted to abandon, and after committing, focus falls to <body> instead of returning to the cell.
A well-behaved inline edit has a clear contract: Enter commits, Escape cancels and restores the original value, focus returns to where editing began, and a validation failure keeps the user in edit mode with an explained error. This page, part of keyboard navigation patterns, implements that contract and handles the interactions with surrounding widgets.
Context and prerequisites
An inline edit is a two-state widget:
- Display mode — the value shown as text, with an “Edit” button (or the text itself as a button) that enters edit mode. A
buttonis focusable and announced as actionable. - Edit mode — an input pre-filled with the current value, focused, with Save and Cancel available by keyboard (Enter and Escape) and optionally as visible buttons.
Decisions to make:
- What does blur do? Commit (spreadsheet-like), cancel, or keep editing. Commit-on-blur is common, but must not commit an invalid value, and must not commit when blur happened because the user pressed Escape.
- What does Escape do when a parent also listens? Inside a
<dialog>, Escape closes the dialog; the inline edit must consume Escape first while editing. - Where does focus go after? Back to the edit button for the same item, so the user can continue from where they were.
The core pattern: an inline edit controller
export function inlineEdit(opts: {
display: HTMLElement; // contains the text and the edit button
button: HTMLButtonElement; // "Edit name"
getValue: () => string;
validate: (v: string) => string | null;
save: (v: string) => Promise<void>;
label: string; // "Name"
announce: (msg: string) => void;
}) {
let input: HTMLInputElement | null = null;
let error: HTMLElement | null = null;
let original = "";
let finishing = false; // guards blur firing during Escape/Enter handling
const exit = () => {
input?.parentElement?.remove();
input = null;
opts.display.hidden = false;
opts.button.focus(); // focus returns where editing began
};
const commit = async () => {
if (!input || finishing) return;
const value = input.value;
if (value === original) { finishing = true; exit(); finishing = false; return; }
const message = opts.validate(value);
if (message) {
error!.textContent = message;
error!.hidden = false;
input.setAttribute("aria-invalid", "true");
input.focus(); // stay in edit mode
return;
}
finishing = true;
try {
await opts.save(value);
exit();
opts.announce(`${opts.label} saved.`);
} catch {
error!.textContent = `${opts.label} could not be saved. Try again, or press Escape to cancel.`;
error!.hidden = false;
input?.focus();
} finally {
finishing = false;
}
};
const cancel = () => {
finishing = true;
exit(); // original value is untouched in the data
finishing = false;
opts.announce(`Editing cancelled. ${opts.label} unchanged.`);
};
opts.button.addEventListener("click", () => {
original = opts.getValue();
const wrapper = document.createElement("div");
wrapper.className = "inline-edit";
const id = `ie-${crypto.randomUUID()}`;
wrapper.innerHTML = `<label for="${id}" class="visually-hidden">${opts.label}</label>
<input id="${id}" aria-describedby="${id}-err"><p id="${id}-err" class="error" hidden></p>
<p class="visually-hidden">Press Enter to save, Escape to cancel.</p>`;
opts.display.after(wrapper);
opts.display.hidden = true;
input = wrapper.querySelector("input")!;
error = wrapper.querySelector(".error");
input.value = original;
input.focus();
input.select();
input.addEventListener("keydown", (e) => {
if (e.isComposing) return; // IME: Enter/Escape belong to the composition
if (e.key === "Enter") { e.preventDefault(); void commit(); }
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation(); // do NOT let an enclosing dialog close
cancel();
}
});
input.addEventListener("blur", () => { if (!finishing) void commit(); });
});
}
Step-by-step walkthrough
- Enter edit mode from a real button. “Edit name” (with the item’s name in its accessible name) is focusable, announced as a button and works with Enter and Space.
- Remember the original value on entry. Cancel restores it by simply not saving; nothing in the data changed while editing.
- Focus and select the input. Users can type to replace or arrow to adjust; the visually hidden instruction tells screen-reader users that Enter saves and Escape cancels.
- Handle Escape first and stop it.
stopPropagation()prevents an enclosing dialog’scancelfrom firing, as discussed in focus trapping in modal forms. - Commit on Enter and blur, but validate first. An invalid value keeps the user in edit mode with
aria-invalidand a message; a failed save keeps their text and explains how to retry or cancel. - Return focus and announce. After saving or cancelling, focus goes back to the edit button, and a short polite message confirms the outcome.
Why blur must not fight Escape
Pressing Escape triggers exit(), which removes the input — and removing a focused element fires blur. A naive blur handler then commits the value the user just tried to abandon. The same happens with Enter: saving removes the input, blur fires, and a second commit starts. The finishing flag makes the order explicit: while a commit or cancel is in progress, blur does nothing. It is a small guard that prevents the most confusing inline-edit bug — Escape that saves.
Failure modes and edge cases
1. Escape closes the surrounding dialog
Without stopPropagation, Escape in an inline edit inside a dialog closes both. Stop the event in the input’s handler while editing.
2. Focus lost after save
Re-rendering a table row after save can replace the edit button, and focusing the old reference fails. Focus by a stable id after the update, or restore focus to the row, per restoring focus after an async submission.
3. Commit-on-blur with invalid values
Blurring an invalid value should not silently discard it or save it. Keep edit mode and the error visible; the user can fix it or press Escape.
4. Multiple inline edits at once
Opening a second inline edit while one is active should first commit or cancel the first. Allowing two simultaneous edit modes confuses focus return and blur handling.
5. Optimistic display
Showing the new value immediately while saving is fine, but a failure must revert the display and explain, as in rolling back optimistic updates on failure.
Verification checklist
Frequently Asked Questions
Should blur commit or cancel?
Commit is the common expectation (spreadsheets, most web apps), provided the value is valid. For destructive or expensive changes, cancelling on blur and requiring Enter or a Save button is safer. Choose one behaviour for the whole product.
Do I need visible Save and Cancel buttons?
They help users who do not know the keyboard shortcuts and touch users who have no Escape key. At minimum, provide them on touch devices; on desktop, the visually hidden instruction plus Enter/Escape is often enough.
How do screen-reader users know the field is editable?
The edit trigger’s accessible name (“Edit name, Ada Lovelace”) says so. Avoid making the text itself editable on click without a button; contenteditable regions are harder to discover and label correctly.