Modal accessibility — no Escape-to-close, focus trap, or scroll lock #111
Labels
No labels
bug
devops
duplicate
enhancement
good first issue
layout
next major release
next minor release
question
research
testing
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lvl0/fedi-feed-router#111
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The shared
x-form-modalcomponent (resources/views/components/form-modal.blade.php) has no keyboard accessibility. Modals can only be dismissed by clicking the backdrop or the × button — there is no Escape handling, no focus trap, and no focus restoration.Current gaps
aria-modal="true"androle="dialog"are present, but without a focus trap screen readers can still escape the dialogReference implementation already in the repo
The Breeze modal at
resources/views/components/modal.blade.phpimplements all of this with Alpine —focusables(),nextFocusable(),x-on:keydown.escape.window, tab/shift-tab handling, anddocument.body.classList.add('overflow-y-hidden').It is used only by the delete-account confirmation (
profile/partials/delete-user-form.blade.php). Its approach can be adapted, though note it is event-driven ($dispatch('open-modal', ...)) whereasx-form-modalis driven by Livewire boolean properties — so the wiring differs.Affected modals
All five using
x-form-modal:Why this surfaced
x-form-modalwas extracted in #108 while fixing modal rendering. The five modals previously had duplicated inline markup, all equally lacking accessibility; consolidating them made the gap uniform and visible in one place — and means fixing it once now covers every modal.Acceptance criteria
Notes
The close mechanism must keep working with Livewire's server-side state (
$showCreateModaletc.) — an Escape key handler that only hides the element client-side would leave the component's boolean out of sync, so it should dispatch to the same Livewire close method.Done in
034d0f7. Fixed once inx-form-modal, so all five modals are covered.Delivered
$wire.{{ $close }}()— the same Livewire method as the × button and backdrop, so$showCreateModalstays in sync. A client-only hide would have left the modal reappearing on the next re-render.aria-label="Close dialog"on the × buttonStructural note
Unlike the Breeze reference modal, every
x-form-modalcall site is wrapped in@if, so the component only exists in the DOM while open. Initial focus therefore runs ininit()and restoration indestroy(), rather than Breeze's$watch('show').Alpine teardown is a
destroy()method on the data object — there is nox-destroydirective (Alpine 3.15.4 registers 18 directives and that is not one of them). Alpine'sMutationObserver(onElRemoved→destroyTree) fires it on any DOM removal, so Livewire's morph triggers it without a Livewire-specific hook. An early draft of this usedx-destroyand would have left the body scroll-locked forever after closing any modal;FormModalAccessibilityTest::test_teardown_uses_a_data_method_not_a_directiveguards against that returning.Latent gaps, neither active today
el.offsetParent !== null, which is also null forposition: fixed. No current modal slot uses fixed positioning. One-line comment in the component.window, so only one modal may be open at a time. The@ifguards make that impossible today, but nothing in the component enforces it — worth checking before adding a nested or concurrent modal. Recorded in.claude/PLATFORM.md.Verification
968 tests / 2538 assertions passing (960 before), Pint clean (304 files), PHPStan clean.
Note the eight new tests assert rendered markup, not behaviour — there is no Dusk or Playwright in this suite, so they would catch the wiring being deleted but not an off-by-one in the focus index. The acceptance criterion "verified with keyboard only, no mouse" was met by hand: Escape, Tab cycling, and focus restoration all confirmed in a browser.