feat(a11y): keyboard + ARIA slider semantics for the Altitude dial

role=slider + tabindex on the dial; Arrow/Home/End stepping; labels become
Enter/Space buttons; aria-valuenow/valuetext track the committed altitude via
renderDial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 09:26:45 -07:00
parent 717bf5b08b
commit 0db602ebd0
3 changed files with 55 additions and 1 deletions
+18
View File
@@ -60,3 +60,21 @@ test.describe("Task 5 — Reduced motion", () => {
.toBe(true); // reduced motion → frozen
});
});
test.describe("Task 6 — Keyboard + ARIA dial", () => {
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
await page.goto("/");
const dial = page.locator("#dial");
await expect(dial).toHaveAttribute("role", "slider");
await expect(dial).toHaveAttribute("tabindex", "0");
await dial.focus();
const before = await page.locator("#scale-name").textContent();
await dial.press("ArrowDown"); // descend one altitude
await expect
.poll(async () => page.locator("#scale-name").textContent())
.not.toBe(before);
await dial.press("Home"); // jump to the top scale (cosmos)
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
await expect(dial).toHaveAttribute("aria-valuenow", "0");
});
});
+34
View File
@@ -753,6 +753,8 @@ function buildDial() {
const t = svg("text", {
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
class: "dial-label", "data-index": String(i),
role: "button", tabindex: "0",
"aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang),
}, dial);
t.textContent = ring.scales[i].id;
}
@@ -778,6 +780,16 @@ function renderDial() {
for (const el of dial.querySelectorAll(".dial-label")) {
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
}
setDialAria();
}
// Reflect the committed altitude into the dial's slider semantics for assistive tech.
function setDialAria() {
if (!dial || !ring) return;
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
dial.setAttribute("aria-valuenow", String(ringIndex));
const s = ring.scales[ringIndex];
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
}
function dialAngle(e) {
@@ -918,6 +930,27 @@ function jumpToScale(idx) {
if (d) autoScrub(Math.round(pos) + d);
}
// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End
// jump to the ends. Enter/Space on a focused label jumps to that scale.
function onDialKey(e) {
if (!ring || ring.scales.length < 2) return;
const t = e.target;
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
jumpToScale(+t.getAttribute("data-index"));
return;
}
let handled = true;
switch (e.key) {
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend
case "Home": jumpToScale(0); break;
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
// Off by default, state persisted in localStorage. Everything here is read from
// data the renderer already has (clips, ring, the alteration response, the preload
@@ -1351,6 +1384,7 @@ async function main() {
window.addEventListener("pointermove", onDialMove);
window.addEventListener("pointerup", onDialUp);
dial.addEventListener("wheel", onWheel, { passive: false });
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
await preloadAllMedia(); // download all media, updating the loading bar
+3 -1
View File
@@ -65,7 +65,9 @@
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>