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 (
-
- {results.map(({ id: ruleId, label, met }) => (
- -
- {met ? (
-
- ) : (
-
- )}
-
- {label}
-
- {met ? "requirement met" : "requirement not met"}
-
- ))}
-
+ <>
+
+ {results.map(({ id: ruleId, label, met }) => (
+ -
+ {met ? (
+
+ ) : (
+
+ )}
+
+ {label}
+
+ {met ? "requirement met" : "requirement not met"}
+
+ ))}
+
+
+ {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();