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'; describe('FormControlMixin', () => { const inputSlot = ''; // @ts-expect-error base constructor same return type 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> ${inputSlot} `); 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'); }); 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(''); }); 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} `)); const wrapper = /** @type {LitElement} */ (await fixture(`
`)); el.parentElement?.appendChild(wrapper); wrapper.appendChild(el); await wrapper.updateComplete; ['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; }); }); // 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
<${tag}>
Added to description by default
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); const ariaLabelId = /** @type {number} */ (_inputNode .getAttribute('aria-labelledby') ?.indexOf(`label-${el._inputId}`)); const ariaLabelA = /** @type {number} */ (_inputNode .getAttribute('aria-labelledby') ?.indexOf('additionalLabelA')); const ariaLabelB = /** @type {number} */ (_inputNode .getAttribute('aria-labelledby') ?.indexOf('additionalLabelB')); expect(ariaLabelId < ariaLabelB && ariaLabelB < ariaLabelA).to.be.true; // 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); const ariaDescId = /** @type {number} */ (_inputNode .getAttribute('aria-describedby') ?.indexOf(`feedback-${el._inputId}`)); const ariaDescA = /** @type {number} */ (_inputNode .getAttribute('aria-describedby') ?.indexOf('additionalDescriptionA')); const ariaDescB = /** @type {number} */ (_inputNode .getAttribute('aria-describedby') ?.indexOf('additionalDescriptionB')); // Should be placed in the end expect(ariaDescId < ariaDescB && ariaDescB < ariaDescA).to.be.true; }); 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('Model-value-changed event propagation', () => { // @ts-expect-error base constructor same return type 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('sends one event for single select choice-groups', async () => { const formSpy = sinon.spy(); const choiceGroupSpy = sinon.spy(); const formEl = await fixture(html` <${groupTag} name="form"> <${groupTag} name="choice-group" ._repropagationRole=${'choice-group'}> <${tag} name="choice-group" id="option1" .checked=${true}> <${tag} name="choice-group" id="option2"> `); const choiceGroupEl = formEl.querySelector('[name=choice-group]'); /** @typedef {{ checked: boolean }} checkedInterface */ const option1El = /** @type {HTMLElement & checkedInterface} */ (formEl.querySelector( '#option1', )); const option2El = /** @type {HTMLElement & checkedInterface} */ (formEl.querySelector( '#option2', )); formEl.addEventListener('model-value-changed', formSpy); choiceGroupEl?.addEventListener('model-value-changed', choiceGroupSpy); // Simulate check option2El.checked = true; option2El.dispatchEvent(new Event('model-value-changed', { bubbles: true })); option1El.checked = false; option1El.dispatchEvent(new Event('model-value-changed', { bubbles: true })); expect(choiceGroupSpy.callCount).to.equal(1); const choiceGroupEv = choiceGroupSpy.firstCall.args[0]; expect(choiceGroupEv.target).to.equal(choiceGroupEl); expect(choiceGroupEv.detail.formPath).to.eql([choiceGroupEl]); expect(choiceGroupEv.detail.isTriggeredByUser).to.be.false; expect(formSpy.callCount).to.equal(1); const formEv = formSpy.firstCall.args[0]; expect(formEv.target).to.equal(formEl); expect(formEv.detail.formPath).to.eql([choiceGroupEl, formEl]); expect(formEv.detail.isTriggeredByUser).to.be.false; }); 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; }); }); }); });