/* eslint-disable class-methods-use-this */ import { css, html, nothing, dedupeMixin } from '@lion/core'; import { FormatMixin } from '../FormatMixin.js'; /** * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputMixin} ChoiceInputMixin * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputModelValue} ChoiceInputModelValue */ /** * @param {ChoiceInputModelValue} nw\ * @param {{value?:any, checked?:boolean}} old */ const hasChanged = (nw, old = {}) => nw.value !== old.value || nw.checked !== old.checked; /** * @type {ChoiceInputMixin} * @param {import('@open-wc/dedupe-mixin').Constructor} superclass */ const ChoiceInputMixinImplementation = superclass => class ChoiceInputMixin extends FormatMixin(superclass) { /** @type {any} */ static get properties() { return { /** * Boolean indicating whether or not this element is checked by the end user. */ checked: { type: Boolean, reflect: true, }, /** * Boolean indicating whether or not this element is disabled. */ disabled: { type: Boolean, reflect: true, }, /** * Whereas 'normal' `.modelValue`s usually store a complex/typed version * of a view value, choice inputs have a slightly different approach. * In order to remain their Single Source of Truth characteristic, choice inputs * store both the value and 'checkedness', in the format { value: 'x', checked: true } * Different from the platform, this also allows to serialize the 'non checkedness', * allowing to restore form state easily and inform the server about unchecked options. */ modelValue: { type: Object, hasChanged, }, /** * The value property of the modelValue. It provides an easy interface for storing * (complex) values in the modelValue */ choiceValue: { type: Object, }, }; } get choiceValue() { return this.modelValue.value; } set choiceValue(value) { this.requestUpdate('choiceValue', this.choiceValue); if (this.modelValue.value !== value) { /** @type {ChoiceInputModelValue} */ this.modelValue = { value, checked: this.modelValue.checked }; } } /** * @param {string} name * @param {any} oldValue */ requestUpdateInternal(name, oldValue) { super.requestUpdateInternal(name, oldValue); if (name === 'modelValue') { if (this.modelValue.checked !== this.checked) { this.__syncModelCheckedToChecked(this.modelValue.checked); } } else if (name === 'checked') { if (this.modelValue.checked !== this.checked) { this.__syncCheckedToModel(this.checked); } } } /** * @param {import('@lion/core').PropertyValues } changedProperties */ firstUpdated(changedProperties) { super.firstUpdated(changedProperties); if (changedProperties.has('checked')) { // Here we set the initial value for our [slot=input] content, // which has been set by our SlotMixin this.__syncCheckedToInputElement(); } } /** * @param {import('@lion/core').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('modelValue')) { this.__syncCheckedToInputElement(); } if ( changedProperties.has('name') && this._parentFormGroup && this._parentFormGroup.name !== this.name ) { this._syncNameToParentFormGroup(); } } constructor() { super(); this.modelValue = { value: '', checked: false }; this.disabled = false; /** @protected */ this._preventDuplicateLabelClick = this._preventDuplicateLabelClick.bind(this); /** @protected */ this._toggleChecked = this._toggleChecked.bind(this); } /** * Styles for [input=radio] and [input=checkbox] wrappers. * For [role=option] extensions, please override completely */ static get styles() { return [ ...(super.styles || []), css` :host { display: flex; flex-wrap: wrap; } :host([hidden]) { display: none; } .choice-field__graphic-container { display: none; } .choice-field__help-text { display: block; flex-basis: 100%; } `, ]; } /** * Template for [input=radio] and [input=checkbox] wrappers. * For [role=option] extensions, please override completely */ render() { return html`
${this._choiceGraphicTemplate()}
${this._afterTemplate()} `; } /** * @protected */ _choiceGraphicTemplate() { return nothing; } /** * @protected */ _afterTemplate() { return nothing; } connectedCallback() { super.connectedCallback(); if (this._labelNode) { this._labelNode.addEventListener('click', this._preventDuplicateLabelClick); } this.addEventListener('user-input-changed', this._toggleChecked); } disconnectedCallback() { super.disconnectedCallback(); if (this._labelNode) { this._labelNode.removeEventListener('click', this._preventDuplicateLabelClick); } this.removeEventListener('user-input-changed', this._toggleChecked); } /** * The native platform fires an event for both the click on the label, and also * the redispatched click on the native input element. * This results in two click events arriving at the host, but we only want one. * This method prevents the duplicate click and ensures the correct isTrusted event * with the correct event.target arrives at the host. * @param {Event} ev * @protected */ // eslint-disable-next-line no-unused-vars _preventDuplicateLabelClick(ev) { const __inputClickHandler = /** @param {Event} _ev */ _ev => { _ev.stopImmediatePropagation(); this._inputNode.removeEventListener('click', __inputClickHandler); }; this._inputNode.addEventListener('click', __inputClickHandler); } /** * @param {Event} ev * @protected */ // eslint-disable-next-line no-unused-vars _toggleChecked(ev) { if (this.disabled) { return; } this.__isHandlingUserInput = true; this.checked = !this.checked; this.__isHandlingUserInput = false; } // TODO: make this less fuzzy by applying these methods in LionRadio and LionCheckbox // via instanceof (or feat. detection for tree-shaking in case parentGroup not needed) /** * Override this in case of extending ChoiceInputMixin and requiring * to sync differently with parent form group name * Right now it checks tag name match where the parent form group tagname * should include the child field tagname ('checkbox' is included in 'checkbox-group') * @protected */ _syncNameToParentFormGroup() { // @ts-expect-error [external]: tagName should be a prop of HTMLElement if (this._parentFormGroup.tagName.includes(this.tagName)) { this.name = this._parentFormGroup?.name || ''; } } /** * @param {boolean} checked * @private */ __syncModelCheckedToChecked(checked) { this.checked = checked; } /** * @param {any} checked * @private */ __syncCheckedToModel(checked) { this.modelValue = { value: this.choiceValue, checked }; } /** * @private */ __syncCheckedToInputElement() { // ._inputNode might not be available yet(slot content) // or at all (no reliance on platform construct, in case of [role=option]) if (this._inputNode) { /** @type {HTMLInputElement} */ (this._inputNode).checked = this.checked; } } /** * @override * This method is overridden from FormatMixin. It originally fired the normalizing * 'user-input-changed' event after listening to the native 'input' event. * However on Chrome on Mac whenever you use the keyboard * it fires the input AND change event. Other Browsers only fires the change event. * Therefore we disable the input event here. * @protected */ _proxyInputEvent() {} /** * @override * hasChanged is designed for async (updated) callback, also check for sync * (requestUpdateInternal) callback * @param {{ modelValue:unknown }} newV * @param {{ modelValue:unknown }} [old] * @protected */ _onModelValueChanged({ modelValue }, old) { let _old; if (old && old.modelValue) { _old = old.modelValue; } // @ts-expect-error [external]: lit private property if (this.constructor._classProperties.get('modelValue').hasChanged(modelValue, _old)) { super._onModelValueChanged({ modelValue }); } } /** * @override * Overridden from FormatMixin, since a different modelValue is used for choice inputs. * Sets modelValue based on checked state (instead of value), so that changes will be detected. */ parser() { return this.modelValue; } /** * @override Overridden from FormatMixin, since a different modelValue is used for choice inputs. * @param {ChoiceInputModelValue } modelValue */ formatter(modelValue) { return modelValue && modelValue.value !== undefined ? modelValue.value : modelValue; } /** * @override * Overridden from LionField, since the modelValue should not be cleared. */ clear() { this.checked = false; } /** * Used for required validator. * @protected */ _isEmpty() { return !this.checked; } /** * @override * Overridden from FormatMixin, since a different modelValue is used for choice inputs. * Synchronization from user input is already arranged in this Mixin. * @protected */ _syncValueUpwards() {} }; export const ChoiceInputMixin = dedupeMixin(ChoiceInputMixinImplementation);