Skip to content

Commit 26d3151

Browse files
feat: support announcing dialogs when entered
1 parent 75097b4 commit 26d3151

File tree

10 files changed

+419
-6
lines changed

10 files changed

+419
-6
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<template>
2+
<OverlayComponent :show="isOpen">
3+
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" class="modal">
4+
<button @click="$emit('close')" id="modal-close-button">Close Modal</button>
5+
<h1 id="modal-title">Modal Title</h1>
6+
<slot></slot>
7+
</div>
8+
</OverlayComponent>
9+
</template>
10+
11+
<script lang="ts">
12+
import { defineComponent } from 'vue'
13+
import OverlayComponent from './OverlayComponent.vue'
14+
15+
export default defineComponent({
16+
name: 'ModalComponent',
17+
components: {
18+
OverlayComponent
19+
},
20+
watch: {
21+
open: {
22+
handler(isOpen: boolean): void {
23+
this.isOpen = isOpen
24+
25+
if (isOpen) {
26+
setTimeout(() => {
27+
const closeButton = document.querySelector('#modal-close-button') as HTMLButtonElement;
28+
29+
closeButton.focus();
30+
}, 0)
31+
}
32+
}
33+
}
34+
},
35+
props: {
36+
open: {
37+
type: Boolean,
38+
required: false,
39+
default: false
40+
}
41+
},
42+
data() {
43+
return {
44+
isOpen: false
45+
}
46+
}
47+
})
48+
</script>
49+
50+
<style>
51+
.modal {
52+
background-color: grey;
53+
display: flex;
54+
align-items: center;
55+
justify-content: center;
56+
min-height: 100vh;
57+
}
58+
</style>

examples/vue/src/OpenModal.vue

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<template>
2+
<button @click="isOpen = true">Open Modal</button>
3+
<ModalComponent :open="isOpen" @close="isOpen = false">Example Modal Content</ModalComponent>
4+
</template>
5+
6+
<script lang="ts">
7+
import { defineComponent } from 'vue'
8+
import ModalComponent from './ModalComponent.vue'
9+
10+
export default defineComponent({
11+
name: 'ModalExample',
12+
components: {
13+
ModalComponent
14+
},
15+
data() {
16+
return {
17+
isOpen: false
18+
}
19+
}
20+
})
21+
</script>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<template>
2+
<span v-if="show">
3+
<teleport to="body">
4+
<span class="overlay">
5+
<slot />
6+
</span>
7+
</teleport>
8+
</span>
9+
</template>
10+
11+
<script lang="ts">
12+
import { defineComponent } from 'vue'
13+
14+
export default defineComponent({
15+
name: 'OverlayComponent',
16+
props: {
17+
show: {
18+
type: Boolean,
19+
required: true,
20+
default: false
21+
}
22+
}
23+
})
24+
</script>
25+
26+
<style>
27+
.overlay {
28+
display: block;
29+
position: fixed;
30+
top: 0;
31+
left: 0;
32+
right: 0;
33+
bottom: 0;
34+
background: #00000030
35+
}
36+
</style>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import { render } from '@testing-library/vue'
3+
import ModalExample from '../OpenModal.vue'
4+
5+
/**
6+
* Replace with:
7+
*
8+
* import { virtual } from '@guidepup/virtual-screen-reader'
9+
*
10+
* in your own code.
11+
*/
12+
import { virtual } from '../../../../src'
13+
14+
describe('Open Modal', () => {
15+
afterEach(async () => {
16+
await virtual.stop()
17+
})
18+
19+
it('announces the modal as a dialog', async () => {
20+
render(ModalExample)
21+
22+
await virtual.start({ container: document.body })
23+
24+
// Navigate to the button and interact to open Modal
25+
await virtual.next()
26+
await virtual.act()
27+
28+
// Navigate through the modal
29+
await virtual.next()
30+
await virtual.next()
31+
await virtual.next()
32+
33+
expect(await virtual.spokenPhraseLog()).toMatchInlineSnapshot(`
34+
[
35+
"document",
36+
"button, Open Modal",
37+
"dialog, Modal Title, modal",
38+
"button, Close Modal",
39+
"heading, Modal Title, level 1",
40+
"Example Modal Content",
41+
"end of dialog, Modal Title, modal",
42+
]
43+
`)
44+
})
45+
})

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.16.3",
3+
"version": "0.17.0",
44
"description": "Virtual Screen Reader driver for unit test automation.",
55
"main": "lib/index.js",
66
"author": "Craig Morten <[email protected]>",

src/Virtual.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,14 @@ export class Virtual implements ScreenReader {
182182

183183
this.#invalidateTreeCache();
184184
const tree = this.#getAccessibilityTree();
185-
const nextIndex = tree.findIndex(({ node }) => node === target);
186185

187186
// This is called when an element in the tree receives focus so it stands
188187
// that we should be able to find said element in the tree (unless it can
189188
// be removed somehow between the focus event firing and this code
190-
// executing?).
189+
// executing... we are waiting for event loop tick so perhaps there is a
190+
// race condition here?).
191191
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
192-
const newActiveNode = tree.at(nextIndex)!;
192+
const newActiveNode = tree.find(({ node }) => node === target);
193193

194194
this.#updateState(newActiveNode, true);
195195
}
@@ -228,11 +228,42 @@ export class Virtual implements ScreenReader {
228228
}
229229

230230
#updateState(accessibilityNode: AccessibilityNode, ignoreIfNoChange = false) {
231-
const spokenPhrase = getSpokenPhrase(accessibilityNode);
232-
const itemText = getItemText(accessibilityNode);
231+
/**
232+
* When the dialog is correctly labeled and focus is moved to an element
233+
* (often an interactive element, such as a button) inside the dialog,
234+
* screen readers should announce the dialog's accessible role, name and
235+
* optionally description, along with announcing the focused element.
236+
*
237+
* REF: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role#possible_effects_on_user_agents_and_assistive_technology
238+
*/
239+
if (
240+
accessibilityNode.parentDialog !== null &&
241+
accessibilityNode.parentDialog !== this.#activeNode.parentDialog
242+
) {
243+
// One of the few cases where you will get two logs for a single
244+
// interaction.
245+
//
246+
// We don't need to perform the `ignoreIfNoChange` check as this will
247+
// only fire if the parent dialog element has changed, and if that
248+
// happens we can be fairly confident that item under the virtual
249+
// cursor has changed.
250+
const tree = this.#getAccessibilityTree();
251+
const parentDialogNode = tree.find(
252+
({ node }) => node === accessibilityNode.parentDialog
253+
);
254+
255+
const spokenPhrase = getSpokenPhrase(parentDialogNode);
256+
const itemText = getItemText(parentDialogNode);
257+
258+
this.#itemTextLog.push(itemText);
259+
this.#spokenPhraseLog.push(spokenPhrase);
260+
}
233261

234262
this.#activeNode = accessibilityNode;
235263

264+
const spokenPhrase = getSpokenPhrase(accessibilityNode);
265+
const itemText = getItemText(accessibilityNode);
266+
236267
if (
237268
ignoreIfNoChange &&
238269
spokenPhrase === this.#spokenPhraseLogWithoutLiveRegions().at(-1) &&

src/createAccessibilityTree.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getIdRefsByAttribute } from "./getIdRefsByAttribute";
22
import { getNodeAccessibilityData } from "./getNodeAccessibilityData";
33
import { getNodeByIdRef } from "./getNodeByIdRef";
44
import { HTMLElementWithValue } from "./getNodeAccessibilityData/getAccessibleValue";
5+
import { isDialogRole } from "./isDialogRole";
56
import { isElement } from "./isElement";
67
import { isInaccessible } from "dom-accessibility-api";
78

@@ -15,6 +16,7 @@ export interface AccessibilityNode {
1516
childrenPresentational: boolean;
1617
node: Node;
1718
parent: Node | null;
19+
parentDialog: Node | null;
1820
role: string;
1921
spokenRole: string;
2022
}
@@ -182,6 +184,7 @@ function flattenTree(tree: AccessibilityNodeTree): AccessibilityNode[] {
182184
childrenPresentational: treeNode.childrenPresentational,
183185
node: treeNode.node,
184186
parent: treeNode.parent,
187+
parentDialog: treeNode.parentDialog,
185188
role: treeNode.role,
186189
spokenRole: `end of ${treeNode.spokenRole}`,
187190
});
@@ -213,6 +216,8 @@ function growTree(
213216

214217
visitedNodes.add(node);
215218

219+
const parentDialog = isDialogRole(tree.role) ? tree.node : tree.parentDialog;
220+
216221
node.childNodes.forEach((childNode) => {
217222
if (isHiddenFromAccessibilityTree(childNode)) {
218223
return;
@@ -260,6 +265,7 @@ function growTree(
260265
childrenPresentational,
261266
node: childNode,
262267
parent: node,
268+
parentDialog,
263269
role,
264270
spokenRole,
265271
},
@@ -323,6 +329,7 @@ function growTree(
323329
childrenPresentational,
324330
node: childNode,
325331
parent: node,
332+
parentDialog,
326333
role,
327334
spokenRole,
328335
},
@@ -373,6 +380,7 @@ export function createAccessibilityTree(node: Node | null) {
373380
childrenPresentational,
374381
node,
375382
parent: null,
383+
parentDialog: null,
376384
role,
377385
spokenRole,
378386
},

src/isDialogRole.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const isDialogRole = (role: string) =>
2+
["dialog", "alertdialog"].includes(role);

0 commit comments

Comments
 (0)