import { dedupeMixin } from '@lion/core'; import { FormRegistrarMixin } from '../registration/FormRegistrarMixin.js'; import { InteractionStateMixin } from '../InteractionStateMixin.js'; /** * @typedef {import('../../types/choice-group/ChoiceGroupMixinTypes').ChoiceGroupMixin} ChoiceGroupMixin * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost * @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost */ /** * ChoiceGroupMixin applies on both Fields (listbox/select-rich/combobox) and FormGroups * (radio-group, checkbox-group) * TODO: Ideally, the ChoiceGroupMixin should not depend on InteractionStateMixin, which is only * designed for usage with Fields, in other words: their interaction states are not derived from * children events, like in FormGroups * * @type {ChoiceGroupMixin} * @param {import('@open-wc/dedupe-mixin').Constructor} superclass */ const ChoiceGroupMixinImplementation = superclass => class ChoiceGroupMixin extends FormRegistrarMixin(InteractionStateMixin(superclass)) { /** @type {any} */ 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. */ multipleChoice: { type: Boolean, attribute: 'multiple-choice', }, }; } get modelValue() { const elems = this._getCheckedElements(); if (this.multipleChoice) { return elems.map(el => el.choiceValue); } return elems[0] ? elems[0].choiceValue : ''; } set modelValue(value) { /** * @param {ChoiceInputHost} el * @param {any} val */ const checkCondition = (el, val) => { if (typeof el.choiceValue === 'object') { return JSON.stringify(el.choiceValue) === JSON.stringify(value); } return el.choiceValue === val; }; if (this.__isInitialModelValue) { this.registrationComplete.then(() => { this.__isInitialModelValue = false; this._setCheckedElements(value, checkCondition); this.requestUpdate('modelValue', this._oldModelValue); }); } else { this._setCheckedElements(value, checkCondition); this.requestUpdate('modelValue', this._oldModelValue); } this._oldModelValue = this.modelValue; } 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 elems.map(el => el.serializedValue.value); } return elems[0] ? elems[0].serializedValue.value : ''; } set serializedValue(value) { /** * @param {ChoiceInputHost} el * @param {string} val */ const checkCondition = (el, val) => el.serializedValue.value === val; if (this.__isInitialSerializedValue) { this.registrationComplete.then(() => { this.__isInitialSerializedValue = false; this._setCheckedElements(value, checkCondition); this.requestUpdate('serializedValue'); }); } else { this._setCheckedElements(value, checkCondition); this.requestUpdate('serializedValue'); } } get formattedValue() { const elems = this._getCheckedElements(); if (this.multipleChoice) { return elems.map(el => el.formattedValue); } return elems[0] ? elems[0].formattedValue : ''; } set formattedValue(value) { /** * @param {{ formattedValue: string }} el * @param {string} val */ const checkCondition = (el, val) => el.formattedValue === val; if (this.__isInitialFormattedValue) { this.registrationComplete.then(() => { this.__isInitialFormattedValue = false; this._setCheckedElements(value, checkCondition); }); } else { this._setCheckedElements(value, checkCondition); } } constructor() { super(); this.multipleChoice = false; /** @type {'child'|'choice-group'|'fieldset'} * @protected */ this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin /** @private */ this.__isInitialModelValue = true; /** @private */ this.__isInitialSerializedValue = true; /** @private */ this.__isInitialFormattedValue = true; } connectedCallback() { super.connectedCallback(); this.registrationComplete.then(() => { this.__isInitialModelValue = false; this.__isInitialSerializedValue = false; this.__isInitialFormattedValue = false; }); } /** * @enhance FormRegistrarMixin */ _completeRegistration() { // Double microtask queue to account for Webkit race condition Promise.resolve().then(() => super._completeRegistration()); } /** @param {import('@lion/core').PropertyValues} changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('name') && this.name !== changedProperties.get('name')) { this.formElements.forEach(child => { // eslint-disable-next-line no-param-reassign child.name = this.name; }); } } /** * @override from FormRegistrarMixin * @param {FormControl} child * @param {number} indexToInsertAt */ addFormElement(child, indexToInsertAt) { this._throwWhenInvalidChildModelValue(child); // eslint-disable-next-line no-param-reassign child.name = this.name; super.addFormElement(child, indexToInsertAt); } clear() { if (this.multipleChoice) { this.modelValue = []; } else { this.modelValue = ''; } } /** * @override from FormControlMixin * @protected */ _triggerInitialModelValueChangedEvent() { this.registrationComplete.then(() => { this._dispatchInitialModelValueChangedEvent(); }); } /** * @override * @param {string} property * @protected */ _getFromAllFormElements(property, filterCondition = () => true) { // For modelValue, serializedValue and formattedValue, an exception should be made, // The reset can be requested from children if ( property === 'modelValue' || property === 'serializedValue' || property === 'formattedValue' ) { return this[property]; } return this.formElements.filter(filterCondition).map(el => el.property); } /** * @param {FormControl} child * @protected */ _throwWhenInvalidChildModelValue(child) { if ( typeof child.modelValue.checked !== 'boolean' || !Object.prototype.hasOwnProperty.call(child.modelValue, 'value') ) { throw new Error( `The ${this.tagName.toLowerCase()} name="${ this.name }" does not allow to register ${child.tagName.toLowerCase()} with .modelValue="${ child.modelValue }" - The modelValue should represent an Object { value: "foo", checked: false }`, ); } } /** * @protected */ _isEmpty() { if (this.multipleChoice) { return this.modelValue.length === 0; } if (typeof this.modelValue === 'string' && this.modelValue === '') { return true; } if (this.modelValue === undefined || this.modelValue === null) { return true; } return false; } /** * @param {CustomEvent & {target:FormControl}} ev * @protected */ _checkSingleChoiceElements(ev) { const { target } = ev; if (target.checked === false) return; const groupName = target.name; this.formElements .filter(i => i.name === groupName) .forEach(choice => { if (choice !== target) { choice.checked = false; // eslint-disable-line no-param-reassign } }); // this.__triggerCheckedValueChanged(); } /** * @protected */ _getCheckedElements() { // We want to filter out disabled values by default return this.formElements.filter(el => el.checked && !el.disabled); } /** * @param {string | any[]} value * @param {Function} check * @protected */ _setCheckedElements(value, check) { if (value === null || value === undefined) { // Uncheck all // eslint-disable-next-line no-return-assign, no-param-reassign this.formElements.forEach(fe => (fe.checked = false)); return; } for (let i = 0; i < this.formElements.length; i += 1) { if (this.multipleChoice) { let valueIsIncluded = value.includes(this.formElements[i].modelValue.value); // For complex values, do a JSON Stringified includes check, because [{ v: 'foo'}].includes({ v: 'foo' }) => false if (typeof this.formElements[i].modelValue.value === 'object') { valueIsIncluded = /** @type {any[]} */ (value) .map(/** @param {Object} v */ v => JSON.stringify(v)) .includes(JSON.stringify(this.formElements[i].modelValue.value)); } this.formElements[i].checked = valueIsIncluded; } else if (check(this.formElements[i], value)) { // Allows checking against custom values e.g. formattedValue or serializedValue this.formElements[i].checked = true; } else { this.formElements[i].checked = false; } } } /** * @private */ __setChoiceGroupTouched() { const value = this.modelValue; if (value != null && value !== this.__previousCheckedValue) { // TODO: what happens here exactly? Needs to be based on user interaction (?) this.touched = true; this.__previousCheckedValue = value; } } /** * @override FormControlMixin * @param {CustomEvent} ev * @protected */ _onBeforeRepropagateChildrenValues(ev) { // Normalize target, since we might receive 'portal events' (from children in a modal, // see select-rich) const target = (ev.detail && ev.detail.element) || ev.target; if (this.multipleChoice || !target.checked) { return; } this.formElements.forEach(option => { if (target.choiceValue !== option.choiceValue) { option.checked = false; // eslint-disable-line no-param-reassign } }); this.__setChoiceGroupTouched(); this.requestUpdate('modelValue', this._oldModelValue); this._oldModelValue = this.modelValue; } /** * Don't repropagate unchecked single choice choiceInputs * @param {FormControlHost & ChoiceInputHost} target * @protected * @overridable */ _repropagationCondition(target) { return !( this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked ); } }; export const ChoiceGroupMixin = dedupeMixin(ChoiceGroupMixinImplementation);