Skip to content

Commit 8916f62

Browse files
feat: update active node for screen reader when focus is changed
1 parent fdf9a14 commit 8916f62

File tree

5 files changed

+135
-28
lines changed

5 files changed

+135
-28
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@guidepup/virtual-screen-reader",
3-
"version": "0.1.3",
3+
"version": "0.2.0",
44
"description": "Virtual screen reader driver for unit test automation.",
55
"main": "lib/index.js",
66
"author": "Craig Morten <craig.morten@hotmail.co.uk>",

src/Virtual.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ const observedAttributes = [
3232
"disabled",
3333
];
3434

35-
// TODO: monitor focus change and update the screen reader active element.
3635
// TODO: handle aria-live, role="polite", role="alert", and other interruptions.
3736

3837
const observeDOM = (function () {
@@ -84,15 +83,47 @@ export class Virtual implements ScreenReader {
8483
#getAccessibilityTree() {
8584
if (!this.#treeCache) {
8685
this.#treeCache = createAccessibilityTree(this.#container);
86+
this.#attachFocusListeners();
8787
}
8888

8989
return this.#treeCache;
9090
}
9191

9292
#invalidateTreeCache() {
93+
this.#detachFocusListeners();
9394
this.#treeCache = null;
9495
}
9596

97+
#attachFocusListeners() {
98+
this.#getAccessibilityTree().forEach((treeNode) => {
99+
treeNode.node.addEventListener(
100+
"focus",
101+
this.#handleFocusChange.bind(this)
102+
);
103+
});
104+
}
105+
106+
#detachFocusListeners() {
107+
this.#getAccessibilityTree().forEach((treeNode) => {
108+
treeNode.node.removeEventListener(
109+
"focus",
110+
this.#handleFocusChange.bind(this)
111+
);
112+
});
113+
}
114+
115+
#handleFocusChange({ target }: FocusEvent) {
116+
const tree = this.#getAccessibilityTree();
117+
const nextIndex = tree.findIndex(({ node }) => node === target);
118+
119+
if (nextIndex === -1) {
120+
return;
121+
}
122+
123+
const newActiveNode = tree.at(nextIndex);
124+
this.#updateState(newActiveNode);
125+
}
126+
96127
#updateState(accessibilityNode: AccessibilityNode) {
97128
const { accessibleDescription, accessibleName, role } = accessibilityNode;
98129
const spokenPhrase = [role, accessibleName, accessibleDescription]

test/int/click.int.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
import { getByText, queryByText } from "@testing-library/dom";
2-
import { setupButtonPage } from "../utils";
32
import { virtual } from "../../src";
43

4+
function setupButtonPage() {
5+
document.body.innerHTML = `
6+
<p id="status">Not Clicked</p>
7+
<div id="hidden" style="display: none;">Hidden</div>
8+
`;
9+
10+
const button = document.createElement("button");
11+
12+
button.addEventListener("click", function (event) {
13+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
14+
document.getElementById(
15+
"status"
16+
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
17+
});
18+
19+
button.innerHTML = "Click Me";
20+
21+
document.body.appendChild(button);
22+
23+
document.body.addEventListener("contextmenu", () => {
24+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
25+
document.getElementById("status")!.innerHTML = `Right Clicked`;
26+
});
27+
}
28+
529
describe("click", () => {
630
beforeEach(() => {
731
setupButtonPage();

test/int/focus.int.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getByText } from "@testing-library/dom";
2+
import { virtual } from "../../src";
3+
4+
function setupFocusChangePage() {
5+
document.body.innerHTML = `
6+
<div id="container">
7+
<button id="1">1</button>
8+
<button id="2">2</button>
9+
<label for="3">3 label</label>
10+
<input id="3" type="text" value="3" />
11+
<label for="4">4 label</label>
12+
<input id="4" type="button" value="4" />
13+
<textarea id="5">5</textarea>
14+
<a id="6" href="#">6</a>
15+
<button id="hidden1" aria-hidden="true">hidden 1</button>
16+
</div>
17+
<button id="outside1">outside 1</button>
18+
`;
19+
}
20+
21+
describe("click", () => {
22+
beforeEach(() => {
23+
setupFocusChangePage();
24+
});
25+
26+
it("should update the screen reader position when a node not currently active for the screen reader is focussed", async () => {
27+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
28+
const container = document.querySelector("#container")! as HTMLElement;
29+
30+
await virtual.start({ container });
31+
32+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 1");
33+
34+
getByText(container, "4").focus();
35+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 4");
36+
37+
await virtual.previous();
38+
await virtual.previous();
39+
expect(await virtual.lastSpokenPhrase()).toEqual("textbox, 3 label");
40+
41+
getByText(container, "6").focus();
42+
expect(await virtual.lastSpokenPhrase()).toEqual("link, 6");
43+
44+
await virtual.previous();
45+
expect(await virtual.lastSpokenPhrase()).toEqual("end of textbox");
46+
47+
await virtual.stop();
48+
});
49+
50+
it("should not shift the screen reader active node if focus shifted outside the container", async () => {
51+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
52+
const container = document.querySelector("#container")! as HTMLElement;
53+
54+
await virtual.start({ container });
55+
56+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 1");
57+
58+
getByText(document.body, "outside 1").focus();
59+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 1");
60+
61+
await virtual.stop();
62+
});
63+
64+
it("should not shift the screen reader active node if focus shifted to a node hidden from screen readers", async () => {
65+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
66+
const container = document.querySelector("#container")! as HTMLElement;
67+
68+
await virtual.start({ container });
69+
70+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 1");
71+
72+
getByText(document.body, "hidden 1").focus();
73+
expect(await virtual.lastSpokenPhrase()).toEqual("button, 1");
74+
75+
await virtual.stop();
76+
});
77+
});

test/utils.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2-
31
export function setupBasicPage() {
42
document.body.innerHTML = `
53
<nav>Nav Text</nav>
@@ -17,26 +15,3 @@ export function setupBasicPage() {
1715
<footer>Footer</footer>
1816
`;
1917
}
20-
21-
export function setupButtonPage() {
22-
document.body.innerHTML = `
23-
<p id="status">Not Clicked</p>
24-
<div id="hidden" style="display: none;">Hidden</div>
25-
`;
26-
27-
const button = document.createElement("button");
28-
29-
button.addEventListener("click", function (event) {
30-
document.getElementById(
31-
"status"
32-
)!.innerHTML = `Clicked ${event.detail} Time(s)`;
33-
});
34-
35-
button.innerHTML = "Click Me";
36-
37-
document.body.appendChild(button);
38-
39-
document.body.addEventListener("contextmenu", () => {
40-
document.getElementById("status")!.innerHTML = `Right Clicked`;
41-
});
42-
}

0 commit comments

Comments
 (0)