Skip to main content
All Docs
FeaturesMaking Tax DigitalUpdated March 25, 2026

Accessibility Improvement: Full Focus Traps for All Dialogs (A11Y-03)

Accessibility Improvement: Full Focus Traps for All Dialogs (A11Y-03)

Version: 1.0.442
Category: Accessibility — Keyboard Navigation

Overview

As part of our ongoing accessibility programme, v1.0.442 closes a keyboard navigation gap that affected three dialogs in the application: ConfirmDialog, DeleteConfirmDialog, and MfaVerifyModal. Each of these dialogs was missing a complete focus trap, meaning keyboard users could Tab out of an open dialog and land on content hidden behind the backdrop.

The main Modal component already had a correct, fully-tested focus trap. This release extracts that implementation into a shared useFocusTrap hook and applies it consistently across all dialog components.


Background

A focus trap confines keyboard focus to the interactive elements within a dialog while it is open. Without one:

  • Pressing Tab on the last button in a dialog moves focus to the page behind it.
  • Screen readers announce content that is visually hidden.
  • Users can activate controls they cannot see, leading to unintended actions.

This behaviour violates WCAG 2.1 SC 2.1.2 (No Keyboard Trap) and the ARIA dialog pattern, which requires that focus remain within a modal dialog until it is explicitly dismissed.


What Was Fixed

ConfirmDialog

ConfirmDialog already saved and restored focus when opening and closing, and correctly handled the Escape key. However, pressing Tab while focused on the last button inside the dialog would move focus out to elements behind the backdrop.

Fix: The useFocusTrap hook is now applied on open. It queries all focusable elements within the dialog's ref and intercepts Tab and Shift+Tab to cycle focus within that list.

DeleteConfirmDialog

DeleteConfirmDialog (rendered within the transaction ledger) handled Escape dismissal but had no focus management of any kind — focus was never moved into the dialog on open, and Tab could immediately exit it.

Fix: Focus is now moved into the dialog on open, trapped within it, and restored to the triggering element on close.

MfaVerifyModal

MfaVerifyModal handled neither Escape nor focus trapping. This is particularly sensitive because the MFA verification step is a security-critical flow.

Fix: Both Escape handling and the full focus trap are now applied via the shared hook.


Shared useFocusTrap Hook

To avoid duplicating focus trap logic across components, the pattern from modal.tsx has been extracted into a reusable hook:

// src/hooks/useFocusTrap.ts
useFocusTrap(dialogRef, open);

The hook:

  1. Runs when open becomes true and a valid dialogRef is attached.
  2. Queries all focusable elements using a standard selector list (FOCUSABLE_SELECTORS).
  3. Moves focus to the first focusable element on open.
  4. Intercepts keydown events for Tab and Shift+Tab, cycling focus within the queried list.
  5. Cleans up the event listener when the dialog closes or the component unmounts.

Any future dialog or modal component should use this hook rather than re-implementing the pattern.


Impact

ComponentEscape closesFocus restored on closeFocus trapped
Modal✅ (pre-existing)✅ (pre-existing)✅ (pre-existing)
ConfirmDialog✅ (pre-existing)✅ (pre-existing)Fixed in 1.0.442
DeleteConfirmDialog✅ (pre-existing)Fixed in 1.0.442Fixed in 1.0.442
MfaVerifyModalFixed in 1.0.442Fixed in 1.0.442Fixed in 1.0.442

For Developers

When building new dialogs or modals, apply the hook as follows:

import { useFocusTrap } from '@/hooks/useFocusTrap';

function MyDialog({ open }: { open: boolean }) {
  const dialogRef = useRef<HTMLDivElement>(null);
  useFocusTrap(dialogRef, open);

  return (
    <div ref={dialogRef} role="dialog" aria-modal="true">
      {/* dialog content */}
    </div>
  );
}

Ensure the container element has role="dialog" and aria-modal="true" set so assistive technologies understand the focus context.


References