Form — Unsaved-changes leave guard
A profile-settings form that flips to a dirty state the moment any field diverges from its saved baseline, then guards every exit against losing that work. In-page route links and the Cancel button are intercepted while dirty and routed through a focus-trapped confirm modal offering Leave and discard or Stay, with Escape mapped to Stay. A native beforeunload handler covers tab close and refresh. Saving validates, re-baselines the form, and switches the guard off, all in dependency-free vanilla JavaScript.
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;
--ok-soft: #e7f6ee;
--warn: #d98a2b;
--warn-soft: #fdf2e3;
--danger: #d4503e;
--danger-d: #b23e2e;
--danger-soft: #fbeeec;
--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);
--sh-3: 0 24px 60px rgba(16, 19, 34, 0.22);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 20px 64px;
background:
radial-gradient(1200px 460px at 14% -8%, var(--brand-50), transparent 60%),
radial-gradient(900px 420px at 100% 0%, var(--accent-soft), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
width: 100%;
max-width: 760px;
display: grid;
grid-template-columns: 188px 1fr;
gap: 20px;
align-items: start;
}
/* ── Side rail (guarded route links) ───────────────── */
.rail {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
}
.rail__brand {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px 12px;
margin-bottom: 6px;
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--ink);
border-bottom: 1px solid var(--line);
}
.rail__brand svg {
color: var(--brand);
}
.rail__link {
display: block;
padding: 9px 12px;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 600;
color: var(--ink-2);
text-decoration: none;
transition:
background 0.15s ease,
color 0.15s ease;
}
.rail__link:hover {
background: var(--brand-50);
color: var(--brand-700);
}
.rail__link:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50);
}
.rail__link.is-current {
background: var(--brand);
color: var(--white);
}
.rail__link.is-current:hover {
background: var(--brand-d);
color: var(--white);
}
/* ── Card ───────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 32px;
}
.card__head {
margin-bottom: 22px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
transition:
background 0.18s ease,
color 0.18s ease;
}
.eyebrow[data-state="clean"] {
background: var(--ok-soft);
color: var(--ok);
}
.eyebrow[data-state="dirty"] {
background: var(--warn-soft);
color: var(--warn);
}
.eyebrow[data-state="dirty"] svg {
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.card__title {
margin: 14px 0 8px;
font-size: 27px;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.18;
}
.card__lede {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 52ch;
}
/* ── Form grid ──────────────────────────────────────── */
.form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.field {
grid-column: 1 / -1;
min-width: 0;
border: 0;
margin: 0;
padding: 0;
}
.field--half {
grid-column: span 1;
}
.field--group {
padding: 0;
}
.field__label {
display: block;
margin-bottom: 4px;
padding: 0;
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
legend.field__label {
float: left;
}
.req {
color: var(--danger);
font-weight: 700;
}
.field__hint {
margin: 0 0 8px;
font-size: 13px;
color: var(--muted);
}
.field__error {
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--danger);
}
.field__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--danger);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M12 2a10 10 0 100 20 10 10 0 000-20zm0 5a1 1 0 011 1v5a1 1 0 11-2 0V8a1 1 0 011-1zm0 9.5a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z'/%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'%3E%3Cpath fill='white' d='M12 2a10 10 0 100 20 10 10 0 000-20zm0 5a1 1 0 011 1v5a1 1 0 11-2 0V8a1 1 0 011-1zm0 9.5a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.field__count {
margin: 6px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-align: right;
}
.field__count.is-max {
color: var(--warn);
}
/* ── Inputs ─────────────────────────────────────────── */
.input {
width: 100%;
padding: 11px 13px;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--white);
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
box-shadow: var(--sh-1);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
}
.input::placeholder {
color: #9aa0bd;
}
.textarea {
resize: vertical;
min-height: 76px;
line-height: 1.5;
}
.select {
appearance: none;
-webkit-appearance: none;
padding-right: 38px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236c7393' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 11px center;
cursor: pointer;
}
.input:hover:not(:disabled) {
border-color: var(--ink-2);
}
.input:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 4px var(--brand-50);
}
.input:disabled {
background: #f1f2f7;
color: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
/* Error / success states on the wrapping .field */
.field.is-error .input {
border-color: var(--danger);
background: #fffafa;
}
.field.is-error .input:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 20%, transparent);
}
.field.is-valid .input {
border-color: var(--ok);
}
.field.is-valid .input:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 18%, transparent);
}
/* ── Checkboxes ─────────────────────────────────────── */
.checks {
clear: both;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.check {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 13px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--ink-2);
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.check:hover {
border-color: var(--ink-2);
}
.check:has(input:checked) {
border-color: var(--brand);
background: var(--brand-50);
}
.check input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.check__box {
flex: none;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border: 1.5px solid var(--line-2);
border-radius: 6px;
background: var(--white);
color: var(--white);
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.check__box svg {
opacity: 0;
transform: scale(0.6);
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.check input:checked ~ .check__box {
background: var(--brand);
border-color: var(--brand);
}
.check input:checked ~ .check__box svg {
opacity: 1;
transform: scale(1);
}
.check input:focus-visible ~ .check__box {
box-shadow: 0 0 0 4px var(--brand-50);
}
/* ── Actions ────────────────────────────────────────── */
.form__actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
font: inherit;
font-size: 15px;
font-weight: 600;
border-radius: var(--r-sm);
border: 1.5px solid transparent;
cursor: pointer;
transition:
transform 0.12s ease,
box-shadow 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
opacity 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--brand-50);
}
.btn--primary {
background: var(--brand);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn--primary:hover:not(:disabled) {
background: var(--brand-d);
}
.btn--primary:disabled {
background: #c3c5e8;
color: #eef0ff;
cursor: not-allowed;
box-shadow: none;
}
.btn--ghost {
background: var(--white);
color: var(--ink-2);
border-color: var(--line-2);
}
.btn--ghost:hover {
border-color: var(--ink-2);
background: #fafbff;
}
.btn--danger {
background: var(--danger);
color: var(--white);
box-shadow: var(--sh-1);
}
.btn--danger:hover {
background: var(--danger-d);
}
.btn--danger:focus-visible {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 28%, transparent);
}
.form__status {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.form__status.is-ok {
color: var(--ok);
}
.form__status.is-error {
color: var(--danger);
}
/* ── Modal (leave guard) ────────────────────────────── */
.modal {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 20px;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(16, 19, 34, 0.5);
backdrop-filter: blur(2px);
animation: fadeIn 0.18s ease;
}
.modal__dialog {
position: relative;
width: 100%;
max-width: 420px;
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--sh-3);
padding: 26px 26px 22px;
text-align: center;
animation: dialogIn 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
.modal__dialog:focus-visible {
outline: none;
}
.modal__icon {
display: inline-grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--warn-soft);
color: var(--warn);
margin-bottom: 12px;
}
.modal__title {
margin: 0 0 8px;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
}
.modal__desc {
margin: 0 auto 20px;
max-width: 38ch;
color: var(--muted);
font-size: 14.5px;
}
.modal__desc strong {
color: var(--ink-2);
}
.modal__actions {
display: flex;
gap: 12px;
}
.modal__actions .btn {
flex: 1;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogIn {
from {
opacity: 0;
transform: translateY(10px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── Toast ──────────────────────────────────────────── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(16px);
max-width: calc(100vw - 40px);
padding: 12px 18px;
background: var(--ink);
color: var(--white);
font-size: 14px;
font-weight: 600;
border-radius: var(--r-md);
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease;
z-index: 90;
}
.toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 720px) {
.page {
grid-template-columns: 1fr;
}
.rail {
position: static;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 12px 14px;
}
.rail__brand {
width: 100%;
margin-bottom: 2px;
padding-bottom: 10px;
}
}
@media (max-width: 520px) {
body {
padding: 18px 14px 48px;
}
.card {
padding: 22px 18px;
border-radius: var(--r-md);
}
.card__title {
font-size: 23px;
}
.form {
grid-template-columns: 1fr;
gap: 16px;
}
.field--half {
grid-column: 1 / -1;
}
.checks {
grid-template-columns: 1fr;
}
.form__actions {
flex-direction: column;
align-items: stretch;
}
.form__actions .btn {
width: 100%;
}
.form__status {
text-align: center;
}
.modal__actions {
flex-direction: column-reverse;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}/* Form — Unsaved-changes leave guard
* ------------------------------------------------------------------
* Tracks a "dirty" state once any field diverges from its saved
* baseline. Any guarded navigation (in-page route links, the Cancel
* button) is intercepted while dirty and routed through an accessible
* confirm modal: Leave & discard / Stay. Saving validates, re-baselines
* the form, and clears the guard. The native `beforeunload` event is
* wired too so tab close / refresh / external links also warn — see the
* note where it's registered (browsers show their own generic dialog;
* the custom modal can't replace it for that event).
*/
(function () {
"use strict";
var form = document.getElementById("profile-form");
var saveBtn = document.getElementById("save-btn");
var cancelBtn = document.getElementById("cancel-btn");
var status = document.getElementById("form-status");
var badge = document.getElementById("dirty-badge");
var badgeText = document.getElementById("dirty-badge-text");
var toastEl = document.getElementById("toast");
var modal = document.getElementById("guard-modal");
var dialog = modal.querySelector(".modal__dialog");
var modalDest = document.getElementById("modal-dest");
var stayBtn = document.getElementById("stay-btn");
var leaveBtn = document.getElementById("leave-btn");
var bio = document.getElementById("bio");
var bioNow = document.getElementById("bio-now");
var bioCount = document.getElementById("bio-count");
// Validation runs only on these required fields.
var REQUIRED = ["displayName", "email"];
var dirty = false; // does the form diverge from baseline?
var baseline = snapshot(); // serialized saved state
var pendingAction = null; // function to run if the user confirms "Leave"
var lastFocused = null; // element to restore focus to on modal close
/* ── Snapshot / dirty detection ─────────────────────────────── */
// Serialize current form values into a comparable string.
function snapshot() {
var data = new FormData(form);
var parts = [];
var keys = ["displayName", "email", "role", "timezone", "bio"];
keys.forEach(function (k) {
parts.push(k + "=" + (data.get(k) || ""));
});
// Checkbox group → sorted list of checked values.
var notify = data.getAll("notify").sort().join(",");
parts.push("notify=" + notify);
return parts.join("|");
}
function recomputeDirty() {
var nowDirty = snapshot() !== baseline;
if (nowDirty === dirty) return;
dirty = nowDirty;
saveBtn.disabled = !dirty;
badge.dataset.state = dirty ? "dirty" : "clean";
badgeText.textContent = dirty ? "Unsaved changes" : "All changes saved";
if (!dirty) {
status.textContent = "";
status.className = "form__status";
}
}
/* ── Field-level validation ─────────────────────────────────── */
function setError(name, message) {
var field = form.querySelector('[data-field="' + name + '"]');
var input = document.getElementById(name);
var err = document.getElementById(name + "-error");
field.classList.add("is-error");
field.classList.remove("is-valid");
input.setAttribute("aria-invalid", "true");
input.setAttribute("aria-describedby", name + "-hint " + name + "-error");
err.textContent = message;
err.hidden = false;
}
function setValid(name) {
var field = form.querySelector('[data-field="' + name + '"]');
var input = document.getElementById(name);
var err = document.getElementById(name + "-error");
field.classList.remove("is-error");
field.classList.add("is-valid");
input.removeAttribute("aria-invalid");
input.setAttribute("aria-describedby", name + "-hint");
err.hidden = true;
err.textContent = "";
}
function clearState(name) {
var field = form.querySelector('[data-field="' + name + '"]');
var input = document.getElementById(name);
var err = document.getElementById(name + "-error");
field.classList.remove("is-error", "is-valid");
input.removeAttribute("aria-invalid");
input.setAttribute("aria-describedby", name + "-hint");
err.hidden = true;
}
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function validateField(name, markValid) {
var value = (document.getElementById(name).value || "").trim();
if (name === "displayName") {
if (!value) return fail(name, "Enter a display name.");
if (value.length < 2) return fail(name, "Use at least 2 characters.");
}
if (name === "email") {
if (!value) return fail(name, "Enter your email address.");
if (!EMAIL_RE.test(value)) return fail(name, "Enter a valid email, e.g. name@atlas.dev.");
}
if (markValid) setValid(name);
return true;
function fail(n, msg) {
setError(n, msg);
return false;
}
}
function validateAll() {
var firstInvalid = null;
REQUIRED.forEach(function (name) {
var ok = validateField(name, true);
if (!ok && !firstInvalid) firstInvalid = document.getElementById(name);
});
return firstInvalid;
}
/* ── Toast helper ───────────────────────────────────────────── */
var toastTimer = null;
function toast(msg) {
toastEl.hidden = false;
toastEl.textContent = msg;
// Force reflow so the transition replays on rapid calls.
void toastEl.offsetWidth;
toastEl.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-visible");
}, 2600);
}
/* ── Modal (accessible, focus-trapped) ──────────────────────── */
function focusable() {
return Array.prototype.slice.call(
dialog.querySelectorAll("button:not([disabled])")
);
}
function openModal(destLabel, onLeave) {
pendingAction = onLeave;
modalDest.textContent = destLabel;
lastFocused = document.activeElement;
modal.hidden = false;
document.body.style.overflow = "hidden";
// Move focus into the dialog; "Stay" is the safe default.
stayBtn.focus();
document.addEventListener("keydown", onKeydown, true);
}
function closeModal() {
modal.hidden = true;
document.body.style.overflow = "";
document.removeEventListener("keydown", onKeydown, true);
pendingAction = null;
if (lastFocused && typeof lastFocused.focus === "function") {
lastFocused.focus();
}
lastFocused = null;
}
function onKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal(); // Esc == Stay
return;
}
if (e.key !== "Tab") return;
// Focus trap: keep Tab / Shift+Tab inside the dialog.
var items = focusable();
if (!items.length) return;
var first = items[0];
var last = items[items.length - 1];
var active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
} else if (items.indexOf(active) === -1) {
e.preventDefault();
first.focus();
}
}
// Backdrop or any [data-close="stay"] element == Stay.
modal.addEventListener("click", function (e) {
if (e.target.getAttribute("data-close") === "stay") closeModal();
});
leaveBtn.addEventListener("click", function () {
var action = pendingAction;
closeModal();
if (typeof action === "function") action();
});
/* ── Guarded navigation ─────────────────────────────────────── */
// Run `proceed` immediately when clean; otherwise confirm first.
function guard(destLabel, proceed) {
if (!dirty) {
proceed();
return;
}
openModal(destLabel, proceed);
}
// Intercept the mock in-page route links.
var railLinks = document.querySelectorAll(".rail__link");
railLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var dest = link.dataset.route || "this page";
guard(dest, function () {
// Simulated client-side route change.
railLinks.forEach(function (l) {
l.classList.remove("is-current");
l.removeAttribute("aria-current");
});
link.classList.add("is-current");
link.setAttribute("aria-current", "page");
// Leaving discards edits → reset to baseline values, clear guard.
form.reset();
REQUIRED.forEach(clearState);
baseline = snapshot();
recomputeDirty();
updateBioCount();
toast("Switched to " + dest + " — edits discarded.");
});
});
});
// Cancel button: same guard, "leaving" the editor.
cancelBtn.addEventListener("click", function () {
guard("the editor", function () {
form.reset();
REQUIRED.forEach(clearState);
baseline = snapshot();
recomputeDirty();
updateBioCount();
status.textContent = "Changes discarded.";
status.className = "form__status";
toast("Changes discarded.");
});
});
/* ── beforeunload (tab close / refresh / external links) ────── */
// The browser shows its OWN generic confirmation for this event; a
// custom modal cannot be substituted here. We only opt in while dirty
// by calling preventDefault() and setting returnValue.
window.addEventListener("beforeunload", function (e) {
if (!dirty) return;
e.preventDefault();
e.returnValue = ""; // required for the prompt in most browsers
});
/* ── Live input wiring ──────────────────────────────────────── */
form.addEventListener("input", function (e) {
recomputeDirty();
var name = e.target.name;
// Re-validate a required field live once it has shown an error.
if (REQUIRED.indexOf(name) !== -1) {
var field = form.querySelector('[data-field="' + name + '"]');
if (field && field.classList.contains("is-error")) validateField(name, true);
}
if (e.target === bio) updateBioCount();
});
form.addEventListener("change", recomputeDirty);
// Blur validation for required fields.
REQUIRED.forEach(function (name) {
document.getElementById(name).addEventListener("blur", function () {
if (document.getElementById(name).value.trim()) validateField(name, true);
});
});
function updateBioCount() {
var len = bio.value.length;
bioNow.textContent = String(len);
bioCount.classList.toggle("is-max", len >= 160);
}
/* ── Save ───────────────────────────────────────────────────── */
form.addEventListener("submit", function (e) {
e.preventDefault();
var firstInvalid = validateAll();
if (firstInvalid) {
status.textContent = "Fix the highlighted fields before saving.";
status.className = "form__status is-error";
firstInvalid.focus();
return;
}
// Simulated save: re-baseline and clear the guard.
saveBtn.disabled = true;
status.textContent = "Saving…";
status.className = "form__status";
setTimeout(function () {
baseline = snapshot();
recomputeDirty(); // dirty → false, guard now inert
status.textContent = "Profile saved.";
status.className = "form__status is-ok";
toast("Profile saved — the leave guard is now off.");
}, 420);
});
// Initialize counters / state on load.
updateBioCount();
recomputeDirty();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form — Unsaved-changes leave guard</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">
<!-- Mock app shell: the in-page nav links are guarded route links -->
<nav class="rail" aria-label="Account settings sections">
<span class="rail__brand">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="9" r="3.4" fill="none" stroke="currentColor" stroke-width="2" />
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
d="M5 19.2c1.4-3 4-4.4 7-4.4s5.6 1.4 7 4.4"
/>
</svg>
Atlas ID
</span>
<a class="rail__link is-current" href="#profile" data-route="Profile" aria-current="page">
Profile
</a>
<a class="rail__link" href="#billing" data-route="Billing">Billing</a>
<a class="rail__link" href="#security" data-route="Security">Security</a>
<a class="rail__link" href="#team" data-route="Team">Team</a>
</nav>
<section class="card" aria-labelledby="form-title">
<header class="card__head">
<span class="eyebrow" data-state="clean" id="dirty-badge" role="status" aria-live="polite">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.5l2 2 4-4M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"
/>
</svg>
<span id="dirty-badge-text">All changes saved</span>
</span>
<h1 id="form-title" class="card__title">Edit your profile</h1>
<p class="card__lede">
Change a field, then try a nav link, Cancel, or closing the tab — a guard
asks before you lose unsaved work. Saving clears the guard.
</p>
</header>
<form id="profile-form" class="form" novalidate>
<div class="field" data-field="displayName">
<label class="field__label" for="displayName">
Display name <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="displayName-hint">Shown across Atlas, e.g. Marisol Quintero.</p>
<p class="field__error" id="displayName-error" hidden></p>
<input
class="input"
id="displayName"
name="displayName"
type="text"
autocomplete="name"
value="Marisol Quintero"
aria-describedby="displayName-hint"
required
/>
</div>
<div class="field" data-field="email">
<label class="field__label" for="email">
Email address <span class="req" aria-hidden="true">*</span>
</label>
<p class="field__hint" id="email-hint">Used for sign-in and account notices.</p>
<p class="field__error" id="email-error" hidden></p>
<input
class="input"
id="email"
name="email"
type="email"
autocomplete="email"
inputmode="email"
value="marisol@atlas.dev"
aria-describedby="email-hint"
required
/>
</div>
<div class="field field--half" data-field="role">
<label class="field__label" for="role">Job title</label>
<p class="field__hint" id="role-hint">Optional — appears on your team card.</p>
<p class="field__error" id="role-error" hidden></p>
<input
class="input"
id="role"
name="role"
type="text"
autocomplete="organization-title"
value="Product Designer"
placeholder="e.g. Product Designer"
aria-describedby="role-hint"
/>
</div>
<div class="field field--half" data-field="timezone">
<label class="field__label" for="timezone">Time zone</label>
<p class="field__hint" id="timezone-hint">We schedule digests in your local time.</p>
<p class="field__error" id="timezone-error" hidden></p>
<select class="input select" id="timezone" name="timezone" aria-describedby="timezone-hint">
<option value="utc-8">Pacific (UTC−8)</option>
<option value="utc-6">Central (UTC−6)</option>
<option value="utc-5" selected>Eastern (UTC−5)</option>
<option value="utc+0">London (UTC+0)</option>
<option value="utc+1">Central Europe (UTC+1)</option>
</select>
</div>
<div class="field" data-field="bio">
<label class="field__label" for="bio">Short bio</label>
<p class="field__hint" id="bio-hint">Up to 160 characters.</p>
<p class="field__error" id="bio-error" hidden></p>
<textarea
class="input textarea"
id="bio"
name="bio"
rows="3"
maxlength="160"
placeholder="A line about what you do."
aria-describedby="bio-hint bio-count"
>Designing calm, fast tools for small teams.</textarea>
<p class="field__count" id="bio-count" aria-live="polite"><span id="bio-now">43</span>/160</p>
</div>
<fieldset class="field field--group" data-field="notify">
<legend class="field__label">Email me about</legend>
<p class="field__hint" id="notify-hint">Toggle the digests you want.</p>
<div class="checks" aria-describedby="notify-hint">
<label class="check">
<input type="checkbox" name="notify" value="product" checked />
<span class="check__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12.5l4.5 4.5L19 7"
/>
</svg>
</span>
<span class="check__text">Product updates</span>
</label>
<label class="check">
<input type="checkbox" name="notify" value="weekly" />
<span class="check__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12.5l4.5 4.5L19 7"
/>
</svg>
</span>
<span class="check__text">Weekly digest</span>
</label>
</div>
</fieldset>
<div class="form__actions">
<button type="submit" class="btn btn--primary" id="save-btn" disabled>
Save changes
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12.5l4.5 4.5L19 6"
/>
</svg>
</button>
<!-- Cancel is itself guarded: it tries to "leave" the editor -->
<button type="button" class="btn btn--ghost" id="cancel-btn">Cancel</button>
<p class="form__status" id="form-status" role="status" aria-live="polite"></p>
</div>
</form>
</section>
</main>
<!-- Accessible confirm modal: focus-trapped, Esc = Stay -->
<div class="modal" id="guard-modal" hidden>
<div class="modal__backdrop" data-close="stay"></div>
<div
class="modal__dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
tabindex="-1"
>
<span class="modal__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3l9.5 16.5H2.5L12 3zM12 9.5v4.5M12 17.2v.2"
/>
</svg>
</span>
<h2 class="modal__title" id="modal-title">You have unsaved changes</h2>
<p class="modal__desc" id="modal-desc">
If you leave <strong id="modal-dest">this page</strong> now, your edits to the
profile form will be discarded. Save them first, or leave anyway.
</p>
<div class="modal__actions">
<button type="button" class="btn btn--ghost" id="stay-btn" data-close="stay">
Stay on page
</button>
<button type="button" class="btn btn--danger" id="leave-btn">Leave & discard</button>
</div>
</div>
</div>
<!-- Toast region -->
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Unsaved-changes leave guard
A mock account-settings editor that protects in-progress edits. The form starts in a clean state with a green “All changes saved” badge and a disabled Save button. As soon as any value diverges from its saved baseline — name, email, job title, time zone, bio, or notification toggles — the badge turns amber to read “Unsaved changes”, and Save enables. The dirty check is a real value snapshot compared against the last saved baseline, so undoing an edit by hand flips the state cleanly back to saved.
While the form is dirty, any attempt to leave is intercepted. The side-rail route links (Profile, Billing, Security, Team) and the Cancel button all funnel through an accessible role="alertdialog" confirm modal that names the destination and offers “Leave and discard” or “Stay on page”. The modal traps Tab focus inside the dialog, opens with focus on the safe default (Stay), maps Escape and a backdrop click to Stay, and restores focus to the triggering control on close. A native beforeunload handler — opted in only while dirty — extends the same protection to closing the tab, refreshing, or following an external link.
Required fields (display name and email) have real inline validation with aria-invalid, error helper text, and a live character counter on the bio. Saving validates first, focuses the first invalid field on failure, and on success re-snapshots the baseline, resets the badge to “All changes saved”, disables Save again, and renders status via an aria-live region plus a transient toast. The layout is a two-column grid with a sticky rail that collapses to a stacked, single-column view under 520px, and it respects prefers-reduced-motion.