mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
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:
co-authored by
Claude Opus 5
parent
dbb195a4ab
commit
9566201377
Vendored
+9
-9
File diff suppressed because one or more lines are too long
@@ -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) => ({
|
||||
'&': '&',
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user