web: make modal sheets accessible (role=dialog, focus trap + restore)
Sheets (new-chat, edit-group, edit-profile, members, add-people, add-device) were plain divs: keyboard/screen-reader users could tab behind them and focus wasn't restored on close. Add role=dialog/aria-modal, trap Tab within the sheet, move focus in on open (unless the caller already focused a field), and restore focus to the trigger on close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+36
−1
web/static/chat.js+36 −1
diff --git a/web/static/chat.js b/web/static/chat.js| index b60edbd..d52e7a9 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -2574,22 +2574,57 @@ function renderText(el, text) { |
| // A centered modal sheet. Click the backdrop or Esc to dismiss. |
| function modal() { |
| + const prevFocus = document.activeElement; // restored when the sheet closes |
| const backdrop = document.createElement("div"); |
| backdrop.className = "overlay"; |
| const card = document.createElement("div"); |
| card.className = "card sheet"; |
| + card.setAttribute("role", "dialog"); |
| + card.setAttribute("aria-modal", "true"); |
| + card.tabIndex = -1; // focusable as a fallback when there's no field/button |
| backdrop.appendChild(card); |
| + const focusables = () => |
| + [...card.querySelectorAll('a[href], button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])')].filter( |
| + (el) => el.offsetParent !== null |
| + ); |
| const close = () => { |
| backdrop.remove(); |
| document.removeEventListener("keydown", onKey); |
| + if (prevFocus && prevFocus.focus) { |
| + try { |
| + prevFocus.focus(); |
| + } catch {} |
| + } |
| }; |
| const onKey = (e) => { |
| - if (e.key === "Escape") close(); |
| + if (e.key === "Escape") { |
| + close(); |
| + return; |
| + } |
| + if (e.key === "Tab") { |
| + // Trap Tab within the sheet so keyboard/screen-reader users can't tab behind it. |
| + const f = focusables(); |
| + if (!f.length) return; |
| + const first = f[0]; |
| + const last = f[f.length - 1]; |
| + if (e.shiftKey && document.activeElement === first) { |
| + e.preventDefault(); |
| + last.focus(); |
| + } else if (!e.shiftKey && document.activeElement === last) { |
| + e.preventDefault(); |
| + first.focus(); |
| + } |
| + } |
| }; |
| backdrop.addEventListener("click", (e) => { |
| if (e.target === backdrop) close(); |
| }); |
| document.addEventListener("keydown", onKey); |
| document.body.appendChild(backdrop); |
| + // Move focus into the sheet once the caller has populated it — unless the caller |
| + // already focused something inside (e.g. a screen-name field). |
| + setTimeout(() => { |
| + if (!card.contains(document.activeElement)) (focusables()[0] || card).focus(); |
| + }, 0); |
| return backdrop; |
| } |