fix(form-core): feedback msg read before next sibling's msg on blur
This commit is contained in:
parent
ccd757fa39
commit
cc02ae2450
3 changed files with 87 additions and 17 deletions
5
.changeset/smart-olives-tap.md
Normal file
5
.changeset/smart-olives-tap.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -305,7 +305,14 @@ const FormControlMixinImplementation = superclass =>
|
||||||
this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' });
|
this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' });
|
||||||
}
|
}
|
||||||
if (_feedbackNode) {
|
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.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' });
|
||||||
}
|
}
|
||||||
this._enhanceLightDomA11yForAdditionalSlots();
|
this._enhanceLightDomA11yForAdditionalSlots();
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { LitElement } from '@lion/core';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { FormControlMixin } from '../src/FormControlMixin.js';
|
import { FormControlMixin } from '../src/FormControlMixin.js';
|
||||||
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.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', () => {
|
describe('FormControlMixin', () => {
|
||||||
|
|
@ -132,21 +134,6 @@ describe('FormControlMixin', () => {
|
||||||
expect(descriptionIdsBefore).to.equal(descriptionIdsAfter);
|
expect(descriptionIdsBefore).to.equal(descriptionIdsAfter);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds aria-live="polite" to the feedback slot', async () => {
|
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
|
||||||
${inputSlot}
|
|
||||||
<div slot="feedback">Added to see attributes</div>
|
|
||||||
</${tag}>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(el.children)
|
|
||||||
.find(child => child.slot === 'feedback')
|
|
||||||
?.getAttribute('aria-live'),
|
|
||||||
).to.equal('polite');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking the label should call `_onLabelClick`', async () => {
|
it('clicking the label should call `_onLabelClick`', async () => {
|
||||||
const spy = sinon.spy();
|
const spy = sinon.spy();
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
|
@ -159,6 +146,77 @@ describe('FormControlMixin', () => {
|
||||||
expect(spy).to.have.been.calledOnce;
|
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}
|
||||||
|
<div slot="feedback">
|
||||||
|
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)
|
||||||
|
</div>
|
||||||
|
</${focusableTag}>
|
||||||
|
<${focusableTag} name="field2">
|
||||||
|
${inputSlot}
|
||||||
|
<div slot="feedback">
|
||||||
|
Should be read after the error message of field 1
|
||||||
|
</div>
|
||||||
|
</${focusableTag}>
|
||||||
|
<div slot="feedback">
|
||||||
|
Group message... Should be read after the error message of field 2
|
||||||
|
</div>
|
||||||
|
</${groupTag}>
|
||||||
|
<${focusableTag} name="field3">
|
||||||
|
${inputSlot}
|
||||||
|
<div slot="feedback">
|
||||||
|
Should be read after the error message of field 2
|
||||||
|
</div>
|
||||||
|
</${focusableTag}>
|
||||||
|
</${groupTag}>
|
||||||
|
`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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', () => {
|
describe('Adding extra labels and descriptions', () => {
|
||||||
it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() /
|
it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() /
|
||||||
removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => {
|
removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue