Modal accessibility — no Escape-to-close, focus trap, or scroll lock #111

Closed
opened 2026-08-01 00:11:59 +02:00 by myrmidex · 1 comment
Owner

Summary

The shared x-form-modal component (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

  • No Escape to close — keyboard users cannot dismiss a modal without reaching for the mouse
  • No focus trap — Tab moves focus out of the modal and into the page behind it
  • No initial focus — focus stays on the trigger button rather than moving into the dialog
  • No focus restoration — on close, focus is not returned to the element that opened the modal
  • No body scroll lock — the page behind scrolls while the modal is open
  • aria-modal="true" and role="dialog" are present, but without a focus trap screen readers can still escape the dialog

Reference implementation already in the repo

The Breeze modal at resources/views/components/modal.blade.php implements all of this with Alpine — focusables(), nextFocusable(), x-on:keydown.escape.window, tab/shift-tab handling, and document.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', ...)) whereas x-form-modal is driven by Livewire boolean properties — so the wiring differs.

Affected modals

All five using x-form-modal:

  • Channels — create, manage accounts
  • Feeds — create
  • Routes — create, edit

Why this surfaced

x-form-modal was 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

  • Escape closes the modal
  • Tab and Shift+Tab cycle focus within the modal only
  • Focus moves into the dialog on open
  • Focus returns to the triggering element on close
  • Body scroll is locked while open
  • Behaviour verified with keyboard only, no mouse
  • Existing Livewire tests still pass

Notes

The close mechanism must keep working with Livewire's server-side state ($showCreateModal etc.) — 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.

## Summary The shared `x-form-modal` component (`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 - **No Escape to close** — keyboard users cannot dismiss a modal without reaching for the mouse - **No focus trap** — Tab moves focus out of the modal and into the page behind it - **No initial focus** — focus stays on the trigger button rather than moving into the dialog - **No focus restoration** — on close, focus is not returned to the element that opened the modal - **No body scroll lock** — the page behind scrolls while the modal is open - `aria-modal="true"` and `role="dialog"` are present, but without a focus trap screen readers can still escape the dialog ## Reference implementation already in the repo The Breeze modal at `resources/views/components/modal.blade.php` implements all of this with Alpine — `focusables()`, `nextFocusable()`, `x-on:keydown.escape.window`, tab/shift-tab handling, and `document.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', ...)`) whereas `x-form-modal` is driven by Livewire boolean properties — so the wiring differs. ## Affected modals All five using `x-form-modal`: - Channels — create, manage accounts - Feeds — create - Routes — create, edit ## Why this surfaced `x-form-modal` was 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 - [ ] Escape closes the modal - [ ] Tab and Shift+Tab cycle focus within the modal only - [ ] Focus moves into the dialog on open - [ ] Focus returns to the triggering element on close - [ ] Body scroll is locked while open - [ ] Behaviour verified with keyboard only, no mouse - [ ] Existing Livewire tests still pass ## Notes The close mechanism must keep working with Livewire's server-side state (`$showCreateModal` etc.) — 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.
myrmidex added this to the v1.4.0 milestone 2026-08-01 00:11:59 +02:00
myrmidex added the
enhancement
label 2026-08-01 00:11:59 +02:00
myrmidex self-assigned this 2026-08-01 00:11:59 +02:00
Author
Owner

Done in 034d0f7. Fixed once in x-form-modal, so all five modals are covered.

Delivered

  • Escape closes the modal via $wire.{{ $close }}() — the same Livewire method as the × button and backdrop, so $showCreateModal stays in sync. A client-only hide would have left the modal reappearing on the next re-render.
  • Tab / Shift+Tab cycle focus within the dialog
  • Focus moves to the first focusable element on open
  • Focus returns to the triggering element on close
  • Body scroll locked while open
  • aria-label="Close dialog" on the × button

Structural note

Unlike the Breeze reference modal, every x-form-modal call site is wrapped in @if, so the component only exists in the DOM while open. Initial focus therefore runs in init() and restoration in destroy(), rather than Breeze's $watch('show').

Alpine teardown is a destroy() method on the data object — there is no x-destroy directive (Alpine 3.15.4 registers 18 directives and that is not one of them). Alpine's MutationObserver (onElRemoveddestroyTree) fires it on any DOM removal, so Livewire's morph triggers it without a Livewire-specific hook. An early draft of this used x-destroy and would have left the body scroll-locked forever after closing any modal; FormModalAccessibilityTest::test_teardown_uses_a_data_method_not_a_directive guards against that returning.

Latent gaps, neither active today

  • The focus trap filters on el.offsetParent !== null, which is also null for position: fixed. No current modal slot uses fixed positioning. One-line comment in the component.
  • The Escape listener is bound to window, so only one modal may be open at a time. The @if guards 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.

Done in `034d0f7`. Fixed once in `x-form-modal`, so all five modals are covered. ## Delivered - Escape closes the modal via `$wire.{{ $close }}()` — the same Livewire method as the × button and backdrop, so `$showCreateModal` stays in sync. A client-only hide would have left the modal reappearing on the next re-render. - Tab / Shift+Tab cycle focus within the dialog - Focus moves to the first focusable element on open - Focus returns to the triggering element on close - Body scroll locked while open - `aria-label="Close dialog"` on the × button ## Structural note Unlike the Breeze reference modal, every `x-form-modal` call site is wrapped in `@if`, so the component only exists in the DOM while open. Initial focus therefore runs in `init()` and restoration in `destroy()`, rather than Breeze's `$watch('show')`. Alpine teardown is a **`destroy()` method on the data object** — there is no `x-destroy` directive (Alpine 3.15.4 registers 18 directives and that is not one of them). Alpine's `MutationObserver` (`onElRemoved` → `destroyTree`) fires it on any DOM removal, so Livewire's morph triggers it without a Livewire-specific hook. An early draft of this used `x-destroy` and would have left the body scroll-locked forever after closing any modal; `FormModalAccessibilityTest::test_teardown_uses_a_data_method_not_a_directive` guards against that returning. ## Latent gaps, neither active today - The focus trap filters on `el.offsetParent !== null`, which is also null for `position: fixed`. No current modal slot uses fixed positioning. One-line comment in the component. - The Escape listener is bound to `window`, so **only one modal may be open at a time**. The `@if` guards 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.
Sign in to join this conversation.
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: lvl0/fedi-feed-router#111
No description provided.