focus: restore body focus on overlay close, test shield, ESC closes results
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
b459159b0f
commit
88108ecc1b
8 files changed
+101
-5
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.9.13');
|
define('APP_VERSION', '0.9.14');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||||
|
|||||||
@@ -506,6 +506,9 @@ export function openCalendar() {
|
|||||||
export function closeCalendar() {
|
export function closeCalendar() {
|
||||||
closeDayPopup();
|
closeDayPopup();
|
||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('calendar-btn')?.addEventListener('click', openCalendar);
|
$('calendar-btn')?.addEventListener('click', openCalendar);
|
||||||
|
|||||||
@@ -391,6 +391,11 @@ export function closeDetail(options = {}) {
|
|||||||
state._returnToCalendar = false;
|
state._returnToCalendar = false;
|
||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
|
// Return focus to body so board shortcuts work immediately.
|
||||||
|
// Skipped when we just focused the search input (openedFromSearch).
|
||||||
|
if (!state.openedFromSearch && document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDetailCard(id) {
|
export function showDetailCard(id) {
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ export function closeModal({ animate = true } = {}) {
|
|||||||
window._openCalendar?.();
|
window._openCalendar?.();
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
|
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
|
||||||
|
// Return focus to body so board shortcuts (arrows, bare keys) work
|
||||||
|
// immediately after closing without the user having to click first.
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ export function openSearch() {
|
|||||||
renderHome();
|
renderHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSearch() { overlay.classList.remove('open'); }
|
export function closeSearch() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
if (document.activeElement && document.activeElement !== document.body) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
function clearPreview() { previewEmpty.classList.remove('hidden'); previewContent.classList.add('hidden'); }
|
||||||
|
|
||||||
|
|||||||
@@ -124,3 +124,63 @@ describe('Board: Card Create & Delete', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,21 @@ function ensureProgressBar() {
|
|||||||
// pushed off-viewport). hideProgress() reverts both.
|
// pushed off-viewport). hideProgress() reverts both.
|
||||||
document.body.style.marginTop = '30px';
|
document.body.style.marginTop = '30px';
|
||||||
document.body.style.height = 'calc(100vh - 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');
|
const bar = document.createElement('div');
|
||||||
bar.id = 'test-progress';
|
bar.id = 'test-progress';
|
||||||
const s = bar.style;
|
const s = bar.style;
|
||||||
|
|||||||
@@ -236,9 +236,7 @@ function updateProgress(current, total, name, failed) {
|
|||||||
}
|
}
|
||||||
function hideProgress() {
|
function hideProgress() {
|
||||||
getProgressBar()?.remove();
|
getProgressBar()?.remove();
|
||||||
// ensureProgressBar() in loader.js pushed the document down by 30px and
|
document.getElementById('test-shield')?.remove();
|
||||||
// shrunk it by the same amount so the footer wouldn't get clipped.
|
|
||||||
// Restore both inline overrides here.
|
|
||||||
document.body.style.marginTop = '';
|
document.body.style.marginTop = '';
|
||||||
document.body.style.height = '';
|
document.body.style.height = '';
|
||||||
}
|
}
|
||||||
@@ -498,4 +496,9 @@ document.addEventListener('keydown', e => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
panel ? close() : open();
|
panel ? close() : open();
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' && panel) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user