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
- ${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 () => {
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}>
+ <${focusableTag} name="field2">
+ ${inputSlot}
+
+ Should be read after the error message of field 1
+
+ ${focusableTag}>
+
+ Group message... Should be read after the error message of field 2
+
+ ${groupTag}>
+ <${focusableTag} name="field3">
+ ${inputSlot}
+
+ Should be read after the error message of field 2
+
+ ${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', () => {
it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() /
removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => {