Form — Inline edit / edit-in-place
A profile details panel where Name, Email, Role, and Bio each display a value beside a pencil button. Clicking Edit swaps the row into an inline input with Save and Cancel; Enter saves, Esc cancels, Save runs real validation and flashes a Saved check, Cancel restores the original. Only one row is editable at a time, focus moves into the field on edit, and an aria-live region announces every outcome. Built with vanilla JavaScript, accessible states, and a responsive layout.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ---------- Page + card ---------- */
.page {
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(20px, 5vw, 56px) 16px;
}
.card {
width: 100%;
max-width: 620px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
.card__head {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 24px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, var(--brand-50), var(--white));
}
.card__avatar {
flex: none;
width: 48px;
height: 48px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.95rem;
letter-spacing: 0.02em;
color: var(--white);
background: linear-gradient(135deg, var(--brand), var(--brand-700));
box-shadow: var(--sh-1);
}
.card__heading {
flex: 1 1 auto;
min-width: 0;
}
.card__title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.card__sub {
margin: 2px 0 0;
font-size: 0.85rem;
color: var(--muted);
}
.card__badge {
flex: none;
font-size: 0.72rem;
font-weight: 600;
color: var(--warn);
background: #fdf3e6;
border: 1px solid #f4dcba;
padding: 4px 9px;
border-radius: 999px;
white-space: nowrap;
}
/* ---------- Rows ---------- */
.rows {
margin: 0;
padding: 6px 0;
}
.row {
display: grid;
grid-template-columns: 200px 1fr;
align-items: start;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--line);
transition: background 0.18s ease;
}
.row:last-child {
border-bottom: 0;
}
.row.is-editing {
background: #fafbff;
}
.row__label {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding-top: 9px;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-2);
}
.row__icon {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--r-sm);
color: var(--brand-d);
background: var(--brand-50);
}
.row__body {
margin: 0;
min-width: 0;
}
/* ---- View mode ---- */
.row__view {
display: flex;
align-items: center;
gap: 10px;
min-height: 38px;
}
.row__value {
flex: 1 1 auto;
min-width: 0;
font-size: 0.95rem;
font-weight: 500;
color: var(--ink);
word-break: break-word;
}
.row__value--multiline {
font-weight: 400;
color: var(--ink-2);
}
.row__value.is-empty {
color: var(--muted);
font-style: italic;
font-weight: 400;
}
.iconbtn {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 6px 11px;
cursor: pointer;
opacity: 0;
transform: translateX(4px);
transition: opacity 0.16s ease, transform 0.16s ease, border-color 0.16s ease,
color 0.16s ease, background 0.16s ease;
}
.row__view:hover .iconbtn,
.row__view:focus-within .iconbtn {
opacity: 1;
transform: none;
}
.iconbtn:hover {
color: var(--brand-d);
border-color: var(--brand);
background: var(--brand-50);
}
.iconbtn:focus-visible {
opacity: 1;
transform: none;
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.28);
}
.row__saved {
flex: none;
display: none;
align-items: center;
gap: 4px;
font-size: 0.78rem;
font-weight: 600;
color: var(--ok);
}
.row__saved.is-on {
display: inline-flex;
animation: pop 0.3s ease;
}
@keyframes pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
transform: scale(1.08);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* ---- Edit mode ---- */
.row__edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
width: 100%;
font: inherit;
font-size: 0.95rem;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
padding: 9px 12px;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.field::placeholder {
color: var(--muted);
}
.field:hover {
border-color: var(--brand);
}
.field:focus-visible,
.field:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.22);
}
.field--area {
resize: vertical;
min-height: 72px;
line-height: 1.55;
}
select.field {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c7393' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 11px center;
padding-right: 36px;
cursor: pointer;
}
.field:disabled {
background: #f1f2f7;
color: var(--muted);
cursor: not-allowed;
border-color: var(--line);
}
/* error state */
.row.is-invalid .field {
border-color: var(--danger);
}
.row.is-invalid .field:focus-visible,
.row.is-invalid .field:focus {
box-shadow: 0 0 0 3px rgba(212, 80, 62, 0.22);
}
/* success state (during active edit, valid input) */
.row.is-valid .field {
border-color: var(--ok);
}
.help {
margin: 0;
font-size: 0.78rem;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.help.is-error {
color: var(--danger);
font-weight: 600;
}
.help.is-error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
background: var(--danger);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E")
center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E")
center / contain no-repeat;
}
/* ---- Actions ---- */
.row__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 2px;
}
.btn {
font: inherit;
font-size: 0.85rem;
font-weight: 600;
border-radius: var(--r-sm);
padding: 8px 16px;
cursor: pointer;
border: 1.5px solid transparent;
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease,
box-shadow 0.16s ease;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(91, 91, 240, 0.3);
}
.btn--primary {
color: var(--white);
background: var(--brand);
box-shadow: var(--sh-1);
}
.btn--primary:hover {
background: var(--brand-d);
}
.btn--primary:active {
background: var(--brand-700);
}
.btn--ghost {
color: var(--ink-2);
background: var(--white);
border-color: var(--line-2);
}
.btn--ghost:hover {
background: #f1f2f7;
border-color: var(--line-2);
}
.row__hint {
margin-left: auto;
font-size: 0.74rem;
color: var(--muted);
}
kbd {
font: inherit;
font-size: 0.7rem;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 5px;
padding: 1px 5px;
}
/* ---------- Toast ---------- */
.toaster {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 50;
pointer-events: none;
width: max-content;
max-width: calc(100vw - 32px);
}
.toast {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.88rem;
font-weight: 500;
color: var(--ink);
background: var(--white);
border: 1px solid var(--line);
border-left: 4px solid var(--ok);
border-radius: var(--r-md);
padding: 11px 16px;
box-shadow: var(--sh-2);
opacity: 0;
transform: translateY(12px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.is-in {
opacity: 1;
transform: none;
}
.toast--error {
border-left-color: var(--danger);
}
.toast__icon {
flex: none;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--white);
background: var(--ok);
}
.toast--error .toast__icon {
background: var(--danger);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.card__head {
flex-wrap: wrap;
padding: 18px 16px;
}
.card__badge {
order: 3;
flex-basis: 100%;
}
.row {
grid-template-columns: 1fr;
gap: 8px;
padding: 16px;
}
.row__label {
padding-top: 0;
}
.iconbtn {
opacity: 1;
transform: none;
}
.row__hint {
display: none;
}
.row__actions .btn {
flex: 1 1 auto;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
var live = document.querySelector("[data-live]");
var dirtyBadge = document.querySelector("[data-dirty-badge]");
var rows = Array.prototype.slice.call(document.querySelectorAll("[data-row]"));
/** The row currently in edit mode (only one allowed). */
var activeRow = null;
/* ---------------- Toast helper ---------------- */
var toaster = document.querySelector("[data-toaster]");
function toast(msg, type) {
if (!toaster) return;
var el = document.createElement("div");
el.className = "toast" + (type === "error" ? " toast--error" : "");
el.setAttribute("role", type === "error" ? "alert" : "status");
var icon = document.createElement("span");
icon.className = "toast__icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML =
type === "error"
? '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
: '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
var text = document.createElement("span");
text.textContent = msg;
el.appendChild(icon);
el.appendChild(text);
toaster.appendChild(el);
requestAnimationFrame(function () {
el.classList.add("is-in");
});
setTimeout(function () {
el.classList.remove("is-in");
setTimeout(function () {
el.remove();
}, 280);
}, 2600);
}
function announce(msg) {
if (live) live.textContent = msg;
}
/* ---------------- Validation ---------------- */
function validate(type, value) {
var v = value.trim();
switch (type) {
case "text":
if (!v) return "Name can’t be empty.";
if (v.length < 2) return "Use at least 2 characters.";
return "";
case "email":
if (!v) return "Email can’t be empty.";
// Pragmatic email check: something@something.tld
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v))
return "Enter a valid email address.";
return "";
case "textarea":
if (v.length > 160) return "Keep your bio under 160 characters.";
return "";
case "select":
return "";
default:
return "";
}
}
/* ---------------- Row controller ---------------- */
function setupRow(row) {
var type = row.getAttribute("data-type");
var label = row.getAttribute("data-field");
var view = row.querySelector("[data-view]");
var form = row.querySelector("[data-editform]");
var input = row.querySelector("[data-input]");
var help = row.querySelector("[data-help]");
var valueEl = row.querySelector("[data-value]");
var savedEl = row.querySelector("[data-saved]");
var editBtn = row.querySelector("[data-edit]");
var cancelBtn = row.querySelector("[data-cancel]");
var counter = row.querySelector("[data-count]");
var defaultHelp = help ? help.innerHTML : "";
var savedTimer = null;
function currentValue() {
return valueEl.classList.contains("is-empty")
? ""
: valueEl.textContent;
}
function showError(msg) {
row.classList.add("is-invalid");
row.classList.remove("is-valid");
input.setAttribute("aria-invalid", "true");
if (help) {
help.classList.add("is-error");
help.textContent = msg;
}
}
function clearError(restoreHelp) {
row.classList.remove("is-invalid");
input.setAttribute("aria-invalid", "false");
if (help && help.classList.contains("is-error")) {
help.classList.remove("is-error");
if (restoreHelp) help.innerHTML = defaultHelp;
}
}
function refreshLiveValidity() {
var msg = validate(type, input.value);
if (msg && row.classList.contains("is-invalid")) {
// keep error message updated while user types after a failed save
showError(msg);
} else {
clearError(true);
if (input.value.trim() && type !== "select") {
row.classList.add("is-valid");
} else {
row.classList.remove("is-valid");
}
}
updateCounter();
}
function updateCounter() {
if (counter) counter.textContent = String(input.value.length);
}
function enterEdit() {
// enforce single active row
if (activeRow && activeRow !== row) {
activeRow.__cancel();
}
activeRow = row;
row.__cancel = exitCancel;
// seed input with current value
input.value = currentValue();
row.classList.add("is-editing");
clearError(true);
row.classList.remove("is-valid");
updateCounter();
view.hidden = true;
form.hidden = false;
// move focus into the field
input.focus();
if (input.setSelectionRange && type !== "select") {
var len = input.value.length;
try {
input.setSelectionRange(len, len);
} catch (e) {
/* select / unsupported */
}
}
}
function leaveEdit() {
row.classList.remove("is-editing", "is-invalid", "is-valid");
form.hidden = true;
view.hidden = false;
if (activeRow === row) activeRow = null;
row.__cancel = null;
if (help) {
help.classList.remove("is-error");
help.innerHTML = defaultHelp;
}
updateCounter();
}
function exitCancel() {
leaveEdit();
editBtn.focus();
}
function commit() {
var raw = input.value;
var msg = validate(type, raw);
if (msg) {
showError(msg);
input.focus();
toast(msg, "error");
announce("Error: " + msg);
return;
}
var next = type === "textarea" ? raw.trim() : raw.trim();
var changed = next !== currentValue();
// write back to view
if (next === "") {
valueEl.textContent = "Not set";
valueEl.classList.add("is-empty");
} else {
valueEl.textContent = next;
valueEl.classList.remove("is-empty");
}
leaveEdit();
editBtn.focus();
// brief "Saved" check
savedEl.classList.add("is-on");
clearTimeout(savedTimer);
savedTimer = setTimeout(function () {
savedEl.classList.remove("is-on");
}, 1800);
if (changed) {
if (dirtyBadge) dirtyBadge.hidden = false;
toast(capitalize(label) + " updated.");
announce(capitalize(label) + " saved.");
} else {
announce(capitalize(label) + " unchanged.");
}
}
/* events */
editBtn.addEventListener("click", enterEdit);
cancelBtn.addEventListener("click", function () {
exitCancel();
announce("Edit cancelled. " + capitalize(label) + " restored.");
});
form.addEventListener("submit", function (e) {
e.preventDefault();
commit();
});
input.addEventListener("input", refreshLiveValidity);
input.addEventListener("keydown", function (e) {
if (e.key === "Escape") {
e.preventDefault();
exitCancel();
announce("Edit cancelled. " + capitalize(label) + " restored.");
} else if (e.key === "Enter" && type !== "textarea") {
// Enter submits for single-line fields & selects; textarea allows newlines
e.preventDefault();
commit();
} else if (e.key === "Enter" && type === "textarea" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
});
// Cancel on blur-out of the whole edit form (click elsewhere) — but not when
// moving focus between the field and its own Save/Cancel buttons.
form.addEventListener("focusout", function (e) {
var to = e.relatedTarget;
if (to && form.contains(to)) return;
// Defer: a click on the Save button fires focusout before the click handler.
setTimeout(function () {
if (row.classList.contains("is-editing") && !form.contains(document.activeElement)) {
exitCancel();
}
}, 120);
});
}
function capitalize(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
rows.forEach(setupRow);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Form — Inline edit / edit-in-place</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<section class="card" aria-labelledby="panel-title">
<header class="card__head">
<div class="card__avatar" aria-hidden="true">MV</div>
<div class="card__heading">
<h1 id="panel-title" class="card__title">Account profile</h1>
<p class="card__sub">Edit any field in place. Only one field can be edited at a time.</p>
</div>
<span class="card__badge" data-dirty-badge hidden>Unsaved changes</span>
</header>
<!-- Live region announcing save / cancel / error outcomes to assistive tech -->
<p class="sr-only" role="status" aria-live="polite" data-live></p>
<dl class="rows" data-rows>
<!-- NAME -->
<div class="row" data-row data-field="name" data-type="text">
<dt class="row__label">
<span class="row__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
Full name
</dt>
<dd class="row__body">
<div class="row__view" data-view>
<span class="row__value" data-value>Mara Velasquez</span>
<button type="button" class="iconbtn" data-edit aria-label="Edit full name">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span>Edit</span>
</button>
<span class="row__saved" data-saved aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Saved
</span>
</div>
<form class="row__edit" data-editform hidden novalidate>
<label class="sr-only" for="f-name">Full name</label>
<input id="f-name" class="field" type="text" data-input
autocomplete="name" maxlength="60"
aria-describedby="h-name" />
<p class="help" id="h-name" data-help>Your display name, up to 60 characters.</p>
<div class="row__actions">
<button type="submit" class="btn btn--primary" data-save>Save</button>
<button type="button" class="btn btn--ghost" data-cancel>Cancel</button>
<span class="row__hint" aria-hidden="true"><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel</span>
</div>
</form>
</dd>
</div>
<!-- EMAIL -->
<div class="row" data-row data-field="email" data-type="email">
<dt class="row__label">
<span class="row__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/></svg>
</span>
Email address
</dt>
<dd class="row__body">
<div class="row__view" data-view>
<span class="row__value" data-value>mara.velasquez@northwind.io</span>
<button type="button" class="iconbtn" data-edit aria-label="Edit email address">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span>Edit</span>
</button>
<span class="row__saved" data-saved aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Saved
</span>
</div>
<form class="row__edit" data-editform hidden novalidate>
<label class="sr-only" for="f-email">Email address</label>
<input id="f-email" class="field" type="email" data-input
autocomplete="email" inputmode="email"
aria-describedby="h-email" />
<p class="help" id="h-email" data-help>We use this for sign-in and account alerts.</p>
<div class="row__actions">
<button type="submit" class="btn btn--primary" data-save>Save</button>
<button type="button" class="btn btn--ghost" data-cancel>Cancel</button>
<span class="row__hint" aria-hidden="true"><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel</span>
</div>
</form>
</dd>
</div>
<!-- ROLE -->
<div class="row" data-row data-field="role" data-type="select">
<dt class="row__label">
<span class="row__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
Workspace role
</dt>
<dd class="row__body">
<div class="row__view" data-view>
<span class="row__value" data-value>Product Designer</span>
<button type="button" class="iconbtn" data-edit aria-label="Edit workspace role">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span>Edit</span>
</button>
<span class="row__saved" data-saved aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Saved
</span>
</div>
<form class="row__edit" data-editform hidden novalidate>
<label class="sr-only" for="f-role">Workspace role</label>
<select id="f-role" class="field" data-input aria-describedby="h-role">
<option>Product Designer</option>
<option>Software Engineer</option>
<option>Product Manager</option>
<option>Data Analyst</option>
<option>Marketing Lead</option>
<option>Customer Success</option>
</select>
<p class="help" id="h-role" data-help>Determines your default workspace permissions.</p>
<div class="row__actions">
<button type="submit" class="btn btn--primary" data-save>Save</button>
<button type="button" class="btn btn--ghost" data-cancel>Cancel</button>
<span class="row__hint" aria-hidden="true"><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel</span>
</div>
</form>
</dd>
</div>
<!-- BIO -->
<div class="row row--block" data-row data-field="bio" data-type="textarea">
<dt class="row__label">
<span class="row__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h11"/></svg>
</span>
Bio
</dt>
<dd class="row__body">
<div class="row__view" data-view>
<span class="row__value row__value--multiline" data-value>Designing calm, accessible product interfaces. Coffee-driven. Based in Lisbon.</span>
<button type="button" class="iconbtn" data-edit aria-label="Edit bio">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
<span>Edit</span>
</button>
<span class="row__saved" data-saved aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
Saved
</span>
</div>
<form class="row__edit" data-editform hidden novalidate>
<label class="sr-only" for="f-bio">Bio</label>
<textarea id="f-bio" class="field field--area" data-input rows="3"
maxlength="160" aria-describedby="h-bio"></textarea>
<p class="help" id="h-bio" data-help>A short blurb. <span data-count>0</span>/160 characters.</p>
<div class="row__actions">
<button type="submit" class="btn btn--primary" data-save>Save</button>
<button type="button" class="btn btn--ghost" data-cancel>Cancel</button>
<span class="row__hint" aria-hidden="true"><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel</span>
</div>
</form>
</dd>
</div>
</dl>
</section>
</main>
<!-- Toast region -->
<div class="toaster" data-toaster role="region" aria-label="Notifications"></div>
<script src="script.js"></script>
</body>
</html>Inline edit / edit-in-place
An account profile panel built as a description list of rows — Full name, Email, Workspace role, and Bio. Each row shows its current value next to an unobtrusive pencil button that fades in on hover or keyboard focus. Activating Edit swaps that single row from read mode into an inline field (text input, email, select, or textarea) with Save and Cancel actions, and focus jumps straight into the field so editing starts immediately.
Editing is deliberately constrained to one row at a time: opening a second row commits-or-cancels the first, keeping the panel calm and the state unambiguous. Pressing Enter saves single-line fields (Cmd/Ctrl+Enter for the bio), Esc cancels and restores the original value, and clicking outside the edit form quietly cancels. Save runs real validation — required checks, an email format rule, and a 160-character bio limit — surfacing inline error text with aria-invalid, an error toast, and a polite aria-live announcement. A successful save flashes a green “Saved” check, fires a confirmation toast, and reveals an “Unsaved changes” badge.
The whole pattern is vanilla JavaScript with no dependencies. It ships full focus-visible rings, error/success/disabled field states, WCAG-AA contrast, a live status region for assistive tech, smooth (motion-reduced-aware) transitions, and a layout that collapses from a two-column grid to a stacked single column below 520px.