From cc02ae2450996be299eda142f262144de03d09f6 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Sat, 10 Apr 2021 17:11:10 +0200 Subject: [PATCH] fix(form-core): feedback msg read before next sibling's msg on blur --- .changeset/smart-olives-tap.md | 5 ++ packages/form-core/src/FormControlMixin.js | 9 +- .../form-core/test/FormControlMixin.test.js | 90 +++++++++++++++---- 3 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 .changeset/smart-olives-tap.md diff --git a/.changeset/smart-olives-tap.md b/.changeset/smart-olives-tap.md new file mode 100644 index 000000000..0ae07075f --- /dev/null +++ b/.changeset/smart-olives-tap.md @@ -0,0 +1,5 @@ +--- +'@lion/form-core': patch +--- + +aria-live is set to assertive on blur, so next focused input message will be read first by screen reader diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js index cc002c46f..858c3323e 100644 --- a/packages/form-core/src/FormControlMixin.js +++ b/packages/form-core/src/FormControlMixin.js @@ -305,7 +305,14 @@ const FormControlMixinImplementation = superclass => this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' }); } if (_feedbackNode) { - _feedbackNode.setAttribute('aria-live', 'polite'); + // Generic focus/blur handling that works for both Fields/FormGroups + this.addEventListener('focusin', () => { + _feedbackNode.setAttribute('aria-live', 'polite'); + }); + this.addEventListener('focusout', () => { + _feedbackNode.setAttribute('aria-live', 'assertive'); + }); + this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' }); } this._enhanceLightDomA11yForAdditionalSlots(); diff --git a/packages/form-core/test/FormControlMixin.test.js b/packages/form-core/test/FormControlMixin.test.js index ef7a8044c..2486bd90a 100644 --- a/packages/form-core/test/FormControlMixin.test.js +++ b/packages/form-core/test/FormControlMixin.test.js @@ -3,9 +3,11 @@ import { LitElement } from '@lion/core'; import sinon from 'sinon'; import { FormControlMixin } from '../src/FormControlMixin.js'; import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js'; +import { FocusMixin } from '../src/FocusMixin.js'; +import { FormGroupMixin } from '../src/form-group/FormGroupMixin.js'; /** - * @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControl + * @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost */ describe('FormControlMixin', () => { @@ -132,21 +134,6 @@ describe('FormControlMixin', () => { expect(descriptionIdsBefore).to.equal(descriptionIdsAfter); }); - it('adds aria-live="polite" to the feedback slot', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - ${inputSlot} -
Added to see attributes
- - `)); - - expect( - Array.from(el.children) - .find(child => child.slot === 'feedback') - ?.getAttribute('aria-live'), - ).to.equal('polite'); - }); - it('clicking the label should call `_onLabelClick`', async () => { const spy = sinon.spy(); const el = /** @type {FormControlMixinClass} */ (await fixture(html` @@ -159,6 +146,77 @@ describe('FormControlMixin', () => { expect(spy).to.have.been.calledOnce; }); + describe('Feedback slot aria-live', () => { + // See: https://www.w3.org/WAI/tutorials/forms/notifications/#on-focus-change + it(`adds aria-live="polite" to the feedback slot on focus, aria-live="assertive" to the feedback slot on blur, + so error messages appearing on blur will be read before those of the next input`, async () => { + const FormControlWithRegistrarMixinClass = class extends FormGroupMixin(LitElement) {}; + + const groupTagString = defineCE(FormControlWithRegistrarMixinClass); + const groupTag = unsafeStatic(groupTagString); + + const focusableTagString = defineCE( + class extends FocusMixin(FormControlMixin(LitElement)) {}, + ); + const focusableTag = unsafeStatic(focusableTagString); + + const formEl = await fixture(html` + <${groupTag} name="form"> + <${groupTag} name="fieldset"> + <${focusableTag} name="field1"> + ${inputSlot} +
+ Error message with: + - aria-live="polite" on focused (during typing an end user should not be bothered for best UX) + - aria-live="assertive" on blur (so that the message that eventually appears + on blur will be read before message of the next focused input) +
+ + <${focusableTag} name="field2"> + ${inputSlot} +
+ Should be read after the error message of field 1 +
+ +
+ Group message... Should be read after the error message of field 2 +
+ + <${focusableTag} name="field3"> + ${inputSlot} +
+ Should be read after the error message of field 2 +
+ + + `); + + /** + * @typedef {* & import('../types/FormControlMixinTypes').FormControlHost} FormControl + */ + const field1El = /** @type {FormControl} */ (formEl.querySelector('[name=field1]')); + const field2El = /** @type {FormControl} */ (formEl.querySelector('[name=field2]')); + const field3El = /** @type {FormControl} */ (formEl.querySelector('[name=field3]')); + const fieldsetEl = /** @type {FormControl} */ (formEl.querySelector('[name=fieldset]')); + + field1El.focus(); + expect(field1El._feedbackNode.getAttribute('aria-live')).to.equal('polite'); + + field2El.focus(); + // field1El just blurred + expect(field1El._feedbackNode.getAttribute('aria-live')).to.equal('assertive'); + expect(field2El._feedbackNode.getAttribute('aria-live')).to.equal('polite'); + + field3El.focus(); + // field2El just blurred + expect(field2El._feedbackNode.getAttribute('aria-live')).to.equal('assertive'); + // fieldsetEl just blurred + + expect(fieldsetEl._feedbackNode.getAttribute('aria-live')).to.equal('assertive'); + expect(field3El._feedbackNode.getAttribute('aria-live')).to.equal('polite'); + }); + }); + describe('Adding extra labels and descriptions', () => { it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() / removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => {