From 31ba9da38b6c8b73329078d6611db370983ffda1 Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:17:45 +0100 Subject: [PATCH] test(#722): add keyboard accessibility coverage for the sell key modal TradeDialog is shared between the buy and sell flows; #700 already implemented and tested keyboard accessibility for the buy modal (initial focus on the amount input, focus trapping, Escape-to-close, Enter/Space activation of Confirm, focus return to the trigger). The sell variant shares that implementation but had no coverage guarding it. Add a sell-variant block to the TradeDialog a11y suite that verifies all five acceptance criteria from #722 against the real controls, plus an end-to-end LandingPage test proving the actual Sell button regains focus after the modal closes with Escape. --- .../__tests__/TradeDialog.a11y.test.tsx | 141 ++++++++++++++++-- .../LandingPage.sellFlow.integration.test.tsx | 24 +++ 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/components/common/__tests__/TradeDialog.a11y.test.tsx b/src/components/common/__tests__/TradeDialog.a11y.test.tsx index d8d30ff..80f646d 100644 --- a/src/components/common/__tests__/TradeDialog.a11y.test.tsx +++ b/src/components/common/__tests__/TradeDialog.a11y.test.tsx @@ -5,13 +5,17 @@ import { useState } from 'react'; import TradeDialog from '@/components/common/TradeDialog'; /** - * Keyboard accessibility for the buy key modal (#700). TradeDialog is built - * on Radix's Dialog primitive, which natively provides focus trapping, - * Escape-to-close, and focus-return-to-trigger-on-close. These tests verify - * that contract holds for THIS dialog's actual controls (amount input, - * Cancel, Confirm) rather than assuming the primitive is wired correctly, - * since `onOpenAutoFocus`/`onEscapeKeyDown` overrides in TradeDialog.tsx - * could silently break any of these if changed carelessly. + * Keyboard accessibility for the buy (#700) and sell (#722) key modals. + * TradeDialog is built on Radix's Dialog primitive, which natively provides + * focus trapping, Escape-to-close, and focus-return-to-trigger-on-close. + * These tests verify that contract holds for THIS dialog's actual controls + * (amount input, Cancel, Confirm) rather than assuming the primitive is + * wired correctly, since `onOpenAutoFocus`/`onEscapeKeyDown` overrides in + * TradeDialog.tsx could silently break any of these if changed carelessly. + * + * The modal is shared between the buy and sell flows (LandingPage.tsx opens + * it with `openTradeDialog('buy' | 'sell')`), so both variants are guarded + * here. */ describe('TradeDialog keyboard accessibility', () => { /** @@ -24,14 +28,15 @@ describe('TradeDialog keyboard accessibility', () => { overrides: Partial> = {} ) { const [open, setOpen] = useState(false); + const side = overrides.side ?? 'buy'; return ( <> { expect(trigger).toHaveFocus(); }); }); + + describe('sell key modal keyboard accessibility (#722)', () => { + it('sets initial focus on the quantity input when the sell modal opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open sell dialog' })); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + }); + + it('traps focus inside the sell modal: Tab from Confirm cycles back into the dialog', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open sell dialog' }); + await user.click(trigger); + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + screen.getByTestId('trade-dialog-confirm').focus(); + expect(screen.getByTestId('trade-dialog-confirm')).toHaveFocus(); + + await user.tab(); + + expect(trigger).not.toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement).not.toBe(document.documentElement); + }); + + it('reaches the confirm button via Tab from the amount input in the sell modal', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.tab(); + await user.tab(); + + expect(screen.getByTestId('trade-dialog-confirm')).toHaveFocus(); + }); + + it('pressing Escape closes the sell modal', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.keyboard('{Escape}'); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('activates the sell confirm button via Enter when focused', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + screen.getByTestId('trade-dialog-confirm').focus(); + await user.keyboard('{Enter}'); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('activates the sell confirm button via Space when focused', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + screen.getByTestId('trade-dialog-confirm').focus(); + await user.keyboard(' '); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('returns focus to the sell trigger button when the modal closes', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open sell dialog' }); + await user.click(trigger); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it('returns focus to the sell trigger when closed via the Cancel button', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open sell dialog' }); + await user.click(trigger); + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.click(screen.getByTestId('trade-dialog-cancel')); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + }); }); diff --git a/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx index bcb549f..5d9d8e2 100644 --- a/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx +++ b/src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx @@ -275,4 +275,28 @@ describe('LandingPage sell flow end-to-end (#644)', () => { { timeout: 5000 } ); }, 15000); + + it('is keyboard accessible end-to-end (#722): focuses the amount input on open, closes on Escape, and returns focus to the Sell button', async () => { + renderLandingPage(); + await screen.findByText('3 keys ยท 0.05 XLM'); + + const [sellButton] = screen.getAllByRole('button', { + name: 'Sell', + hidden: true, + }); + sellButton.focus(); + fireEvent.click(sellButton); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + fireEvent.keyDown(document.activeElement as Element, { key: 'Escape' }); + + await waitFor(() => { + expect(sellButton).toHaveFocus(); + }); + + expect(screen.queryByTestId('trade-dialog-amount')).not.toBeInTheDocument(); + }, 15000); });