import { unsafeHTML } from '@lion/core'; import { localize } from '@lion/localize'; import { localizeTearDown } from '@lion/localize/test-helpers.js'; import { Required, Validator } from '@lion/form-core'; import { expect, fixture, html, triggerBlurFor, triggerFocusFor, unsafeStatic, } from '@open-wc/testing'; import sinon from 'sinon'; import '../lion-field.js'; /** * @typedef {import('../src/LionField.js').LionField} LionField * @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl */ /** @typedef {HTMLElement & {shadowRoot: HTMLElement, assignedNodes: Function}} ShadowHTMLElement */ const tagString = 'lion-field'; const tag = unsafeStatic(tagString); const inputSlotString = ''; const inputSlot = unsafeHTML(inputSlotString); /** * @param {import("../index.js").LionField} formControl * @param {string} newViewValue */ function mimicUserInput(formControl, newViewValue) { formControl.value = newViewValue; // eslint-disable-line no-param-reassign formControl._inputNode.dispatchEvent(new CustomEvent('input', { bubbles: true })); } beforeEach(() => { localizeTearDown(); }); /** * @param {HTMLElement} el * @param {string} slot */ function getSlot(el, slot) { const children = /** @type {any[]} */ (Array.from(el.children)); return children.find(child => child.slot === slot); } describe('', () => { it(`puts a unique id "${tagString}-[hash]" on the native input`, async () => { const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); expect(getSlot(el, 'input').id).to.equal(el._inputId); }); it(`has a fieldName based on the label`, async () => { const el1 = /** @type {LionField} */ (await fixture( html`<${tag} label="foo">${inputSlot}`, )); expect(el1.fieldName).to.equal(el1._labelNode.textContent); const el2 = /** @type {LionField} */ (await fixture( html`<${tag}>${inputSlot}`, )); expect(el2.fieldName).to.equal(el2._labelNode.textContent); }); it(`has a fieldName based on the name if no label exists`, async () => { const el = /** @type {LionField} */ (await fixture( html`<${tag} name="foo">${inputSlot}`, )); expect(el.fieldName).to.equal(el.name); }); it(`can override fieldName`, async () => { const el = /** @type {LionField} */ (await fixture( html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlot}`, )); expect(el.__fieldName).to.equal(el.fieldName); }); it('fires focus/blur event on host and native input if focused/blurred', async () => { const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); const cbFocusHost = sinon.spy(); el.addEventListener('focus', cbFocusHost); const cbFocusNativeInput = sinon.spy(); el._inputNode.addEventListener('focus', cbFocusNativeInput); const cbBlurHost = sinon.spy(); el.addEventListener('blur', cbBlurHost); const cbBlurNativeInput = sinon.spy(); el._inputNode.addEventListener('blur', cbBlurNativeInput); await triggerFocusFor(el); expect(document.activeElement).to.equal(el._inputNode); expect(cbFocusHost.callCount).to.equal(1); expect(cbFocusNativeInput.callCount).to.equal(1); expect(cbBlurHost.callCount).to.equal(0); expect(cbBlurNativeInput.callCount).to.equal(0); await triggerBlurFor(el); expect(cbBlurHost.callCount).to.equal(1); expect(cbBlurNativeInput.callCount).to.equal(1); await triggerFocusFor(el); expect(document.activeElement).to.equal(el._inputNode); expect(cbFocusHost.callCount).to.equal(2); expect(cbFocusNativeInput.callCount).to.equal(2); await triggerBlurFor(el); expect(cbBlurHost.callCount).to.equal(2); expect(cbBlurNativeInput.callCount).to.equal(2); }); it('offers simple getter "this.focused" returning true/false for the current focus state', async () => { const el = /** @type {LionField} */ (await fixture(html`<${tag}>${inputSlot}`)); expect(el.focused).to.equal(false); await triggerFocusFor(el); expect(el.focused).to.equal(true); await triggerBlurFor(el); expect(el.focused).to.equal(false); }); it('can be cleared which erases value, validation and interaction states', async () => { const el = /** @type {LionField} */ (await fixture( html`<${tag} value="Some value from attribute">${inputSlot}`, )); el.clear(); expect(el.modelValue).to.equal(''); el.modelValue = 'Some value from property'; expect(el.modelValue).to.equal('Some value from property'); el.clear(); expect(el.modelValue).to.equal(''); }); it('can be reset which restores original modelValue', async () => { const el = /** @type {LionField} */ (await fixture(html` <${tag} .modelValue="${'foo'}"> ${inputSlot} `)); expect(el._initialModelValue).to.equal('foo'); el.modelValue = 'bar'; el.reset(); expect(el.modelValue).to.equal('foo'); }); describe('Accessibility', () => { it(`by setting corresponding aria-labelledby (for label) and aria-describedby (for helpText, feedback) ~~~ [helpText]
[feedback] ~~~`, async () => { const el = /** @type {LionField} */ (await fixture(html`<${tag}> ${inputSlot} Enter your Name No name entered `)); const nativeInput = getSlot(el, 'input'); expect(nativeInput.getAttribute('aria-labelledby')).to.equal(`label-${el._inputId}`); expect(nativeInput.getAttribute('aria-describedby')).to.contain(`help-text-${el._inputId}`); expect(nativeInput.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`); }); it(`allows additional slots (prefix, suffix, before, after) to be included in labelledby (via attribute data-label) and in describedby (via attribute data-description)`, async () => { const el = /** @type {LionField} */ (await fixture(html`<${tag}> ${inputSlot} [before] [after] [prefix] [suffix] `)); const nativeInput = getSlot(el, 'input'); expect(nativeInput.getAttribute('aria-labelledby')).to.contain( `before-${el._inputId} after-${el._inputId}`, ); expect(nativeInput.getAttribute('aria-describedby')).to.contain( `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`, () => { beforeEach(() => { // Reset and preload validation translations localizeTearDown(); localize.addData('en-GB', 'lion-validate', { error: { hasX: 'This is error message for hasX', }, }); }); it('should conditionally show error', async () => { const HasX = class extends Validator { static get validatorName() { return 'HasX'; } /** * @param {string} value */ execute(value) { const result = value.indexOf('x') === -1; return result; } }; const el = /** @type {LionField} */ (await fixture(html` <${tag} .validators=${[new HasX()]} .modelValue=${'a@b.nl'} > ${inputSlot} `)); /** * @param {import("../index.js").LionField} _sceneEl * @param {{ index?: number; el: any; wantedShowsFeedbackFor: any; }} scenario */ const executeScenario = async (_sceneEl, scenario) => { const sceneEl = _sceneEl; sceneEl.resetInteractionState(); sceneEl.touched = scenario.el.touched; sceneEl.dirty = scenario.el.dirty; sceneEl.prefilled = scenario.el.prefilled; sceneEl.submitted = scenario.el.submitted; await sceneEl.updateComplete; await sceneEl.feedbackComplete; expect(sceneEl.showsFeedbackFor).to.deep.equal(scenario.wantedShowsFeedbackFor); }; await executeScenario(el, { index: 0, el: { touched: true, dirty: true, prefilled: false, submitted: false }, wantedShowsFeedbackFor: ['error'], }); await executeScenario(el, { index: 1, el: { touched: false, dirty: false, prefilled: true, submitted: false }, wantedShowsFeedbackFor: ['error'], }); await executeScenario(el, { index: 2, el: { touched: false, dirty: false, prefilled: false, submitted: true }, wantedShowsFeedbackFor: ['error'], }); await executeScenario(el, { index: 3, el: { touched: false, dirty: true, prefilled: false, submitted: false }, wantedShowsFeedbackFor: [], }); await executeScenario(el, { index: 4, el: { touched: true, dirty: false, prefilled: false, submitted: false }, wantedShowsFeedbackFor: [], }); }); it('should not run validation when disabled', async () => { const HasX = class extends Validator { static get validatorName() { return 'HasX'; } /** * @param {string} value */ execute(value) { const result = value.indexOf('x') === -1; return result; } }; const disabledEl = /** @type {LionField} */ (await fixture(html` <${tag} disabled .validators=${[new HasX()]} .modelValue=${'a@b.nl'} > ${inputSlot} `)); const el = /** @type {LionField} */ (await fixture(html` <${tag} .validators=${[new HasX()]} .modelValue=${'a@b.nl'} > ${inputSlot} `)); expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.validationStates.error.HasX).to.exist; expect(disabledEl.hasFeedbackFor).to.deep.equal([]); expect(disabledEl.validationStates.error).to.deep.equal({}); }); it('can be required', async () => { const el = /** @type {LionField} */ (await fixture(html` <${tag} .validators=${[new Required()]} >${inputSlot} `)); expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.validationStates.error.Required).to.exist; el.modelValue = 'cat'; expect(el.hasFeedbackFor).to.deep.equal([]); expect(el.validationStates.error.Required).to.not.exist; }); it('will only update formattedValue when valid on `user-input-changed`', async () => { const formatterSpy = sinon.spy(value => `foo: ${value}`); const Bar = class extends Validator { static get validatorName() { return 'Bar'; } /** * @param {string} value */ execute(value) { const hasError = value !== 'bar'; return hasError; } }; const el = /** @type {LionField} */ (await fixture(html` <${tag} .modelValue=${'init-string'} .formatter=${formatterSpy} .validators=${[new Bar()]} >${inputSlot} `)); expect(formatterSpy.callCount).to.equal(0); expect(el.formattedValue).to.equal('init-string'); el.modelValue = 'bar'; expect(formatterSpy.callCount).to.equal(1); expect(el.formattedValue).to.equal('foo: bar'); mimicUserInput(el, 'foo'); expect(formatterSpy.callCount).to.equal(1); expect(el.value).to.equal('foo'); }); }); describe(`Content projection`, () => { it('renders correctly all slot elements in light DOM', async () => { const el = /** @type {LionField} */ (await fixture(html` <${tag}> ${inputSlot} [help-text] [before] [after] [prefix] [suffix] [feedback] `)); const names = [ 'label', 'input', 'help-text', 'before', 'after', 'prefix', 'suffix', 'feedback', ]; names.forEach(slotName => { const slotLight = /** @type {HTMLElement} */ (el.querySelector(`[slot="${slotName}"]`)); slotLight.setAttribute('test-me', 'ok'); // @ts-expect-error const slot = /** @type {ShadowHTMLElement} */ (el.shadowRoot.querySelector( `slot[name="${slotName}"]`, )); const assignedNodes = slot.assignedNodes(); expect(assignedNodes.length).to.equal(1); expect(assignedNodes[0].getAttribute('test-me')).to.equal('ok'); }); }); }); });