mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 14:23:13 +00:00
fix(auth): announce password rule changes to screen readers
This commit is contained in:
parent
e6250fba88
commit
cadfe1ee8d
2 changed files with 70 additions and 21 deletions
|
|
@ -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 (
|
||||
<ul
|
||||
id={id}
|
||||
aria-label="Password requirements"
|
||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
||||
>
|
||||
{results.map(({ id: ruleId, label, met }) => (
|
||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
||||
{met ? (
|
||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
||||
)}
|
||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul
|
||||
id={id}
|
||||
aria-label="Password requirements"
|
||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
||||
>
|
||||
{results.map(({ id: ruleId, label, met }) => (
|
||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
||||
{met ? (
|
||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
||||
) : (
|
||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
||||
)}
|
||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Text variant="feedback" className="sr-only">
|
||||
{announcement}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<ChecklistHarness />);
|
||||
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(<ChecklistHarness />);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue