diff --git a/src/components/auth/password-checklist.tsx b/src/components/auth/password-checklist.tsx index 7abfa718..41b5e7ac 100644 --- a/src/components/auth/password-checklist.tsx +++ b/src/components/auth/password-checklist.tsx @@ -1,7 +1,8 @@ +import { useState } from "react"; import { Circle, CircleCheck } from "lucide-react"; import { Text } from "@/components/ui/text"; -import { evaluatePasswordRules } from "@/domain/auth/password-policy"; +import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy"; import { cn } from "@/lib/utils"; export type PasswordChecklistProps = { @@ -10,29 +11,53 @@ export type PasswordChecklistProps = { className?: string; }; -/** Live, per-rule feedback for the shared password policy. */ +function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string { + return next + .filter((rule, index) => rule.met !== previous[index]?.met) + .map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`) + .join(". "); +} + +/** + * Live, per-rule feedback for the shared password policy. A polite status region + * announces only the rules whose state changed, so screen-reader users hear + * progress while typing without the whole list being re-read on each keystroke. + */ export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) { const results = evaluatePasswordRules(password); + const signature = results.map((rule) => (rule.met ? "1" : "0")).join(""); + const [previous, setPrevious] = useState({ signature, results }); + const [announcement, setAnnouncement] = useState(""); + + if (previous.signature !== signature) { + setPrevious({ signature, results }); + setAnnouncement(describeChanges(previous.results, results)); + } return ( - + <> + + + {announcement} + + ); } diff --git a/src/test/components/auth/password-checklist.test.tsx b/src/test/components/auth/password-checklist.test.tsx index 391310b4..2b7bc6dc 100644 --- a/src/test/components/auth/password-checklist.test.tsx +++ b/src/test/components/auth/password-checklist.test.tsx @@ -62,6 +62,30 @@ describe("PasswordChecklist", () => { expect(screen.getAllByText("requirement met")).toHaveLength(4); }); + it("announces only the rules whose state changed through a polite status region", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Password"); + const status = screen.getByRole("status"); + + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent(/^$/); + + await user.type(input, "A"); + expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/); + + await user.type(input, "b"); + expect(status).toHaveTextContent(/^One uppercase letter: requirement met$/); + + await user.type(input, "1!xy"); + expect(status).toHaveTextContent(/^At least 6 characters: requirement met$/); + + await user.clear(input); + expect(status).toHaveTextContent( + "At least 6 characters: requirement not met. One uppercase letter: requirement not met. One number: requirement not met. One special character: requirement not met", + ); + }); + it("marks a rule unmet again when the user deletes the character that satisfied it", async () => { const user = userEvent.setup(); render();