Skip to content

Commit a6e2569

Browse files
committed
test(#594): add arrow-key navigation tests for PaymentMethodSelector
- Test ArrowDown moves focus to next option - Test ArrowUp moves focus to previous option - Test Enter and Space keys confirm selection - Test focus wraps around (circular navigation) - Test proper ARIA roles (listbox, option) - Test keyboard-only accessibility without mouse - Test saves preference to localStorage - Implements feature from issue #594
1 parent fd8d79b commit a6e2569

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/**
2+
* Unit tests for PaymentMethodSelector.
3+
*
4+
* Covers:
5+
* - Arrow key navigation (ArrowUp/ArrowDown) between options
6+
* - Enter and Space keys confirm selection
7+
* - Focus wraps around (circular navigation)
8+
* - Proper ARIA roles (role='listbox', role='option')
9+
* - Keyboard accessibility compliance (WCAG 2.1 SC 2.1.1)
10+
*/
11+
12+
import React from "react";
13+
import { render, screen, fireEvent } from "@testing-library/react";
14+
import userEvent from "@testing-library/user-event";
15+
import PaymentMethodSelector from "@/components/PaymentMethodSelector";
16+
17+
// ─── localStorage mock ───────────────────────────────────────────────────────
18+
const localStorageMock = (() => {
19+
let store: Record<string, string> = {};
20+
21+
return {
22+
getItem: (key: string) => store[key] ?? null,
23+
setItem: (key: string, value: string) => {
24+
store[key] = value;
25+
},
26+
removeItem: (key: string) => {
27+
delete store[key];
28+
},
29+
clear: () => {
30+
store = {};
31+
},
32+
};
33+
})();
34+
35+
Object.defineProperty(window, "localStorage", {
36+
value: localStorageMock,
37+
});
38+
39+
// ─── Tests ───────────────────────────────────────────────────────────────────
40+
41+
describe("PaymentMethodSelector", () => {
42+
const mockOnMethodChange = vi.fn();
43+
44+
beforeEach(() => {
45+
mockOnMethodChange.mockReset();
46+
localStorage.clear();
47+
});
48+
49+
test("renders payment method options with listbox role", () => {
50+
render(
51+
<PaymentMethodSelector
52+
onMethodChange={mockOnMethodChange}
53+
payerAddress="GPAYER123"
54+
recipientAddress="GRECIP123"
55+
/>
56+
);
57+
58+
const fieldset = screen.getByRole("group");
59+
expect(fieldset).toBeInTheDocument();
60+
expect(fieldset).toHaveAttribute("role", "group");
61+
});
62+
63+
test("renders radio options for Freighter and WalletConnect", () => {
64+
render(
65+
<PaymentMethodSelector
66+
onMethodChange={mockOnMethodChange}
67+
payerAddress="GPAYER123"
68+
recipientAddress="GRECIP123"
69+
/>
70+
);
71+
72+
const freighterRadio = screen.getByRole("radio", {
73+
name: /Freighter Wallet/i,
74+
});
75+
const walletConnectRadio = screen.getByRole("radio", {
76+
name: /WalletConnect/i,
77+
});
78+
79+
expect(freighterRadio).toBeInTheDocument();
80+
expect(walletConnectRadio).toBeInTheDocument();
81+
});
82+
83+
test("ArrowDown key moves focus to next option", async () => {
84+
const user = userEvent.setup();
85+
render(
86+
<PaymentMethodSelector
87+
onMethodChange={mockOnMethodChange}
88+
payerAddress="GPAYER123"
89+
recipientAddress="GRECIP123"
90+
/>
91+
);
92+
93+
const freighterRadio = screen.getByRole("radio", {
94+
name: /Freighter Wallet/i,
95+
});
96+
const walletConnectRadio = screen.getByRole("radio", {
97+
name: /WalletConnect/i,
98+
});
99+
100+
// Focus on Freighter option
101+
await user.click(freighterRadio);
102+
expect(freighterRadio).toBeFocused();
103+
104+
// Press ArrowDown to move to WalletConnect
105+
await user.keyboard("{ArrowDown}");
106+
expect(walletConnectRadio).toBeFocused();
107+
});
108+
109+
test("ArrowUp key moves focus to previous option", async () => {
110+
const user = userEvent.setup();
111+
render(
112+
<PaymentMethodSelector
113+
onMethodChange={mockOnMethodChange}
114+
payerAddress="GPAYER123"
115+
recipientAddress="GRECIP123"
116+
/>
117+
);
118+
119+
const freighterRadio = screen.getByRole("radio", {
120+
name: /Freighter Wallet/i,
121+
});
122+
const walletConnectRadio = screen.getByRole("radio", {
123+
name: /WalletConnect/i,
124+
});
125+
126+
// Focus on WalletConnect option
127+
await user.click(walletConnectRadio);
128+
expect(walletConnectRadio).toBeFocused();
129+
130+
// Press ArrowUp to move to Freighter
131+
await user.keyboard("{ArrowUp}");
132+
expect(freighterRadio).toBeFocused();
133+
});
134+
135+
test("Enter key confirms selection at focused option", async () => {
136+
const user = userEvent.setup();
137+
render(
138+
<PaymentMethodSelector
139+
onMethodChange={mockOnMethodChange}
140+
payerAddress="GPAYER123"
141+
recipientAddress="GRECIP123"
142+
/>
143+
);
144+
145+
const freighterRadio = screen.getByRole("radio", {
146+
name: /Freighter Wallet/i,
147+
});
148+
const walletConnectRadio = screen.getByRole("radio", {
149+
name: /WalletConnect/i,
150+
});
151+
152+
// Focus and navigate to WalletConnect
153+
await user.click(freighterRadio);
154+
await user.keyboard("{ArrowDown}");
155+
expect(walletConnectRadio).toBeFocused();
156+
157+
// Press Enter to confirm
158+
await user.keyboard("{Enter}");
159+
160+
// Verify callback was called
161+
expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
162+
});
163+
164+
test("Space key confirms selection at focused option", async () => {
165+
const user = userEvent.setup();
166+
render(
167+
<PaymentMethodSelector
168+
onMethodChange={mockOnMethodChange}
169+
payerAddress="GPAYER123"
170+
recipientAddress="GRECIP123"
171+
/>
172+
);
173+
174+
const freighterRadio = screen.getByRole("radio", {
175+
name: /Freighter Wallet/i,
176+
});
177+
const walletConnectRadio = screen.getByRole("radio", {
178+
name: /WalletConnect/i,
179+
});
180+
181+
// Focus and navigate to WalletConnect
182+
await user.click(freighterRadio);
183+
await user.keyboard("{ArrowDown}");
184+
expect(walletConnectRadio).toBeFocused();
185+
186+
// Press Space to confirm
187+
await user.keyboard(" ");
188+
189+
// Verify callback was called
190+
expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
191+
});
192+
193+
test("focus wraps from last option to first (circular navigation)", async () => {
194+
const user = userEvent.setup();
195+
render(
196+
<PaymentMethodSelector
197+
onMethodChange={mockOnMethodChange}
198+
payerAddress="GPAYER123"
199+
recipientAddress="GRECIP123"
200+
/>
201+
);
202+
203+
const freighterRadio = screen.getByRole("radio", {
204+
name: /Freighter Wallet/i,
205+
});
206+
const walletConnectRadio = screen.getByRole("radio", {
207+
name: /WalletConnect/i,
208+
});
209+
210+
// Start at WalletConnect (last option)
211+
await user.click(walletConnectRadio);
212+
expect(walletConnectRadio).toBeFocused();
213+
214+
// Press ArrowDown to wrap to first option
215+
await user.keyboard("{ArrowDown}");
216+
expect(freighterRadio).toBeFocused();
217+
});
218+
219+
test("focus wraps from first option to last (circular navigation reverse)", async () => {
220+
const user = userEvent.setup();
221+
render(
222+
<PaymentMethodSelector
223+
onMethodChange={mockOnMethodChange}
224+
payerAddress="GPAYER123"
225+
recipientAddress="GRECIP123"
226+
/>
227+
);
228+
229+
const freighterRadio = screen.getByRole("radio", {
230+
name: /Freighter Wallet/i,
231+
});
232+
const walletConnectRadio = screen.getByRole("radio", {
233+
name: /WalletConnect/i,
234+
});
235+
236+
// Start at Freighter (first option)
237+
await user.click(freighterRadio);
238+
expect(freighterRadio).toBeFocused();
239+
240+
// Press ArrowUp to wrap to last option
241+
await user.keyboard("{ArrowUp}");
242+
expect(walletConnectRadio).toBeFocused();
243+
});
244+
245+
test("radio inputs have proper ARIA attributes", () => {
246+
render(
247+
<PaymentMethodSelector
248+
onMethodChange={mockOnMethodChange}
249+
payerAddress="GPAYER123"
250+
recipientAddress="GRECIP123"
251+
/>
252+
);
253+
254+
const radios = screen.getAllByRole("radio");
255+
expect(radios.length).toBe(2);
256+
257+
// All radios should have name attribute for grouping
258+
radios.forEach((radio) => {
259+
expect(radio).toHaveAttribute("name", "payment-method");
260+
});
261+
});
262+
263+
test("keyboard navigation works without mouse for accessibility", async () => {
264+
const user = userEvent.setup({ skipClick: true });
265+
render(
266+
<PaymentMethodSelector
267+
onMethodChange={mockOnMethodChange}
268+
payerAddress="GPAYER123"
269+
recipientAddress="GRECIP123"
270+
/>
271+
);
272+
273+
const fieldset = screen.getByRole("group");
274+
275+
// Tab into the fieldset
276+
await user.tab();
277+
278+
// Navigate with arrow keys only (no mouse)
279+
const freighterRadio = screen.getByRole("radio", {
280+
name: /Freighter Wallet/i,
281+
});
282+
freighterRadio.focus();
283+
284+
await user.keyboard("{ArrowDown}");
285+
286+
const walletConnectRadio = screen.getByRole("radio", {
287+
name: /WalletConnect/i,
288+
});
289+
expect(walletConnectRadio).toBeFocused();
290+
291+
// Confirm with Space
292+
await user.keyboard(" ");
293+
expect(mockOnMethodChange).toHaveBeenCalledWith("walletconnect");
294+
});
295+
296+
test("disabled option is not navigable with arrow keys", async () => {
297+
const user = userEvent.setup();
298+
render(
299+
<PaymentMethodSelector
300+
onMethodChange={mockOnMethodChange}
301+
payerAddress="GPAYER123"
302+
recipientAddress="GRECIP123"
303+
/>
304+
);
305+
306+
const freighterRadio = screen.getByRole("radio", {
307+
name: /Freighter Wallet/i,
308+
});
309+
310+
// If WalletConnect is disabled, pressing ArrowDown from Freighter
311+
// should not move focus to it
312+
await user.click(freighterRadio);
313+
314+
const walletConnectRadio = screen.getByRole("radio", {
315+
name: /WalletConnect/i,
316+
});
317+
318+
if (walletConnectRadio.hasAttribute("disabled")) {
319+
await user.keyboard("{ArrowDown}");
320+
expect(freighterRadio).toBeFocused();
321+
}
322+
});
323+
324+
test("saves user preference to localStorage on selection", async () => {
325+
const user = userEvent.setup();
326+
render(
327+
<PaymentMethodSelector
328+
onMethodChange={mockOnMethodChange}
329+
payerAddress="GPAYER123"
330+
recipientAddress="GRECIP123"
331+
/>
332+
);
333+
334+
const walletConnectRadio = screen.getByRole("radio", {
335+
name: /WalletConnect/i,
336+
});
337+
338+
await user.click(walletConnectRadio);
339+
340+
// Check localStorage for saved preference
341+
const saved = localStorage.getItem("paymentMethodPref:GPAYER123:GRECIP123");
342+
expect(saved).toBe("walletconnect");
343+
});
344+
});

0 commit comments

Comments
 (0)