From 7794463bfcdcb474c7ee5ef3112126de259a3a8e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 12 Oct 2026 12:13:37 -0700 Subject: [PATCH] Add aria-valuetext to the seeking, volume and balance sliders Screen readers announced these range inputs as bare numbers (e.g. "26" for the seeking bar). Describe their values instead: - Seeking Bar: "0:32 of 2:00" (the scrub position while dragging) - Volume Bar: "75%" - Balance: "Center", "20% Left" or "20% Right" --- packages/webamp/js/components/Balance.test.ts | 9 +++++++++ packages/webamp/js/components/Balance.tsx | 9 +++++++++ .../js/components/MainWindow/Position.test.tsx | 10 ++++++++++ .../js/components/MainWindow/Position.tsx | 17 +++++++++++++---- packages/webamp/js/components/Volume.tsx | 1 + 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 packages/webamp/js/components/Balance.test.ts diff --git a/packages/webamp/js/components/Balance.test.ts b/packages/webamp/js/components/Balance.test.ts new file mode 100644 index 00000000..fa17779e --- /dev/null +++ b/packages/webamp/js/components/Balance.test.ts @@ -0,0 +1,9 @@ +import { balanceValueText } from "./Balance"; + +describe("balanceValueText", () => { + it("describes the balance", () => { + expect(balanceValueText(0)).toBe("Center"); + expect(balanceValueText(-20)).toBe("20% Left"); + expect(balanceValueText(100)).toBe("100% Right"); + }); +}); diff --git a/packages/webamp/js/components/Balance.tsx b/packages/webamp/js/components/Balance.tsx index 07c9d176..3722ce06 100644 --- a/packages/webamp/js/components/Balance.tsx +++ b/packages/webamp/js/components/Balance.tsx @@ -4,6 +4,14 @@ import * as Actions from "../actionCreators"; import * as Selectors from "../selectors"; import { useTypedSelector, useActionCreator } from "../hooks"; +// E.g. "Center" or "20% Left" +export function balanceValueText(balance: number): string { + if (balance === 0) { + return "Center"; + } + return `${Math.abs(balance)}% ${balance > 0 ? "Right" : "Left"}`; +} + interface Props { id?: string; style?: React.CSSProperties; @@ -29,6 +37,7 @@ export default function Balance({ style, className, id }: Props) { onPointerDown={() => setFocus("balance")} onPointerUp={unsetFocus} title="Balance" + aria-valuetext={balanceValueText(balance)} /> ); } diff --git a/packages/webamp/js/components/MainWindow/Position.test.tsx b/packages/webamp/js/components/MainWindow/Position.test.tsx index f08d949f..a5d3dc19 100644 --- a/packages/webamp/js/components/MainWindow/Position.test.tsx +++ b/packages/webamp/js/components/MainWindow/Position.test.tsx @@ -125,4 +125,14 @@ describe("Position", () => { pointer("pointerup"); expect(seeks()).toEqual([60]); }); + + it("describes the position as elapsed time of total time", () => { + expect(input.getAttribute("aria-valuetext")).toBe("0:30 of 2:00"); + }); + + it("describes the scrub position while dragging", () => { + pointer("pointerdown"); + changeValue("50"); + expect(input.getAttribute("aria-valuetext")).toBe("1:00 of 2:00"); + }); }); diff --git a/packages/webamp/js/components/MainWindow/Position.tsx b/packages/webamp/js/components/MainWindow/Position.tsx index 3b0cb4f2..67037111 100644 --- a/packages/webamp/js/components/MainWindow/Position.tsx +++ b/packages/webamp/js/components/MainWindow/Position.tsx @@ -1,6 +1,7 @@ import { FormEvent, KeyboardEvent, memo, useCallback, useRef } from "react"; import * as Selectors from "../../selectors"; import * as Actions from "../../actionCreators"; +import { getTimeStr } from "../../utils"; import { useTypedSelector, useTypedDispatch } from "../../hooks"; function usePosition() { @@ -10,14 +11,21 @@ function usePosition() { const scrubPosition = useTypedSelector(Selectors.getUserInputScrubPosition); const userInputFocus = useTypedSelector(Selectors.getUserInputFocus); - const displayedPosition = - userInputFocus === "position" ? scrubPosition : position; + const scrubbing = userInputFocus === "position"; + const displayedPosition = scrubbing ? scrubPosition : position; - return [position, displayedPosition]; + // E.g. "0:32 of 2:00" + let valueText; + if (duration != null) { + const time = scrubbing ? (duration * scrubPosition) / 100 : timeElapsed; + valueText = `${getTimeStr(time)} of ${getTimeStr(duration)}`; + } + + return [position, displayedPosition, valueText] as const; } const Position = memo(() => { - const [position, displayedPosition] = usePosition(); + const [position, displayedPosition, valueText] = usePosition(); const dispatch = useTypedDispatch(); const seekToPercentComplete = useCallback( @@ -124,6 +132,7 @@ const Position = memo(() => { onPointerUp={handlePointerUp} onPointerDown={handlePointerDown} title="Seeking Bar" + aria-valuetext={valueText} /> ); }); diff --git a/packages/webamp/js/components/Volume.tsx b/packages/webamp/js/components/Volume.tsx index f43c75ac..359c0365 100644 --- a/packages/webamp/js/components/Volume.tsx +++ b/packages/webamp/js/components/Volume.tsx @@ -29,6 +29,7 @@ export default function Volume({ id, style, className }: Props) { onPointerDown={() => setFocus("volume")} onPointerUp={unsetFocus} title="Volume Bar" + aria-valuetext={`${volume}%`} /> ); }