From 0a9861431280137e774c1f91fb68ca060c6e3f3f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 21:19:43 +0200 Subject: [PATCH 1/2] fix(web): keep Settings focus and restart progress clear --- .changeset/settings-dialog-focus.md | 9 ++ web/settings-shell.test.mjs | 176 ++++++++++++++++++++++++++-- web/settings.js | 63 +++++++++- web/style.css | 4 + 4 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 .changeset/settings-dialog-focus.md diff --git a/.changeset/settings-dialog-focus.md b/.changeset/settings-dialog-focus.md new file mode 100644 index 00000000..03c80c1c --- /dev/null +++ b/.changeset/settings-dialog-focus.md @@ -0,0 +1,9 @@ +--- +"ftw": patch +--- + +Move focus into Settings when it opens, keep Tab within its visible controls, +and let Escape close it. Closing Settings returns focus to its opening button, +including the shortcut in More. The restart prompt takes focus while open and +returns it when Restart later closes the prompt. +Show restart progress only after Restart now starts the request. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index d231c39d..f3dfc855 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -19,7 +19,8 @@ const ELEMENT_IDS = [ "settings-save", "settings-status", "settings-tabs", "settings-body", ]; -function stubElement() { +function stubElement(ownerDocument = {}) { + const classes = new Set(); return { textContent: "", className: "", @@ -27,7 +28,21 @@ function stubElement() { dataset: {}, style: {}, handlers: {}, - classList: { add() {}, remove() {}, toggle() {} }, + attributes: {}, + isConnected: true, + tabIndex: 0, + classList: { + add: name => classes.add(name), remove: name => classes.delete(name), + contains: name => classes.has(name), toggle() {}, + }, + setAttribute(name, value) { this.attributes[name] = value; }, + focus() { ownerDocument.activeElement = this; }, + getClientRects() { return this.hidden ? [] : [{}]; }, + matches(selector) { return selector === ":disabled" && !!this.disabled; }, + contains(el) { + for (; el; el = el.parentElement) if (el === this) return true; + return false; + }, addEventListener(type, fn) { this.handlers[type] = fn; }, querySelectorAll: () => [], appendChild() {}, @@ -36,16 +51,19 @@ function stubElement() { function loadShell(saveResponse, ok = true) { const elements = {}; - for (const id of ELEMENT_IDS) elements[id] = stubElement(); + const document = { + getElementById: id => elements[id] || null, + createElement: () => stubElement(document), + }; + for (const id of ELEMENT_IDS) elements[id] = stubElement(document); + elements["settings-modal"].classList.add("hidden"); const requests = []; const responses = {}; const sandbox = { window: { FTWSettings: { tabs: {} } }, - document: { - getElementById: (id) => elements[id] || null, - createElement: () => stubElement(), - }, + document, + getComputedStyle: element => ({ visibility: element.visibility || "visible" }), fetch(path, opts) { requests.push({ path, opts }); return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(responses[path] ?? saveResponse) }); @@ -58,13 +76,155 @@ function loadShell(saveResponse, ok = true) { sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(source, sandbox); - return { elements, requests, responses, tabs: sandbox.window.FTWSettings.tabs, + return { elements, document, requests, responses, tabs: sandbox.window.FTWSettings.tabs, loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) }; } // One turn of the event loop, which is all the save chain needs to settle. const settled = () => new Promise((resolve) => setImmediate(resolve)); +describe("Settings dialog keyboard access", () => { + async function open(rig, opener = rig.elements["settings-btn"]) { + opener.focus(); + rig.elements["settings-btn"].handlers.click(); + await settled(); + } + + function key(rig, name, properties = {}) { + const event = { key: name, preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, ...properties }; + rig.elements["settings-modal"].handlers.keydown?.(event); + return event; + } + + it("focuses the dialog after loading and Escape returns to the actual opener", async () => { + const rig = loadShell({}); + const more = stubElement(rig.document); + more.focus(); + rig.elements["settings-btn"].handlers.click(); + assert.equal(rig.document.activeElement, more, "loading does not focus the hidden dialog"); + stubElement(rig.document).focus(); + await settled(); + assert.equal(rig.document.activeElement, rig.elements["settings-close"]); + assert.deepEqual(rig.elements["settings-modal"].attributes, + { role: "dialog", "aria-modal": "true", "aria-label": "Settings" }); + const event = key(rig, "Escape"); + assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), true); + assert.equal(rig.document.activeElement, more, "More delegates a click to the hidden header button"); + assert.ok(event.defaultPrevented && event.stopped); + }); + + it("returns focus after close or backdrop click and keeps inside clicks open", async () => { + const rig = loadShell({}); + const modal = rig.elements["settings-modal"]; + await open(rig); + modal.handlers.click({ target: rig.elements["settings-body"] }); + assert.equal(modal.classList.contains("hidden"), false); + rig.elements["settings-close"].handlers.click(); + assert.equal(rig.document.activeElement, rig.elements["settings-btn"]); + const nextOpener = stubElement(rig.document); + await open(rig, nextOpener); + modal.handlers.click({ target: modal }); + assert.equal(modal.classList.contains("hidden"), true); + assert.equal(rig.document.activeElement, nextOpener); + }); + + it("wraps Tab in both directions and skips hidden, disabled and untabbable controls", async () => { + const rig = loadShell({}); + const first = rig.elements["settings-close"], last = stubElement(rig.document); + const unavailable = [{ hidden: true }, { disabled: true }, { tabIndex: -1 }, { visibility: "hidden" }] + .map(properties => Object.assign(stubElement(rig.document), properties)); + rig.elements["settings-modal"].querySelectorAll = () => [first, last, ...unavailable]; + await open(rig); + assert.ok(key(rig, "Tab", { shiftKey: true }).defaultPrevented); + assert.equal(rig.document.activeElement, last); + assert.ok(key(rig, "Tab").defaultPrevented); + assert.equal(rig.document.activeElement, first); + assert.equal(key(rig, "Tab").defaultPrevented, undefined, "ordinary Tab stays native"); + assert.equal(key(rig, "Enter").defaultPrevented, undefined); + }); + + it("leaves a handled Escape alone and stops handling keys when closed", async () => { + const rig = loadShell({}); + await open(rig); + key(rig, "Escape", { defaultPrevented: true }); + assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), false); + rig.elements["settings-close"].handlers.click(); + assert.equal(key(rig, "Tab").defaultPrevented, undefined); + }); + + it("uses summaries of closed details and includes their controls after opening", async () => { + const rig = loadShell({}), modal = rig.elements["settings-modal"], first = rig.elements["settings-close"]; + const summary = stubElement(rig.document), innerSummary = stubElement(rig.document), field = stubElement(rig.document); + const details = { tagName: "DETAILS", open: false, parentElement: modal, querySelector: () => summary }; + const nested = { tagName: "DETAILS", open: false, parentElement: details, querySelector: () => innerSummary }; + summary.parentElement = details; + summary.contains = el => el === summary; + innerSummary.parentElement = nested; + innerSummary.contains = el => el === innerSummary; + field.parentElement = details; + modal.querySelectorAll = () => [first, summary, innerSummary, field]; + await open(rig); + key(rig, "Tab", { shiftKey: true }); + assert.equal(rig.document.activeElement, summary); + key(rig, "Tab"); + assert.equal(rig.document.activeElement, first); + details.open = true; + key(rig, "Tab", { shiftKey: true }); + assert.equal(rig.document.activeElement, field, "recompute controls on each keypress"); + key(rig, "Tab"); + assert.equal(rig.document.activeElement, first); + }); + + it("does not focus an opener that was removed or hidden", async () => { + for (const properties of [{ isConnected: false }, { hidden: true }]) { + const rig = loadShell({}), opener = stubElement(rig.document); + await open(rig, opener); + Object.assign(opener, properties); + key(rig, "Escape"); + assert.notEqual(rig.document.activeElement, opener); + assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), true); + } + }); + + it("keeps focus in Settings when an in-tab action replaces its own button", async () => { + const rig = loadShell({}), body = rig.elements["settings-body"], button = stubElement(rig.document); + const selectedTab = stubElement(rig.document); + body.parentElement = selectedTab.parentElement = rig.elements["settings-modal"]; + button.parentElement = body; + let context; + rig.tabs.control = { render: ctx => { context = ctx; return ""; } }; + rig.tabs.devices = { render: () => "" }; + rig.elements["settings-tabs"].querySelector = () => selectedTab; + Object.defineProperty(body, "innerHTML", { set() { + if (body.contains(rig.document.activeElement)) rig.document.activeElement = null; + } }); + await open(rig); + button.focus(); + context.navigateTab("devices"); + assert.equal(rig.document.activeElement, selectedTab); + rig.tabs.devices.after = () => button.focus(); + button.focus(); + context.renderTab("devices"); + assert.equal(rig.document.activeElement, button, "keep explicit focus from the new tab's hook"); + }); + + it("hands focus to Restart later and returns it to Save when that dialog closes", async () => { + const rig = loadShell({ restart_required: true }); + for (const id of ["restart-modal", "restart-reasons", "restart-later", "restart-now", "restart-progress", "restart-progress-text"]) + rig.elements[id] = stubElement(rig.document); + await open(rig); + rig.elements["settings-save"].focus(); + rig.elements["settings-save"].handlers.click(); + await settled(); + assert.equal(rig.document.activeElement, rig.elements["restart-later"]); + rig.elements["restart-later"].onclick(); + assert.equal(rig.document.activeElement, rig.elements["settings-save"]); + assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), false); + assert.equal(rig.requests.some(request => request.path === "/api/restart"), false); + }); +}); + async function formShell(original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }) { const rig = loadShell(structuredClone(original)); const field = (path, type, value) => Object.assign(stubElement(), { dataset: { path }, type, value }); diff --git a/web/settings.js b/web/settings.js index 9501edc0..521a9641 100644 --- a/web/settings.js +++ b/web/settings.js @@ -45,8 +45,16 @@ var configETag = null; var currentTab = "control"; var fieldValues = new WeakMap(); + var returnFocus = null; + + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-label", "Settings"); openBtn.addEventListener("click", function () { + // More delegates to the header button, so remember the focused opener + // before the async config request instead of using the click target. + var opener = document.activeElement; apiFetch("/api/config") .then(function (r) { configETag = r.headers && r.headers.get ? r.headers.get("ETag") : null; @@ -57,17 +65,54 @@ modal.classList.remove("hidden"); renderTab(currentTab); setStatus(""); + returnFocus = opener; + closeBtn.focus(); }) .catch(function (e) { setStatus("Failed to load config: " + e, "error"); }); }); - closeBtn.addEventListener("click", function () { + function closeSettings() { modal.classList.add("hidden"); - }); + if (returnFocus && returnFocus.isConnected && returnFocus.getClientRects().length) { + returnFocus.focus(); + } + returnFocus = null; + } + + closeBtn.addEventListener("click", closeSettings); modal.addEventListener("click", function (e) { - if (e.target === modal) modal.classList.add("hidden"); + if (e.target === modal) closeSettings(); + }); + // Scope this to Settings so a separate dialog can own its keyboard input. + modal.addEventListener("keydown", function (e) { + if (modal.classList.contains("hidden") || e.defaultPrevented) return; + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + closeSettings(); + return; + } + if (e.key !== "Tab") return; + var controls = Array.from(modal.querySelectorAll("button, a[href], input, select, textarea, summary, [tabindex]")) + .filter(function (el) { + // Closed details can still report layout boxes for their contents. + for (var parent = el.parentElement; parent && parent !== modal; parent = parent.parentElement) { + if (parent.tagName === "DETAILS" && !parent.open) { + var summary = parent.querySelector(":scope > summary"); + if (!summary || !summary.contains(el)) return false; + } + } + return el.tabIndex >= 0 && !el.matches(":disabled") && el.getClientRects().length && + getComputedStyle(el).visibility !== "hidden"; + }); + var first = controls[0], last = controls[controls.length - 1]; + if (first && ((e.shiftKey && document.activeElement === first) || + (!e.shiftKey && document.activeElement === last))) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } }); tabsEl.addEventListener("click", function (e) { @@ -159,7 +204,12 @@ laterBtn.disabled = false; modalEl.classList.remove("hidden"); - laterBtn.onclick = function () { modalEl.classList.add("hidden"); }; + var restartOpener = document.activeElement; + laterBtn.focus(); + laterBtn.onclick = function () { + modalEl.classList.add("hidden"); + if (restartOpener && restartOpener.isConnected) restartOpener.focus(); + }; nowBtn.onclick = function () { triggerRestart(modalEl, nowBtn, laterBtn, progressEl, progressTextEl); }; } @@ -300,6 +350,7 @@ } function renderTab(tab) { + var hadBodyFocus = bodyEl.contains(document.activeElement); saveBtn.hidden = tab === "loadpoints" || (tab === "devices" && !!S.chargerSetup); saveBtn.style.display = saveBtn.hidden ? "none" : ""; var def = S.tabs[tab]; @@ -344,5 +395,9 @@ if (def.after) { try { def.after(ctx); } catch (e) { console.error("tab after:", tab, e); } } + // In-tab actions can replace their own focused button while rendering. + if (hadBodyFocus && !modal.contains(document.activeElement)) { + (tabsEl.querySelector("button.active") || closeBtn).focus(); + } } })(); diff --git a/web/style.css b/web/style.css index c5b34b80..df06f951 100644 --- a/web/style.css +++ b/web/style.css @@ -1367,6 +1367,10 @@ footer { display: none; } +#restart-progress.hidden { + display: none; +} + .modal-content { background: var(--surface); border: 1px solid var(--border); From 0de0c9c3ea5418b569460ac52f47c86d7a92bfa4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 21:31:01 +0200 Subject: [PATCH 2/2] fix(web): keep restart focus in the top dialog --- .changeset/settings-dialog-focus.md | 6 +- web/settings-shell.test.mjs | 85 ++++++++++++++++++++++++++++- web/settings.js | 42 +++++++++++++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/.changeset/settings-dialog-focus.md b/.changeset/settings-dialog-focus.md index 03c80c1c..1efb528a 100644 --- a/.changeset/settings-dialog-focus.md +++ b/.changeset/settings-dialog-focus.md @@ -4,6 +4,8 @@ Move focus into Settings when it opens, keep Tab within its visible controls, and let Escape close it. Closing Settings returns focus to its opening button, -including the shortcut in More. The restart prompt takes focus while open and -returns it when Restart later closes the prompt. +including the shortcut in More. The restart prompt keeps focus while open, +blocks the background, and returns focus when Restart later or Escape closes +the prompt. During a pending restart, focus stays in the prompt and Escape +does not close it. Show restart progress only after Restart now starts the request. diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index f3dfc855..d3da6e0a 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -60,12 +60,15 @@ function loadShell(saveResponse, ok = true) { const requests = []; const responses = {}; + const alerts = []; const sandbox = { window: { FTWSettings: { tabs: {} } }, document, getComputedStyle: element => ({ visibility: element.visibility || "visible" }), + alert: message => alerts.push(message), fetch(path, opts) { requests.push({ path, opts }); + if (typeof responses[path] === "function") return responses[path](opts); return Promise.resolve({ ok, status: ok ? 200 : 400, headers: { get: () => '"config-7"' }, json: () => Promise.resolve(responses[path] ?? saveResponse) }); }, // The shell only uses timers to clear the "Saved" status and to poll after @@ -76,7 +79,7 @@ function loadShell(saveResponse, ok = true) { sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(source, sandbox); - return { elements, document, requests, responses, tabs: sandbox.window.FTWSettings.tabs, + return { elements, document, requests, responses, alerts, tabs: sandbox.window.FTWSettings.tabs, loadTab: file => vm.runInContext(readFileSync(new URL(file, import.meta.url), "utf8"), sandbox) }; } @@ -209,20 +212,98 @@ describe("Settings dialog keyboard access", () => { assert.equal(rig.document.activeElement, button, "keep explicit focus from the new tab's hook"); }); - it("hands focus to Restart later and returns it to Save when that dialog closes", async () => { + async function restartShell() { const rig = loadShell({ restart_required: true }); for (const id of ["restart-modal", "restart-reasons", "restart-later", "restart-now", "restart-progress", "restart-progress-text"]) rig.elements[id] = stubElement(rig.document); + rig.elements["restart-modal"].classList.add("hidden"); + const dialog = stubElement(rig.document), background = stubElement(rig.document), alreadyInert = stubElement(rig.document); + alreadyInert.inert = true; + rig.elements["restart-modal"].querySelector = () => dialog; + rig.document.body = { children: [rig.elements["settings-modal"], background, alreadyInert, rig.elements["restart-modal"]] }; await open(rig); rig.elements["settings-save"].focus(); rig.elements["settings-save"].handlers.click(); await settled(); + return { ...rig, dialog, background, alreadyInert }; + } + + function restartKey(rig, name, properties = {}) { + const event = { key: name, preventDefault() { this.defaultPrevented = true; }, + stopPropagation() { this.stopped = true; }, ...properties }; + rig.elements["restart-modal"].onkeydown?.(event); + return event; + } + + it("hands focus to Restart later and returns it to Save when that dialog closes", async () => { + const rig = await restartShell(); assert.equal(rig.document.activeElement, rig.elements["restart-later"]); rig.elements["restart-later"].onclick(); assert.equal(rig.document.activeElement, rig.elements["settings-save"]); assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), false); assert.equal(rig.requests.some(request => request.path === "/api/restart"), false); }); + + it("keeps the restart prompt modal and wraps focus until Escape chooses Later", async () => { + const rig = await restartShell(); + assert.equal(rig.dialog.attributes.role, "dialog"); + assert.equal(rig.dialog.attributes["aria-modal"], "true"); + assert.equal(rig.dialog.attributes["aria-label"], "Restart required"); + assert.equal(rig.background.inert, true); + assert.equal(rig.elements["settings-modal"].inert, true); + assert.notEqual(rig.elements["restart-modal"].inert, true); + rig.elements["settings-save"].handlers.click(); + await settled(); + assert.equal(rig.document.activeElement, rig.elements["restart-later"]); + for (const shiftKey of [false, true]) { + assert.ok(restartKey(rig, "Tab", { shiftKey }).defaultPrevented); + assert.equal(rig.document.activeElement, rig.elements["restart-now"]); + restartKey(rig, "Tab", { shiftKey }); + assert.equal(rig.document.activeElement, rig.elements["restart-later"]); + } + restartKey(rig, "Escape", { defaultPrevented: true }); + assert.equal(rig.elements["restart-modal"].classList.contains("hidden"), false); + assert.ok(restartKey(rig, "Escape").stopped); + assert.equal(rig.elements["restart-modal"].classList.contains("hidden"), true); + assert.equal(rig.elements["settings-modal"].classList.contains("hidden"), false); + assert.equal(rig.document.activeElement, rig.elements["settings-save"]); + assert.equal(rig.background.inert, false); + assert.equal(rig.elements["settings-modal"].inert, false); + assert.equal(rig.alreadyInert.inert, true, "leave pre-existing inert state alone"); + assert.equal(rig.elements["restart-modal"].onkeydown, null); + }); + + it("keeps pending restart focus in the prompt and permits Later after a failure", async () => { + const rig = await restartShell(); + let finish; + rig.responses["/api/restart"] = () => new Promise(resolve => { finish = resolve; }); + rig.elements["restart-now"].onclick(); + assert.equal(rig.document.activeElement, rig.dialog); + assert.equal(rig.elements["restart-later"].disabled, true); + assert.equal(rig.elements["restart-now"].disabled, true); + assert.equal(rig.elements["restart-progress"].classList.contains("hidden"), false); + rig.elements["settings-save"].handlers.click(); + await settled(); + assert.equal(rig.elements["restart-later"].disabled, true, "a late save response must not unlock the pending prompt"); + assert.equal(rig.document.activeElement, rig.dialog); + for (const shiftKey of [false, true]) { + assert.ok(restartKey(rig, "Tab", { shiftKey }).defaultPrevented); + assert.equal(rig.document.activeElement, rig.dialog); + } + restartKey(rig, "Escape"); + rig.elements["restart-later"].onclick(); + assert.equal(rig.elements["restart-modal"].classList.contains("hidden"), false); + assert.equal(rig.background.inert, true); + finish({ ok: false, status: 500, json: async () => ({ error: "offline" }) }); + await settled(); + assert.deepEqual(rig.alerts, ["Restart failed: offline"]); + assert.equal(rig.document.activeElement, rig.elements["restart-later"]); + assert.equal(rig.elements["restart-progress"].classList.contains("hidden"), true); + restartKey(rig, "Escape"); + assert.equal(rig.document.activeElement, rig.elements["settings-save"]); + assert.equal(rig.background.inert, false); + assert.equal(rig.requests.filter(request => request.path === "/api/restart").length, 1); + }); }); async function formShell(original = { site: { name: "Home" }, planner: { enabled: true }, hidden: { keep: 17 } }) { diff --git a/web/settings.js b/web/settings.js index 521a9641..509a5d4d 100644 --- a/web/settings.js +++ b/web/settings.js @@ -183,6 +183,15 @@ var progressEl = document.getElementById("restart-progress"); var progressTextEl = document.getElementById("restart-progress-text"); if (!modalEl || !listEl || !laterBtn || !nowBtn) return; + // A second save response must not reset an open or pending prompt. + if (!modalEl.classList.contains("hidden")) return; + + var dialogEl = modalEl.querySelector(".modal-content"); + dialogEl.setAttribute("role", "dialog"); + dialogEl.setAttribute("aria-modal", "true"); + dialogEl.setAttribute("aria-label", "Restart required"); + dialogEl.setAttribute("tabindex", "-1"); + progressEl.setAttribute("role", "status"); listEl.innerHTML = ""; if (reasons.length === 0) { @@ -205,19 +214,45 @@ modalEl.classList.remove("hidden"); var restartOpener = document.activeElement; + // The restart prompt sits above Settings. Keep all other body children + // out of keyboard navigation and the accessibility tree until it closes. + var background = Array.from(document.body.children).filter(function (el) { + return el !== modalEl && !el.contains(modalEl) && !el.inert; + }); + background.forEach(function (el) { el.inert = true; }); laterBtn.focus(); - laterBtn.onclick = function () { + function closeRestart() { + if (laterBtn.disabled) return; modalEl.classList.add("hidden"); + modalEl.onkeydown = null; + background.forEach(function (el) { el.inert = false; }); if (restartOpener && restartOpener.isConnected) restartOpener.focus(); + } + laterBtn.onclick = closeRestart; + modalEl.onkeydown = function (e) { + if (e.defaultPrevented) return; + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + closeRestart(); + } else if (e.key === "Tab") { + e.preventDefault(); + var buttons = [laterBtn, nowBtn].filter(function (button) { return !button.disabled; }); + var index = buttons.indexOf(document.activeElement); + var next = index < 0 ? (e.shiftKey ? buttons.length - 1 : 0) : + (index + (e.shiftKey ? -1 : 1) + buttons.length) % buttons.length; + (buttons[next] || dialogEl).focus(); + } }; - nowBtn.onclick = function () { triggerRestart(modalEl, nowBtn, laterBtn, progressEl, progressTextEl); }; + nowBtn.onclick = function () { triggerRestart(dialogEl, nowBtn, laterBtn, progressEl, progressTextEl); }; } - function triggerRestart(modalEl, nowBtn, laterBtn, progressEl, progressTextEl) { + function triggerRestart(dialogEl, nowBtn, laterBtn, progressEl, progressTextEl) { nowBtn.disabled = true; laterBtn.disabled = true; progressEl.classList.remove("hidden"); progressTextEl.textContent = "Restarting…"; + dialogEl.focus(); apiFetch("/api/restart", { method: "POST" }) .then(function (r) { @@ -236,6 +271,7 @@ laterBtn.disabled = false; progressEl.classList.add("hidden"); alert("Restart failed: " + e.message); + laterBtn.focus(); }); }