From 88108ecc1b2576cbf29251332164a73e1a9d4f58 Mon Sep 17 00:00:00 2001 From: th Date: Sun, 12 Apr 2026 11:02:27 +0200 Subject: [PATCH] focus: restore body focus on overlay close, test shield, ESC closes results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus restoration: * editor.js closeModal(): blur activeElement after the close so board shortcuts (arrows, bare keys) work immediately without clicking. * detail.js closeDetail(): same blur, skipped when openedFromSearch (search input should keep focus in that case). * search.js closeSearch(): blur after removing .open. * calendar.js closeCalendar(): blur after closeDayPopup. All four check activeElement !== body before blurring to avoid no-ops. Test shield (loader.js): * ensureProgressBar() now also creates #test-shield — a transparent fixed overlay (z-index one below the progress bar) covering the viewport below the bar. Blocks all real mouse interaction while tests run; synthetic events from the test suite still work because they bypass pointer-events. cursor:not-allowed as visual feedback. Removed by hideProgress() alongside the progress bar. ESC closes test results (runner.js): * The existing keydown listener now also handles Escape when the test-panel is visible. stopPropagation prevents the app's Escape chain from firing a second close underneath. Tests (board.test.js): * New describe "Board: Focus restoration after overlay close": - closing the editor returns focus to body - closing the detail view returns focus to body - closing search returns focus to body - arrow keys work after editor round-trip (enter → enter → escape) — the exact flow the user reported as broken. Dispatches ArrowDown → Enter (detail) → Enter (editor) → Escape (close) → asserts body focus → ArrowDown → asserts .card-focused exists. * Fixed .not.toBe() → (x !== y).toBe(true) since the custom runner doesn't have a .not chain. chore: bump version to 0.9.14 Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- public/js/modules/calendar.js | 3 ++ public/js/modules/detail.js | 5 +++ public/js/modules/editor.js | 5 +++ public/js/modules/search.js | 7 +++- public/plugins/tests/board.test.js | 60 ++++++++++++++++++++++++++++++ public/plugins/tests/loader.js | 15 ++++++++ public/plugins/tests/runner.js | 9 +++-- 8 files changed, 101 insertions(+), 5 deletions(-) diff --git a/config.php b/config.php index e4b944f..1c3849d 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ { } }); }); + +describe('Board: Focus restoration after overlay close', () => { + afterEach(() => closeAll()); + + it('closing the editor returns focus to body', async () => { + click('#add-btn'); + await sleep(200); + // Editor is open — focus should be inside the modal (title input) + expect(document.activeElement !== document.body).toBe(true); + click('#modal-cancel'); + await sleep(200); + expect(document.activeElement).toBe(document.body); + }); + + it('closing the detail view returns focus to body', async () => { + const card = $('#board [data-id]'); + if (!card) return; + click(card); + await sleep(300); + if (!$('#detail')?.classList.contains('open')) return; + click('#detail-close'); + await sleep(200); + expect(document.activeElement).toBe(document.body); + }); + + it('closing search returns focus to body', async () => { + click('#search-btn'); + await sleep(200); + if (!$('#search')?.classList.contains('open')) return; + expect(document.activeElement?.id).toBe('search-input'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await sleep(200); + expect(document.activeElement).toBe(document.body); + }); + + it('arrow keys work after editor round-trip (enter → enter → escape)', async () => { + const card = $('#board [data-id]'); + if (!card) return; + // Focus a card + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await sleep(100); + if (!document.querySelector('#board .card-focused')) return; + // Enter → detail + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await sleep(300); + if (!$('#detail')?.classList.contains('open')) return; + // Enter → close detail, open editor + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await sleep(300); + if (!$('#modal')?.classList.contains('open')) return; + // Escape → close editor + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await sleep(200); + expect(document.activeElement).toBe(document.body); + // Arrows should work again + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await sleep(100); + expect(document.querySelector('#board .card-focused')).toExist(); + }); +}); diff --git a/public/plugins/tests/loader.js b/public/plugins/tests/loader.js index 6431fe6..97b5e7e 100644 --- a/public/plugins/tests/loader.js +++ b/public/plugins/tests/loader.js @@ -49,6 +49,21 @@ function ensureProgressBar() { // pushed off-viewport). hideProgress() reverts both. document.body.style.marginTop = '30px'; document.body.style.height = 'calc(100vh - 30px)'; + // Transparent overlay that blocks all mouse interaction while tests + // run. Tests dispatch synthetic events so they still work; only real + // user clicks/hovers are absorbed. Removed by hideProgress(). + let shield = document.getElementById('test-shield'); + if (!shield) { + shield = document.createElement('div'); + shield.id = 'test-shield'; + const ss = shield.style; + ss.setProperty('position', 'fixed', 'important'); + ss.setProperty('inset', '30px 0 0 0', 'important'); + ss.setProperty('z-index', '2147483646', 'important'); + ss.setProperty('background', 'transparent', 'important'); + ss.setProperty('cursor', 'not-allowed', 'important'); + document.documentElement.appendChild(shield); + } const bar = document.createElement('div'); bar.id = 'test-progress'; const s = bar.style; diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js index 66a5211..5967035 100644 --- a/public/plugins/tests/runner.js +++ b/public/plugins/tests/runner.js @@ -236,9 +236,7 @@ function updateProgress(current, total, name, failed) { } function hideProgress() { getProgressBar()?.remove(); - // ensureProgressBar() in loader.js pushed the document down by 30px and - // shrunk it by the same amount so the footer wouldn't get clipped. - // Restore both inline overrides here. + document.getElementById('test-shield')?.remove(); document.body.style.marginTop = ''; document.body.style.height = ''; } @@ -498,4 +496,9 @@ document.addEventListener('keydown', e => { e.preventDefault(); panel ? close() : open(); } + if (e.key === 'Escape' && panel) { + e.preventDefault(); + e.stopPropagation(); + close(); + } });