Skip to content

Commit 7919d95

Browse files
docs: Vue remaining character count example (#46)
* fix: exclude live region spoken phrases when performing "no change" check * docs: add example for text area with remaining character count live region
1 parent 49dfdb6 commit 7919d95

File tree

7 files changed

+194
-6
lines changed

7 files changed

+194
-6
lines changed

examples/vue/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@
22

33
A basic example combining Vue, Vitest, Vue Testing Library, and Virtual Screen Reader.
44

5+
There are two components with tests:
6+
7+
1. A button that increments a polite live region counter
8+
2. A text area with a remaining character count live region with debounce logic
9+
510
Run this example with:
611

712
```bash
813
cd examples/vue
914
yarn install --frozen-lockfile
1015
yarn test
1116
```
17+
18+
> [!IMPORTANT]
19+
> This example serves to demonstrate how you can use the Virtual screen reader. The components themselves may not be using best accessibility practices.
20+
>
21+
> Always evaluate your own components for accessibility and test with real users.

examples/vue/src/IncrementCounter.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ export default {
1414

1515
<template>
1616
<h1>Increment Counter</h1>
17-
<p aria-live="polite">Times clicked: {{ count }}</p>
17+
<span aria-live="polite">Times clicked: {{ count }}</span>
1818
<button @click="increment">increment</button>
1919
</template>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<template>
2+
<h1>Text Area Counter</h1>
3+
<textarea @input="handleInput" :maxCount="maxCount"></textarea>
4+
<span data-testid="remaining-count">{{ remainingCharacters }} characters left</span>
5+
<span class="visually-hidden" aria-live="polite">{{ remainingCharactersForAnnouncement }} characters left</span>
6+
</template>
7+
8+
<script lang="ts">
9+
import { defineComponent } from 'vue'
10+
11+
// Announce character count at most every 1 second, EXCEPT
12+
const DEBOUNCE_TIMEOUT = 1000
13+
// When have fewer than 10 characters remaining, in which case announce every
14+
// character.
15+
const DEBOUNCE_CHAR_LIMIT = 10
16+
17+
export default defineComponent({
18+
name: 'TextAreaCounter',
19+
props: {
20+
maxCount: {
21+
type: Number,
22+
default: 50
23+
},
24+
modelValue: {
25+
type: String,
26+
default: '',
27+
required: false
28+
}
29+
},
30+
data() {
31+
const remainingCharacters = this.maxCount - this.modelValue.length
32+
33+
return {
34+
debounceTimerId: 0,
35+
remainingCharacters,
36+
remainingCharactersForAnnouncement: remainingCharacters
37+
}
38+
},
39+
methods: {
40+
handleInput(event: Event): void {
41+
const value = (event.target as HTMLTextAreaElement).value
42+
this.$emit('update:modelValue', value)
43+
44+
const remainingCharacters = this.maxCount - value.length
45+
this.remainingCharacters = remainingCharacters
46+
this.handleRemainingCharactersAnnouncement(remainingCharacters)
47+
},
48+
handleRemainingCharactersAnnouncement(remainingCharacters: number) {
49+
if (remainingCharacters <= DEBOUNCE_CHAR_LIMIT) {
50+
clearTimeout(this.debounceTimerId)
51+
52+
this.remainingCharactersForAnnouncement = remainingCharacters
53+
54+
return
55+
}
56+
57+
clearTimeout(this.debounceTimerId)
58+
59+
this.debounceTimerId = setTimeout(() => {
60+
this.remainingCharactersForAnnouncement = remainingCharacters
61+
}, DEBOUNCE_TIMEOUT)
62+
}
63+
}
64+
})
65+
</script>
66+
67+
<style>
68+
.visually-hidden {
69+
position: absolute;
70+
width: 1px;
71+
height: 1px;
72+
padding: 0;
73+
margin: -1px;
74+
overflow: hidden;
75+
clip: rect(0, 0, 0, 0);
76+
white-space: nowrap;
77+
border: 0;
78+
}
79+
</style>

examples/vue/src/__tests__/IncrementCounter.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { afterEach, describe, expect, it } from 'vitest'
22
import { render, screen, waitFor } from '@testing-library/vue'
33
import IncrementCounter from '../IncrementCounter.vue'
44

@@ -12,11 +12,15 @@ import IncrementCounter from '../IncrementCounter.vue'
1212
import { virtual } from '../../../../src'
1313

1414
describe('Increment Counter', () => {
15+
afterEach(async () => {
16+
await virtual.stop()
17+
})
18+
1519
it('increments value on click', async () => {
1620
const { container } = render(IncrementCounter)
1721

1822
// Start the virtual screen reader for just the Component under test
19-
virtual.start({ container })
23+
await virtual.start({ container })
2024

2125
// Move to the increment button
2226
await virtual.next()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import { render, screen, waitFor } from '@testing-library/vue'
3+
import TextareaCounter from '../TextAreaCounter.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('Text Area Counter', () => {
15+
afterEach(async () => {
16+
await virtual.stop()
17+
})
18+
19+
it('announces the remaining character count after 1 second if there are more than 10 allowed characters remaining', async () => {
20+
const { container } = render(TextareaCounter)
21+
22+
await virtual.start({ container })
23+
await virtual.next()
24+
25+
// Type 9 characters
26+
await virtual.type('123456789')
27+
await waitFor(() => screen.getByDisplayValue('123456789'))
28+
await waitFor(() =>
29+
screen.getByText('41 characters left', { selector: '[data-testid="remaining-count"]' })
30+
)
31+
32+
// Has not yet announced the character count
33+
expect(await virtual.spokenPhraseLog()).toMatchInlineSnapshot(`
34+
[
35+
"heading, Text Area Counter, level 1",
36+
"textbox",
37+
]
38+
`)
39+
40+
// Wait 1 second
41+
await new Promise((resolve) => setTimeout(resolve, 1000))
42+
43+
// Ensure that the screen reader experience is as expected
44+
expect(await virtual.spokenPhraseLog()).toMatchInlineSnapshot(`
45+
[
46+
"heading, Text Area Counter, level 1",
47+
"textbox",
48+
"polite: 41 characters left",
49+
]
50+
`)
51+
})
52+
53+
it('announces the remaining character count for every character when in the last 10 remaining characters', async () => {
54+
const { container } = render(TextareaCounter)
55+
56+
await virtual.start({ container })
57+
await virtual.next()
58+
59+
// Type 50 characters
60+
await virtual.type('12345678901234567890123456789012345678901234567890')
61+
await waitFor(() =>
62+
screen.getByDisplayValue('12345678901234567890123456789012345678901234567890')
63+
)
64+
await waitFor(() =>
65+
screen.getByText('0 characters left', { selector: '[data-testid="remaining-count"]' })
66+
)
67+
68+
// Ensure that the screen reader experience is as expected
69+
expect(await virtual.spokenPhraseLog()).toMatchInlineSnapshot(`
70+
[
71+
"heading, Text Area Counter, level 1",
72+
"textbox",
73+
"polite: 10 characters left",
74+
"polite: 9 characters left",
75+
"polite: 8 characters left",
76+
"polite: 7 characters left",
77+
"polite: 6 characters left",
78+
"polite: 5 characters left",
79+
"polite: 4 characters left",
80+
"polite: 3 characters left",
81+
"polite: 2 characters left",
82+
"polite: 1 characters left",
83+
"polite: 0 characters left",
84+
]
85+
`)
86+
})
87+
})

src/Virtual.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
ERR_VIRTUAL_MISSING_CONTAINER,
1414
ERR_VIRTUAL_NOT_STARTED,
1515
} from "./errors";
16+
import { getLiveSpokenPhrase, Live } from "./getLiveSpokenPhrase";
1617
import { getElementFromNode } from "./getElementFromNode";
1718
import { getItemText } from "./getItemText";
18-
import { getLiveSpokenPhrase } from "./getLiveSpokenPhrase";
1919
import { getSpokenPhrase } from "./getSpokenPhrase";
2020
import { observeDOM } from "./observeDOM";
2121
import { tick } from "./tick";
@@ -219,6 +219,14 @@ export class Virtual implements ScreenReader {
219219
});
220220
}
221221

222+
#spokenPhraseLogWithoutLiveRegions() {
223+
return this.#spokenPhraseLog.filter(
224+
(spokenPhrase) =>
225+
!spokenPhrase.startsWith(Live.ASSERTIVE) &&
226+
!spokenPhrase.startsWith(Live.POLITE)
227+
);
228+
}
229+
222230
#updateState(accessibilityNode: AccessibilityNode, ignoreIfNoChange = false) {
223231
const spokenPhrase = getSpokenPhrase(accessibilityNode);
224232
const itemText = getItemText(accessibilityNode);
@@ -227,7 +235,7 @@ export class Virtual implements ScreenReader {
227235

228236
if (
229237
ignoreIfNoChange &&
230-
spokenPhrase === this.#spokenPhraseLog.at(-1) &&
238+
spokenPhrase === this.#spokenPhraseLogWithoutLiveRegions().at(-1) &&
231239
itemText === this.#itemTextLog.at(-1)
232240
) {
233241
return;

src/getLiveSpokenPhrase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { sanitizeString } from "./sanitizeString";
3636
* - https://www.w3.org/TR/wai-aria-1.2/#aria-live
3737
*/
3838

39-
enum Live {
39+
export enum Live {
4040
ASSERTIVE = "assertive",
4141
OFF = "off",
4242
POLITE = "polite",

0 commit comments

Comments
 (0)