lion/packages/fieldset/src/FormGroupMixin.js
Alex Ghiu 1b6c3a44c8 fix: normalization model-value-changed events
Co-authored-by: Thijs Louisse <Thijs.Louisse@ing.com>
2020-03-19 09:59:30 +01:00

398 lines
13 KiB
JavaScript

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);
}
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.registrationHasCompleted) {
await this.registrationComplete;
}
this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') {
el.initInteractionState();
}
});
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('disabled')) {
if (this.disabled) {
this.__requestChildrenToBeDisabled();
} else {
this.__retractRequestChildrenToBeDisabled();
}
}
if (changedProperties.has('focused')) {
if (this.focused === true) {
this.__setupOutsideClickHandling();
}
}
}
__setupOutsideClickHandling() {
if (!this.__hasActiveOutsideClickHandling) {
document.addEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = true;
}
}
_checkForOutsideClick(event) {
const outsideGroupClicked = !this.contains(event.target);
if (outsideGroupClicked) {
this.touched = true;
}
}
__requestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.makeRequestToBeDisabled) {
child.makeRequestToBeDisabled();
}
});
}
__retractRequestChildrenToBeDisabled() {
this.formElements.forEach(child => {
if (child.retractRequestToBeDisabled) {
child.retractRequestToBeDisabled();
}
});
}
// eslint-disable-next-line class-methods-use-this
_inputGroupTemplate() {
return html`
<div class="input-group">
<slot></slot>
</div>
`;
}
/**
* @desc Handles interaction state 'submitted'.
* This allows children to enable visibility of validation feedback
*/
submitGroup() {
this.submitted = true;
this.formElements.forEach(child => {
if (typeof child.submitGroup === 'function') {
child.submitGroup();
} else {
child.submitted = true; // eslint-disable-line no-param-reassign
}
});
}
resetGroup() {
this.formElements.forEach(child => {
if (typeof child.resetGroup === 'function') {
child.resetGroup();
} else if (typeof child.reset === 'function') {
child.reset();
}
});
this.resetInteractionState();
}
resetInteractionState() {
this.submitted = false;
this.touched = false;
this.dirty = false;
this.formElements.forEach(formElement => {
if (typeof formElement.resetInteractionState === 'function') {
formElement.resetInteractionState();
}
});
}
_getFromAllFormElements(property, filterCondition = el => !el.disabled) {
const result = {};
this.formElements.keys().forEach(name => {
const elem = this.formElements[name];
if (elem instanceof FormControlsCollection) {
result[name] = elem.filter(el => filterCondition(el)).map(el => el[property]);
} else if (filterCondition(elem)) {
if (typeof elem._getFromAllFormElements === 'function') {
result[name] = elem._getFromAllFormElements(property, filterCondition);
} else {
result[name] = elem[property];
}
}
});
return result;
}
_setValueForAllFormElements(property, value) {
this.formElements.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign
});
}
async _setValueMapForAllFormElements(property, values) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
if (values && typeof values === 'object') {
Object.keys(values).forEach(name => {
if (Array.isArray(this.formElements[name])) {
this.formElements[name].forEach((el, index) => {
el[property] = values[name][index]; // eslint-disable-line no-param-reassign
});
}
this.formElements[name][property] = values[name];
});
}
}
_anyFormElementHas(property) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
_anyFormElementHasFeedbackFor(state) {
return Object.keys(this.formElements).some(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].some(el => !!el.hasFeedbackFor.includes(state));
}
return !!this.formElements[name].hasFeedbackFor.includes(state);
});
}
_everyFormElementHas(property) {
return Object.keys(this.formElements).every(name => {
if (Array.isArray(this.formElements[name])) {
return this.formElements[name].every(el => !!el[property]);
}
return !!this.formElements[name][property];
});
}
/**
* Gets triggered by event 'validate-performed' which enabled us to handle 2 different situations
* - react on modelValue change, which says something about the validity as a whole
* (at least two checkboxes for instance) and nothing about the children's values
* - children validity states have changed, so fieldset needs to update itself based on that
*/
__onChildValidatePerformed(ev) {
if (ev && this.isRegisteredFormElement(ev.target)) {
this.validate();
}
}
_syncFocused() {
this.focused = this._anyFormElementHas('focused');
}
_onFocusOut(ev) {
const lastEl = this.formElements[this.formElements.length - 1];
if (ev.target === lastEl) {
this.touched = true;
}
this.focused = false;
}
_syncDirty() {
this.dirty = this._anyFormElementHas('dirty');
}
__linkChildrenMessagesToParent(child) {
// aria-describedby of (nested) children
let parent = this;
while (parent) {
this.constructor._addDescriptionElementIdsToField(
child,
parent._getAriaDescriptionElements(),
);
// Also check if the newly added child needs to refer grandparents
parent = parent.__parentFormGroup;
}
}
/**
* @override of FormRegistrarMixin.
* @desc Connects ValidateMixin and DisabledMixin
* On top of this, error messages of children are linked to their parents
*/
addFormElement(child, indexToInsertAt) {
super.addFormElement(child, indexToInsertAt);
if (this.disabled) {
// eslint-disable-next-line no-param-reassign
child.makeRequestToBeDisabled();
}
// TODO: Unlink in removeFormElement
this.__linkChildrenMessagesToParent(child);
this.validate();
}
/**
* Gathers initial model values of all children. Used
* when resetGroup() is called.
*/
get _initialModelValue() {
return this._getFromAllFormElements('_initialModelValue');
}
/**
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
* parent(fieldset)
* @param {LionField} field - the child: lion-field/lion-input/lion-textarea
* @param {array} descriptionElements - description elements like feedback and help-text
*/
static _addDescriptionElementIdsToField(field, descriptionElements) {
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
orderedEls.forEach(el => {
if (field.addToAriaDescribedBy) {
field.addToAriaDescribedBy(el, { reorder: false });
}
});
}
/**
* @override of FormRegistrarMixin. Connects ValidateMixin
*/
removeFormElement(...args) {
super.removeFormElement(...args);
this.validate();
}
},
);