Skip to content

Commit e50de18

Browse files
authored
Merge pull request #996 from CmxTop/fix/fe-kyc-animations-tests-a11y-optimistic-updates-963-964-965-966
feat(frontend): improve KYC Submission Form - screen reader support a…
2 parents ce04e3a + 285349e commit e50de18

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

frontend/src/components/KycSubmissionForm.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,4 +412,134 @@ describe("KycSubmissionForm", () => {
412412
render(React.createElement(KycSubmissionForm));
413413
expect(screen.getByRole("region")).toBeInTheDocument();
414414
});
415+
416+
// ── Screen reader support ─────────────────────────────────────────────────
417+
418+
it("step listitems have descriptive aria-labels including step name and status", () => {
419+
const { container } = render(React.createElement(KycSubmissionForm));
420+
const items = container.querySelectorAll('[role="listitem"]');
421+
expect(items[0]).toHaveAttribute("aria-label", expect.stringContaining("personalInfo"));
422+
expect(items[0]).toHaveAttribute("aria-label", expect.stringContaining("current"));
423+
expect(items[1]).toHaveAttribute("aria-label", expect.stringContaining("addressInfo"));
424+
expect(items[1]).toHaveAttribute("aria-label", expect.stringContaining("upcoming"));
425+
});
426+
427+
it("step listitem status updates to completed after advancing past it", () => {
428+
const { container } = render(React.createElement(KycSubmissionForm));
429+
navigateToStep(1);
430+
const items = container.querySelectorAll('[role="listitem"]');
431+
expect(items[0]).toHaveAttribute("aria-label", expect.stringContaining("completed"));
432+
expect(items[1]).toHaveAttribute("aria-label", expect.stringContaining("current"));
433+
});
434+
435+
it("progressbar aria-label includes current step name", () => {
436+
render(React.createElement(KycSubmissionForm));
437+
const progressbar = screen.getByRole("progressbar");
438+
expect(progressbar).toHaveAttribute("aria-label", expect.stringContaining("personalInfo"));
439+
});
440+
441+
it("progressbar aria-label updates to next step name after navigation", () => {
442+
render(React.createElement(KycSubmissionForm));
443+
navigateToStep(1);
444+
const progressbar = screen.getByRole("progressbar");
445+
expect(progressbar).toHaveAttribute("aria-label", expect.stringContaining("addressInfo"));
446+
});
447+
448+
it("back button has descriptive aria-label with destination step name", () => {
449+
render(React.createElement(KycSubmissionForm));
450+
navigateToStep(1);
451+
const backBtn = screen.getByText("back").closest("button")!;
452+
expect(backBtn).toHaveAttribute("aria-label", expect.stringContaining("personalInfo"));
453+
});
454+
455+
it("next button has descriptive aria-label with destination step name", () => {
456+
render(React.createElement(KycSubmissionForm));
457+
const nextBtn = screen.getByText("next").closest("button")!;
458+
expect(nextBtn).toHaveAttribute("aria-label", expect.stringContaining("addressInfo"));
459+
});
460+
461+
// ── Additional unit tests ─────────────────────────────────────────────────
462+
463+
it("shows validation error message text for required fields", () => {
464+
render(React.createElement(KycSubmissionForm));
465+
fireEvent.click(screen.getByText("next"));
466+
expect(screen.getAllByText("required").length).toBeGreaterThanOrEqual(1);
467+
});
468+
469+
it("sets aria-describedby on invalid firstName input pointing to error element", () => {
470+
render(React.createElement(KycSubmissionForm));
471+
fireEvent.click(screen.getByText("next"));
472+
const firstNameInput = screen.getByPlaceholderText("firstName");
473+
const describedBy = firstNameInput.getAttribute("aria-describedby");
474+
expect(describedBy).toBeTruthy();
475+
// useId() produces IDs with colons — use getElementById, not querySelector
476+
const errorEl = document.getElementById(describedBy!);
477+
expect(errorEl).toBeInTheDocument();
478+
});
479+
480+
it("clears validation errors after filling required fields and navigating", () => {
481+
render(React.createElement(KycSubmissionForm));
482+
fireEvent.click(screen.getByText("next"));
483+
expect(screen.getByPlaceholderText("firstName")).toHaveAttribute("aria-invalid", "true");
484+
485+
fillPersonalStep();
486+
fireEvent.click(screen.getByText("next"));
487+
fireEvent.click(screen.getByText("back"));
488+
expect(screen.getByPlaceholderText("firstName")).toHaveAttribute("aria-invalid", "false");
489+
});
490+
491+
it("accepts file upload on idBack input", () => {
492+
render(React.createElement(KycSubmissionForm));
493+
navigateToStep(2);
494+
const idBackInput = screen.getByLabelText("idBack") as HTMLInputElement;
495+
const file = new File(["content"], "id-back.png", { type: "image/png" });
496+
fireEvent.change(idBackInput, { target: { files: [file] } });
497+
expect(idBackInput.files?.[0]).toBe(file);
498+
});
499+
500+
it("accepts file upload on selfie input", () => {
501+
render(React.createElement(KycSubmissionForm));
502+
navigateToStep(2);
503+
const selfieInput = screen.getByLabelText("selfie") as HTMLInputElement;
504+
const file = new File(["content"], "selfie.jpg", { type: "image/jpeg" });
505+
fireEvent.change(selfieInput, { target: { files: [file] } });
506+
expect(selfieInput.files?.[0]).toBe(file);
507+
});
508+
509+
it("updates idType select on documents step", () => {
510+
render(React.createElement(KycSubmissionForm));
511+
navigateToStep(2);
512+
const select = screen.getByLabelText("idType") as HTMLSelectElement;
513+
fireEvent.change(select, { target: { value: "passport" } });
514+
expect(select.value).toBe("passport");
515+
});
516+
517+
it("updates idNumber field on documents step", () => {
518+
render(React.createElement(KycSubmissionForm));
519+
navigateToStep(2);
520+
const idNumberInput = screen.getByPlaceholderText("idNumber") as HTMLInputElement;
521+
fireEvent.change(idNumberInput, { target: { value: "A1234567" } });
522+
expect(idNumberInput.value).toBe("A1234567");
523+
});
524+
525+
it("review step shows dash for empty optional fields", () => {
526+
render(React.createElement(KycSubmissionForm));
527+
fillPersonalStep();
528+
fireEvent.click(screen.getByText("next")); // → address
529+
fireEvent.click(screen.getByText("next")); // → documents
530+
fireEvent.click(screen.getByText("next")); // → review
531+
// city was not filled, so it renders the placeholder dash
532+
const dashes = screen.getAllByText("—");
533+
expect(dashes.length).toBeGreaterThan(0);
534+
});
535+
536+
it("shows error alert in review step when submission fails", async () => {
537+
(global.fetch as any).mockResolvedValue({ ok: false });
538+
render(React.createElement(KycSubmissionForm));
539+
navigateToStep(3);
540+
fireEvent.click(screen.getByText("submit"));
541+
await waitFor(() => {
542+
expect(screen.getByRole("alert")).toBeInTheDocument();
543+
});
544+
});
415545
});

frontend/src/components/KycSubmissionForm.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313
const STEPS: KycStep[] = ["personal", "address", "documents", "review"];
1414
const TOTAL_STEPS = STEPS.length;
1515

16+
const STEP_LABEL_KEYS: Record<KycStep, string> = {
17+
personal: "personalInfo",
18+
address: "addressInfo",
19+
documents: "documents",
20+
review: "review",
21+
};
22+
1623
const stepVariants: Variants = {
1724
enter: (dir: number) => ({
1825
x: dir > 0 ? 48 : -48,
@@ -101,8 +108,9 @@ function KycSubmissionForm() {
101108
setDirection(1);
102109
dispatch({ type: "SET_STEP", step: STEPS[stepIndex + 1]! });
103110
setStepErrors({});
111+
const nextStep = STEPS[stepIndex + 1]!;
104112
setAnnouncement(
105-
`${t("step") || "Step"} ${stepIndex + 2} ${t("of") || "of"} ${TOTAL_STEPS}`,
113+
`${t("step") || "Step"} ${stepIndex + 2} ${t("of") || "of"} ${TOTAL_STEPS}: ${t(STEP_LABEL_KEYS[nextStep]) || nextStep}`,
106114
);
107115
}
108116
}, [validateCurrentStep, stepIndex, t]);
@@ -220,7 +228,7 @@ function KycSubmissionForm() {
220228
aria-valuenow={stepIndex + 1}
221229
aria-valuemin={1}
222230
aria-valuemax={TOTAL_STEPS}
223-
aria-label={`${t("step") || "Step"} ${stepIndex + 1} ${t("of") || "of"} ${TOTAL_STEPS}`}
231+
aria-label={`${t("step") || "Step"} ${stepIndex + 1} ${t("of") || "of"} ${TOTAL_STEPS}: ${t(STEP_LABEL_KEYS[state.currentStep]) || state.currentStep}`}
224232
className="space-y-2"
225233
>
226234
<div className="flex justify-between text-xs text-pluto-600">
@@ -233,6 +241,7 @@ function KycSubmissionForm() {
233241
<div
234242
key={s}
235243
role="listitem"
244+
aria-label={`${t(STEP_LABEL_KEYS[s]) || s}${i < stepIndex ? "completed" : i === stepIndex ? "current" : "upcoming"}`}
236245
aria-current={i === stepIndex ? "step" : undefined}
237246
className={`h-2 flex-1 rounded-full transition-colors duration-300 ${
238247
i <= stepIndex ? "bg-pluto-600" : "bg-pluto-100"
@@ -547,6 +556,7 @@ function KycSubmissionForm() {
547556
type="button"
548557
onClick={goBack}
549558
disabled={stepIndex === 0}
559+
aria-label={stepIndex > 0 ? `${t("back")} to ${t(STEP_LABEL_KEYS[STEPS[stepIndex - 1]!]) || STEPS[stepIndex - 1]}` : t("back")}
550560
className="flex-1 rounded-xl border border-pluto-200 bg-white px-6 py-3 font-semibold text-pluto-900 hover:bg-pluto-50 focus:outline-none focus:ring-2 focus:ring-pluto-400 disabled:opacity-40 disabled:cursor-not-allowed"
551561
>
552562
{t("back")}
@@ -556,6 +566,7 @@ function KycSubmissionForm() {
556566
<button
557567
type="button"
558568
onClick={goNext}
569+
aria-label={`${t("next")}: ${t(STEP_LABEL_KEYS[STEPS[stepIndex + 1]!]) || STEPS[stepIndex + 1]}`}
559570
className="flex-1 rounded-xl bg-pluto-600 px-6 py-3 font-semibold text-white hover:bg-pluto-700 focus:outline-none focus:ring-2 focus:ring-pluto-400"
560571
>
561572
{t("next")}

0 commit comments

Comments
 (0)