appview: give the confirm modal an accessible name and a focus trap

The modal guards tag deletion, device revocation and webhook deletion, so it is
the wrong control to leave unlabelled and escapable.

Note the original report was half wrong: role="dialog" and aria-modal="true"
were already set on the outer element, and are in the deployed bundle. The
observation was most likely taken against the inner .modal-box. Neither was
added here.

What was actually missing: the h2 existed but nothing pointed at it, so a screen
reader announced a dialog with no name; and the only focus management was
focusing Cancel on open, so Tab walked straight out into the skip link and page
header behind the backdrop.

Adds aria-labelledby and aria-describedby with sequence-suffixed ids so two
modals cannot cross-reference. aria-describedby matters more than usual here:
none of the three call sites' messages contain ". ", so the title is always the
generic "Are you sure?" and the specific text is the body.

The trap re-queries on each keypress rather than caching at open, and handles
three cases: focus escaping to body gets pulled back, first plus Shift+Tab wraps
to last, last plus Tab wraps to first. Focus is restored to the opener on every
close path, guarded by document.contains, because confirming a tag deletion
fires an htmx swap that can remove the button that opened the modal. Restoration
happens before onConfirm so htmx sees a sane focus state.

Native <dialog> would give the trap and Escape for free, but it renders in the
top layer while this is styled entirely with daisyUI .modal classes that assume
a positioned div, so converting means CSS work plus the seamark theme fork.
Worth doing as its own change, not smuggled into this one.

Includes the bundle rebuild, since nothing in the dev loop keeps that artifact
current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
This commit is contained in:
Evan Jarrett
2026-09-02 22:31:04 -05:00
co-authored by Claude Opus 5
parent dbb195a4ab
commit 9566201377
2 changed files with 88 additions and 18 deletions
File diff suppressed because one or more lines are too long
+79 -9
View File
@@ -4,6 +4,22 @@
const ICONS_BASE = '/icons.svg';
// Elements that can hold keyboard focus inside the modal. Kept narrow on
// purpose: the modal only ever contains buttons today, but the trap must not
// silently break if a link or input is added later.
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
// Ids must be unique in the document, so two modals can coexist without
// aria-labelledby pointing at the wrong heading.
let modalSeq = 0;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
@@ -19,17 +35,26 @@ function showConfirmModal(message, onConfirm) {
const title = idx > -1 ? message.slice(0, idx + 1) : 'Are you sure?';
const body = idx > -1 ? message.slice(idx + 2) : message;
// Whatever had focus when the modal opened gets it back when it closes.
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const seq = ++modalSeq;
const titleId = `confirm-modal-title-${seq}`;
const bodyId = `confirm-modal-body-${seq}`;
const modal = document.createElement('div');
modal.className = 'modal modal-open';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', titleId);
modal.setAttribute('aria-describedby', bodyId);
modal.innerHTML = `
<div class="modal-box bg-base-200 max-w-md">
<h2 class="text-lg font-bold flex items-center gap-2 text-error">
<h2 id="${titleId}" class="text-lg font-bold flex items-center gap-2 text-error">
<svg class="icon size-5" aria-hidden="true"><use href="${ICONS_BASE}#alert-triangle"></use></svg>
${escapeHtml(title)}
</h2>
<p class="py-4 text-base-content/80">${escapeHtml(body)}</p>
<p id="${bodyId}" class="py-4 text-base-content/80">${escapeHtml(body)}</p>
<div class="modal-action">
<button type="button" class="btn" data-confirm-cancel>Cancel</button>
<button type="button" class="btn btn-error" data-confirm-ok>Confirm</button>
@@ -46,15 +71,60 @@ function showConfirmModal(message, onConfirm) {
// Cancel is the safer default for destructive actions.
setTimeout(() => cancelBtn.focus(), 0);
function cleanup() {
document.removeEventListener('keydown', onKey);
modal.remove();
}
function onKey(e) {
if (e.key === 'Escape') cleanup();
function focusableItems() {
return Array.from(modal.querySelectorAll(FOCUSABLE))
.filter((el) => el.getClientRects().length > 0);
}
document.addEventListener('keydown', onKey);
let closed = false;
function cleanup() {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKey, true);
modal.remove();
// Restore focus to the opener. It may already be gone (an htmx swap
// triggered by the confirmed action can remove it), so check first.
if (opener && typeof opener.focus === 'function' && document.contains(opener)) {
opener.focus();
}
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
cleanup();
return;
}
if (e.key !== 'Tab') return;
const items = focusableItems();
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
// Focus is outside the modal (page behind it, or the browser chrome
// handed it back to the body). Pull it in rather than letting Tab
// walk the page under the backdrop.
if (!modal.contains(active)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
return;
}
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
// Capture phase so the trap sees Tab before anything on the page can.
document.addEventListener('keydown', onKey, true);
cancelBtn.addEventListener('click', cleanup);
backdrop.addEventListener('click', cleanup);
okBtn.addEventListener('click', () => {