feat: api normalisation formElements and values

This commit is contained in:
Thijs Louisse 2020-02-20 10:44:17 +01:00
parent 417b37a616
commit 9b905c492a
22 changed files with 1120 additions and 767 deletions

View file

@ -1,12 +1,13 @@
import { LitElement } from '@lion/core';
import { ChoiceGroupMixin } from '@lion/choice-input';
import { LionFieldset } from '@lion/fieldset';
import { FormGroupMixin } from '@lion/fieldset';
/**
* A wrapper around multiple checkboxes
*
* @extends {LionFieldset}
*/
export class LionCheckboxGroup extends ChoiceGroupMixin(LionFieldset) {
export class LionCheckboxGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
constructor() {
super();
this.multipleChoice = true;

View file

@ -5,12 +5,30 @@ export const ChoiceGroupMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line
class ChoiceGroupMixin extends FormRegistrarMixin(InteractionStateMixin(superclass)) {
static get properties() {
return {
/**
* @desc When false (default), modelValue and serializedValue will reflect the
* currently selected choice (usually a string). When true, modelValue will and
* serializedValue will be an array of strings.
* @type {boolean}
*/
multipleChoice: {
type: Boolean,
attribute: 'multiple-choice',
},
};
}
get modelValue() {
const elems = this._getCheckedElements();
if (this.multipleChoice) {
// TODO: holds for both modelValue and serializedValue of choiceInput:
// consider only allowing strings as values, in which case 'el.value' would suffice
// and choice-input could be simplified
return elems.map(el => el.modelValue.value);
}
return elems ? elems.modelValue.value : '';
return elems[0] ? elems[0].modelValue.value : '';
}
set modelValue(value) {
@ -18,11 +36,19 @@ export const ChoiceGroupMixin = dedupeMixin(
}
get serializedValue() {
// We want to filter out disabled values out by default:
// The goal of serializing values could either be submitting state to a backend
// ot storing state in a backend. For this, only values that are entered by the end
// user are relevant, choice values are always defined by the Application Developer
// and known by the backend.
// Assuming values are always defined as strings, modelValues and serializedValues
// are the same.
const elems = this._getCheckedElements();
if (this.multipleChoice) {
return this.modelValue;
return elems.map(el => el.serializedValue.value);
}
return elems ? elems.serializedValue : '';
return elems[0] ? elems[0].serializedValue.value : '';
}
set serializedValue(value) {
@ -53,24 +79,22 @@ export const ChoiceGroupMixin = dedupeMixin(
*/
addFormElement(child, indexToInsertAt) {
this._throwWhenInvalidChildModelValue(child);
// TODO: nice to have or does it have a function (since names are meant as keys for
// formElements)?
this.__delegateNameAttribute(child);
super.addFormElement(child, indexToInsertAt);
}
/**
* @override from LionFieldset
* @override
*/
// eslint-disable-next-line class-methods-use-this
get _childrenCanHaveSameName() {
return true;
}
/**
* @override from LionFieldset
*/
// eslint-disable-next-line class-methods-use-this
get _childNamesCanBeDuplicate() {
return true;
_getFromAllFormElements(property, filterCondition = () => true) {
// For modelValue and serializedValue, an exception should be made,
// The reset can be requested from children
if (property === 'modelValue' || property === 'serializedValue') {
return this[property];
}
return this.formElements.filter(filterCondition).map(el => el.property);
}
_throwWhenInvalidChildModelValue(child) {
@ -108,7 +132,7 @@ export const ChoiceGroupMixin = dedupeMixin(
if (target.checked === false) return;
const groupName = target.name;
this.formElementsArray
this.formElements
.filter(i => i.name === groupName)
.forEach(choice => {
if (choice !== target) {
@ -119,11 +143,8 @@ export const ChoiceGroupMixin = dedupeMixin(
}
_getCheckedElements() {
const filtered = this.formElementsArray.filter(el => el.checked === true);
if (this.multipleChoice) {
return filtered;
}
return filtered.length > 0 ? filtered[0] : undefined;
// We want to filter out disabled values out by default
return this.formElements.filter(el => el.checked && !el.disabled);
}
async _setCheckedElements(value, check) {
@ -131,12 +152,12 @@ export const ChoiceGroupMixin = dedupeMixin(
await this.registrationReady;
}
for (let i = 0; i < this.formElementsArray.length; i += 1) {
for (let i = 0; i < this.formElements.length; i += 1) {
if (this.multipleChoice) {
this.formElementsArray[i].checked = value.includes(this.formElementsArray[i].value);
} else if (check(this.formElementsArray[i], value)) {
this.formElements[i].checked = value.includes(this.formElements[i].value);
} else if (check(this.formElements[i], value)) {
// Allows checking against custom values e.g. formattedValue or serializedValue
this.formElementsArray[i].checked = true;
this.formElements[i].checked = true;
}
}
}

View file

@ -1,20 +1,21 @@
import { html } from '@lion/core';
import { LionFieldset } from '@lion/fieldset';
import { html, LitElement } from '@lion/core';
import { FormGroupMixin } from '@lion/fieldset';
import { LionInput } from '@lion/input';
import { Required } from '@lion/validate';
import { expect, fixture, nextFrame } from '@open-wc/testing';
import { ChoiceGroupMixin } from '../src/ChoiceGroupMixin.js';
import { ChoiceInputMixin } from '../src/ChoiceInputMixin.js';
import '@lion/fieldset/lion-fieldset.js';
describe('ChoiceGroupMixin', () => {
before(() => {
class ChoiceInput extends ChoiceInputMixin(LionInput) {}
customElements.define('choice-group-input', ChoiceInput);
class ChoiceGroup extends ChoiceGroupMixin(LionFieldset) {}
class ChoiceGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {}
customElements.define('choice-group', ChoiceGroup);
class ChoiceGroupMultiple extends ChoiceGroupMixin(LionFieldset) {
class ChoiceGroupMultiple extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
constructor() {
super();
this.multipleChoice = true;
@ -33,9 +34,9 @@ describe('ChoiceGroupMixin', () => {
`);
await nextFrame();
expect(el.modelValue).to.equal('female');
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(el.modelValue).to.equal('male');
el.formElementsArray[2].checked = true;
el.formElements[2].checked = true;
expect(el.modelValue).to.equal('other');
});
@ -68,15 +69,15 @@ describe('ChoiceGroupMixin', () => {
`);
await nextFrame();
expect(el.formElementsArray[0].name).to.equal('gender');
expect(el.formElementsArray[1].name).to.equal('gender');
expect(el.formElements[0].name).to.equal('gender');
expect(el.formElements[1].name).to.equal('gender');
const validChild = await fixture(html`
<choice-group-input .choiceValue=${'male'}></choice-group-input>
`);
el.appendChild(validChild);
expect(el.formElementsArray[2].name).to.equal('gender');
expect(el.formElements[2].name).to.equal('gender');
});
it('throws if a child element with a different name than the group tries to register', async () => {
@ -112,7 +113,7 @@ describe('ChoiceGroupMixin', () => {
await el.updateComplete;
expect(el.modelValue).to.equal('other');
expect(el.formElementsArray[2].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true;
});
it('can handle complex data via choiceValue', async () => {
@ -127,7 +128,7 @@ describe('ChoiceGroupMixin', () => {
await nextFrame();
expect(el.modelValue).to.equal(date);
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(el.modelValue).to.deep.equal({ some: 'data' });
});
@ -141,7 +142,7 @@ describe('ChoiceGroupMixin', () => {
await nextFrame();
expect(el.modelValue).to.equal(0);
el.formElementsArray[1].checked = true;
el.formElements[1].checked = true;
expect(el.modelValue).to.equal('');
});
@ -160,7 +161,7 @@ describe('ChoiceGroupMixin', () => {
await nextFrame();
expect(el.modelValue).to.equal('female');
el.modelValue = 'other';
expect(el.formElementsArray[2].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true;
});
it('expect child nodes to only fire one model-value-changed event per instance', async () => {
@ -180,14 +181,14 @@ describe('ChoiceGroupMixin', () => {
await nextFrame();
counter = 0; // reset after setup which may result in different results
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(counter).to.equal(2); // male becomes checked, female becomes unchecked
// not changed values trigger no event
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(counter).to.equal(2);
el.formElementsArray[2].checked = true;
el.formElements[2].checked = true;
expect(counter).to.equal(4); // other becomes checked, male becomes unchecked
// not found values trigger no event
@ -211,12 +212,12 @@ describe('ChoiceGroupMixin', () => {
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).to.have.a.property('Required');
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
el.formElementsArray[1].checked = true;
el.formElements[1].checked = true;
expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error');
expect(el.validationStates.error).not.to.have.a.property('Required');
@ -229,8 +230,8 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'female'}></choice-group-input>
</choice-group>
`);
el.formElementsArray[0].checked = true;
expect(el.serializedValue).to.deep.equal({ checked: true, value: 'male' });
el.formElements[0].checked = true;
expect(el.serializedValue).to.deep.equal('male');
});
it('returns serialized value on unchecked state', async () => {
@ -256,9 +257,9 @@ describe('ChoiceGroupMixin', () => {
`);
await nextFrame();
expect(el.modelValue).to.eql(['female']);
el.formElementsArray[0].checked = true;
el.formElements[0].checked = true;
expect(el.modelValue).to.eql(['male', 'female']);
el.formElementsArray[2].checked = true;
el.formElements[2].checked = true;
expect(el.modelValue).to.eql(['male', 'female', 'other']);
});
@ -276,8 +277,8 @@ describe('ChoiceGroupMixin', () => {
await el.updateComplete;
el.modelValue = ['male', 'other'];
expect(el.modelValue).to.eql(['male', 'other']);
expect(el.formElementsArray[0].checked).to.be.true;
expect(el.formElementsArray[2].checked).to.be.true;
expect(el.formElements[0].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true;
});
it('unchecks non-matching checkboxes when setting the modelValue', async () => {
@ -293,13 +294,40 @@ describe('ChoiceGroupMixin', () => {
await el.registrationReady;
await el.updateComplete;
expect(el.modelValue).to.eql(['male', 'other']);
expect(el.formElementsArray[0].checked).to.be.true;
expect(el.formElementsArray[2].checked).to.be.true;
expect(el.formElements[0].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true;
el.modelValue = ['female'];
expect(el.formElementsArray[0].checked).to.be.false;
expect(el.formElementsArray[1].checked).to.be.true;
expect(el.formElementsArray[2].checked).to.be.false;
expect(el.formElements[0].checked).to.be.false;
expect(el.formElements[1].checked).to.be.true;
expect(el.formElements[2].checked).to.be.false;
});
});
describe('Integration with a parent form/fieldset', () => {
it('will serialize all children with their serializedValue', async () => {
const el = await fixture(html`
<lion-fieldset>
<choice-group name="gender">
<choice-group-input .choiceValue=${'male'} checked disabled></choice-group-input>
<choice-group-input .choiceValue=${'female'} checked></choice-group-input>
<choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group>
</lion-fieldset>
`);
await nextFrame();
await el.registrationReady;
await el.updateComplete;
expect(el.serializedValue).to.eql({
gender: 'female',
});
const choiceGroupEl = el.querySelector('[name="gender"]');
choiceGroupEl.multipleChoice = true;
expect(el.serializedValue).to.eql({
gender: ['female'],
});
});
});
});

View file

@ -1,9 +1,10 @@
export { FieldCustomMixin } from './src/FieldCustomMixin.js';
export { FocusMixin } from './src/FocusMixin.js';
export { FormatMixin } from './src/FormatMixin.js';
export { FieldCustomMixin } from './src/FieldCustomMixin.js';
export { FormControlMixin } from './src/FormControlMixin.js';
export { InteractionStateMixin } from './src/InteractionStateMixin.js'; // applies FocusMixin
export { LionField } from './src/LionField.js';
export { FormRegisteringMixin } from './src/FormRegisteringMixin.js';
export { FormRegistrarMixin } from './src/FormRegistrarMixin.js';
export { FormRegistrarPortalMixin } from './src/FormRegistrarPortalMixin.js';
export { FormRegisteringMixin } from './src/registration/FormRegisteringMixin.js';
export { FormRegistrarMixin } from './src/registration/FormRegistrarMixin.js';
export { FormRegistrarPortalMixin } from './src/registration/FormRegistrarPortalMixin.js';
export { FormControlsCollection } from './src/registration/FormControlsCollection.js';

View file

@ -1,5 +1,5 @@
import { html, css, nothing, dedupeMixin, SlotMixin } from '@lion/core';
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
import { FormRegisteringMixin } from './registration/FormRegisteringMixin.js';
import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js';
/**
@ -28,11 +28,18 @@ export const FormControlMixin = dedupeMixin(
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
static get properties() {
return {
/**
* The name the element will be registered on to the .formElements collection
* of the parent.
*/
name: {
type: String,
reflect: true,
},
/**
* When no light dom defined and prop set
*/
label: String,
/**
* When no light dom defined and prop set
*/
@ -40,12 +47,10 @@ export const FormControlMixin = dedupeMixin(
type: String,
attribute: 'help-text',
},
/**
* Contains all elements that should end up in aria-labelledby of `._inputNode`
*/
_ariaLabelledNodes: Array,
/**
* Contains all elements that should end up in aria-describedby of `._inputNode`
*/
@ -73,6 +78,14 @@ export const FormControlMixin = dedupeMixin(
this.requestUpdate('helpText', oldValue);
}
set fieldName(value) {
this.__fieldName = value;
}
get fieldName() {
return this.__fieldName || this.label || this.name;
}
get slots() {
return {
...super.slots,
@ -146,9 +159,6 @@ export const FormControlMixin = dedupeMixin(
this._enhanceLightDomA11y();
}
/**
* Public methods
*/
_enhanceLightDomClasses() {
if (this._inputNode) {
this._inputNode.classList.add('form-control');

View file

@ -1,121 +0,0 @@
import { dedupeMixin } from '@lion/core';
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
import { formRegistrarManager } from './formRegistrarManager.js';
/**
* This allows an element to become the manager of a register
*/
export const FormRegistrarMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarMixin extends FormRegisteringMixin(superclass) {
get formElements() {
return this.__formElements;
}
set formElements(value) {
this.__formElements = value;
}
get formElementsArray() {
return this.__formElements;
}
constructor() {
super();
this.formElements = [];
this.__readyForRegistration = false;
this.__hasBeenRendered = false;
this.registrationReady = new Promise(resolve => {
this.__resolveRegistrationReady = resolve;
});
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
formRegistrarManager.add(this);
if (this.__hasBeenRendered) {
formRegistrarManager.becomesReady();
}
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
formRegistrarManager.remove(this);
}
isRegisteredFormElement(el) {
return this.formElementsArray.some(exitingEl => exitingEl === el);
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__resolveRegistrationReady();
this.__readyForRegistration = true;
formRegistrarManager.becomesReady();
this.__hasBeenRendered = true;
}
addFormElement(child, index) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child.__parentFormGroup = this;
if (index > 0) {
this.formElements.splice(index, 0, child);
} else {
this.formElements.push(child);
}
}
removeFormElement(child) {
const index = this.formElements.indexOf(child);
if (index > -1) {
this.formElements.splice(index, 1);
}
}
_onRequestToAddFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
// Check for siblings to determine the right order to insert into formElements
// If there is no next sibling, index is -1
let indexToInsertAt = -1;
if (this.formElements && Array.isArray(this.formElements)) {
indexToInsertAt = this.formElements.indexOf(child.nextElementSibling);
}
this.addFormElement(child, indexToInsertAt);
}
_onRequestToRemoveFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (!this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
this.removeFormElement(child);
}
},
);

View file

@ -0,0 +1,98 @@
/**
* @desc This class closely mimics the natively
* supported HTMLFormControlsCollection. It can be accessed
* both like an array and an object (based on control/element names).
* @example
* // This is how a native form works:
* <form>
* <input id="a" name="a">
* <fieldset>
* <input id="b1" name="b[]">
* <input id="b2" name="b[]">
* <input id="c" name="c">
* </fieldset>
* <select id="d" name="d">
* <option></option>
* </select>
* <fieldset>
* <input type="radio" id="e1" name="e">
* <input type="radio" id="e2" name="e">
* </fieldset>
* <select id="f" name="f" multiple>
* <option></option>
* </select>
* <fieldset>
* <input type="checkbox" id="g1" name="g">
* <input type="checkbox" id="g2" name="g">
* </fieldset>
* </form>
*
* form.elements[0]; // Element input#a
* form.elements[1]; // Element input#b1
* form.elements[2]; // Element input#b2
* form.elements[3]; // Element input#c
* form.elements.a; // Element input#a
* form.elements.b; // RadioNodeList<Element> [input#b1, input#b2]
* form.elements.c; // input#c
*
* // This is how a Lion form works (for simplicity Lion components have the 'l'-prefix):
* <l-form>
* <form>
*
* <!-- fields -->
*
* <l-input id="a" name="a"></l-input>
*
*
* <!-- field sets ('sub forms') -->
*
* <l-fieldset>
* <l-input id="b1" name="b"</l-input>
* <l-input id="b2" name="b"></l-input>
* <l-input id="c" name="c"></l-input>
* </l-fieldset>
*
*
* <!-- choice groups (children are 'end points') -->
*
* <!-- single selection choice groups -->
* <l-select id="d" name="d">
* <l-option></l-option>
* </l-select>
* <l-radio-group id="e" name="e">
* <l-radio></l-radio>
* <l-radio></l-radio>
* </l-radio-group>
*
* <!-- multi selection choice groups -->
* <l-select id="f" name="f" multiple>
* <l-option></l-option>
* </l-select>
* <l-checkbox-group id="g" name="g">
* <l-checkbox></l-checkbox>
* <l-checkbox></l-checkbox>
* </l-checkbox-group>
*
* </form>
* </l-form>
*
* lionForm.formElements[0]; // Element l-input#a
* lionForm.formElements[1]; // Element l-input#b1
* lionForm.formElements[2]; // Element l-input#b2
* lionForm.formElements.a; // Element l-input#a
* lionForm.formElements['b[]']; // Array<Element> [l-input#b1, l-input#b2]
* lionForm.formElements.c; // Element l-input#c
*
* lionForm.formElements[d-g].formElements; // Array<Element>
*
* lionForm.formElements[d-e].value; // String
* lionForm.formElements[f-g].value; // Array<String>
*/
export class FormControlsCollection extends Array {
/**
* @desc Gives back the named keys and filters out array indexes
*/
keys() {
return Object.keys(this).filter(k => Number.isNaN(Number(k)));
}
}

View file

@ -0,0 +1,184 @@
// eslint-disable-next-line max-classes-per-file
import { dedupeMixin } from '@lion/core';
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
import { formRegistrarManager } from './formRegistrarManager.js';
import { FormControlsCollection } from './FormControlsCollection.js';
// TODO: rename .formElements to .formControls? (or .$controls ?)
/**
* @desc This allows an element to become the manager of a register.
* It basically keeps track of a FormControlsCollection that it stores in .formElements
* This will always be an array of all elements.
* In case of a form or fieldset(sub form), it will also act as a key based object with FormControl
* (fields, choice groups or fieldsets)as keys.
* For choice groups, the value will only stay an array.
* See FormControlsCollection for more information
*/
export const FormRegistrarMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
class FormRegistrarMixin extends FormRegisteringMixin(superclass) {
static get properties() {
return {
/**
* @desc Flag that determines how ".formElements" should behave.
* For a regular fieldset (see LionFieldset) we expect ".formElements"
* to be accessible as an object.
* In case of a radio-group, a checkbox-group or a select/listbox,
* it should act like an array (see ChoiceGroupMixin).
* Usually, when false, we deal with a choice-group (radio-group, checkbox-group,
* (multi)select)
* @type {boolean}
*/
_isFormOrFieldset: Boolean,
};
}
constructor() {
super();
this.formElements = new FormControlsCollection();
this._isFormOrFieldset = false;
this.__readyForRegistration = false;
this.__hasBeenRendered = false;
this.registrationReady = new Promise(resolve => {
this.__resolveRegistrationReady = resolve;
});
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
formRegistrarManager.add(this);
if (this.__hasBeenRendered) {
formRegistrarManager.becomesReady();
}
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
formRegistrarManager.remove(this);
}
isRegisteredFormElement(el) {
return this.formElements.some(exitingEl => exitingEl === el);
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__resolveRegistrationReady();
this.__readyForRegistration = true;
formRegistrarManager.becomesReady();
this.__hasBeenRendered = true;
}
addFormElement(child, indexToInsertAt) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child.__parentFormGroup = this;
// 1. Add children as array element
if (indexToInsertAt > 0) {
this.formElements.splice(indexToInsertAt, 0, child);
} else {
this.formElements.push(child);
}
// 2. Add children as object key
if (this._isFormOrFieldset) {
const { name } = child;
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
}
if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
}
if (name.substr(-2) === '[]') {
if (!Array.isArray(this.formElements[name])) {
this.formElements[name] = new FormControlsCollection();
}
if (indexToInsertAt > 0) {
this.formElements[name].splice(indexToInsertAt, 0, child);
} else {
this.formElements[name].push(child);
}
} else if (!this.formElements[name]) {
this.formElements[name] = child;
} else {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(
`Name "${name}" is already registered - if you want an array add [] to the end`,
);
}
}
}
removeFormElement(child) {
// 1. Handle array based children
const index = this.formElements.indexOf(child);
if (index > -1) {
this.formElements.splice(index, 1);
}
// 2. Handle name based object keys
if (this._isFormOrFieldset) {
const { name } = child;
if (name.substr(-2) === '[]' && this.formElements[name]) {
const idx = this.formElements[name].indexOf(child);
if (idx > -1) {
this.formElements[name].splice(idx, 1);
}
} else if (this.formElements[name]) {
delete this.formElements[name];
}
}
}
_onRequestToAddFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
// Check for siblings to determine the right order to insert into formElements
// If there is no next sibling, index is -1
let indexToInsertAt = -1;
if (this.formElements && Array.isArray(this.formElements)) {
indexToInsertAt = this.formElements.indexOf(child.nextElementSibling);
}
this.addFormElement(child, indexToInsertAt);
}
_onRequestToRemoveFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't remove ourselves
return;
}
if (!this.isRegisteredFormElement(child)) {
// do not remove non existing elements
return;
}
ev.stopPropagation();
this.removeFormElement(child);
}
},
);

View file

@ -1,19 +1,18 @@
import { LitElement } from '@lion/core';
import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon';
import { FormRegisteringMixin } from '../src/FormRegisteringMixin.js';
import { formRegistrarManager } from '../src/formRegistrarManager.js';
import { FormRegistrarMixin } from '../src/FormRegistrarMixin.js';
import { FormRegistrarPortalMixin } from '../src/FormRegistrarPortalMixin.js';
import { FormRegisteringMixin } from '../src/registration/FormRegisteringMixin.js';
import { formRegistrarManager } from '../src/registration/formRegistrarManager.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
import { FormRegistrarPortalMixin } from '../src/registration/FormRegistrarPortalMixin.js';
export const runRegistrationSuite = customConfig => {
const cfg = {
baseElement: HTMLElement,
suffix: null,
...customConfig,
};
describe(`FormRegistrationMixins${cfg.suffix ? ` (${cfg.suffix})` : ''}`, () => {
describe('FormRegistrationMixins', () => {
let parentTag;
let childTag;
let portalTag;

View file

@ -1 +1,2 @@
export { LionFieldset } from './src/LionFieldset.js';
export { FormGroupMixin } from './src/FormGroupMixin.js';

View file

@ -0,0 +1,403 @@
import { html, dedupeMixin, SlotMixin } from '@lion/core';
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
import { FormControlMixin, FormRegistrarMixin, FormControlsCollection } from '@lion/field';
import { getAriaElementsInRightDomOrder } from '@lion/field/src/utils/getAriaElementsInRightDomOrder.js';
import { ValidateMixin } from '@lion/validate';
import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
/**
* @desc Form group mixin serves as the basis for (sub) forms. Designed to be put on
* elements with role=group (or radiogroup)
* It bridges all the functionality of the child form controls:
* ValidateMixin, InteractionStateMixin, FormatMixin, FormControlMixin etc.
* It is designed to be used on top of FormRegstrarMixin and ChoiceGroupMixin
* Also, the LionFieldset element (which supports name based retrieval of children via formElements
* and the automatic grouping of formElements via '[]')
*
*/
export const FormGroupMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-shadow
class FormGroupMixin extends FormRegistrarMixin(
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(superclass)))),
) {
static get properties() {
return {
/**
* Interaction state that can be used to compute the visibility of
* feedback messages
*/
// TODO: Move property submitted to InteractionStateMixin.
submitted: {
type: Boolean,
reflect: true,
},
/**
* Interaction state that will be active when any of the children
* is focused.
*/
focused: {
type: Boolean,
reflect: true,
},
/**
* Interaction state that will be active when any of the children
* is dirty (see InteractionStateMixin for more details.)
*/
dirty: {
type: Boolean,
reflect: true,
},
/**
* Interaction state that will be active when the group as a whole is
* blurred
*/
touched: {
type: Boolean,
reflect: true,
},
/**
* Interaction state that will be active when all of the children
* are prefilled (see InteractionStateMixin for more details.)
*/
prefilled: {
type: Boolean,
reflect: true,
},
};
}
get _inputNode() {
return this;
}
get modelValue() {
return this._getFromAllFormElements('modelValue');
}
set modelValue(values) {
this._setValueMapForAllFormElements('modelValue', values);
}
get serializedValue() {
return this._getFromAllFormElements('serializedValue');
}
set serializedValue(values) {
this._setValueMapForAllFormElements('serializedValue', values);
}
get formattedValue() {
return this._getFromAllFormElements('formattedValue');
}
set formattedValue(values) {
this._setValueMapForAllFormElements('formattedValue', values);
}
// TODO: should it be any or every? Should we maybe keep track of both,
// so we can configure feedback visibility depending on scenario?
// Should we allow configuring feedback visibility on validator instances
// for maximal flexibility?
// Document this...
get prefilled() {
return this._everyFormElementHas('prefilled');
}
constructor() {
super();
this.disabled = false;
this.submitted = false;
this.dirty = false;
this.touched = false;
this.focused = false;
this.__addedSubValidators = false;
this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
this.addEventListener('focusin', this._syncFocused);
this.addEventListener('focusout', this._onFocusOut);
this.addEventListener('dirty-changed', this._syncDirty);
this.addEventListener('validate-performed', this.__onChildValidatePerformed);
this.defaultValidators = [new FormElementsHaveNoError()];
}
connectedCallback() {
// eslint-disable-next-line wc/guard-super-call
super.connectedCallback();
this.setAttribute('role', 'group');
this.__initInteractionStates();
}
disconnectedCallback() {
super.disconnectedCallback(); // eslint-disable-line wc/guard-super-call
if (this.__hasActiveOutsideClickHandling) {
document.removeEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = false;
}
}
async __initInteractionStates() {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') {
el.initInteractionState();
}
});
}
updated(changedProps) {
super.updated(changedProps);
if (changedProps.has('disabled')) {
if (this.disabled) {
this.__requestChildrenToBeDisabled();
} else {
this.__retractRequestChildrenToBeDisabled();
}
}
if (changedProps.has('focused')) {
if (this.focused === true) {
this.__setupOutsideClickHandling();
}
}
}
__setupOutsideClickHandling() {
if (!this.__hasActiveOutsideClickHandling) {
document.addEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = true;
}
}
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
if (outsideGroupClicked) {
this.touched = true;
}
}
__requestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.makeRequestToBeDisabled) {
child.makeRequestToBeDisabled();
}
});
}
__retractRequestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.retractRequestToBeDisabled) {
child.retractRequestToBeDisabled();
}
});
}
// eslint-disable-next-line class-methods-use-this
inputGroupTemplate() {
return html`
<div class="input-group">
<slot></slot>
</div>
`;
}
/**
* @desc Handles interaction state 'submitted'.
* This allows children to enable visibility of validation feedback
*/
submitGroup() {
this.submitted = true;
this.formElements.forEach(child => {
if (typeof child.submitGroup === 'function') {
child.submitGroup();
} else {
child.submitted = true; // eslint-disable-line no-param-reassign
}
});
}
resetGroup() {
this.formElements.forEach(child => {
if (typeof child.resetGroup === 'function') {
child.resetGroup();
} else if (typeof child.reset === 'function') {
child.reset();
}
});
this.resetInteractionState();
}
resetInteractionState() {
this.submitted = false;
this.touched = false;
this.dirty = false;
this.formElements.forEach(formElement => {
if (typeof formElement.resetInteractionState === 'function') {
formElement.resetInteractionState();
}
});
}
_getFromAllFormElements(property, filterCondition = el => !el.disabled) {
const result = {};
this.formElements.keys().forEach(name => {
const elem = this.formElements[name];
if (elem instanceof FormControlsCollection) {
result[name] = elem.filter(el => filterCondition(el)).map(el => el[property]);
} else if (filterCondition(elem)) {
if (typeof elem._getFromAllFormElements === 'function') {
result[name] = elem._getFromAllFormElements(property, filterCondition);
} else {
result[name] = elem[property];
}
}
});
return result;
}
_setValueForAllFormElements(property, value) {
this.formElements.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign
});
}
async _setValueMapForAllFormElements(property, values) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
if (values && typeof values === 'object') {
Object.keys(values).forEach(name => {
if (Array.isArray(this.formElements[name])) {
this.formElements[name].forEach((el, index) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
this.formElements[name][property] = values[name];
});
}
}
_anyFormElementHas(property) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
_anyFormElementHasFeedbackFor(state) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el.hasFeedbackFor.includes(state));
}
return !!this.formElements[name].hasFeedbackFor.includes(state);
});
}
_everyFormElementHas(property) {
return Object.keys(this.formElements).every(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].every(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
/**
* Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations
* - react on modelValue change, which says something about the validity as a whole
* (at least two checkboxes for instance) and nothing about the children's values
* - children validity states have changed, so fieldset needs to update itself based on that
*/
__onChildValidatePerformed(ev) {
if (ev && this.isRegisteredFormElement(ev.target)) {
this.validate();
}
}
_syncFocused() {
this.focused = this._anyFormElementHas('focused');
}
_onFocusOut(ev) {
const lastEl = this.formElements[this.formElements.length - 1];
if (ev.target === lastEl) {
this.touched = true;
}
this.focused = false;
}
_syncDirty() {
this.dirty = this._anyFormElementHas('dirty');
}
__linkChildrenMessagesToParent(child) {
// aria-describedby of (nested) children
let parent = this;
while (parent) {
this.constructor._addDescriptionElementIdsToField(
child,
parent._getAriaDescriptionElements(),
);
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
}
/**
* @override of FormRegistrarMixin.
* @desc Connects ValidateMixin and DisabledMixin
* On top of this, error messages of children are linked to their parents
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
if (this.disabled) {
// eslint-disable-next-line no-param-reassign
child.makeRequestToBeDisabled();
}
// TODO: Unlink in removeFormElement
this.__linkChildrenMessagesToParent(child);
this.validate();
}
/**
* Gathers initial model values of all children. Used
* when resetGroup() is called.
*/
get _initialModelValue() {
return this._getFromAllFormElements('_initialModelValue');
}
/**
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
* parent(fieldset)
* @param {LionField} field - the child: lion-field/lion-input/lion-textarea
* @param {array} descriptionElements - description elements like feedback and help-text
*/
static _addDescriptionElementIdsToField(field, descriptionElements) {
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
orderedEls.forEach(el => {
if (field.addToAriaDescribedBy) {
field.addToAriaDescribedBy(el, { reorder: false });
}
});
}
/**
* @override of FormRegistrarMixin. Connects ValidateMixin
*/
removeFormElement(...args) {
super.removeFormElement(...args);
this.validate();
}
},
);

View file

@ -1,473 +1,29 @@
import { html, LitElement, SlotMixin } from '@lion/core';
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
import { FormControlMixin, FormRegistrarMixin } from '@lion/field';
import { getAriaElementsInRightDomOrder } from '@lion/field/src/utils/getAriaElementsInRightDomOrder.js';
import { ValidateMixin } from '@lion/validate';
import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
import { LitElement } from '@lion/core';
import { FormGroupMixin } from './FormGroupMixin.js';
/**
* LionFieldset: fieldset wrapper providing extra features and integration with lion-field elements.
* @desc LionFieldset is basically a 'sub form' and can have its own nested sub forms.
* It mimics the native <fieldset> element in this sense, but has all the functionality of
* a FormControl (advanced styling, validation, interaction states etc.) Also see
* FormGroupMixin it depends on.
*
* LionFieldset enables the '_isFormOrFieldset' flag in FormRegistrarMixin. This makes .formElements
* act not only as an array, but also as an object (see FormRegistarMixin for more information).
* As a bonus, It can also group children having names ending with '[]'.
*
* Above will be helpful for both forms and sub forms, which can contain sub forms as children
* as well and allow for a nested form structure.
* Contrary, other form groups (choice groups like radio-group, checkbox-group and (multi)select)
* don't: they should be considered 'end nodes' or 'leaves' of the form and their children/formElements
* cannot be accessed individually via object keys.
*
* @customElement lion-fieldset
* @extends {LitElement}
*/
export class LionFieldset extends FormRegistrarMixin(
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(LitElement)))),
) {
static get properties() {
return {
name: {
type: String,
},
// TODO: Move property submitted to InteractionStateMixin.
submitted: {
type: Boolean,
reflect: true,
},
focused: {
type: Boolean,
reflect: true,
},
dirty: {
type: Boolean,
reflect: true,
},
touched: {
type: Boolean,
reflect: true,
},
};
}
get touched() {
return this.__touched;
}
set touched(value) {
const oldVal = this.__touched;
this.__touched = value;
this.requestUpdate('touched', oldVal);
}
get _inputNode() {
return this;
}
get modelValue() {
return this._getFromAllFormElements('modelValue');
}
set modelValue(values) {
this._setValueMapForAllFormElements('modelValue', values);
}
get serializedValue() {
return this._getFromAllFormElements('serializedValue');
}
set serializedValue(values) {
this._setValueMapForAllFormElements('serializedValue', values);
}
get formattedValue() {
return this._getFromAllFormElements('formattedValue');
}
set formattedValue(values) {
this._setValueMapForAllFormElements('formattedValue', values);
}
get prefilled() {
return this._everyFormElementHas('prefilled');
}
get formElementsArray() {
return Object.keys(this.formElements).reduce((result, name) => {
const element = this.formElements[name];
return result.concat(Array.isArray(element) ? element : [element]);
}, []);
}
set fieldName(value) {
this.__fieldName = value;
}
get fieldName() {
const label =
this.label ||
(this.querySelector('[slot=label]') && this.querySelector('[slot=label]').textContent);
return this.__fieldName || label || this.name;
}
export class LionFieldset extends FormGroupMixin(LitElement) {
constructor() {
super();
this.disabled = false;
this.submitted = false;
this.dirty = false;
this.touched = false;
this.focused = false;
this.formElements = {};
this.__addedSubValidators = false;
this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
this.addEventListener('focusin', this._syncFocused);
this.addEventListener('focusout', this._onFocusOut);
this.addEventListener('dirty-changed', this._syncDirty);
this.addEventListener('validate-performed', this.__validate);
this.defaultValidators = [new FormElementsHaveNoError()];
}
connectedCallback() {
// eslint-disable-next-line wc/guard-super-call
super.connectedCallback();
this._setRole();
}
disconnectedCallback() {
super.disconnectedCallback(); // eslint-disable-line wc/guard-super-call
if (this.__hasActiveOutsideClickHandling) {
document.removeEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = false;
}
}
updated(changedProps) {
super.updated(changedProps);
if (changedProps.has('disabled')) {
if (this.disabled) {
this.__requestChildrenToBeDisabled();
} else {
this.__retractRequestChildrenToBeDisabled();
}
}
if (changedProps.has('focused')) {
if (this.focused === true) {
this.__setupOutsideClickHandling();
}
}
}
__setupOutsideClickHandling() {
if (!this.__hasActiveOutsideClickHandling) {
document.addEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = true;
}
}
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
if (outsideGroupClicked) {
this.touched = true;
}
}
__requestChildrenToBeDisabled() {
this.formElementsArray.forEach(child => {
if (child.makeRequestToBeDisabled) {
child.makeRequestToBeDisabled();
}
});
}
__retractRequestChildrenToBeDisabled() {
this.formElementsArray.forEach(child => {
if (child.retractRequestToBeDisabled) {
child.retractRequestToBeDisabled();
}
});
}
// eslint-disable-next-line class-methods-use-this
inputGroupTemplate() {
return html`
<div class="input-group">
<slot></slot>
</div>
`;
}
submitGroup() {
this.submitted = true;
this.formElementsArray.forEach(child => {
if (typeof child.submitGroup === 'function') {
child.submitGroup();
} else {
child.submitted = true; // eslint-disable-line no-param-reassign
}
});
}
serializeGroup() {
const childrenNames = Object.keys(this.formElements);
const serializedValues = childrenNames.length > 0 ? {} : undefined;
childrenNames.forEach(name => {
const element = this.formElements[name];
if (Array.isArray(element)) {
serializedValues[name] = this.__serializeElements(element);
} else {
const serializedValue = this.__serializeElement(element);
if (serializedValue || serializedValue === 0) {
serializedValues[name] = serializedValue;
}
}
});
return serializedValues;
}
resetGroup() {
this.formElementsArray.forEach(child => {
if (typeof child.resetGroup === 'function') {
child.resetGroup();
} else if (typeof child.reset === 'function') {
child.reset();
}
});
this.resetInteractionState();
}
resetInteractionState() {
this.submitted = false;
this.touched = false;
this.dirty = false;
this.formElementsArray.forEach(formElement => {
if (typeof formElement.resetInteractionState === 'function') {
formElement.resetInteractionState();
}
});
}
_getFromAllFormElements(property) {
if (!this.formElements) {
return undefined;
}
const childrenNames = Object.keys(this.formElements);
const values = {};
childrenNames.forEach(name => {
if (Array.isArray(this.formElements[name])) {
// grouped via myName[]
values[name] = this.formElements[name].map(node => node.modelValue);
} else {
// not grouped
values[name] = this.formElements[name][property];
}
});
return values;
}
_setValueForAllFormElements(property, value) {
this.formElementsArray.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign
});
}
async _setValueMapForAllFormElements(property, values) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
if (values && typeof values === 'object') {
Object.keys(values).forEach(name => {
if (Array.isArray(this.formElements[name])) {
this.formElements[name].forEach((el, index) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
this.formElements[name][property] = values[name];
});
}
}
_anyFormElementHas(property) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
_anyFormElementHasFeedbackFor(state) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el.hasFeedbackFor.includes(state));
}
return !!this.formElements[name].hasFeedbackFor.includes(state);
});
}
_everyFormElementHas(property) {
return Object.keys(this.formElements).every(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].every(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
/**
* Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations
* - react on modelValue change, which says something about the validity as a whole
* (at least two checkboxes for instance) and nothing about the children's values
* - children validity states have changed, so fieldset needs to update itself based on that
*/
__validate(ev) {
if (ev && this.isRegisteredFormElement(ev.target)) {
this.validate();
}
}
_syncFocused() {
this.focused = this._anyFormElementHas('focused');
}
_onFocusOut(ev) {
const lastEl = this.formElementsArray[this.formElementsArray.length - 1];
if (ev.target === lastEl) {
this.touched = true;
}
this.focused = false;
}
_syncDirty() {
this.dirty = this._anyFormElementHas('dirty');
}
_setRole(role) {
this.setAttribute('role', role || 'group');
}
// eslint-disable-next-line class-methods-use-this
__serializeElement(element) {
if (!element.disabled) {
if (typeof element.serializeGroup === 'function') {
return element.serializeGroup();
}
return element.serializedValue;
}
return undefined;
}
__serializeElements(elements) {
const serializedValues = [];
elements.forEach(element => {
const serializedValue = this.__serializeElement(element);
if (serializedValue || serializedValue === 0) {
serializedValues.push(serializedValue);
}
});
return serializedValues;
}
/**
* Adds the element to an object with the child name as a key
* Note: this is different to the default behavior of just beeing an array
*
* @override
*/
addFormElement(child, indexToInsertAt) {
const { name } = child;
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
}
if (name === this.name && !this._childrenCanHaveSameName) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
}
if (this.disabled) {
// eslint-disable-next-line no-param-reassign
child.makeRequestToBeDisabled();
}
if (name.substr(-2) === '[]' || this._childNamesCanBeDuplicate) {
if (!Array.isArray(this.formElements[name])) {
this.formElements[name] = [];
}
if (indexToInsertAt > 0) {
this.formElements[name].splice(indexToInsertAt, 0, child);
} else {
this.formElements[name].push(child);
}
} else if (!this.formElements[name]) {
this.formElements[name] = child;
} else {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(
`Name "${name}" is already registered - if you want an array add [] to the end`,
);
}
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child.__parentFormGroup = this;
// aria-describedby of (nested) children
// TODO: Teardown in removeFormElement
let parent = this;
while (parent) {
this.constructor._addDescriptionElementIdsToField(
child,
parent._getAriaDescriptionElements(),
);
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
this.validate();
}
// eslint-disable-next-line class-methods-use-this
get _childrenCanHaveSameName() {
return false;
}
// eslint-disable-next-line class-methods-use-this
get _childNamesCanBeDuplicate() {
return false;
}
/**
* Gathers initial model values of all children. Used
* when resetGroup() is called.
*/
get _initialModelValue() {
return this._getFromAllFormElements('_initialModelValue');
}
/**
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
* parent(fieldset)
* @param {LionField} field - the child: lion-field/lion-input/lion-textarea
* @param {array} descriptionElements - description elements like feedback and help-text
*/
static _addDescriptionElementIdsToField(field, descriptionElements) {
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
orderedEls.forEach(el => {
if (field.addToAriaDescribedBy) {
field.addToAriaDescribedBy(el, { reorder: false });
}
});
}
removeFormElement(child) {
const { name } = child;
if (name.substr(-2) === '[]' && this.formElements[name]) {
const index = this.formElements[name].indexOf(child);
if (index > -1) {
this.formElements[name].splice(index, 1);
}
} else if (this.formElements[name]) {
delete this.formElements[name];
}
this.validate();
/** @override from FormRegistrarMixin */
this._isFormOrFieldset = true;
}
}

View file

@ -1,6 +1,7 @@
import {
expect,
fixture,
fixtureSync,
html,
unsafeStatic,
triggerFocusFor,
@ -39,7 +40,9 @@ beforeEach(() => {
localizeTearDown();
});
// TODO: seperate fieldset and FormGroup tests
describe('<lion-fieldset>', () => {
// TODO: Tests below belong to FormControlMixin. Preferably run suite integration test
it(`has a fieldName based on the label`, async () => {
const el1 = await fixture(html`<${tag} label="foo">${inputSlots}</${tag}>`);
expect(el1.fieldName).to.equal(el1._labelNode.textContent);
@ -60,13 +63,15 @@ describe('<lion-fieldset>', () => {
expect(el.__fieldName).to.equal(el.fieldName);
});
it(`${tagString} has an up to date list of every form element in #formElements`, async () => {
// TODO: Tests below belong to FormRegistrarMixin. Preferably run suite integration test
it(`${tagString} has an up to date list of every form element in .formElements`, async () => {
const el = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
expect(Object.keys(el.formElements).length).to.equal(3);
expect(el.formElements.keys().length).to.equal(3);
expect(el.formElements['hobbies[]'].length).to.equal(2);
el.removeChild(el.formElements['hobbies[]'][0]);
expect(Object.keys(el.formElements).length).to.equal(3);
expect(el.formElements.keys().length).to.equal(3);
expect(el.formElements['hobbies[]'].length).to.equal(1);
});
@ -79,9 +84,9 @@ describe('<lion-fieldset>', () => {
</${tag}>
`);
await nextFrame();
expect(el.formElementsArray.length).to.equal(1);
expect(el.formElements.length).to.equal(1);
el.children[0].removeChild(el.formElements.foo);
expect(el.formElementsArray.length).to.equal(0);
expect(el.formElements.length).to.equal(0);
});
it('handles names with ending [] as an array', async () => {
@ -91,7 +96,7 @@ describe('<lion-fieldset>', () => {
el.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' };
el.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' };
expect(Object.keys(el.formElements).length).to.equal(3);
expect(el.formElements.keys().length).to.equal(3);
expect(el.formElements['hobbies[]'].length).to.equal(2);
expect(el.formElements['hobbies[]'][0].modelValue.value).to.equal('chess');
expect(el.formElements['gender[]'][0].modelValue.value).to.equal('male');
@ -166,15 +171,17 @@ describe('<lion-fieldset>', () => {
const el = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
const newField = await fixture(html`<${childTag} name="lastName"></${childTag}>`);
expect(Object.keys(el.formElements).length).to.equal(3);
expect(el.formElements.keys().length).to.equal(3);
el.appendChild(newField);
expect(Object.keys(el.formElements).length).to.equal(4);
expect(el.formElements.keys().length).to.equal(4);
el._inputNode.removeChild(newField);
expect(Object.keys(el.formElements).length).to.equal(3);
expect(el.formElements.keys().length).to.equal(3);
});
// TODO: Tests below belong to FormGroupMixin. Preferably run suite integration test
it('can read/write all values (of every input) via this.modelValue', async () => {
const el = await fixture(html`
<${tag}>
@ -231,6 +238,32 @@ describe('<lion-fieldset>', () => {
expect(el.formElements.lastName.modelValue).to.equal(2);
});
it('does not list disabled values in this.modelValue', async () => {
const el = await fixture(html`
<${tag}>
<${childTag} name="a" disabled .modelValue="${'x'}"></${childTag}>
<${childTag} name="b" .modelValue="${'x'}"></${childTag}>
<${tag} name="newFieldset">
<${childTag} name="c" .modelValue="${'x'}"></${childTag}>
<${childTag} name="d" disabled .modelValue="${'x'}"></${childTag}>
</${tag}>
<${tag} name="disabledFieldset" disabled>
<${childTag} name="e" .modelValue="${'x'}"></${childTag}>
</${tag}>
</${tag}>
`);
await el.registrationReady;
const newFieldset = el.querySelector('lion-fieldset');
await newFieldset.registrationReady;
expect(el.modelValue).to.deep.equal({
b: 'x',
newFieldset: {
c: 'x',
},
});
});
it('does not throw if setter data of this.modelValue can not be handled', async () => {
const el = await fixture(html`
<${tag}>
@ -308,7 +341,7 @@ describe('<lion-fieldset>', () => {
expect(el.modelValue).to.eql(initialSerializedValue);
});
describe('validation', () => {
describe('Validation', () => {
it('validates on init', async () => {
class IsCat extends Validator {
constructor() {
@ -409,7 +442,7 @@ describe('<lion-fieldset>', () => {
});
});
describe('interaction states', () => {
describe('Interaction states', () => {
it('has false states (dirty, touched, prefilled) on init', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
@ -453,7 +486,7 @@ describe('<lion-fieldset>', () => {
it('becomes prefilled if all form elements are prefilled', async () => {
const el = await fixture(html`
<${tag}>
<${childTag} name="input1" prefilled></${childTag}>
<${childTag} name="input1" .modelValue="${'prefilled'}"></${childTag}>
<${childTag} name="input2"></${childTag}>
</${tag}>
`);
@ -462,8 +495,8 @@ describe('<lion-fieldset>', () => {
const el2 = await fixture(html`
<${tag}>
<${childTag} name="input1" prefilled></${childTag}>
<${childTag} name="input2" prefilled></${childTag}>
<${childTag} name="input1" .modelValue="${'prefilled'}"></${childTag}>
<${childTag} name="input2" .modelValue="${'prefilled'}"></${childTag}>
</${tag}>
`);
await nextFrame();
@ -515,7 +548,7 @@ describe('<lion-fieldset>', () => {
`);
outside.click();
expect(el.touched, 'unfocused fieldset should stays untouched').to.be.false;
expect(el.touched, 'unfocused fieldset should stay untouched').to.be.false;
el.children[1].focus();
el.children[2].focus();
@ -524,7 +557,6 @@ describe('<lion-fieldset>', () => {
outside.click(); // blur the group via a click
outside.focus(); // a real mouse click moves focus as well
expect(el.touched).to.be.true;
expect(el2.touched).to.be.false;
});
@ -591,9 +623,30 @@ describe('<lion-fieldset>', () => {
expect(el.validationStates.error.Input1IsTen).to.be.true;
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
it.skip('(re)initializes children interaction states on registration ready', async () => {
const fieldset = await fixtureSync(html`
<${tag} .modelValue="${{ a: '1', b: '2' }}">
<${childTag} name="a"></${childTag}>
<${childTag} name="b"></${childTag}>
</${tag}>`);
const childA = fieldset.querySelector('[name="a"]');
const childB = fieldset.querySelector('[name="b"]');
const spyA = sinon.spy(childA, 'initInteractionState');
const spyB = sinon.spy(childB, 'initInteractionState');
expect(fieldset.prefilled).to.be.false;
expect(fieldset.dirty).to.be.false;
await fieldset.registrationReady;
await nextFrame();
expect(spyA).to.have.been.called;
expect(spyB).to.have.been.called;
expect(fieldset.prefilled).to.be.true;
expect(fieldset.dirty).to.be.false;
});
});
describe('serialize', () => {
// TODO: this should be tested in FormGroupMixin
describe('serializedValue', () => {
it('use form elements serializedValue', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
@ -604,7 +657,7 @@ describe('<lion-fieldset>', () => {
fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
fieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
expect(fieldset.formElements['hobbies[]'][0].serializedValue).to.equal('Bar-serialized');
expect(fieldset.serializeGroup()).to.deep.equal({
expect(fieldset.serializedValue).to.deep.equal({
'hobbies[]': ['Bar-serialized', { checked: false, value: 'rugby' }],
'gender[]': [
{ checked: false, value: 'male' },
@ -614,6 +667,27 @@ describe('<lion-fieldset>', () => {
});
});
it('treats names with ending [] as arrays', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' };
fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' };
fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' };
fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
fieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
expect(fieldset.serializedValue).to.deep.equal({
'hobbies[]': [
{ checked: false, value: 'chess' },
{ checked: false, value: 'rugby' },
],
'gender[]': [
{ checked: false, value: 'male' },
{ checked: false, value: 'female' },
],
color: { checked: false, value: 'blue' },
});
});
it('0 is a valid value to be serialized', async () => {
const fieldset = await fixture(html`
<${tag}>
@ -621,48 +695,22 @@ describe('<lion-fieldset>', () => {
</${tag}>`);
await nextFrame();
fieldset.formElements.price.modelValue = 0;
expect(fieldset.serializeGroup()).to.deep.equal({ price: 0 });
expect(fieldset.serializedValue).to.deep.equal({ price: 0 });
});
it('__serializeElements serializes 0 as a valid value', async () => {
const fieldset = await fixture(html`<${tag}></${tag}>`);
it.skip('serializes undefined values as ""(nb radios/checkboxes are always serialized)', async () => {
const fieldset = await fixture(html`
<${tag}>
<${childTag} name="custom[]"></${childTag}>
<${childTag} name="custom[]"></${childTag}>
</${tag}>
`);
await nextFrame();
const elements = [{ serializedValue: 0 }];
expect(fieldset.__serializeElements(elements)).to.deep.equal([0]);
});
fieldset.formElements['custom[]'][0].modelValue = 'custom 1';
fieldset.formElements['custom[]'][1].modelValue = undefined;
it('form elements which are not disabled', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
fieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
fieldset.formElements['hobbies[]'][0].modelValue = { checked: true, value: 'football' };
fieldset.formElements['gender[]'][0].modelValue = { checked: true, value: 'male' };
fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' };
fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
fieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
expect(fieldset.serializeGroup()).to.deep.equal({
'hobbies[]': [
{ checked: true, value: 'football' },
{ checked: false, value: 'rugby' },
],
'gender[]': [
{ checked: true, value: 'male' },
{ checked: false, value: 'female' },
],
color: { checked: false, value: 'blue' },
});
fieldset.formElements.color.disabled = true;
expect(fieldset.serializeGroup()).to.deep.equal({
'hobbies[]': [
{ checked: true, value: 'football' },
{ checked: false, value: 'rugby' },
],
'gender[]': [
{ checked: true, value: 'male' },
{ checked: false, value: 'female' },
],
expect(fieldset.serializedValue).to.deep.equal({
'custom[]': ['custom 1', ''],
});
});
@ -681,9 +729,9 @@ describe('<lion-fieldset>', () => {
newFieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
fieldset.formElements.comment.modelValue = 'Foo';
expect(Object.keys(fieldset.formElements).length).to.equal(2);
expect(Object.keys(newFieldset.formElements).length).to.equal(3);
expect(fieldset.serializeGroup()).to.deep.equal({
expect(fieldset.formElements.keys().length).to.equal(2);
expect(newFieldset.formElements.keys().length).to.equal(3);
expect(fieldset.serializedValue).to.deep.equal({
comment: 'Foo',
newfieldset: {
'hobbies[]': [
@ -699,7 +747,23 @@ describe('<lion-fieldset>', () => {
});
});
it('will exclude form elements within an disabled fieldset', async () => {
it('does not serialize disabled values', async () => {
const fieldset = await fixture(html`
<${tag}>
<${childTag} name="custom[]"></${childTag}>
<${childTag} name="custom[]"></${childTag}>
</${tag}>
`);
await nextFrame();
fieldset.formElements['custom[]'][0].modelValue = 'custom 1';
fieldset.formElements['custom[]'][1].disabled = true;
expect(fieldset.serializedValue).to.deep.equal({
'custom[]': ['custom 1'],
});
});
it('will exclude form elements within a disabled fieldset', async () => {
const fieldset = await fixture(html`
<${tag} name="userData">
<${childTag} name="comment"></${childTag}>
@ -717,7 +781,7 @@ describe('<lion-fieldset>', () => {
newFieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
newFieldset.formElements.color.disabled = true;
expect(fieldset.serializeGroup()).to.deep.equal({
expect(fieldset.serializedValue).to.deep.equal({
comment: 'Foo',
newfieldset: {
'hobbies[]': [
@ -732,7 +796,7 @@ describe('<lion-fieldset>', () => {
});
newFieldset.formElements.color.disabled = false;
expect(fieldset.serializeGroup()).to.deep.equal({
expect(fieldset.serializedValue).to.deep.equal({
comment: 'Foo',
newfieldset: {
'hobbies[]': [
@ -747,43 +811,6 @@ describe('<lion-fieldset>', () => {
},
});
});
it('treats names with ending [] as arrays', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame();
fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' };
fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'rugby' };
fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' };
fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
fieldset.formElements.color.modelValue = { checked: false, value: 'blue' };
expect(fieldset.serializeGroup()).to.deep.equal({
'hobbies[]': [
{ checked: false, value: 'chess' },
{ checked: false, value: 'rugby' },
],
'gender[]': [
{ checked: false, value: 'male' },
{ checked: false, value: 'female' },
],
color: { checked: false, value: 'blue' },
});
});
it('does not serialize undefined values (nb radios/checkboxes are always serialized)', async () => {
const fieldset = await fixture(html`
<${tag}>
<${childTag} name="custom[]"></${childTag}>
<${childTag} name="custom[]"></${childTag}>
</${tag}>
`);
await nextFrame();
fieldset.formElements['custom[]'][0].modelValue = 'custom 1';
fieldset.formElements['custom[]'][1].modelValue = undefined;
expect(fieldset.serializeGroup()).to.deep.equal({
'custom[]': ['custom 1'],
});
});
});
describe('Reset', () => {
@ -883,7 +910,7 @@ describe('<lion-fieldset>', () => {
fieldset.submitted = true;
fieldset.resetGroup();
expect(fieldset.submitted).to.equal(false);
fieldset.formElementsArray.forEach(el => {
fieldset.formElements.forEach(el => {
expect(el.submitted).to.equal(false);
});
});

View file

@ -0,0 +1,33 @@
import { expect, fixture, html } from '@open-wc/testing';
import './helpers/umbrella-form.js';
// Test umbrella form
describe('Form Integrations', () => {
it.skip('".serializedValue" returns all non disabled fields based on form structure', async () => {
const el = await fixture(
html`
<umbrella-form></umbrella-form>
`,
);
const formEl = el._lionFormNode;
expect(formEl.serializedValue).to.eql({
bio: '',
'checkers[]': [[]],
comments: '',
date: '2000-12-12',
datepicker: '2020-12-12',
dinosaurs: '',
email: '',
favoriteColor: 'hotpink',
full_name: {
first_name: '',
last_name: '',
},
iban: '',
lyrics: '1',
money: '',
range: 2.3,
terms: [],
});
});
});

View file

@ -0,0 +1,106 @@
import { LitElement, html } from '@lion/core';
import { Required, MinLength } from '@lion/validate';
export class UmbrellaForm extends LitElement {
get _lionFormNode() {
return this.shadowRoot.querySelector('lion-form');
}
render() {
return html`
<lion-form>
<form>
<lion-fieldset name="full_name">
<lion-input
name="first_name"
label="First Name"
.validators="${[new Required()]}"
></lion-input>
<lion-input
name="last_name"
label="Last Name"
.validators="${[new Required()]}"
></lion-input>
</lion-fieldset>
<lion-input-date
name="date"
label="Date of application"
.modelValue="${new Date('2000-12-12')}"
.validators="${[new Required()]}"
></lion-input-date>
<lion-input-datepicker
name="datepicker"
label="Date to be picked"
.modelValue="${new Date('2020-12-12')}"
.validators="${[new Required()]}"
></lion-input-datepicker>
<lion-textarea
name="bio"
label="Biography"
.validators="${[new Required(), new MinLength(10)]}"
help-text="Please enter at least 10 characters"
></lion-textarea>
<lion-input-amount name="money" label="Money"></lion-input-amount>
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-checkbox-group
label="What do you like?"
name="checkers[]"
.validators="${[new Required()]}"
>
<lion-checkbox .choiceValue=${'foo'} label="I like foo"></lion-checkbox>
<lion-checkbox .choiceValue=${'bar'} label="I like bar"></lion-checkbox>
<lion-checkbox .choiceValue=${'baz'} label="I like baz"></lion-checkbox>
</lion-checkbox-group>
<lion-radio-group
name="dinosaurs"
label="Favorite dinosaur"
.validators="${[new Required()]}"
>
<lion-radio .choiceValue=${'allosaurus'} label="allosaurus"></lion-radio>
<lion-radio .choiceValue=${'brontosaurus'} label="brontosaurus"></lion-radio>
<lion-radio .choiceValue=${'diplodocus'} label="diplodocus"></lion-radio>
</lion-radio-group>
<lion-select-rich name="favoriteColor" label="Favorite color">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'hotpink'} checked>Hotpink</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
</lion-options>
</lion-select-rich>
<lion-select label="Lyrics" name="lyrics" .validators="${[new Required()]}">
<select slot="input">
<option value="1">Fire up that loud</option>
<option value="2">Another round of shots...</option>
<option value="3">Drop down for what?</option>
</select>
</lion-select>
<lion-input-range
name="range"
min="1"
max="5"
.modelValue="${2.3}"
unit="%"
step="0.1"
label="Input range"
></lion-input-range>
<lion-checkbox-group name="terms" .validators="${[new Required()]}">
<lion-checkbox label="I blindly accept all terms and conditions"></lion-checkbox>
</lion-checkbox-group>
<lion-textarea name="comments" label="Comments"></lion-textarea>
<div class="buttons">
<lion-button raised>Submit</lion-button>
<lion-button
type="button"
raised
@click=${ev =>
ev.currentTarget.parentElement.parentElement.parentElement.resetGroup()}
>Reset</lion-button
>
</div>
</form>
</lion-form>
`;
}
}
customElements.define('umbrella-form', UmbrellaForm);

View file

@ -1,15 +1,16 @@
import { LitElement } from '@lion/core';
import { ChoiceGroupMixin } from '@lion/choice-input';
import { LionFieldset } from '@lion/fieldset';
import { FormGroupMixin } from '@lion/fieldset';
/**
* A wrapper around multiple radios.
*
* @extends {LionFieldset}
*/
export class LionRadioGroup extends ChoiceGroupMixin(LionFieldset) {
export class LionRadioGroup extends ChoiceGroupMixin(FormGroupMixin(LitElement)) {
connectedCallback() {
// eslint-disable-next-line wc/guard-super-call
super.connectedCallback();
this._setRole('radiogroup');
this.setAttribute('role', 'radiogroup');
}
}

View file

@ -37,9 +37,9 @@ describe('<lion-radio-group>', () => {
</lion-radio-group>
`);
await nextFrame();
const male = el.formElementsArray[0];
const male = el.formElements[0];
const maleInput = male.querySelector('input');
const female = el.formElementsArray[1];
const female = el.formElements[1];
const femaleInput = female.querySelector('input');
expect(male.checked).to.equal(false);

View file

@ -1,7 +1,7 @@
import { ChoiceGroupMixin } from '@lion/choice-input';
import { css, html, LitElement, SlotMixin } from '@lion/core';
import { FormControlMixin, FormRegistrarMixin, InteractionStateMixin } from '@lion/field';
import { formRegistrarManager } from '@lion/field/src/formRegistrarManager.js';
import { formRegistrarManager } from '@lion/field/src/registration/formRegistrarManager.js';
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
import { ValidateMixin } from '@lion/validate';
import '../lion-select-invoker.js';
@ -132,6 +132,11 @@ export class LionSelectRich extends ChoiceGroupMixin(
this.requestUpdate('modelValue');
}
// TODO: quick and dirty fix. Should be possible to do it nicer on a higher layer
get serializedValue() {
return this.modelValue;
}
get checkedIndex() {
let checkedIndex = -1;
this.formElements.forEach((option, i) => {

View file

@ -41,15 +41,15 @@ describe('lion-select-rich', () => {
`);
await nextFrame();
expect(el.formElementsArray[0].name).to.equal('foo');
expect(el.formElementsArray[1].name).to.equal('foo');
expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].name).to.equal('foo');
const validChild = await fixture(html`
<lion-option .choiceValue=${30}>Item 3</lion-option>
`);
el.appendChild(validChild);
expect(el.formElementsArray[2].name).to.equal('foo');
expect(el.formElements[2].name).to.equal('foo');
});
it('throws if a child element without a modelValue like { value: "foo", checked: false } tries to register', async () => {
@ -108,7 +108,7 @@ describe('lion-select-rich', () => {
`);
expect(el.modelValue).to.equal('other');
expect(el.formElementsArray[2].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true;
});
it(`has a fieldName based on the label`, async () => {