From fb1522dda58ce7dfcbc719e9dd9ac92289bb59eb Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Thu, 8 Apr 2021 19:54:34 +0200 Subject: [PATCH] fix(form-core): fieldset label as child label suffix --- .changeset/rotten-dots-argue.md | 5 + packages/form-core/src/FormControlMixin.js | 49 +- .../src/form-group/FormGroupMixin.js | 10 +- .../form-group/FormGroupMixin-input.suite.js | 30 +- .../form-core/test/FormControlMixin.test.js | 442 +++++++++++------- packages/form-core/test/lion-field.test.js | 53 --- .../types/FormControlMixinTypes.d.ts | 12 + 7 files changed, 367 insertions(+), 234 deletions(-) create mode 100644 .changeset/rotten-dots-argue.md diff --git a/.changeset/rotten-dots-argue.md b/.changeset/rotten-dots-argue.md new file mode 100644 index 000000000..03ccbebf3 --- /dev/null +++ b/.changeset/rotten-dots-argue.md @@ -0,0 +1,5 @@ +--- +'@lion/form-core': patch +--- + +**form-core**: fieldset label as child label suffix. Mimics native fieldset a11y diff --git a/packages/form-core/src/FormControlMixin.js b/packages/form-core/src/FormControlMixin.js index 0556a9890..1cf90158e 100644 --- a/packages/form-core/src/FormControlMixin.js +++ b/packages/form-core/src/FormControlMixin.js @@ -321,10 +321,10 @@ const FormControlMixinImplementation = superclass => additionalSlots.forEach(additionalSlot => { const element = this.__getDirectSlotChild(additionalSlot); if (element) { - if (element.hasAttribute('data-label') === true) { + if (element.hasAttribute('data-label')) { this.addToAriaLabelledBy(element, { idPrefix: additionalSlot }); } - if (element.hasAttribute('data-description') === true) { + if (element.hasAttribute('data-description')) { this.addToAriaDescribedBy(element, { idPrefix: additionalSlot }); } } @@ -346,6 +346,7 @@ const FormControlMixinImplementation = superclass => if (reorder) { const insideNodes = nodes.filter(n => this.contains(n)); const outsideNodes = nodes.filter(n => !this.contains(n)); + // eslint-disable-next-line no-param-reassign nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes]; } @@ -704,12 +705,7 @@ const FormControlMixinImplementation = superclass => * @param {HTMLElement} element * @param {{idPrefix?:string; reorder?: boolean}} customConfig */ - addToAriaLabelledBy(element, customConfig = {}) { - const { idPrefix, reorder } = { - reorder: true, - ...customConfig, - }; - + addToAriaLabelledBy(element, { idPrefix = '', reorder = true } = {}) { // eslint-disable-next-line no-param-reassign element.id = element.id || `${idPrefix}-${this._inputId}`; if (!this._ariaLabelledNodes.includes(element)) { @@ -720,18 +716,27 @@ const FormControlMixinImplementation = superclass => } } + /** + * Meant for Application Developers wanting to delete from aria-labelledby attribute. + * @param {HTMLElement} element + */ + removeFromAriaLabelledBy(element) { + if (this._ariaLabelledNodes.includes(element)) { + this._ariaLabelledNodes.splice(this._ariaLabelledNodes.indexOf(element), 1); + this._ariaLabelledNodes = [...this._ariaLabelledNodes]; + + // This value will be read when we need to reflect to attr + /** @type {boolean} */ + this.__reorderAriaLabelledNodes = false; + } + } + /** * Meant for Application Developers wanting to add to aria-describedby attribute. * @param {HTMLElement} element * @param {{idPrefix?:string; reorder?: boolean}} customConfig */ - addToAriaDescribedBy(element, customConfig = {}) { - const { idPrefix, reorder } = { - // chronologically sorts children of host element('this') - reorder: true, - ...customConfig, - }; - + addToAriaDescribedBy(element, { idPrefix = '', reorder = true } = {}) { // eslint-disable-next-line no-param-reassign element.id = element.id || `${idPrefix}-${this._inputId}`; if (!this._ariaDescribedNodes.includes(element)) { @@ -742,6 +747,20 @@ const FormControlMixinImplementation = superclass => } } + /** + * Meant for Application Developers wanting to delete from aria-labelledby attribute. + * @param {HTMLElement} element + */ + removeFromAriaDescribedBy(element) { + if (this._ariaDescribedNodes.includes(element)) { + this._ariaDescribedNodes.splice(this._ariaDescribedNodes.indexOf(element), 1); + this._ariaDescribedNodes = [...this._ariaDescribedNodes]; + // This value will be read when we need to reflect to attr + /** @type {boolean} */ + this.__reorderAriaLabelledNodes = false; + } + } + /** * @param {string} slotName * @return {HTMLElement | undefined} diff --git a/packages/form-core/src/form-group/FormGroupMixin.js b/packages/form-core/src/form-group/FormGroupMixin.js index 69de0baa0..dfbd2a972 100644 --- a/packages/form-core/src/form-group/FormGroupMixin.js +++ b/packages/form-core/src/form-group/FormGroupMixin.js @@ -454,6 +454,10 @@ const FormGroupMixinImplementation = superclass => // TODO: Unlink in removeFormElement this.__linkChildrenMessagesToParent(child); this.validate({ clearCurrentResult: true }); + + if (typeof child.addToAriaLabelledBy === 'function' && this._labelNode) { + child.addToAriaLabelledBy(this._labelNode, { reorder: false }); + } } /** @@ -481,11 +485,15 @@ const FormGroupMixinImplementation = superclass => /** * @override of FormRegistrarMixin. Connects ValidateMixin - * @param {FormRegisteringHost} el + * @param {FormRegisteringHost & FormControlHost} el */ removeFormElement(el) { super.removeFormElement(el); this.validate({ clearCurrentResult: true }); + + if (typeof el.removeFromAriaLabelledBy === 'function' && this._labelNode) { + el.removeFromAriaLabelledBy(this._labelNode, { reorder: false }); + } } }; diff --git a/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js b/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js index 96c21fbf1..b9ab35d6e 100644 --- a/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js +++ b/packages/form-core/test-suites/form-group/FormGroupMixin-input.suite.js @@ -40,7 +40,7 @@ export function runFormGroupMixinInputSuite(cfg = {}) { localizeTearDown(); }); - describe('FormGroupMixin with LionInput', () => { + describe('FormGroupMixin with LionField', () => { it('serializes undefined values as "" (nb radios/checkboxes are always serialized)', async () => { const fieldset = /** @type {FormGroup} */ (await fixture(html` <${tag}> @@ -55,6 +55,34 @@ export function runFormGroupMixinInputSuite(cfg = {}) { 'custom[]': ['custom 1', ''], }); }); + + it('suffixes child labels with group label, just like in
', async () => { + const el = /** @type {FormGroup} */ (await fixture(html` + <${tag} label="set"> + <${childTag} name="A" label="fieldA"> + <${childTag} name="B" label="fieldB"> + + `)); + + /** + * @param {LionInput} formControl + */ + function getLabels(formControl) { + return /** @type {string} */ (formControl._inputNode.getAttribute('aria-labelledby')).split( + ' ', + ); + } + const field1 = el.formElements[0]; + const field2 = el.formElements[1]; + + expect(getLabels(field1)).to.eql([field1._labelNode.id, el._labelNode.id]); + expect(getLabels(field2)).to.eql([field2._labelNode.id, el._labelNode.id]); + + // Test the cleanup on disconnected + el.removeChild(field1); + await field1.updateComplete; + expect(getLabels(field1)).to.eql([field1._labelNode.id]); + }); }); describe('Screen reader relations (aria-describedby) for child inputs and fieldsets', () => { diff --git a/packages/form-core/test/FormControlMixin.test.js b/packages/form-core/test/FormControlMixin.test.js index 6e7686b90..35a2d8180 100644 --- a/packages/form-core/test/FormControlMixin.test.js +++ b/packages/form-core/test/FormControlMixin.test.js @@ -5,35 +5,13 @@ import { FormControlMixin } from '../src/FormControlMixin.js'; import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js'; describe('FormControlMixin', () => { - const inputSlot = ''; + const inputSlot = html``; class FormControlMixinClass extends FormControlMixin(LitElement) {} const tagString = defineCE(FormControlMixinClass); const tag = unsafeStatic(tagString); - it('has a label', async () => { - const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag} label="Email address">${inputSlot} - `)); - - expect(elAttr.label).to.equal('Email address', 'as an attribute'); - - const elProp = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag} - .label=${'Email address'} - >${inputSlot} - `)); - expect(elProp.label).to.equal('Email address', 'as a property'); - - const elElem = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - - ${inputSlot} - `)); - expect(elElem.label).to.equal('Email address', 'as an element'); - }); - it('is hidden when attribute hidden is true', async () => { const el = await fixture(html` <${tag} hidden> @@ -43,172 +21,308 @@ describe('FormControlMixin', () => { expect(el).not.to.be.displayed; }); - it('has a label that supports inner html', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - - ${inputSlot} - `)); - expect(el.label).to.equal('Email address'); - }); + describe('Label and helpText api', () => { + it('has a label', async () => { + const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag} label="Email address">${inputSlot} + `)); - it('only takes label of direct child', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - <${tag} label="Email address"> + expect(elAttr.label).to.equal('Email address', 'as an attribute'); + + const elProp = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag} + .label=${'Email address'} + >${inputSlot} + `)); + expect(elProp.label).to.equal('Email address', 'as a property'); + + const elElem = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> + ${inputSlot} - - `)); - expect(el.label).to.equal(''); - }); + `)); + expect(elElem.label).to.equal('Email address', 'as an element'); + }); - it('can have a help-text', async () => { - const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag} help-text="We will not send you any spam">${inputSlot} - `)); - expect(elAttr.helpText).to.equal('We will not send you any spam', 'as an attribute'); - - const elProp = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag} - .helpText=${'We will not send you any spam'} - >${inputSlot} - `)); - expect(elProp.helpText).to.equal('We will not send you any spam', 'as a property'); - - const elElem = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> -
We will not send you any spam
- ${inputSlot} - `)); - expect(elElem.helpText).to.equal('We will not send you any spam', 'as an element'); - }); - - it('can have a help-text that supports inner html', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> -
We will not send you any spam
- ${inputSlot} - `)); - expect(el.helpText).to.equal('We will not send you any spam'); - }); - - it('only takes help-text of direct child', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - <${tag} help-text="We will not send you any spam"> + it('has a label that supports inner html', async () => { + const el = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> + ${inputSlot} - - `)); - expect(el.helpText).to.equal(''); - }); + `)); + expect(el.label).to.equal('Email address'); + }); - it('does not duplicate aria-describedby and aria-labelledby ids', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(` - <${tagString} help-text="This element will be disconnected/reconnected">${inputSlot} - `)); + it('only takes label of direct child', async () => { + const el = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> + <${tag} label="Email address"> + ${inputSlot} + + `)); + expect(el.label).to.equal(''); + }); - const wrapper = /** @type {LitElement} */ (await fixture(`
`)); - el.parentElement?.appendChild(wrapper); - wrapper.appendChild(el); - await wrapper.updateComplete; + it('can have a help-text', async () => { + const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag} help-text="We will not send you any spam">${inputSlot} + `)); + expect(elAttr.helpText).to.equal('We will not send you any spam', 'as an attribute'); - ['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => { - const ariaAttribute = Array.from(el.children) - .find(child => child.slot === 'input') - ?.getAttribute(ariaAttributeName) - ?.trim() - .split(' '); - const hasDuplicate = !!ariaAttribute?.find((elm, i) => ariaAttribute.indexOf(elm) !== i); - expect(hasDuplicate).to.be.false; + const elProp = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag} + .helpText=${'We will not send you any spam'} + >${inputSlot} + `)); + expect(elProp.helpText).to.equal('We will not send you any spam', 'as a property'); + + const elElem = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> +
We will not send you any spam
+ ${inputSlot} + `)); + expect(elElem.helpText).to.equal('We will not send you any spam', 'as an element'); + }); + + it('can have a help-text that supports inner html', async () => { + const el = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> +
We will not send you any spam
+ ${inputSlot} + `)); + expect(el.helpText).to.equal('We will not send you any spam'); + }); + + it('only takes help-text of direct child', async () => { + const el = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag}> + <${tag} help-text="We will not send you any spam"> + ${inputSlot} + + `)); + expect(el.helpText).to.equal(''); }); }); - // FIXME: Broken test - it.skip('internally sorts aria-describedby and aria-labelledby ids', async () => { - const wrapper = await fixture(html` -
-
should go after input internals
-
should go after input internals
+ describe('Accessibility', () => { + it('does not duplicate aria-describedby and aria-labelledby ids on reconnect', async () => { + const wrapper = /** @type {HTMLElement} */ (await fixture(html` +
+ <${tag} help-text="This element will be disconnected/reconnected">${inputSlot} +
+ `)); + const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString)); + const labelIdsBefore = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')); + const descriptionIdsBefore = /** @type {string} */ (el._inputNode.getAttribute( + 'aria-describedby', + )); + // Reconnect + wrapper.removeChild(el); + wrapper.appendChild(el); + const labelIdsAfter = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')); + const descriptionIdsAfter = /** @type {string} */ (el._inputNode.getAttribute( + 'aria-describedby', + )); + + expect(labelIdsBefore).to.equal(labelIdsAfter); + expect(descriptionIdsBefore).to.equal(descriptionIdsAfter); + }); + + it('adds aria-live="polite" to the feedback slot', async () => { + const el = /** @type {FormControlMixinClass} */ (await fixture(html` <${tag}> - - -
Added to description by default
+ ${inputSlot} +
Added to see attributes
-
should go after input internals
-
should go after input internals
-
`); - const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString)); - const { _inputNode } = el; + `)); - // 1. addToAriaLabelledBy() - // external inputs should go in order defined by user - const labelA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelA')); - const labelB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelB')); - el.addToAriaLabelledBy(labelA); - el.addToAriaLabelledBy(labelB); + expect( + Array.from(el.children) + .find(child => child.slot === 'feedback') + ?.getAttribute('aria-live'), + ).to.equal('polite'); + }); - const ariaLabelId = /** @type {number} */ (_inputNode - .getAttribute('aria-labelledby') - ?.indexOf(`label-${el._inputId}`)); + it('clicking the label should call `_onLabelClick`', async () => { + const spy = sinon.spy(); + const el = /** @type {FormControlMixinClass} */ (await fixture(html` + <${tag} ._onLabelClick="${spy}"> + ${inputSlot} + + `)); + expect(spy).to.not.have.been.called; + el._labelNode.click(); + expect(spy).to.have.been.calledOnce; + }); - const ariaLabelA = /** @type {number} */ (_inputNode - .getAttribute('aria-labelledby') - ?.indexOf('additionalLabelA')); + describe('Adding extra labels and descriptions', () => { + it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() / + removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => { + const wrapper = /** @type {HTMLElement} */ (await fixture(html` +
+ <${tag}> + ${inputSlot} + +
Added to description by default
+ +
This also needs to be read whenever the input has focus
+
Same for this
+
`)); + const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString)); + // wait until the field element is done rendering + await el.updateComplete; + await el.updateComplete; - const ariaLabelB = /** @type {number} */ (_inputNode - .getAttribute('aria-labelledby') - ?.indexOf('additionalLabelB')); + // 1a. addToAriaLabelledBy() + // Check if the aria attr is filled initially + expect(/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'))).to.contain( + `label-${el._inputId}`, + ); + const additionalLabel = /** @type {HTMLElement} */ (wrapper.querySelector( + '#additionalLabel', + )); + el.addToAriaLabelledBy(additionalLabel); + await el.updateComplete; + let labelledbyAttr = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')); + // Now check if ids are added to the end (not overridden) + expect(labelledbyAttr).to.contain(`additionalLabel`); + // Should be placed in the end + expect( + labelledbyAttr.indexOf(`label-${el._inputId}`) < + labelledbyAttr.indexOf('additionalLabel'), + ); - expect(ariaLabelId < ariaLabelB && ariaLabelB < ariaLabelA).to.be.true; + // 1b. removeFromAriaLabelledBy() + el.removeFromAriaLabelledBy(additionalLabel); + await el.updateComplete; + labelledbyAttr = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')); + // Now check if ids are added to the end (not overridden) + expect(labelledbyAttr).to.not.contain(`additionalLabel`); - // 2. addToAriaDescribedBy() - // Check if the aria attr is filled initially - const descA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionA')); - const descB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionB')); - el.addToAriaDescribedBy(descB); - el.addToAriaDescribedBy(descA); + // 2a. addToAriaDescribedBy() + // Check if the aria attr is filled initially + expect(/** @type {string} */ (el._inputNode.getAttribute('aria-describedby'))).to.contain( + `feedback-${el._inputId}`, + ); + // const additionalDescription = /** @type {HTMLElement} */ (wrapper.querySelector( + // '#additionalDescription', + // )); + // el.addToAriaDescribedBy(additionalDescription); + // await el.updateComplete; - const ariaDescId = /** @type {number} */ (_inputNode - .getAttribute('aria-describedby') - ?.indexOf(`feedback-${el._inputId}`)); + // let describedbyAttr = /** @type {string} */ (el._inputNode.getAttribute( + // 'aria-describedby', + // )); - const ariaDescA = /** @type {number} */ (_inputNode - .getAttribute('aria-describedby') - ?.indexOf('additionalDescriptionA')); + // // Now check if ids are added to the end (not overridden) + // expect(describedbyAttr).to.contain(`feedback-${el._inputId}`); + // // Should be placed in the end + // expect( + // describedbyAttr.indexOf(`feedback-${el._inputId}`) < + // describedbyAttr.indexOf('additionalDescription'), + // ); - const ariaDescB = /** @type {number} */ (_inputNode - .getAttribute('aria-describedby') - ?.indexOf('additionalDescriptionB')); + // // 2b. removeFromAriaDescription() + // el.removeFromAriaDescribedBy(additionalDescription); + // await el.updateComplete; - // Should be placed in the end - expect(ariaDescId < ariaDescB && ariaDescB < ariaDescA).to.be.true; - }); + // describedbyAttr = /** @type {string} */ (el._inputNode.getAttribute('aria-describedby')); + // // Now check if ids are added to the end (not overridden) + // expect(describedbyAttr).to.not.contain(`additionalDescription`); + }); - it('adds aria-live="polite" to the feedback slot', async () => { - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag}> - ${inputSlot} -
Added to see attributes
- - `)); + it('sorts internal elements, and allows opt-out', async () => { + const wrapper = await fixture(html` +
+ <${tag}> + + +
+ Added to description by default +
+ +
should go after input internals
+
should go after input internals
+
`); + const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString)); - expect( - Array.from(el.children) - .find(child => child.slot === 'feedback') - ?.getAttribute('aria-live'), - ).to.equal('polite'); - }); + // N.B. in real life we would never add the input to aria-describedby or -labelledby, + // but this example purely demonstrates dom order is respected. + // A real life scenario would be for instance when + // a Field or FormGroup would be extended and an extra slot would be added in the template + const myInput = /** @type {HTMLElement} */ (wrapper.querySelector('#myInput')); + el.addToAriaLabelledBy(myInput); + await el.updateComplete; + el.addToAriaDescribedBy(myInput); + await el.updateComplete; - it('clicking the label should call `_onLabelClick`', async () => { - const spy = sinon.spy(); - const el = /** @type {FormControlMixinClass} */ (await fixture(html` - <${tag} ._onLabelClick="${spy}"> - ${inputSlot} - - `)); - expect(spy).to.not.have.been.called; - el._labelNode.click(); - expect(spy).to.have.been.calledOnce; + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '), + ).to.eql(['myInput', 'internalLabel']); + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '), + ).to.eql(['myInput', 'internalDescription']); + + // cleanup + el.removeFromAriaLabelledBy(myInput); + await el.updateComplete; + el.removeFromAriaDescribedBy(myInput); + await el.updateComplete; + + // opt-out of reorder + el.addToAriaLabelledBy(myInput, { reorder: false }); + await el.updateComplete; + el.addToAriaDescribedBy(myInput, { reorder: false }); + await el.updateComplete; + + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '), + ).to.eql(['internalLabel', 'myInput']); + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '), + ).to.eql(['internalDescription', 'myInput']); + }); + + it('respects provided order for external elements', async () => { + const wrapper = await fixture(html` +
+
should go after input internals
+
should go after input internals
+ <${tag}> + + +
Added to description by default
+ +
should go after input internals
+
should go after input internals
+
`); + const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString)); + + // 1. addToAriaLabelledBy() + const labelA = /** @type {HTMLElement} */ (wrapper.querySelector('#externalLabelA')); + const labelB = /** @type {HTMLElement} */ (wrapper.querySelector('#externalLabelB')); + // external inputs should go in order defined by user + el.addToAriaLabelledBy(labelA); + el.addToAriaLabelledBy(labelB); + await el.updateComplete; + + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '), + ).to.eql(['internalLabel', 'externalLabelA', 'externalLabelB']); + + // 2. addToAriaDescribedBy() + const descrA = /** @type {HTMLElement} */ (wrapper.querySelector('#externalDescriptionA')); + const descrB = /** @type {HTMLElement} */ (wrapper.querySelector('#externalDescriptionB')); + + el.addToAriaDescribedBy(descrA); + el.addToAriaDescribedBy(descrB); + await el.updateComplete; + + expect( + /** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '), + ).to.eql(['internalDescription', 'externalDescriptionA', 'externalDescriptionB']); + }); + }); }); describe('Model-value-changed event propagation', () => { diff --git a/packages/form-core/test/lion-field.test.js b/packages/form-core/test/lion-field.test.js index 399ddcf50..d6078fd67 100644 --- a/packages/form-core/test/lion-field.test.js +++ b/packages/form-core/test/lion-field.test.js @@ -193,59 +193,6 @@ describe('', () => { `prefix-${el._inputId} suffix-${el._inputId}`, ); }); - - // TODO: Move test below to FormControlMixin.test.js. - it(`allows to add to aria description or label via addToAriaLabelledBy() and - addToAriaDescribedBy()`, async () => { - const wrapper = /** @type {HTMLElement} */ (await fixture(html` -
- <${tag}> - ${inputSlot} - -
Added to description by default
- -
This also needs to be read whenever the input has focus
-
Same for this
-
`)); - const el = /** @type {LionField} */ (wrapper.querySelector(tagString)); - // wait until the field element is done rendering - await el.updateComplete; - await el.updateComplete; - - const { _inputNode } = el; - - // 1. addToAriaLabel() - // Check if the aria attr is filled initially - expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`); - const additionalLabel = /** @type {HTMLElement} */ (wrapper.querySelector( - '#additionalLabel', - )); - el.addToAriaLabelledBy(additionalLabel); - const labelledbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-labelledby')); - // Now check if ids are added to the end (not overridden) - expect(labelledbyAttr).to.contain(`label-${el._inputId}`); - // Should be placed in the end - expect( - labelledbyAttr.indexOf(`label-${el._inputId}`) < labelledbyAttr.indexOf('additionalLabel'), - ); - - // 2. addToAriaDescription() - // Check if the aria attr is filled initially - expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`); - const additionalDescription = /** @type {HTMLElement} */ (wrapper.querySelector( - '#additionalDescription', - )); - el.addToAriaDescribedBy(additionalDescription); - const describedbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-describedby')); - - // Now check if ids are added to the end (not overridden) - expect(describedbyAttr).to.contain(`feedback-${el._inputId}`); - // Should be placed in the end - expect( - describedbyAttr.indexOf(`feedback-${el._inputId}`) < - describedbyAttr.indexOf('additionalDescription'), - ); - }); }); describe(`Validation`, () => { diff --git a/packages/form-core/types/FormControlMixinTypes.d.ts b/packages/form-core/types/FormControlMixinTypes.d.ts index e25aa6a73..7af27b5df 100644 --- a/packages/form-core/types/FormControlMixinTypes.d.ts +++ b/packages/form-core/types/FormControlMixinTypes.d.ts @@ -163,6 +163,18 @@ export declare class FormControlHost { reorder?: boolean | undefined; }, ): void; + public removeFromAriaLabelledBy( + element: HTMLElement, + customConfig?: { + reorder?: boolean | undefined; + }, + ): void; + public removeFromAriaDescribedBy( + element: HTMLElement, + customConfig?: { + reorder?: boolean | undefined; + }, + ): void; __reorderAriaDescribedNodes: boolean | undefined; __getDirectSlotChild(slotName: string): HTMLElement; __dispatchInitialModelValueChangedEvent(): void;