import { expect, html, defineCE, unsafeStatic, fixture } from '@open-wc/testing'; import { LitElement } from '@lion/core'; import sinon from 'sinon'; import { FormControlMixin } from '../src/FormControlMixin.js'; import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js'; /** * @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControl */ describe('FormControlMixin', () => { const inputSlot = html``; class FormControlMixinClass extends FormControlMixin(LitElement) {} const tagString = defineCE(FormControlMixinClass); const tag = unsafeStatic(tagString); it('is hidden when attribute hidden is true', async () => { const el = await fixture(html` <${tag} hidden> ${inputSlot} `); expect(el).not.to.be.displayed; }); describe('Label and helpText api', () => { 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('has a label that supports inner html', async () => { const el = /** @type {FormControlMixinClass} */ (await fixture(html` <${tag}> ${inputSlot} `)); expect(el.label).to.equal('Email address'); }); 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(''); }); 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"> ${inputSlot} `)); expect(el.helpText).to.equal(''); }); }); 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}> ${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` <${tag} ._onLabelClick="${spy}"> ${inputSlot} `)); expect(spy).to.not.have.been.called; el._labelNode.click(); expect(spy).to.have.been.calledOnce; }); 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; // @ts-ignore allow protected accessors in tests const inputId = el._inputId; // 1a. addToAriaLabelledBy() // Check if the aria attr is filled initially expect(/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'))).to.contain( `label-${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-${inputId}`) < labelledbyAttr.indexOf('additionalLabel'), ); // 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`); // 2a. addToAriaDescribedBy() // Check if the aria attr is filled initially expect(/** @type {string} */ (el._inputNode.getAttribute('aria-describedby'))).to.contain( `feedback-${inputId}`, ); }); 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)); // 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; 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', () => { const FormControlWithRegistrarMixinClass = class extends FormControlMixin( FormRegistrarMixin(LitElement), ) {}; const groupElem = defineCE(FormControlWithRegistrarMixinClass); const groupTag = unsafeStatic(groupElem); describe('On initialization', () => { it('redispatches one event from host', async () => { const formSpy = sinon.spy(); const fieldsetSpy = sinon.spy(); const formEl = /** @type {FormControlMixinClass} */ (await fixture(html` <${groupTag} name="form" ._repropagationRole=${'form-group'} @model-value-changed=${formSpy}> <${groupTag} name="fieldset" ._repropagationRole=${'form-group'} @model-value-changed=${fieldsetSpy}> <${tag} name="field"> `)); const fieldsetEl = formEl.querySelector('[name=fieldset]'); expect(fieldsetSpy.callCount).to.equal(1); const fieldsetEv = fieldsetSpy.firstCall.args[0]; expect(fieldsetEv.target).to.equal(fieldsetEl); expect(fieldsetEv.detail.formPath).to.eql([fieldsetEl]); expect(fieldsetEv.detail.initialize).to.be.true; expect(formSpy.callCount).to.equal(1); const formEv = formSpy.firstCall.args[0]; expect(formEv.target).to.equal(formEl); expect(formEv.detail.formPath).to.eql([formEl]); expect(formEv.detail.initialize).to.be.true; }); }); /** * After initialization means: events triggered programmatically or by user actions */ describe('After initialization', () => { it('redispatches one event from host and keeps formPath history', async () => { const formSpy = sinon.spy(); const fieldsetSpy = sinon.spy(); const fieldSpy = sinon.spy(); const formEl = await fixture(html` <${groupTag} name="form"> <${groupTag} name="fieldset"> <${tag} name="field"> `); const fieldEl = formEl.querySelector('[name=field]'); const fieldsetEl = formEl.querySelector('[name=fieldset]'); formEl.addEventListener('model-value-changed', formSpy); fieldsetEl?.addEventListener('model-value-changed', fieldsetSpy); fieldEl?.addEventListener('model-value-changed', fieldSpy); fieldEl?.dispatchEvent(new Event('model-value-changed', { bubbles: true })); expect(fieldsetSpy.callCount).to.equal(1); const fieldsetEv = fieldsetSpy.firstCall.args[0]; expect(fieldsetEv.target).to.equal(fieldsetEl); expect(fieldsetEv.detail.formPath).to.eql([fieldEl, fieldsetEl]); expect(formSpy.callCount).to.equal(1); const formEv = formSpy.firstCall.args[0]; expect(formEv.target).to.equal(formEl); expect(formEv.detail.formPath).to.eql([fieldEl, fieldsetEl, formEl]); }); it('sets "isTriggeredByUser" event detail when event triggered by user', async () => { const formSpy = sinon.spy(); const fieldsetSpy = sinon.spy(); const fieldSpy = sinon.spy(); const formEl = await fixture(html` <${groupTag} name="form"> <${groupTag} name="fieldset"> <${tag} name="field"> `); const fieldEl = formEl.querySelector('[name=field]'); const fieldsetEl = formEl.querySelector('[name=fieldset]'); formEl.addEventListener('model-value-changed', formSpy); fieldsetEl?.addEventListener('model-value-changed', fieldsetSpy); fieldEl?.addEventListener('model-value-changed', fieldSpy); fieldEl?.dispatchEvent( new CustomEvent('model-value-changed', { bubbles: true, detail: { isTriggeredByUser: true }, }), ); const fieldsetEv = fieldsetSpy.firstCall.args[0]; expect(fieldsetEv.detail.isTriggeredByUser).to.be.true; const formEv = formSpy.firstCall.args[0]; expect(formEv.detail.isTriggeredByUser).to.be.true; }); }); }); });