370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
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<import('@lion/core').LitElement>} 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);
|