Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 132 additions & 9 deletions src/components/common/__tests__/TradeDialog.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
/**
Expand All @@ -24,14 +28,15 @@ describe('TradeDialog keyboard accessibility', () => {
overrides: Partial<React.ComponentProps<typeof TradeDialog>> = {}
) {
const [open, setOpen] = useState(false);
const side = overrides.side ?? 'buy';
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open buy dialog
{`Open ${side} dialog`}
</button>
<TradeDialog
open={open}
side="buy"
side={side}
creatorName="Alice"
availableHoldings={10}
onOpenChange={setOpen}
Expand Down Expand Up @@ -176,4 +181,122 @@ describe('TradeDialog keyboard accessibility', () => {
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(<DialogWithTrigger side="sell" />);

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(<DialogWithTrigger side="sell" />);

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(<DialogWithTrigger side="sell" open />);

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(<DialogWithTrigger side="sell" onOpenChange={onOpenChange} open />);

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(<DialogWithTrigger side="sell" onConfirm={onConfirm} open />);

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(<DialogWithTrigger side="sell" onConfirm={onConfirm} open />);

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(<DialogWithTrigger side="sell" />);

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(<DialogWithTrigger side="sell" />);

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();
});
});
});
});
24 changes: 24 additions & 0 deletions src/pages/__tests__/LandingPage.sellFlow.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading