From 309836e12d8cfc0eea36d57e11ebf20fb885b447 Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Tue, 3 Sep 2019 21:34:12 +0100 Subject: [PATCH 01/14] ARIA LIVE REGION : Use two aria live regions and alternate updates between each. Removes need for comma hack (which gets annonced in VoiceOver, and doesn't always trigger NVDA reliably in a delete scenario). Also circumvents undesirable react DOM update behaviour where individual text nodes are updated in the single live region, causing multiple update events and duplicate screen reader announcements as a result. --- src/status.js | 50 +++++++++++++++++++++++---------------- test/functional/index.js | 37 +++++++++++++++++++++++++++++ test/integration/index.js | 25 ++++++++++++++++++++ 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/status.js b/src/status.js index fc04634e..0a9a448c 100644 --- a/src/status.js +++ b/src/status.js @@ -56,25 +56,35 @@ export default class Status extends Component { content = tResults(length, contentSelectedOption) } - return
- {content} - {bump ? ',' : ',,'} -
+ return ( +
+
+ {bump ? content : ''} +
+
+ {!bump ? content : ''} +
+
+ ) } } diff --git a/test/functional/index.js b/test/functional/index.js index 19e70849..780fffea 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -1,6 +1,7 @@ /* global after, describe, before, beforeEach, expect, it */ import { createElement, render } from 'preact' /** @jsx createElement */ import Autocomplete from '../../src/autocomplete' +import Status from '../../src/status' function suggest (query, syncResults) { var results = [ @@ -483,3 +484,39 @@ describe('Autocomplete', () => { }) }) }) + +describe('Status', () => { + describe('rendering', () => { + let scratch + + before(() => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch) + }) + + beforeEach(() => { + scratch.innerHTML = '' + }) + + after(() => { + scratch.parentNode.removeChild(scratch) + scratch = null + }) + + it('renders a pair of aria live regions', () => { + render(, scratch) + expect(scratch.innerHTML).to.contain('div') + + let wrapperElement = scratch.getElementsByTagName('div')[0] + let ariaLiveA = wrapperElement.getElementsByTagName('div')[0] + let ariaLiveB = wrapperElement.getElementsByTagName('div')[1] + + expect(ariaLiveA.getAttribute('role')).to.equal('status', 'first aria live region should be marked as role=status') + expect(ariaLiveA.getAttribute('aria-atomic')).to.equal('true', 'first aria live region should be marked as atomic') + expect(ariaLiveA.getAttribute('aria-live')).to.equal('polite', 'first aria live region should be marked as polite') + expect(ariaLiveB.getAttribute('role')).to.equal('status', 'second aria live region should be marked as role=status') + expect(ariaLiveB.getAttribute('aria-atomic')).to.equal('true', 'second aria live region should be marked as atomic') + expect(ariaLiveB.getAttribute('aria-live')).to.equal('polite', 'second aria live region should be marked as polite') + }) + }) +}) diff --git a/test/integration/index.js b/test/integration/index.js index 3fc2de73..9780f402 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -7,6 +7,7 @@ const isIE = browserName === 'internet explorer' // const isIE9 = isIE && version === '9' // const isIE10 = isIE && version === '10' // const isIE11 = isIE && version === '11.103' +const liveRegionWaitTimeMillis = 20 const basicExample = () => { describe('basic example', function () { @@ -38,6 +39,30 @@ const basicExample = () => { expect(browser.isVisible(menu)).to.equal(true) }) + it('should announce status changes using two alternately updated aria live regions', () => { + const flip = browser.$('div#ariaLiveA') + const flop = browser.$('div#ariaLiveB') + + browser.click(input) + browser.setValue(input, 'a') + browser.waitUntil(() => { return flip.getAttribute('textContent') !== '' }, + liveRegionWaitTimeMillis, + 'expected the first aria live region to be populated within ' + liveRegionWaitTimeMillis + ' milliseconds' + ) + browser.addValue(input, 's') + browser.waitUntil(() => { return (flip.getAttribute('textContent') === '' && flop.getAttribute('textContent') !== '') }, + liveRegionWaitTimeMillis, + 'expected the first aria live region to be cleared, and the second to be populated within ' + + liveRegionWaitTimeMillis + ' milliseconds' + ) + browser.addValue(input, 'h') + browser.waitUntil(() => { return (flip.getAttribute('textContent') !== '' && flop.getAttribute('textContent') === '') }, + liveRegionWaitTimeMillis, + 'expected the first aria live region to be populated, and the second to be cleared within ' + + liveRegionWaitTimeMillis + ' milliseconds' + ) + }) + describe('keyboard use', () => { it('should allow typing', () => { browser.click(input) From f5f9ca8eca0ed5580b48e9138d0323ebc8998e8b Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Mon, 9 Sep 2019 21:19:34 +0100 Subject: [PATCH 02/14] ARIA LIVE REGION : Introduce debouncing of aria live region updates. Resolves Mac VoiceOver problem where typing echo will otherwise interrupt (and therefore mask) screen reader announcement. Also imrpoves reliability of announcements in JAWS+IE combination, when responding to rapid user typing. --- src/status.js | 42 +++++++++++++++++++++++++++++++-------- test/integration/index.js | 4 +++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/status.js b/src/status.js index 0a9a448c..fcb26fc9 100644 --- a/src/status.js +++ b/src/status.js @@ -1,5 +1,22 @@ import { createElement, Component } from 'preact' /** @jsx createElement */ +const debounce = function (func, wait, immediate) { + var timeout + return function () { + var context = this + var args = arguments + var later = function () { + timeout = null + if (!immediate) func.apply(context, args) + } + var callNow = immediate && !timeout + clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) func.apply(context, args) + } +} +const statusDebounceMillis = 1400 + export default class Status extends Component { static defaultProps = { tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results.`, @@ -16,14 +33,21 @@ export default class Status extends Component { }; state = { - bump: false + bump: false, + debounced: false + } + + componentWillMount () { + const that = this + this.debounceStatusUpdate = debounce(function () { + if (!that.state.debounced) { + that.setState(({ bump }) => ({ bump: !bump, debounced: true })) + } + }, statusDebounceMillis) } componentWillReceiveProps ({ queryLength }) { - const hasChanged = queryLength !== this.props.queryLength - if (hasChanged) { - this.setState(({ bump }) => ({ bump: !bump })) - } + this.setState({ debounced: false }) } render () { @@ -38,7 +62,7 @@ export default class Status extends Component { tSelectedOption, tResults } = this.props - const { bump } = this.state + const { bump, debounced } = this.state const queryTooShort = queryLength < minQueryLength const noResults = length === 0 @@ -56,6 +80,8 @@ export default class Status extends Component { content = tResults(length, contentSelectedOption) } + this.debounceStatusUpdate() + return (
- {bump ? content : ''} + {debounced && bump ? content : ''}
- {!bump ? content : ''} + {debounced && !bump ? content : ''}
) diff --git a/test/integration/index.js b/test/integration/index.js index 9780f402..d06e23bf 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -7,7 +7,7 @@ const isIE = browserName === 'internet explorer' // const isIE9 = isIE && version === '9' // const isIE10 = isIE && version === '10' // const isIE11 = isIE && version === '11.103' -const liveRegionWaitTimeMillis = 20 +const liveRegionWaitTimeMillis = 2000 const basicExample = () => { describe('basic example', function () { @@ -45,6 +45,8 @@ const basicExample = () => { browser.click(input) browser.setValue(input, 'a') + expect(flip.getAttribute('textContent')).to.equal('') + expect(flop.getAttribute('textContent')).to.equal('') browser.waitUntil(() => { return flip.getAttribute('textContent') !== '' }, liveRegionWaitTimeMillis, 'expected the first aria live region to be populated within ' + liveRegionWaitTimeMillis + ' milliseconds' From 5618c28e6a3ecbf1ccfd17356129baeac6bab846 Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Tue, 3 Sep 2019 22:18:33 +0100 Subject: [PATCH 03/14] ARIA LIVE REGION : Aria live region content refinements to prevent announcement of 'dot' at the end of each update, and also to use the term 'highlighted' in favour of 'selected'. (Latter update a DAC / Chris Moore recommendation, as the term 'selected' can confuse users when they haven't yet confirmed a specific choice.) --- README.md | 6 +++--- src/status.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0d73cfc7..4ab17780 100644 --- a/README.md +++ b/README.md @@ -278,19 +278,19 @@ Type: `Function` A function that receives no arguments and should return the text used in the dropdown to indicate that there are no results. -#### `tStatusQueryTooShort` (default: `` (minQueryLength) => `Type in ${minQueryLength} or more characters for results.` ``) +#### `tStatusQueryTooShort` (default: `` (minQueryLength) => `Type in ${minQueryLength} or more characters for results` ``) Type: `Function` A function that receives one argument that indicates the minimal amount of characters needed for the dropdown to trigger and should return the text used in the accessibility hint to indicate that the query is too short. -#### `tStatusNoResults` (default: `() => 'No search results.'`) +#### `tStatusNoResults` (default: `() => 'No search results'`) Type: `Function` A function that receives no arguments and should return the text that is used in the accessibility hint to indicate that there are no results. -#### `tStatusSelectedOption` (default: `` (selectedOption, length, index) => `${selectedOption} (${index + 1} of ${length}) is selected.` ``) +#### `tStatusSelectedOption` (default: `` (selectedOption, length, index) => `${selectedOption} ${index + 1} of ${length} is highlighted` ``) Type: `Function` diff --git a/src/status.js b/src/status.js index fcb26fc9..6ae3b369 100644 --- a/src/status.js +++ b/src/status.js @@ -19,9 +19,9 @@ const statusDebounceMillis = 1400 export default class Status extends Component { static defaultProps = { - tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results.`, - tNoResults: () => 'No search results.', - tSelectedOption: (selectedOption, length, index) => `${selectedOption} (${index + 1} of ${length}) is selected.`, + tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results`, + tNoResults: () => 'No search results', + tSelectedOption: (selectedOption, length, index) => `${selectedOption} ${index + 1} of ${length} is highlighted`, tResults: (length, contentSelectedOption) => { const words = { result: (length === 1) ? 'result' : 'results', From 15bc9cd46f9cde267575d3cb1170aa8214a0eabe Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Mon, 9 Sep 2019 21:22:14 +0100 Subject: [PATCH 04/14] ARIA LIVE REGION : Remove inner span element from each of the aria live region divs. This change addresses the problem of JAWS failing to annouce aria live updates, unless 'virtual PC cursor mode' is first disabled by the user. This mode is enabled by default, and user will otherwise have no cue that they're missing anything. --- src/status.js | 4 ++-- test/integration/index.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/status.js b/src/status.js index 6ae3b369..c5690994 100644 --- a/src/status.js +++ b/src/status.js @@ -101,14 +101,14 @@ export default class Status extends Component { role='status' aria-atomic='true' aria-live='polite'> - {debounced && bump ? content : ''} + {debounced && bump ? content : ''}
- {debounced && !bump ? content : ''} + {debounced && !bump ? content : ''}
) diff --git a/test/integration/index.js b/test/integration/index.js index d06e23bf..1f7c5621 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -45,20 +45,20 @@ const basicExample = () => { browser.click(input) browser.setValue(input, 'a') - expect(flip.getAttribute('textContent')).to.equal('') - expect(flop.getAttribute('textContent')).to.equal('') - browser.waitUntil(() => { return flip.getAttribute('textContent') !== '' }, + expect(flip.getText()).to.equal('') + expect(flop.getText()).to.equal('') + browser.waitUntil(() => { return flip.getText() !== '' }, liveRegionWaitTimeMillis, 'expected the first aria live region to be populated within ' + liveRegionWaitTimeMillis + ' milliseconds' ) browser.addValue(input, 's') - browser.waitUntil(() => { return (flip.getAttribute('textContent') === '' && flop.getAttribute('textContent') !== '') }, + browser.waitUntil(() => { return (flip.getText() === '' && flop.getText() !== '') }, liveRegionWaitTimeMillis, 'expected the first aria live region to be cleared, and the second to be populated within ' + liveRegionWaitTimeMillis + ' milliseconds' ) browser.addValue(input, 'h') - browser.waitUntil(() => { return (flip.getAttribute('textContent') !== '' && flop.getAttribute('textContent') === '') }, + browser.waitUntil(() => { return (flip.getText() !== '' && flop.getText() === '') }, liveRegionWaitTimeMillis, 'expected the first aria live region to be populated, and the second to be cleared within ' + liveRegionWaitTimeMillis + ' milliseconds' From 09e460186dba9ecc23edca66d096c62fdc897237 Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Wed, 4 Sep 2019 21:42:58 +0100 Subject: [PATCH 05/14] INPUT ELEMENT : Move the combobox role onto the input element, rather than the higher level composing div. This change is a regression to the ARIA 1.0 pattern rather than 1.1, but it resolves the issue of the input not being recognised as a form control in screen reader form navigation modes. Indications are that the spec is ahead of screen reader implementation (VoiceOver/NVDA/JAWS). --- src/autocomplete.js | 4 ++-- test/functional/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/autocomplete.js b/src/autocomplete.js index 23af27e3..0dc8c42c 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -448,7 +448,7 @@ export default class Autocomplete extends Component { } return ( -
+
{ this.elementReferences[-1] = inputElement }} type='text' - role='textbox' + role='combobox' required={required} value={query} /> diff --git a/test/functional/index.js b/test/functional/index.js index 780fffea..0b93238c 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -85,7 +85,7 @@ describe('Autocomplete', () => { let inputElement = wrapperElement.getElementsByTagName('input')[0] let dropdownElement = wrapperElement.getElementsByTagName('ul')[0] - expect(inputElement.getAttribute('role')).to.equal('textbox', 'input should have textbox role') + expect(inputElement.getAttribute('role')).to.equal('combobox', 'input should have combobox role') expect(dropdownElement.getAttribute('role')).to.equal('listbox', 'menu should have listbox role') }) }) From 3869abac6c3c83ee029998dfd787ba001aabf145 Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Wed, 4 Sep 2019 21:49:56 +0100 Subject: [PATCH 06/14] INPUT ELEMENT : Move the aria-expanded attribute from the composing div element, onto the input element. Also stepping back to ARIA 1.0, this change triggers NVDA to announce the expanded/collapsed state of the result list. (Not so in JAWS) --- src/autocomplete.js | 3 ++- test/functional/index.js | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/autocomplete.js b/src/autocomplete.js index 0dc8c42c..bd2baa08 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -448,7 +448,7 @@ export default class Autocomplete extends Component { } return ( -
+
{ expect(scratch.innerHTML).to.contain('class="bob__menu') }) - it('renders with the correct aria attributes', () => { + it('renders with an aria-expanded attribute', () => { render(, scratch) let wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0] + let inputElement = wrapperElement.getElementsByTagName('input')[0] - expect(wrapperElement.getAttribute('aria-expanded')).to.equal('false') + expect(inputElement.getAttribute('aria-expanded')).to.equal('false') }) it('renders with the correct roles', () => { From fb19d2b83930a7ba4d590fd6aebaa491a6d9165d Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Wed, 4 Sep 2019 22:25:25 +0100 Subject: [PATCH 07/14] INPUT ELEMENT : Addition of dynamic aria-autocomplete attribute (value driven by 'autoselect' configuration option). Presence of this attribute slightly improves the focus announcement in NVDA, where the screen reader will include 'has autocomplete'. JAWS/VoiceOver no improvement, but no detriment. --- src/autocomplete.js | 1 + test/functional/index.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/autocomplete.js b/src/autocomplete.js index bd2baa08..909321f0 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -469,6 +469,7 @@ export default class Autocomplete extends Component { aria-expanded={menuOpen ? 'true' : 'false'} aria-activedescendant={optionFocused ? `${id}__option--${focused}` : false} aria-owns={`${id}__listbox`} + aria-autocomplete={(this.hasAutoselect()) ? 'both' : 'list'} autoComplete='off' className={`${inputClassName}${inputModifierFocused}${inputModifierType}`} id={id} diff --git a/test/functional/index.js b/test/functional/index.js index e34c3c01..144599df 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -79,6 +79,26 @@ describe('Autocomplete', () => { expect(inputElement.getAttribute('aria-expanded')).to.equal('false') }) + describe('renders with an aria-autocomplete attribute', () => { + it('of value "list", when autoselect is not enabled', () => { + render(, scratch) + + let wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0] + let inputElement = wrapperElement.getElementsByTagName('input')[0] + + expect(inputElement.getAttribute('aria-autocomplete')).to.equal('list') + }) + + it('of value "both", when autoselect is enabled', () => { + render(, scratch) + + let wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0] + let inputElement = wrapperElement.getElementsByTagName('input')[0] + + expect(inputElement.getAttribute('aria-autocomplete')).to.equal('both') + }) + }) + it('renders with the correct roles', () => { render(, scratch) From 43b0bcecdbb52e1bf808c3cdd924109c68b8868d Mon Sep 17 00:00:00 2001 From: Mark Hunter Date: Wed, 4 Sep 2019 22:33:17 +0100 Subject: [PATCH 08/14] EXAMPLE PAGE : Addition of a single-instance form example page. Helps during assistive tech testing, removes clutter and allows easier focus. --- examples/form-single.html | 379 ++++++++++++++++++++++++++++++++++++++ examples/form.html | 2 + 2 files changed, 381 insertions(+) create mode 100644 examples/form-single.html diff --git a/examples/form-single.html b/examples/form-single.html new file mode 100644 index 00000000..e4b2a08a --- /dev/null +++ b/examples/form-single.html @@ -0,0 +1,379 @@ + + + + + + Accessible Autocomplete form example + + + + +
+

Accessible Autocomplete form example

+ + + +
+ +
+ +
+ + +
+
+ + + + + diff --git a/examples/form.html b/examples/form.html index 7f6fcf36..8f43d327 100644 --- a/examples/form.html +++ b/examples/form.html @@ -57,6 +57,8 @@

Accessible Autocomplete form example

+

Another HTML form example, with a single form field

+