Skip to content

Commit 85fc0c6

Browse files
Vonngclaude
andcommitted
fix(a11y): name collapsed sidebar controls and credential fields
Collapsed sidebar buttons carried no accessible name, so screen readers announced them as unlabelled. Access Key inputs now declare their autocomplete intent instead of letting password managers guess. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 437c56c commit 85fc0c6

5 files changed

Lines changed: 174 additions & 1 deletion

File tree

web-app/src/screens/Console/Account/AddServiceAccountScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ const AddServiceAccount = () => {
179179
label={"Access Key"}
180180
id={"accessKey"}
181181
name={"accessKey"}
182+
autoComplete="section-service-account username"
182183
placeholder={"Enter Access Key"}
183184
onChange={(e) => {
184185
setAccessKey(e.target.value);
@@ -191,6 +192,7 @@ const AddServiceAccount = () => {
191192
id={"secretKey"}
192193
name={"secretKey"}
193194
type={"password"}
195+
autoComplete="section-service-account new-password"
194196
placeholder={"Enter Secret Key"}
195197
onChange={(e) => {
196198
setSecretKey(e.target.value);

web-app/src/screens/Console/Menu/MenuWrapper.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <http://www.gnu.org/licenses/>.
1616

17-
import React from "react";
17+
import React, { useLayoutEffect } from "react";
1818
import { useSelector } from "react-redux";
1919
import {
2020
Box,
@@ -45,6 +45,33 @@ const MenuWrapper = () => {
4545

4646
const allowedMenuItems = validRoutes(features);
4747

48+
useLayoutEffect(() => {
49+
const menuToggle = document.querySelector<HTMLElement>(
50+
".menuBox .menuHeaderContainer",
51+
);
52+
if (!menuToggle) {
53+
return;
54+
}
55+
56+
const toggleLabel = sidebarOpen ? "Collapse menu" : "Expand menu";
57+
const handleKeyDown = (event: KeyboardEvent) => {
58+
if (event.key === "Enter" || event.key === " ") {
59+
event.preventDefault();
60+
menuToggle.click();
61+
}
62+
};
63+
64+
menuToggle.setAttribute("role", "button");
65+
menuToggle.setAttribute("tabindex", "0");
66+
menuToggle.setAttribute("aria-label", toggleLabel);
67+
menuToggle.setAttribute("aria-expanded", String(sidebarOpen));
68+
menuToggle.addEventListener("keydown", handleKeyDown);
69+
70+
return () => {
71+
menuToggle.removeEventListener("keydown", handleKeyDown);
72+
};
73+
}, [sidebarOpen]);
74+
4875
return (
4976
<Menu
5077
isOpen={sidebarOpen}
@@ -65,6 +92,10 @@ const MenuWrapper = () => {
6592
"& .menuHeaderContainer": {
6693
position: "relative",
6794
},
95+
"& .menuHeaderContainer:focus-visible": {
96+
outline: "2px solid rgba(88, 141, 192, 0.85)",
97+
outlineOffset: -2,
98+
},
6899
"& .menuLogoContainer > svg": {
69100
display: "block",
70101
visibility: "hidden",
@@ -125,6 +156,19 @@ const MenuWrapper = () => {
125156
transform: "translateX(-10px)",
126157
transition: "opacity 0.08s ease, transform 0.12s ease",
127158
},
159+
"&.collapsed .labelContainer": {
160+
display: "block",
161+
position: "absolute",
162+
width: 1,
163+
height: 1,
164+
padding: 0,
165+
margin: -1,
166+
overflow: "hidden",
167+
clip: "rect(0, 0, 0, 0)",
168+
clipPath: "inset(50%)",
169+
whiteSpace: "nowrap",
170+
border: 0,
171+
},
128172
}}
129173
callPathAction={(path) => {
130174
navigate(path);

web-app/src/screens/Console/Users/AddUserServiceAccountScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ const AddServiceAccount = () => {
191191
label={"Access Key"}
192192
id={"accessKey"}
193193
name={"accessKey"}
194+
autoComplete="section-service-account username"
194195
placeholder={"Enter Access Key"}
195196
onChange={(e) => {
196197
setAccessKey(e.target.value);
@@ -203,6 +204,7 @@ const AddServiceAccount = () => {
203204
id={"secretKey"}
204205
name={"secretKey"}
205206
type={"password"}
207+
autoComplete="section-service-account new-password"
206208
placeholder={"Enter Secret Key"}
207209
onChange={(e) => {
208210
setSecretKey(e.target.value);

web-app/tests/permissions-3/admin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import * as roles from "../utils/roles";
1818
import * as elements from "../utils/elements-menu";
19+
import { Selector } from "testcafe";
1920
import {
2021
bucketsElement,
2122
dashboardElement,
@@ -74,3 +75,22 @@ test("All sidebar items exist", async (t) => {
7475
.expect(licenseExists)
7576
.ok();
7677
});
78+
79+
test("Create Access Key forms identify new credentials", async (t) => {
80+
const accessKeyInput = Selector("#accessKey");
81+
const secretKeyInput = Selector("#secretKey");
82+
83+
for (const path of [
84+
"/access-keys/new-account",
85+
"/identity/users/new-user-sa/minioadmin",
86+
]) {
87+
await t
88+
.navigateTo(`http://localhost:9090${path}`)
89+
.expect(accessKeyInput.getAttribute("autocomplete"))
90+
.eql("section-service-account username")
91+
.expect(secretKeyInput.getAttribute("autocomplete"))
92+
.eql("section-service-account new-password")
93+
.expect(secretKeyInput.getAttribute("type"))
94+
.eql("password");
95+
}
96+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// This file is part of MinIO Console Server
2+
// Copyright (c) 2026 MinIO, Inc.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
9+
import { ClientFunction, Selector } from "testcafe";
10+
import * as roles from "../utils/roles";
11+
12+
const menu = Selector(".menuBox");
13+
const menuToggle = Selector(".menuBox .menuHeaderContainer");
14+
15+
const unnamedMenuControls = ClientFunction(() =>
16+
Array.from(
17+
document.querySelectorAll<HTMLElement>(
18+
'.menuBox button, .menuBox a, .menuBox [role="button"]',
19+
),
20+
)
21+
.filter((control) => {
22+
const style = window.getComputedStyle(control);
23+
return style.display !== "none" && style.visibility !== "hidden";
24+
})
25+
.filter((control) => {
26+
if (
27+
control.getAttribute("aria-label")?.trim() ||
28+
control.getAttribute("aria-labelledby")?.trim()
29+
) {
30+
return false;
31+
}
32+
33+
return !control.innerText.trim();
34+
})
35+
.map((control) => control.id || control.className),
36+
);
37+
38+
const blurFocusedElement = ClientFunction(() => {
39+
(document.activeElement as HTMLElement | null)?.blur();
40+
});
41+
42+
const focusedOutlineStyle = ClientFunction(
43+
() => window.getComputedStyle(document.activeElement!).outlineStyle,
44+
);
45+
const focusedElementClass = ClientFunction(
46+
() => (document.activeElement as HTMLElement | null)?.className,
47+
);
48+
const focusedElementId = ClientFunction(
49+
() => (document.activeElement as HTMLElement | null)?.id,
50+
);
51+
52+
fixture("Sidebar accessibility")
53+
.page("http://localhost:9090")
54+
.beforeEach(async (t) => {
55+
await t.useRole(roles.admin).resizeWindow(1280, 800);
56+
if (await menu.hasClass("collapsed")) {
57+
await t.click(menuToggle);
58+
}
59+
})
60+
.afterEach(async (t) => {
61+
await t.resizeWindow(1280, 800);
62+
});
63+
64+
test("navigation controls remain named when the sidebar collapses", async (t) => {
65+
await t
66+
.expect(menu.hasClass("collapsed"))
67+
.notOk()
68+
.expect(menuToggle.getAttribute("aria-label"))
69+
.eql("Collapse menu")
70+
.expect(menuToggle.getAttribute("aria-expanded"))
71+
.eql("true")
72+
.expect(unnamedMenuControls())
73+
.eql([]);
74+
75+
await blurFocusedElement();
76+
await t
77+
.pressKey("tab")
78+
.expect(focusedElementClass())
79+
.contains("menuHeaderContainer")
80+
.expect(focusedOutlineStyle())
81+
.notEql("none")
82+
.pressKey("tab")
83+
.expect(focusedElementId())
84+
.eql("menu-create-bucket")
85+
.pressKey("shift+tab")
86+
.pressKey("enter")
87+
.expect(menu.hasClass("collapsed"))
88+
.ok()
89+
.expect(menuToggle.getAttribute("aria-label"))
90+
.eql("Expand menu")
91+
.expect(menuToggle.getAttribute("aria-expanded"))
92+
.eql("false")
93+
.expect(unnamedMenuControls())
94+
.eql([]);
95+
96+
await t
97+
.pressKey("space")
98+
.expect(menu.hasClass("collapsed"))
99+
.notOk()
100+
.resizeWindow(390, 844)
101+
.expect(menu.hasClass("collapsed"))
102+
.ok("Sidebar should collapse at the mobile breakpoint", { timeout: 2000 })
103+
.expect(unnamedMenuControls())
104+
.eql([]);
105+
});

0 commit comments

Comments
 (0)