fix(a11y): reminder picker modal focus-trap parity (LC-158) #210

Merged
nrupard merged 2 commits from chore/lc-158-reminder-modal-a11y into main 2026-05-26 15:49:13 +02:00
Owner

What

First slice of LC-158 (UI-pattern consolidation, audit gap C3): bring the reminder picker modal to focus-trap parity with the poll/scheduled modals.

Problem

The reminder picker is a full-screen overlay modal (fixed inset-0, role="dialog") like poll/scheduled, but had no focus trap, no Escape-to-close, and no focus restoration - the weakest a11y of the overlay modals.

Fix

Wire it onto the shared window.__lcDialogTrap: capture the opener, trap Tab inside the dialog, move initial focus in, and route every cancel path (X, backdrop, Escape) through one close() that disposes the trap, restores focus to the opener, and clears the slot. Success paths (preset / custom POST) swap the confirm fragment into the slot - removing the dialog and its trap listener outright - so they need no explicit teardown. The inline onclick/onmousedown handlers are replaced by script-bound listeners so all paths share the teardown.

The status picker is intentionally left as-is: it is an inline sidebar dropdown, not an overlay modal, and already has Escape + focus-restore. A Tab focus-trap (the only thing __lcDialogTrap adds) would be wrong there - Tab should move out to the page, not cycle within a non-modal dropdown.

Deferred (sub-issue)

The other C3 consolidation items - confirm-dialog pattern guideline (hx-confirm vs native confirm() vs text-verify), form-error fragment unification, and folding the mention-popover avatar back onto partials/avatar.html - are filed as a follow-up; they are cosmetic/guideline and partly behavior-changing (e.g. hx-confirm only guards htmx requests, not plain form submits), so not bundled here.

Test

just check (Askama templates compile), routes_reminders tests green. JS trap/escape behavior is not unit-tested in this repo (consistent with the poll/scheduled modal JS); the change mirrors their established pattern.

Part of the LC-159 post-audit story.

🤖 Generated with Claude Code

## What First slice of LC-158 (UI-pattern consolidation, audit gap C3): bring the reminder picker modal to focus-trap parity with the poll/scheduled modals. ## Problem The reminder picker is a full-screen overlay modal (`fixed inset-0`, `role="dialog"`) like poll/scheduled, but had no focus trap, no Escape-to-close, and no focus restoration - the weakest a11y of the overlay modals. ## Fix Wire it onto the shared `window.__lcDialogTrap`: capture the opener, trap Tab inside the dialog, move initial focus in, and route every cancel path (X, backdrop, Escape) through one `close()` that disposes the trap, restores focus to the opener, and clears the slot. Success paths (preset / custom POST) swap the confirm fragment into the slot - removing the dialog and its trap listener outright - so they need no explicit teardown. The inline `onclick`/`onmousedown` handlers are replaced by script-bound listeners so all paths share the teardown. The **status picker is intentionally left as-is**: it is an inline sidebar dropdown, not an overlay modal, and already has Escape + focus-restore. A Tab focus-trap (the only thing `__lcDialogTrap` adds) would be *wrong* there - Tab should move out to the page, not cycle within a non-modal dropdown. ## Deferred (sub-issue) The other C3 consolidation items - confirm-dialog pattern guideline (`hx-confirm` vs native `confirm()` vs text-verify), form-error fragment unification, and folding the mention-popover avatar back onto `partials/avatar.html` - are filed as a follow-up; they are cosmetic/guideline and partly behavior-changing (e.g. `hx-confirm` only guards htmx requests, not plain form submits), so not bundled here. ## Test `just check` (Askama templates compile), `routes_reminders` tests green. JS trap/escape behavior is not unit-tested in this repo (consistent with the poll/scheduled modal JS); the change mirrors their established pattern. Part of the LC-159 post-audit story. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(a11y): bring the reminder picker modal to focus-trap parity (LC-158)
All checks were successful
check-secrets / TruffleHog (push) Successful in 3s
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 4s
Check / clippy + fmt + tests (pull_request) Successful in 1m31s
8eb46be4e6
The reminder picker is a full-screen overlay modal (fixed inset-0, role=dialog) like the poll and scheduled modals, but unlike them it had no focus trap, no Escape-to-close, and no focus restoration - the weakest a11y of the overlay modals (audit C3).

Wire it onto the shared window.__lcDialogTrap: capture the opener, trap Tab inside the dialog, move initial focus in, and route every cancel path (X button, backdrop click, Escape) through one close() that disposes the trap, restores focus to the opener, and clears the slot. Success paths (preset / custom POST) swap the confirm fragment into the slot, which removes the dialog and its trap listener outright, so they need no explicit teardown. The inline onclick/onmousedown close handlers are replaced by script-bound listeners so all paths share the teardown.

The status picker is intentionally left as-is: it is an inline sidebar dropdown, not an overlay modal, and already has Escape + focus-restore; a Tab focus-trap (the only thing __lcDialogTrap adds) would be wrong there - Tab should move out to the page, not cycle within a non-modal dropdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
refactor(a11y): dispose the reminder-picker trap on every path (LC-158)
All checks were successful
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / TruffleHog (pull_request) Successful in 9s
check-secrets / Kingfisher (pull_request) Successful in 10s
check-secrets / Nosey parker (push) Successful in 10s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 1m46s
5ea41131e1
Review follow-up: the success paths swapped the dialog out without disposing the focus trap, relying on the detached node being GC'd - functionally fine but fragile and divergent from the scheduled-modal convention where every path runs cleanup. Route all paths through an explicit disposeTrap(): the custom-fetch success disposes inline before swapping, and the HTMX preset path disposes via htmx:beforeCleanupElement as the dialog is torn down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch chore/lc-158-reminder-modal-a11y 2026-05-26 15:49:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!210
No description provided.