diff --git a/packages/webamp-docs/docs/12_changelog.md b/packages/webamp-docs/docs/12_changelog.md
index 9ebe8bba9..3462fc22c 100644
--- a/packages/webamp-docs/docs/12_changelog.md
+++ b/packages/webamp-docs/docs/12_changelog.md
@@ -15,6 +15,7 @@ If you want access to the changes in this section before they are officially rel
- The Milkdrop window now has a stable id, `#milkdrop-window`, like `#main-window`, `#equalizer-window` and `#playlist-window`.
- Added a new Webamp instance method: [`webamp.onMediaError(cb)`](./06_API/03_instance-methods.md#onmediaerrorcb-error--code-number---void---void), which is called when a track fails to load or play.
- Added a new Webamp instance method: [`webamp.setTracks(tracks)`](./06_API/03_instance-methods.md#settrackstracks-track-void), which replaces the playlist without starting playback.
+- Each window is now exposed to assistive technology as a labelled group: "Main window", "Equalizer", "Playlist" or "Milkdrop".
### Bug Fixes
diff --git a/packages/webamp/js/components/FocusTarget.test.tsx b/packages/webamp/js/components/FocusTarget.test.tsx
new file mode 100644
index 000000000..25f335eab
--- /dev/null
+++ b/packages/webamp/js/components/FocusTarget.test.tsx
@@ -0,0 +1,49 @@
+// @types/react is still on v18, which does not declare React 19's `act`.
+// @ts-ignore
+import { act } from "react";
+import { createRoot, Root } from "react-dom/client";
+import { Provider } from "react-redux";
+import { legacy_createStore as createStore } from "redux";
+import reducer from "../reducers";
+import { WindowId } from "../types";
+import FocusTarget from "./FocusTarget";
+
+// Tell React that this environment supports `act`.
+// @ts-ignore
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+describe("FocusTarget", () => {
+ it.each([
+ ["main", "Main window"],
+ ["equalizer", "Equalizer"],
+ ["playlist", "Playlist"],
+ ["milkdrop", "Milkdrop"],
+ ])("labels the %s window as a group", (windowId, label) => {
+ act(() =>
+ root.render(
+
+
+
+
+
+ )
+ );
+ const group = container.firstElementChild!;
+ expect(group.getAttribute("role")).toBe("group");
+ expect(group.getAttribute("aria-label")).toBe(label);
+ });
+});
diff --git a/packages/webamp/js/components/FocusTarget.tsx b/packages/webamp/js/components/FocusTarget.tsx
index bdb4df95e..ce1179a56 100644
--- a/packages/webamp/js/components/FocusTarget.tsx
+++ b/packages/webamp/js/components/FocusTarget.tsx
@@ -4,6 +4,15 @@ import * as Actions from "../actionCreators";
import * as Selectors from "../selectors";
import { useActionCreator, useTypedSelector } from "../hooks";
+// Accessible names for each window, so screen reader users know which window
+// a control belongs to (several windows have a "Close" button, for example).
+const WINDOW_LABELS: Record = {
+ main: "Main window",
+ equalizer: "Equalizer",
+ playlist: "Playlist",
+ milkdrop: "Milkdrop",
+};
+
interface Props {
onKeyDown?(e: KeyboardEvent): void;
windowId: WindowId;
@@ -71,6 +80,8 @@ function FocusTarget({ onKeyDown, windowId, children }: Props) {
onPointerDown={focusHandler}
onFocus={focusHandler}
tabIndex={-1}
+ role="group"
+ aria-label={WINDOW_LABELS[windowId]}
style={{ height: "100%", width: "100%" }}
>
{children}