diff --git a/packages/checkbox-group/src/LionCheckboxGroup.js b/packages/checkbox-group/src/LionCheckboxGroup.js index 4a3e1046b..6708a0619 100644 --- a/packages/checkbox-group/src/LionCheckboxGroup.js +++ b/packages/checkbox-group/src/LionCheckboxGroup.js @@ -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; diff --git a/packages/choice-input/src/ChoiceGroupMixin.js b/packages/choice-input/src/ChoiceGroupMixin.js index 123d92ac1..6bc3d93be 100644 --- a/packages/choice-input/src/ChoiceGroupMixin.js +++ b/packages/choice-input/src/ChoiceGroupMixin.js @@ -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; } } } diff --git a/packages/choice-input/test/ChoiceGroupMixin.test.js b/packages/choice-input/test/ChoiceGroupMixin.test.js index c8566f6c2..5d1f01f32 100644 --- a/packages/choice-input/test/ChoiceGroupMixin.test.js +++ b/packages/choice-input/test/ChoiceGroupMixin.test.js @@ -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` `); 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', () => { `); - 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` + + + + + + + + `); + + 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'], + }); }); }); }); diff --git a/packages/field/index.js b/packages/field/index.js index 3dff79439..6c1d2d28d 100644 --- a/packages/field/index.js +++ b/packages/field/index.js @@ -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'; diff --git a/packages/field/src/FormControlMixin.js b/packages/field/src/FormControlMixin.js index c58717615..6cfd78a90 100644 --- a/packages/field/src/FormControlMixin.js +++ b/packages/field/src/FormControlMixin.js @@ -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'); diff --git a/packages/field/src/FormRegistrarMixin.js b/packages/field/src/FormRegistrarMixin.js deleted file mode 100644 index bb341805a..000000000 --- a/packages/field/src/FormRegistrarMixin.js +++ /dev/null @@ -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); - } - }, -); diff --git a/packages/field/src/registration/FormControlsCollection.js b/packages/field/src/registration/FormControlsCollection.js new file mode 100644 index 000000000..f425588ad --- /dev/null +++ b/packages/field/src/registration/FormControlsCollection.js @@ -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.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 [input#b1, input#b2] + * form.elements.c; // input#c + * + * // This is how a Lion form works (for simplicity Lion components have the 'l'-prefix): + * + *
+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
+ *
+ * + * 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 [l-input#b1, l-input#b2] + * lionForm.formElements.c; // Element l-input#c + * + * lionForm.formElements[d-g].formElements; // Array + * + * lionForm.formElements[d-e].value; // String + * lionForm.formElements[f-g].value; // Array + */ +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))); + } +} diff --git a/packages/field/src/FormRegisteringMixin.js b/packages/field/src/registration/FormRegisteringMixin.js similarity index 100% rename from packages/field/src/FormRegisteringMixin.js rename to packages/field/src/registration/FormRegisteringMixin.js diff --git a/packages/field/src/registration/FormRegistrarMixin.js b/packages/field/src/registration/FormRegistrarMixin.js new file mode 100644 index 000000000..e98bf0a28 --- /dev/null +++ b/packages/field/src/registration/FormRegistrarMixin.js @@ -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); + } + }, +); diff --git a/packages/field/src/FormRegistrarPortalMixin.js b/packages/field/src/registration/FormRegistrarPortalMixin.js similarity index 100% rename from packages/field/src/FormRegistrarPortalMixin.js rename to packages/field/src/registration/FormRegistrarPortalMixin.js diff --git a/packages/field/src/formRegistrarManager.js b/packages/field/src/registration/formRegistrarManager.js similarity index 100% rename from packages/field/src/formRegistrarManager.js rename to packages/field/src/registration/formRegistrarManager.js diff --git a/packages/field/test-suites/FormRegistrationMixins.suite.js b/packages/field/test-suites/FormRegistrationMixins.suite.js index 871dfd4a3..6d06949d9 100644 --- a/packages/field/test-suites/FormRegistrationMixins.suite.js +++ b/packages/field/test-suites/FormRegistrationMixins.suite.js @@ -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; diff --git a/packages/fieldset/index.js b/packages/fieldset/index.js index ea598fcfd..5b8206ecb 100644 --- a/packages/fieldset/index.js +++ b/packages/fieldset/index.js @@ -1 +1,2 @@ export { LionFieldset } from './src/LionFieldset.js'; +export { FormGroupMixin } from './src/FormGroupMixin.js'; diff --git a/packages/fieldset/src/FormGroupMixin.js b/packages/fieldset/src/FormGroupMixin.js new file mode 100644 index 000000000..1b3b771ba --- /dev/null +++ b/packages/fieldset/src/FormGroupMixin.js @@ -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` +
+ +
+ `; + } + + /** + * @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(); + } + }, +); diff --git a/packages/fieldset/src/LionFieldset.js b/packages/fieldset/src/LionFieldset.js index ece64a2a7..9ea5829d1 100644 --- a/packages/fieldset/src/LionFieldset.js +++ b/packages/fieldset/src/LionFieldset.js @@ -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
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` -
- -
- `; - } - - 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; } } diff --git a/packages/fieldset/test/lion-fieldset.test.js b/packages/fieldset/test/lion-fieldset.test.js index 568c5d569..497ae31b2 100644 --- a/packages/fieldset/test/lion-fieldset.test.js +++ b/packages/fieldset/test/lion-fieldset.test.js @@ -1,6 +1,7 @@ import { expect, fixture, + fixtureSync, html, unsafeStatic, triggerFocusFor, @@ -39,7 +40,9 @@ beforeEach(() => { localizeTearDown(); }); +// TODO: seperate fieldset and FormGroup tests describe('', () => { + // 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}`); expect(el1.fieldName).to.equal(el1._labelNode.textContent); @@ -60,13 +63,15 @@ describe('', () => { 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}`); 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('', () => { `); 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('', () => { 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('', () => { const el = await fixture(html`<${tag}>${inputSlots}`); const newField = await fixture(html`<${childTag} name="lastName">`); - 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('', () => { 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} name="b" .modelValue="${'x'}"> + <${tag} name="newFieldset"> + <${childTag} name="c" .modelValue="${'x'}"> + <${childTag} name="d" disabled .modelValue="${'x'}"> + + <${tag} name="disabledFieldset" disabled> + <${childTag} name="e" .modelValue="${'x'}"> + + + `); + 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('', () => { 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('', () => { }); }); - describe('interaction states', () => { + describe('Interaction states', () => { it('has false states (dirty, touched, prefilled) on init', async () => { const fieldset = await fixture(html`<${tag}>${inputSlots}`); await nextFrame(); @@ -453,7 +486,7 @@ describe('', () => { it('becomes prefilled if all form elements are prefilled', async () => { const el = await fixture(html` <${tag}> - <${childTag} name="input1" prefilled> + <${childTag} name="input1" .modelValue="${'prefilled'}"> <${childTag} name="input2"> `); @@ -462,8 +495,8 @@ describe('', () => { const el2 = await fixture(html` <${tag}> - <${childTag} name="input1" prefilled> - <${childTag} name="input2" prefilled> + <${childTag} name="input1" .modelValue="${'prefilled'}"> + <${childTag} name="input2" .modelValue="${'prefilled'}"> `); await nextFrame(); @@ -515,7 +548,7 @@ describe('', () => { `); 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('', () => { 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('', () => { 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} name="b"> + `); + 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}`); await nextFrame(); @@ -604,7 +657,7 @@ describe('', () => { 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('', () => { }); }); + it('treats names with ending [] as arrays', async () => { + const fieldset = await fixture(html`<${tag}>${inputSlots}`); + 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('', () => { `); 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}>`); + it.skip('serializes undefined values as ""(nb radios/checkboxes are always serialized)', async () => { + const fieldset = await fixture(html` + <${tag}> + <${childTag} name="custom[]"> + <${childTag} name="custom[]"> + + `); 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}`); - 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('', () => { 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('', () => { }); }); - 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} name="custom[]"> + + `); + 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"> @@ -717,7 +781,7 @@ describe('', () => { 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('', () => { }); 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('', () => { }, }); }); - - it('treats names with ending [] as arrays', async () => { - const fieldset = await fixture(html`<${tag}>${inputSlots}`); - 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} name="custom[]"> - - `); - 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('', () => { 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); }); }); diff --git a/packages/form-system/test/form-integrations.test.js b/packages/form-system/test/form-integrations.test.js new file mode 100644 index 000000000..602cf16ba --- /dev/null +++ b/packages/form-system/test/form-integrations.test.js @@ -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` + + `, + ); + 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: [], + }); + }); +}); diff --git a/packages/form-system/test/helpers/umbrella-form.js b/packages/form-system/test/helpers/umbrella-form.js new file mode 100644 index 000000000..35d1eedb4 --- /dev/null +++ b/packages/form-system/test/helpers/umbrella-form.js @@ -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` + +
+ + + + + + + + + + + + + + + + + + + + + + + Red + Hotpink + Teal + + + + + + + + + + +
+ Submit + + ev.currentTarget.parentElement.parentElement.parentElement.resetGroup()} + >Reset +
+
+
+ `; + } +} +customElements.define('umbrella-form', UmbrellaForm); diff --git a/packages/radio-group/src/LionRadioGroup.js b/packages/radio-group/src/LionRadioGroup.js index 080441e57..2417b2ea0 100644 --- a/packages/radio-group/src/LionRadioGroup.js +++ b/packages/radio-group/src/LionRadioGroup.js @@ -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'); } } diff --git a/packages/radio-group/test/lion-radio-group.test.js b/packages/radio-group/test/lion-radio-group.test.js index ddc835977..621e6f50c 100644 --- a/packages/radio-group/test/lion-radio-group.test.js +++ b/packages/radio-group/test/lion-radio-group.test.js @@ -37,9 +37,9 @@ describe('', () => { `); 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); diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js index d2f3bf120..22dbdda67 100644 --- a/packages/select-rich/src/LionSelectRich.js +++ b/packages/select-rich/src/LionSelectRich.js @@ -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) => { diff --git a/packages/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js index 296b2e515..5704db4c1 100644 --- a/packages/select-rich/test/lion-select-rich.test.js +++ b/packages/select-rich/test/lion-select-rich.test.js @@ -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` Item 3 `); 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 () => {