Skip to content

Commit 10438c0

Browse files
Olayinka AdelakunOlayinka Adelakun
authored andcommitted
fix(a11y): label Password and Profile Picture form landmarks on settings/general
1 parent 7f28257 commit 10438c0

4 files changed

Lines changed: 290 additions & 1 deletion

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { MemoryRouter } from "react-router-dom";
5+
import { AuthContext } from "@/contexts/authContext";
6+
import useAlertStore from "@/stores/alertStore";
7+
import useAuthStore from "@/stores/authStore";
8+
import type { AuthContextType } from "@/types/contexts/auth";
9+
import { axe } from "@/utils/a11y-test";
10+
11+
const mockResetPasswordMutate = jest.fn();
12+
const mockUpdateUserMutate = jest.fn();
13+
const mockAddApiKeyMutate = jest.fn();
14+
15+
jest.mock("@radix-ui/react-form", () => {
16+
const React = require("react");
17+
return {
18+
__esModule: true,
19+
Root: ({ children, ...props }) =>
20+
React.createElement("form", props, children),
21+
Field: ({ children, name }) =>
22+
React.createElement("div", { "data-field": name }, children),
23+
Label: ({ children, ...props }) =>
24+
React.createElement("label", props, children),
25+
Control: ({ children }) => children,
26+
Message: ({ children, ...props }) =>
27+
React.createElement("p", props, children),
28+
Submit: ({ children }) => children,
29+
};
30+
});
31+
32+
jest.mock("@/controllers/API/queries/api-keys", () => ({
33+
usePostAddApiKey: () => ({ mutate: mockAddApiKeyMutate }),
34+
}));
35+
36+
jest.mock("@/controllers/API/queries/auth", () => ({
37+
useResetPassword: () => ({ mutate: mockResetPasswordMutate }),
38+
useUpdateUser: () => ({ mutate: mockUpdateUserMutate }),
39+
}));
40+
41+
const PROFILE_PICTURES = {
42+
files: [],
43+
People: ["avatar-01.svg"],
44+
};
45+
46+
jest.mock("@/controllers/API/queries/files", () => ({
47+
useGetProfilePicturesQuery: () => ({
48+
isLoading: false,
49+
isFetching: false,
50+
data: PROFILE_PICTURES,
51+
}),
52+
}));
53+
54+
jest.mock(
55+
"@/pages/SettingsPage/pages/GeneralPage/components/ProfilePictureForm/components/profilePictureChooserComponent/hooks/use-preload-images",
56+
() => {
57+
const { useEffect } = require("react");
58+
return {
59+
__esModule: true,
60+
default: (setImagesLoaded: (v: boolean) => void) => {
61+
useEffect(() => {
62+
setImagesLoaded(true);
63+
}, []);
64+
},
65+
};
66+
},
67+
);
68+
69+
jest.mock("@/customization/utils/custom-pre-load-image-url", () => ({
70+
customPreLoadImageUrl: (path: string) =>
71+
`/api/v1/files/profile_pictures/${path}`,
72+
}));
73+
74+
jest.mock("@/customization/components/custom-terms-links", () => ({
75+
CustomTermsLinks: () => <div>Terms links</div>,
76+
}));
77+
78+
import GeneralPage from "../index";
79+
80+
const AUTH_CONTEXT_VALUE: AuthContextType = {
81+
accessToken: "token",
82+
login: jest.fn(),
83+
userData: { id: "1" } as AuthContextType["userData"],
84+
setUserData: jest.fn(),
85+
authenticationErrorCount: 0,
86+
apiKey: null,
87+
setApiKey: jest.fn(),
88+
storeApiKey: jest.fn(),
89+
getUser: jest.fn(),
90+
clearAuthSession: jest.fn(),
91+
};
92+
93+
function renderGeneralPage() {
94+
const queryClient = new QueryClient();
95+
return render(
96+
<MemoryRouter>
97+
<QueryClientProvider client={queryClient}>
98+
<AuthContext.Provider value={AUTH_CONTEXT_VALUE}>
99+
<GeneralPage />
100+
</AuthContext.Provider>
101+
</QueryClientProvider>
102+
</MemoryRouter>,
103+
);
104+
}
105+
106+
describe("GeneralPage accessibility", () => {
107+
beforeAll(() => {
108+
if (!Element.prototype.hasPointerCapture) {
109+
Element.prototype.hasPointerCapture = jest.fn(() => false);
110+
}
111+
if (!Element.prototype.releasePointerCapture) {
112+
Element.prototype.releasePointerCapture = jest.fn();
113+
}
114+
if (!Element.prototype.scrollIntoView) {
115+
Element.prototype.scrollIntoView = jest.fn();
116+
}
117+
});
118+
119+
beforeEach(() => {
120+
jest.clearAllMocks();
121+
useAlertStore.setState({
122+
notificationList: [],
123+
tempNotificationList: [],
124+
});
125+
useAuthStore.setState({ autoLogin: false });
126+
});
127+
128+
it("should_have_no_axe_violations_on_initial_render", async () => {
129+
const { container } = renderGeneralPage();
130+
131+
expect(await axe(container)).toHaveNoViolations();
132+
});
133+
134+
it("should_expose_a_single_page_heading_for_the_page_title", () => {
135+
renderGeneralPage();
136+
137+
expect(
138+
screen.getByRole("heading", { name: /general/i }),
139+
).toBeInTheDocument();
140+
});
141+
142+
it("renders_the_language_password_and_profile_picture_sections", () => {
143+
renderGeneralPage();
144+
145+
expect(
146+
screen.getByRole("combobox", { name: "Select language" }),
147+
).toBeInTheDocument();
148+
// One "Save" submit button per section (password + profile picture).
149+
expect(screen.getAllByRole("button", { name: /save/i })).toHaveLength(2);
150+
});
151+
152+
it("gives_the_password_and_profile_picture_forms_distinct_accessible_names", () => {
153+
// Regression lock: axe-core only treats a <form> as a "form" landmark
154+
// once it has an accessible name, so an unlabeled duplicate never shows
155+
// up as an axe violation (verified: landmark-unique is "inapplicable"
156+
// for unlabeled forms, not passing). The IBM ACE page scanner is
157+
// stricter and flags any duplicate unlabeled forms, which is what
158+
// caught this on the real /settings/general page.
159+
renderGeneralPage();
160+
161+
expect(screen.getByRole("form", { name: "Password" })).toBeInTheDocument();
162+
expect(
163+
screen.getByRole("form", { name: "Profile Picture" }),
164+
).toBeInTheDocument();
165+
});
166+
167+
it("should_have_no_axe_violations_with_the_language_popover_open", async () => {
168+
const user = userEvent.setup();
169+
renderGeneralPage();
170+
171+
await user.click(screen.getByRole("combobox", { name: "Select language" }));
172+
await screen.findByRole("listbox");
173+
174+
// Radix portals the popover to document.body, outside the render
175+
// container, and the region rule is a page-level landmark concern that
176+
// a bare unit render cannot satisfy.
177+
expect(
178+
await axe(document.body, { rules: { region: { enabled: false } } }),
179+
).toHaveNoViolations();
180+
});
181+
182+
it("does_not_render_the_password_form_when_autologin_is_enabled", () => {
183+
useAuthStore.setState({ autoLogin: true });
184+
renderGeneralPage();
185+
186+
expect(
187+
screen.queryByPlaceholderText("settings.passwordPlaceholder"),
188+
).not.toBeInTheDocument();
189+
});
190+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { SUPPORTED_LANGUAGES } from "@/constants/languages";
4+
import { axe } from "@/utils/a11y-test";
5+
6+
const mockLoadLanguage = jest.fn().mockResolvedValue(undefined);
7+
8+
jest.mock("@/i18n", () => ({
9+
loadLanguage: mockLoadLanguage,
10+
}));
11+
12+
jest.mock("@tanstack/react-query", () => ({
13+
...jest.requireActual("@tanstack/react-query"),
14+
useQueryClient: () => ({ invalidateQueries: jest.fn() }),
15+
}));
16+
17+
jest.mock("@/stores/typesStore", () => ({
18+
useTypesStore: (selector: (s: { setTypes: () => void }) => unknown) =>
19+
selector({ setTypes: jest.fn() }),
20+
}));
21+
22+
import LanguageFormComponent from "../index";
23+
24+
describe("LanguageForm accessibility", () => {
25+
beforeAll(() => {
26+
if (!Element.prototype.hasPointerCapture) {
27+
Element.prototype.hasPointerCapture = jest.fn(() => false);
28+
}
29+
if (!Element.prototype.releasePointerCapture) {
30+
Element.prototype.releasePointerCapture = jest.fn();
31+
}
32+
if (!Element.prototype.scrollIntoView) {
33+
Element.prototype.scrollIntoView = jest.fn();
34+
}
35+
});
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
localStorage.clear();
40+
});
41+
42+
it("should_have_no_axe_violations_when_closed", async () => {
43+
const { container } = render(<LanguageFormComponent />);
44+
45+
expect(await axe(container)).toHaveNoViolations();
46+
});
47+
48+
it("should_expose_named_combobox_for_the_language_trigger", () => {
49+
render(<LanguageFormComponent />);
50+
51+
expect(
52+
screen.getByRole("combobox", { name: "Select language" }),
53+
).toBeInTheDocument();
54+
});
55+
56+
it("should_expose_every_supported_language_as_a_named_option", async () => {
57+
const user = userEvent.setup();
58+
render(<LanguageFormComponent />);
59+
60+
await user.click(screen.getByRole("combobox", { name: "Select language" }));
61+
await screen.findByRole("listbox");
62+
63+
SUPPORTED_LANGUAGES.forEach((lang) => {
64+
expect(
65+
screen.getByRole("option", { name: new RegExp(lang.label) }),
66+
).toBeInTheDocument();
67+
});
68+
});
69+
70+
it("should_open_via_keyboard_and_return_focus_to_the_trigger_on_escape", async () => {
71+
const user = userEvent.setup();
72+
render(<LanguageFormComponent />);
73+
74+
const trigger = screen.getByRole("combobox", { name: "Select language" });
75+
trigger.focus();
76+
expect(trigger).toHaveFocus();
77+
78+
await user.keyboard("{Enter}");
79+
await screen.findByRole("listbox");
80+
81+
await user.keyboard("{Escape}");
82+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
83+
expect(trigger).toHaveFocus();
84+
});
85+
86+
it("marks_the_current_language_as_selected_in_the_listbox", async () => {
87+
const user = userEvent.setup();
88+
render(<LanguageFormComponent />);
89+
90+
await user.click(screen.getByRole("combobox", { name: "Select language" }));
91+
await screen.findByRole("listbox");
92+
93+
expect(screen.getByRole("option", { name: /English/i })).toHaveAttribute(
94+
"aria-selected",
95+
"true",
96+
);
97+
});
98+
});

src/frontend/src/pages/SettingsPage/pages/GeneralPage/components/PasswordForm/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const PasswordFormComponent = ({
3131
return (
3232
<>
3333
<Form.Root
34+
aria-label={t("settings.passwordTitle")}
3435
onSubmit={(event) => {
3536
handlePatchPassword(password, cnfPassword, handleInput);
3637
event.preventDefault();

src/frontend/src/pages/SettingsPage/pages/GeneralPage/components/ProfilePictureForm/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ const ProfilePictureFormComponent = ({
2828
profilePicture,
2929
handleInput,
3030
handlePatchProfilePicture,
31-
handleGetProfilePictures,
3231
userData,
3332
}: ProfilePictureFormComponentProps) => {
3433
const { t } = useTranslation();
3534
const { isLoading, data, isFetching } = useGetProfilePicturesQuery();
3635

3736
return (
3837
<Form.Root
38+
aria-label={t("settings.profilePictureTitle")}
3939
onSubmit={(event) => {
4040
handlePatchProfilePicture(profilePicture);
4141
event.preventDefault();

0 commit comments

Comments
 (0)