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