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`
`; } /** * @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(); } }, );