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();
+ }
});
|