From 227eed685c3461eeb270633a919f1b8258210496 Mon Sep 17 00:00:00 2001 From: unajiogenyi05-hub Date: Sat, 25 Jul 2026 13:54:11 +0000 Subject: [PATCH] a11y(dialogs): full keyboard operability - Fix ConfirmDialog cancel button: add focus-visible ring (was unstyled) - Fix ConfirmDialog confirm button: tone-aware ring (blue default / red destructive) replacing the broken primary-600 token - Enable restoreFocus in useDialogFocusTrap so trigger regains focus after dialog closes (WCAG 2.1 SC 3.2.2) - Add ConfirmDialogKeyboard.test.tsx with 21 tests covering: tab order, wrap forward/backward, Enter/Space activation, Escape from any position, focus restoration, focus-visible classes, and destructive tone keyboard flow --- src/components/ConfirmDialog.tsx | 15 +- .../__tests__/ConfirmDialogKeyboard.test.tsx | 371 ++++++++++++++++++ 2 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 src/components/__tests__/ConfirmDialogKeyboard.test.tsx diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 36ff1265..2095284e 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -56,6 +56,9 @@ export const ConfirmDialog: React.FC = ({ dialogRef, initialFocusRef: cancelBtnRef, onEscape: onCancel, + // Restore focus to the element that opened the dialog (trigger button) + // when the dialog closes or unmounts — satisfies WCAG 2.1 SC 3.2.2. + restoreFocus: true, }); useEffect(() => { @@ -138,18 +141,26 @@ export const ConfirmDialog: React.FC = ({

{description}

+ {/* Cancel — receives initial focus; explicit focus-visible ring for keyboard users */} + {/* Confirm — styled to its tone; consistent focus-visible ring */} diff --git a/src/components/__tests__/ConfirmDialogKeyboard.test.tsx b/src/components/__tests__/ConfirmDialogKeyboard.test.tsx new file mode 100644 index 00000000..8e3f4494 --- /dev/null +++ b/src/components/__tests__/ConfirmDialogKeyboard.test.tsx @@ -0,0 +1,371 @@ +/** + * Keyboard operability tests for ConfirmDialog. + * + * Requirements verified: + * - All interactive controls are reachable and operable by keyboard. + * - Logical focus order (cancel before confirm). + * - Initial focus lands on cancel button when dialog opens. + * - Tab wraps forward (last → first) and Shift+Tab wraps backward (first → last). + * - Enter / Space activate each button. + * - Escape triggers onCancel from any focus position. + * - Both buttons carry a visible focus-visible class for CSS ring styling. + * - Focus is restored to the trigger element after the dialog closes. + */ + +import React, { useRef, useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfirmDialog } from '../ConfirmDialog'; + +// --------------------------------------------------------------------------- +// Harness — wraps ConfirmDialog with a trigger button so focus-restoration +// tests can verify the trigger re-receives focus after close. +// --------------------------------------------------------------------------- + +interface HarnessProps { + tone?: 'default' | 'destructive'; + onConfirm?: () => void; + onCancel?: () => void; +} + +function DialogHarness({ + tone = 'default', + onConfirm = jest.fn(), + onCancel = jest.fn(), +}: HarnessProps) { + const [open, setOpen] = useState(false); + const wrappedConfirm = () => { + onConfirm(); + setOpen(false); + }; + const wrappedCancel = () => { + onCancel(); + setOpen(false); + }; + return ( + <> + + + + ); +} + +// --------------------------------------------------------------------------- +// Focus order +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — focus order', () => { + it('initial focus is on the cancel button when the dialog opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + + expect(screen.getByRole('button', { name: 'No, cancel' })).toHaveFocus(); + }); + + it('Tab moves focus from cancel to confirm (logical order)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Cancel has focus; Tab → Confirm + await user.tab(); + + expect(screen.getByRole('button', { name: 'Yes, confirm' })).toHaveFocus(); + }); + + it('Tab wraps from confirm back to cancel (focus trap — forward)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Cancel → Confirm → (wrap) → Cancel + await user.tab(); + await user.tab(); + + expect(screen.getByRole('button', { name: 'No, cancel' })).toHaveFocus(); + }); + + it('Shift+Tab wraps from cancel back to confirm (focus trap — backward)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Cancel has focus; Shift+Tab → (wrap) → Confirm + await user.tab({ shift: true }); + + expect(screen.getByRole('button', { name: 'Yes, confirm' })).toHaveFocus(); + }); + + it('focus does not escape the dialog when tabbing from the last button', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Tab to confirm, then Tab again — should wrap, not escape to the trigger + await user.tab(); + await user.tab(); + + // Focus must be on one of the dialog buttons (cancel or confirm) + const focused = document.activeElement; + const dialogButtons = screen + .getAllByRole('button') + .filter((b) => b.getAttribute('aria-hidden') !== 'true'); + expect(dialogButtons).toContain(focused); + }); +}); + +// --------------------------------------------------------------------------- +// Enter / Space activation +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — Enter and Space activation', () => { + it('pressing Enter on the confirm button calls onConfirm', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Tab to confirm + await user.tab(); + await user.keyboard('{Enter}'); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('pressing Space on the confirm button calls onConfirm', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.tab(); + await user.keyboard(' '); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('pressing Enter on the cancel button calls onCancel', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + // Cancel already has focus + await user.keyboard('{Enter}'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('pressing Space on the cancel button calls onCancel', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.keyboard(' '); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Escape key +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — Escape key', () => { + it('Escape calls onCancel when focus is on cancel button', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.keyboard('{Escape}'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('Escape calls onCancel when focus is on confirm button', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.tab(); // move to confirm + await user.keyboard('{Escape}'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('dialog is removed from the DOM after Escape', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Focus restoration +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — focus restoration', () => { + it('focus returns to the trigger after closing with Escape', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(trigger).toHaveFocus(); + }); + + it('focus returns to the trigger after cancel button is activated', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + await user.click(trigger); + await user.keyboard('{Enter}'); // activate cancel (initial focus) + + expect(trigger).toHaveFocus(); + }); + + it('focus returns to the trigger after confirm button is activated', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + await user.click(trigger); + await user.tab(); // move to confirm + await user.keyboard('{Enter}'); + + expect(trigger).toHaveFocus(); + }); +}); + +// --------------------------------------------------------------------------- +// Visible focus styles +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — visible focus styles', () => { + it('cancel button has focus-visible class for CSS ring styling', () => { + render( + , + ); + + const cancelBtn = screen.getByRole('button', { name: 'Cancel' }); + expect(cancelBtn.className).toMatch(/focus-visible/); + }); + + it('confirm button has focus-visible class for CSS ring styling', () => { + render( + , + ); + + const confirmBtn = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmBtn.className).toMatch(/focus-visible/); + }); + + it('destructive confirm button has a red focus ring class', () => { + render( + , + ); + + const deleteBtn = screen.getByRole('button', { name: 'Delete' }); + // Destructive tone should carry a red ring variant + expect(deleteBtn.className).toMatch(/ring-red/); + }); + + it('default confirm button does not carry a red focus ring class', () => { + render( + , + ); + + const confirmBtn = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmBtn.className).not.toMatch(/ring-red/); + // Should use blue instead + expect(confirmBtn.className).toMatch(/ring-blue/); + }); +}); + +// --------------------------------------------------------------------------- +// Destructive tone keyboard operability +// --------------------------------------------------------------------------- + +describe('ConfirmDialog keyboard — destructive tone', () => { + it('alertdialog responds to Escape and calls onCancel', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('alertdialog confirm is reachable by Tab and activatable with Enter', async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.tab(); // cancel → confirm + await user.keyboard('{Enter}'); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +});