From 39a15e4f7269c7916a8d89538f37f62a19a3a97c Mon Sep 17 00:00:00 2001 From: TING-KUAN Date: Sat, 24 Oct 2026 19:32:50 +0800 Subject: [PATCH] Disarm Escape's Tab exit on click or blur Escape arms the next Tab to move focus out of the editor, and only the editor's next keydown disarmed it. A click back into the editor, or focus leaving and returning by keyboard, fires no keydown there, so the next Tab still left the editor instead of indenting. Clearing the flag on pointerdown and blur ends the arm once the candidate is editing again or has already left, while Escape followed directly by Tab or Shift+Tab stays the keyboard way out, since neither event fires between those keys. Closes #465 --- tests/browser/editor-pipeline.test.js | 57 +++++++++++++++++++++++++++ web/interview.js | 8 ++++ 2 files changed, 65 insertions(+) diff --git a/tests/browser/editor-pipeline.test.js b/tests/browser/editor-pipeline.test.js index 689911d8..ba087f04 100644 --- a/tests/browser/editor-pipeline.test.js +++ b/tests/browser/editor-pipeline.test.js @@ -653,6 +653,63 @@ test("disabled auto indentation uses native Enter and retains manual indentation } }); +test("a click back into the editor after Escape lets Tab indent again", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, errors } = ctx; + try { + await setCode(page, "x"); + await page.keyboard.press("Escape"); + await page.locator("#editor").click(); + // The click leaves the caret wherever it landed; put it back at the end. + await setCode(page, "x"); + await page.keyboard.press("Tab"); + assert.deepEqual(await caretState(page), { + value: "x ", + start: 5, + end: 5, + }); + assert.equal( + await page + .locator("#editor") + .evaluate((editor) => document.activeElement === editor), + true, + ); + assert.deepEqual(errors, []); + } finally { + await page.close(); + } +}); + +test("leaving the editor after Escape disarms its Tab exit", async (t) => { + const ctx = await editorPage(t); + if (!ctx) return; + const { page, errors } = ctx; + try { + await setCode(page, "x"); + await page.keyboard.press("Escape"); + // Focus goes elsewhere without that Tab, as a click on another control + // sends it, and returns with no click in the editor, as Shift+Tab does. + await page.locator("#editor").evaluate((editor) => editor.blur()); + await setCode(page, "x"); + await page.keyboard.press("Tab"); + assert.deepEqual(await caretState(page), { + value: "x ", + start: 5, + end: 5, + }); + assert.equal( + await page + .locator("#editor") + .evaluate((editor) => document.activeElement === editor), + true, + ); + assert.deepEqual(errors, []); + } finally { + await page.close(); + } +}); + for (const direction of ["forward", "backward"]) { for (const { command, code, end, edited, start, finish } of [ { diff --git a/web/interview.js b/web/interview.js index 6b6bc289..c635be68 100644 --- a/web/interview.js +++ b/web/interview.js @@ -1337,6 +1337,14 @@ function bindEvents() { // that instead, or a candidate driving the page from the keyboard is stuck // in the textarea with no way out. let tabLeavesEditor = false; + // Escape arms only the keyboard's next move. A click in the editor is the + // candidate editing again, and a blur means they left some other way, so + // neither may leave the next Tab armed. + for (const type of ["pointerdown", "blur"]) { + nodes.editor.addEventListener(type, () => { + tabLeavesEditor = false; + }); + } nodes.editor.addEventListener("keydown", (event) => { if (event.key === "Escape") { tabLeavesEditor = true;