import { LitElement } from '@lion/core';
import { getFormControlMembers } from '@lion/form-core/test-helpers';
import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon';
import { FocusMixin } from '../src/FocusMixin.js';
import { FormGroupMixin } from '../src/form-group/FormGroupMixin.js';
import { FormControlMixin } from '../src/FormControlMixin.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
/**
* @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost
*/
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}
${tag}>`);
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}${tag}>
`)
);
expect(elAttr.label).to.equal('Email address', 'as an attribute');
const elProp = /** @type {FormControlMixinClass} */ (
await fixture(html`
<${tag}
.label=${'Email address'}
>${inputSlot}
${tag}>`)
);
expect(elProp.label).to.equal('Email address', 'as a property');
const elElem = /** @type {FormControlMixinClass} */ (
await fixture(html`
<${tag}>
${inputSlot}
${tag}>`)
);
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}
${tag}>`)
);
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}
${tag}>
${tag}>`)
);
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}${tag}>
`)
);
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}
${tag}>`)
);
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}
${tag}>`)
);
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}
${tag}>`)
);
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}
${tag}>
${tag}>`)
);
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}${tag}>
`)
);
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
const { _inputNode } = getFormControlMembers(el);
const labelIdsBefore = /** @type {string} */ (_inputNode.getAttribute('aria-labelledby'));
const descriptionIdsBefore = /** @type {string} */ (
_inputNode.getAttribute('aria-describedby')
);
// Reconnect
wrapper.removeChild(el);
wrapper.appendChild(el);
const labelIdsAfter = /** @type {string} */ (_inputNode.getAttribute('aria-labelledby'));
const descriptionIdsAfter = /** @type {string} */ (
_inputNode.getAttribute('aria-describedby')
);
expect(labelIdsBefore).to.equal(labelIdsAfter);
expect(descriptionIdsBefore).to.equal(descriptionIdsAfter);
});
it('clicking the label should call `_onLabelClick`', async () => {
const spy = sinon.spy();
const el = /** @type {FormControlMixinClass} */ (
await fixture(html`
<${tag} ._onLabelClick="${spy}">
${inputSlot}
${tag}>
`)
);
const { _labelNode } = getFormControlMembers(el);
expect(spy).to.not.have.been.called;
_labelNode.click();
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)) {
/**
* @configure FocusMixin
*/
get _focusableNode() {
return this._inputNode;
}
},
);
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)
This also needs to be read whenever the input has focus
Same for this
`)
);
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
const { _inputNode } = getFormControlMembers(el);
// 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} */ (_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} */ (_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} */ (_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} */ (_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
${tag}>
should go after input internals
should go after input internals
`);
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
const { _inputNode } = getFormControlMembers(el);
// 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} */ (_inputNode.getAttribute('aria-labelledby')).split(' '),
).to.eql(['myInput', 'internalLabel']);
expect(
/** @type {string} */ (_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} */ (_inputNode.getAttribute('aria-labelledby')).split(' '),
).to.eql(['internalLabel', 'myInput']);
expect(
/** @type {string} */ (_inputNode.getAttribute('aria-describedby')).split(' '),
).to.eql(['internalDescription', 'myInput']);
});
it('respects provided order for external elements', async () => {
const wrapper = await fixture(html`