From 49e7ae34f022668d2eebb9a98389de5a5a256f4d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 22 Dec 2020 14:36:09 -0500 Subject: [PATCH 1/3] feat: add first version of checkbox inderminate --- packages/checkbox-group/README.md | 15 +++ packages/checkbox-group/index.js | 1 + .../lion-checkbox-indeterminate.js | 3 + packages/checkbox-group/package.json | 3 +- .../src/LionCheckboxIndeterminate.js | 91 +++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 packages/checkbox-group/lion-checkbox-indeterminate.js create mode 100644 packages/checkbox-group/src/LionCheckboxIndeterminate.js diff --git a/packages/checkbox-group/README.md b/packages/checkbox-group/README.md index 5584c8c29..0cd5d842b 100644 --- a/packages/checkbox-group/README.md +++ b/packages/checkbox-group/README.md @@ -11,6 +11,7 @@ Its purpose is to provide a way for users to check **multiple** options amongst import { html } from '@lion/core'; import { Required, Validator } from '@lion/form-core'; import './lion-checkbox-group.js'; +import './lion-checkbox-indeterminate.js'; import './lion-checkbox.js'; export default { @@ -166,3 +167,17 @@ export const event = () => html` Selected scientists: N/A `; ``` + +### Indeterminate + +```js preview-story +export const indeterminate = () => html` + + + + + + + +`; +``` diff --git a/packages/checkbox-group/index.js b/packages/checkbox-group/index.js index 6f20e3181..c07ad86a5 100644 --- a/packages/checkbox-group/index.js +++ b/packages/checkbox-group/index.js @@ -1,2 +1,3 @@ export { LionCheckboxGroup } from './src/LionCheckboxGroup.js'; +export { LionCheckboxIndeterminate } from './src/LionCheckboxIndeterminate.js'; export { LionCheckbox } from './src/LionCheckbox.js'; diff --git a/packages/checkbox-group/lion-checkbox-indeterminate.js b/packages/checkbox-group/lion-checkbox-indeterminate.js new file mode 100644 index 000000000..37b65d36b --- /dev/null +++ b/packages/checkbox-group/lion-checkbox-indeterminate.js @@ -0,0 +1,3 @@ +import { LionCheckboxIndeterminate } from './src/LionCheckboxIndeterminate.js'; + +customElements.define('lion-checkbox-indeterminate', LionCheckboxIndeterminate); diff --git a/packages/checkbox-group/package.json b/packages/checkbox-group/package.json index 044b29017..8e6bfa78d 100644 --- a/packages/checkbox-group/package.json +++ b/packages/checkbox-group/package.json @@ -31,7 +31,8 @@ }, "sideEffects": [ "lion-checkbox.js", - "lion-checkbox-group.js" + "lion-checkbox-group.js", + "lion-checkbox-indeterminate.js" ], "dependencies": { "@lion/core": "0.13.8", diff --git a/packages/checkbox-group/src/LionCheckboxIndeterminate.js b/packages/checkbox-group/src/LionCheckboxIndeterminate.js new file mode 100644 index 000000000..a164ec8c4 --- /dev/null +++ b/packages/checkbox-group/src/LionCheckboxIndeterminate.js @@ -0,0 +1,91 @@ +import { LionCheckbox } from './LionCheckbox.js'; + +export class LionCheckboxIndeterminate extends LionCheckbox { + static get properties() { + return { + /** + * Indeterminate state of the checkbox + */ + indeterminate: { + type: Boolean, + reflect: true, + }, + }; + } + + get _checkboxGroupNode() { + return /** @type {import('./LionCheckboxGroup').LionCheckboxGroup} */ (this.parentElement); + } + + get _subCheckboxes() { + return this._checkboxGroupNode.formElements.filter(checkbox => checkbox !== this); + } + + _parentModelValueChanged() { + const checkedElements = this._subCheckboxes.filter(checkbox => checkbox.checked); + switch (this._subCheckboxes.length - checkedElements.length) { + // all checked + case 0: + this.indeterminate = false; + this.checked = true; + break; + // none checked + case this._subCheckboxes.length: + this.indeterminate = false; + this.checked = false; + break; + default: + this.indeterminate = true; + } + } + + _ownModelValueChanged(ev) { + if (ev.target === this) { + this._subCheckboxes.forEach(checkbox => { + // eslint-disable-next-line no-param-reassign + checkbox.checked = this.checked; + }); + } + } + + constructor() { + super(); + this.indeterminate = false; + } + + connectedCallback() { + super.connectedCallback(); + this._checkboxGroupNode.addEventListener( + 'model-value-changed', + this._parentModelValueChanged.bind(this), + ); + this.addEventListener('model-value-changed', this._ownModelValueChanged); + } + + /** @param {import('lit-element').PropertyValues } changedProperties */ + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('indeterminate')) { + this._inputNode.indeterminate = this.indeterminate; + } + } + + /** + * @override + * clicking on indeterminate status will set the status as checked + */ + __toggleChecked() { + if (this.disabled) { + return; + } + + // always turn off indeterminate + // and set checked to true + if (this.indeterminate) { + this.indeterminate = false; + this.checked = true; + } else { + this.checked = !this.checked; + } + } +} From f98aab23f1ee20ff2434e606ca8550dc776d7023 Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Wed, 20 Jan 2021 12:11:34 +0100 Subject: [PATCH 2/3] feat(form-core): make __parentFormGroup and __toggleChecked protected --- .changeset/lazy-chicken-speak.md | 6 ++++++ .changeset/witty-mugs-vanish.md | 6 ++++++ .../src/choice-group/ChoiceGroupMixin.js | 2 +- .../src/choice-group/ChoiceInputMixin.js | 16 +++++++++------- .../form-core/src/form-group/FormGroupMixin.js | 6 +++--- .../src/registration/FormRegisteringMixin.js | 6 +++--- .../src/registration/FormRegistrarMixin.js | 4 ++-- packages/form-core/test/lion-field.test.js | 2 +- .../choice-group/ChoiceInputMixinTypes.d.ts | 2 +- .../registration/FormRegisteringMixinTypes.d.ts | 2 +- .../registration/FormRegistrarMixinTypes.d.ts | 2 +- packages/listbox/src/LionOption.js | 2 +- packages/listbox/types/LionOption.d.ts | 2 +- packages/switch/src/LionSwitch.js | 4 ++-- packages/switch/src/LionSwitchButton.js | 10 +++++----- 15 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 .changeset/lazy-chicken-speak.md create mode 100644 .changeset/witty-mugs-vanish.md diff --git a/.changeset/lazy-chicken-speak.md b/.changeset/lazy-chicken-speak.md new file mode 100644 index 000000000..937297113 --- /dev/null +++ b/.changeset/lazy-chicken-speak.md @@ -0,0 +1,6 @@ +--- +'@lion/form-core': patch +'@lion/listbox': patch +--- + +Make \_\_parentFormGroup --> \_parentFormGroup so it is protected and not private diff --git a/.changeset/witty-mugs-vanish.md b/.changeset/witty-mugs-vanish.md new file mode 100644 index 000000000..3f6cd4aa2 --- /dev/null +++ b/.changeset/witty-mugs-vanish.md @@ -0,0 +1,6 @@ +--- +'@lion/form-core': patch +'@lion/switch': patch +--- + +Make \_\_toggleChecked protected property (\_toggleChecked) diff --git a/packages/form-core/src/choice-group/ChoiceGroupMixin.js b/packages/form-core/src/choice-group/ChoiceGroupMixin.js index b8c02ad02..98a6d1702 100644 --- a/packages/form-core/src/choice-group/ChoiceGroupMixin.js +++ b/packages/form-core/src/choice-group/ChoiceGroupMixin.js @@ -6,7 +6,7 @@ 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 {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost */ diff --git a/packages/form-core/src/choice-group/ChoiceInputMixin.js b/packages/form-core/src/choice-group/ChoiceInputMixin.js index e737fcddb..a3709c75e 100644 --- a/packages/form-core/src/choice-group/ChoiceInputMixin.js +++ b/packages/form-core/src/choice-group/ChoiceInputMixin.js @@ -5,7 +5,7 @@ import { FormatMixin } from '../FormatMixin.js'; /** * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost - * @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl + * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputMixin} ChoiceInputMixin * @typedef {import('../../types/choice-group/ChoiceInputMixinTypes').ChoiceInputModelValue} ChoiceInputModelValue */ @@ -115,9 +115,9 @@ const ChoiceInputMixinImplementation = superclass => if ( changedProperties.has('name') && // @ts-expect-error not all choice inputs have a parent form group, since this mixin does not have a strict contract with the registration system - this.__parentFormGroup && + this._parentFormGroup && // @ts-expect-error - this.__parentFormGroup.name !== this.name + this._parentFormGroup.name !== this.name ) { // @ts-expect-error not all choice inputs have a name prop, because this mixin does not have a strict contract with form control mixin this.name = changedProperties.get('name'); @@ -129,7 +129,7 @@ const ChoiceInputMixinImplementation = superclass => this.modelValue = { value: '', checked: false }; this.disabled = false; this._preventDuplicateLabelClick = this._preventDuplicateLabelClick.bind(this); - this.__toggleChecked = this.__toggleChecked.bind(this); + this._toggleChecked = this._toggleChecked.bind(this); } /** @@ -193,7 +193,7 @@ const ChoiceInputMixinImplementation = superclass => if (this._labelNode) { this._labelNode.addEventListener('click', this._preventDuplicateLabelClick); } - this.addEventListener('user-input-changed', this.__toggleChecked); + this.addEventListener('user-input-changed', this._toggleChecked); } disconnectedCallback() { @@ -201,7 +201,7 @@ const ChoiceInputMixinImplementation = superclass => if (this._labelNode) { this._labelNode.removeEventListener('click', this._preventDuplicateLabelClick); } - this.removeEventListener('user-input-changed', this.__toggleChecked); + this.removeEventListener('user-input-changed', this._toggleChecked); } /** @@ -221,7 +221,9 @@ const ChoiceInputMixinImplementation = superclass => this._inputNode.addEventListener('click', __inputClickHandler); } - __toggleChecked() { + /** @param {Event} ev */ + // eslint-disable-next-line no-unused-vars + _toggleChecked(ev) { if (this.disabled) { return; } diff --git a/packages/form-core/src/form-group/FormGroupMixin.js b/packages/form-core/src/form-group/FormGroupMixin.js index a6005b444..84b62b661 100644 --- a/packages/form-core/src/form-group/FormGroupMixin.js +++ b/packages/form-core/src/form-group/FormGroupMixin.js @@ -12,7 +12,7 @@ import { FormElementsHaveNoError } from './FormElementsHaveNoError.js'; * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost * @typedef {import('../../types/registration/FormRegisteringMixinTypes').FormRegisteringHost} FormRegisteringHost * @typedef {import('../../types/registration/FormRegistrarMixinTypes').ElementWithParentFormGroup} ElementWithParentFormGroup - * @typedef {FormControlHost & HTMLElement & {__parentFormGroup?: HTMLElement, checked?: boolean, disabled: boolean, hasFeedbackFor: string[], makeRequestToBeDisabled: Function }} FormControl + * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?: HTMLElement, checked?: boolean, disabled: boolean, hasFeedbackFor: string[], makeRequestToBeDisabled: Function }} FormControl */ /** @@ -449,12 +449,12 @@ const FormGroupMixinImplementation = superclass => __linkChildrenMessagesToParent(child) { // aria-describedby of (nested) children const unTypedThis = /** @type {unknown} */ (this); - let parent = /** @type {FormControlHost & { __parentFormGroup:any }} */ (unTypedThis); + let parent = /** @type {FormControlHost & { _parentFormGroup:any }} */ (unTypedThis); const ctor = /** @type {typeof FormGroupMixin} */ (this.constructor); while (parent) { ctor._addDescriptionElementIdsToField(child, parent._getAriaDescriptionElements()); // Also check if the newly added child needs to refer grandparents - parent = parent.__parentFormGroup; + parent = parent._parentFormGroup; } } diff --git a/packages/form-core/src/registration/FormRegisteringMixin.js b/packages/form-core/src/registration/FormRegisteringMixin.js index 6060275b2..61d858034 100644 --- a/packages/form-core/src/registration/FormRegisteringMixin.js +++ b/packages/form-core/src/registration/FormRegisteringMixin.js @@ -19,7 +19,7 @@ const FormRegisteringMixinImplementation = superclass => constructor() { super(); /** @type {FormRegistrarHost | undefined} */ - this.__parentFormGroup = undefined; + this._parentFormGroup = undefined; } connectedCallback() { @@ -42,8 +42,8 @@ const FormRegisteringMixinImplementation = superclass => // @ts-expect-error check it anyway, because could be lit-element extension super.disconnectedCallback(); } - if (this.__parentFormGroup) { - this.__parentFormGroup.removeFormElement(this); + if (this._parentFormGroup) { + this._parentFormGroup.removeFormElement(this); } } }; diff --git a/packages/form-core/src/registration/FormRegistrarMixin.js b/packages/form-core/src/registration/FormRegistrarMixin.js index 5bef28b20..b61cca825 100644 --- a/packages/form-core/src/registration/FormRegistrarMixin.js +++ b/packages/form-core/src/registration/FormRegistrarMixin.js @@ -11,7 +11,7 @@ import { FormRegisteringMixin } from './FormRegisteringMixin.js'; /** * @typedef {import('../../types/FormControlMixinTypes').FormControlHost} FormControlHost - * @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl + * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl */ /** @@ -77,7 +77,7 @@ const FormRegistrarMixinImplementation = superclass => addFormElement(child, indexToInsertAt) { // This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent // eslint-disable-next-line no-param-reassign - child.__parentFormGroup = this; + child._parentFormGroup = this; // 1. Add children as array element if (indexToInsertAt >= 0) { diff --git a/packages/form-core/test/lion-field.test.js b/packages/form-core/test/lion-field.test.js index e7bbc635a..87ec127c6 100644 --- a/packages/form-core/test/lion-field.test.js +++ b/packages/form-core/test/lion-field.test.js @@ -16,7 +16,7 @@ import '../lion-field.js'; /** * @typedef {import('../src/LionField.js').LionField} LionField * @typedef {import('../types/FormControlMixinTypes').FormControlHost} FormControlHost - * @typedef {FormControlHost & HTMLElement & {__parentFormGroup?:HTMLElement, checked?:boolean}} FormControl + * @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl */ /** @typedef {HTMLElement & {shadowRoot: HTMLElement, assignedNodes: Function}} ShadowHTMLElement */ diff --git a/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts b/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts index 85f1e3e54..473cfd206 100644 --- a/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts +++ b/packages/form-core/types/choice-group/ChoiceInputMixinTypes.d.ts @@ -43,7 +43,7 @@ export declare class ChoiceInputHost { _preventDuplicateLabelClick(ev: Event): void; - __toggleChecked(): void; + _toggleChecked(ev: Event): void; __syncModelCheckedToChecked(checked: boolean): void; diff --git a/packages/form-core/types/registration/FormRegisteringMixinTypes.d.ts b/packages/form-core/types/registration/FormRegisteringMixinTypes.d.ts index 3f418755b..8a0804d5e 100644 --- a/packages/form-core/types/registration/FormRegisteringMixinTypes.d.ts +++ b/packages/form-core/types/registration/FormRegisteringMixinTypes.d.ts @@ -6,7 +6,7 @@ export declare class FormRegisteringHost { constructor(...args: any[]); connectedCallback(): void; disconnectedCallback(): void; - __parentFormGroup?: FormRegistrarHost; + _parentFormGroup?: FormRegistrarHost; } export declare function FormRegisteringImplementation>( diff --git a/packages/form-core/types/registration/FormRegistrarMixinTypes.d.ts b/packages/form-core/types/registration/FormRegistrarMixinTypes.d.ts index f6a8a230d..823d17c4e 100644 --- a/packages/form-core/types/registration/FormRegistrarMixinTypes.d.ts +++ b/packages/form-core/types/registration/FormRegistrarMixinTypes.d.ts @@ -5,7 +5,7 @@ import { FormControlHost } from '../../types/FormControlMixinTypes'; import { LitElement } from '@lion/core'; export declare class ElementWithParentFormGroup { - __parentFormGroup: FormRegistrarHost; + _parentFormGroup: FormRegistrarHost; } export declare class FormRegistrarHost { diff --git a/packages/listbox/src/LionOption.js b/packages/listbox/src/LionOption.js index 4ee22ffdb..873db1415 100644 --- a/packages/listbox/src/LionOption.js +++ b/packages/listbox/src/LionOption.js @@ -121,7 +121,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi if (this.disabled) { return; } - const parentForm = /** @type {unknown} */ (this.__parentFormGroup); + const parentForm = /** @type {unknown} */ (this._parentFormGroup); this.__isHandlingUserInput = true; if (parentForm && /** @type {ChoiceGroupHost} */ (parentForm).multipleChoice) { this.checked = !this.checked; diff --git a/packages/listbox/types/LionOption.d.ts b/packages/listbox/types/LionOption.d.ts index b36816f2e..22c454d67 100644 --- a/packages/listbox/types/LionOption.d.ts +++ b/packages/listbox/types/LionOption.d.ts @@ -2,5 +2,5 @@ import { ChoiceGroupHost } from '@lion/form-core/types/choice-group/ChoiceGroupM export declare class LionOptionHost { constructor(...args: any[]); - private __parentFormGroup: ChoiceGroupHost; + protected _parentFormGroup: ChoiceGroupHost; } diff --git a/packages/switch/src/LionSwitch.js b/packages/switch/src/LionSwitch.js index ce44ce644..89bcddfdf 100644 --- a/packages/switch/src/LionSwitch.js +++ b/packages/switch/src/LionSwitch.js @@ -80,7 +80,7 @@ export class LionSwitch extends ScopedElementsMixin(ChoiceInputMixin(LionField)) this._inputNode.addEventListener('checked-changed', this.__handleButtonSwitchCheckedChanged); } if (this._labelNode) { - this._labelNode.addEventListener('click', this.__toggleChecked); + this._labelNode.addEventListener('click', this._toggleChecked); } this._syncButtonSwitch(); this.submitted = true; @@ -94,7 +94,7 @@ export class LionSwitch extends ScopedElementsMixin(ChoiceInputMixin(LionField)) ); } if (this._labelNode) { - this._labelNode.removeEventListener('click', this.__toggleChecked); + this._labelNode.removeEventListener('click', this._toggleChecked); } } diff --git a/packages/switch/src/LionSwitchButton.js b/packages/switch/src/LionSwitchButton.js index ab74c1d65..d6ddacd1d 100644 --- a/packages/switch/src/LionSwitchButton.js +++ b/packages/switch/src/LionSwitchButton.js @@ -77,7 +77,7 @@ export class LionSwitchButton extends DisabledWithTabIndexMixin(LitElement) { this.role = 'switch'; this.checked = false; - this.__toggleChecked = this.__toggleChecked.bind(this); + this._toggleChecked = this._toggleChecked.bind(this); this.__handleKeydown = this.__handleKeydown.bind(this); this.__handleKeyup = this.__handleKeyup.bind(this); } @@ -85,19 +85,19 @@ export class LionSwitchButton extends DisabledWithTabIndexMixin(LitElement) { connectedCallback() { super.connectedCallback(); this.setAttribute('aria-checked', `${this.checked}`); - this.addEventListener('click', this.__toggleChecked); + this.addEventListener('click', this._toggleChecked); this.addEventListener('keydown', this.__handleKeydown); this.addEventListener('keyup', this.__handleKeyup); } disconnectedCallback() { super.disconnectedCallback(); - this.removeEventListener('click', this.__toggleChecked); + this.removeEventListener('click', this._toggleChecked); this.removeEventListener('keydown', this.__handleKeydown); this.removeEventListener('keyup', this.__handleKeyup); } - __toggleChecked() { + _toggleChecked() { if (this.disabled) { return; } @@ -132,7 +132,7 @@ export class LionSwitchButton extends DisabledWithTabIndexMixin(LitElement) { */ __handleKeyup(e) { if ([32 /* space */, 13 /* enter */].indexOf(e.keyCode) !== -1) { - this.__toggleChecked(); + this._toggleChecked(); } } From 8d2b2513844dfcac3945763b5de7d7b2a8fce6da Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Wed, 20 Jan 2021 12:13:16 +0100 Subject: [PATCH 3/3] feat(checkbox-group): add checkbox-indeterminate component --- .changeset/five-onions-hope.md | 5 + packages/checkbox-group/README.md | 72 +++- .../src/LionCheckboxIndeterminate.js | 116 ++++-- ...heckbox-indeterminate-integrations.test.js | 4 + .../test/lion-checkbox-indeterminate.test.js | 370 ++++++++++++++++++ 5 files changed, 530 insertions(+), 37 deletions(-) create mode 100644 .changeset/five-onions-hope.md create mode 100644 packages/checkbox-group/test/lion-checkbox-indeterminate-integrations.test.js create mode 100644 packages/checkbox-group/test/lion-checkbox-indeterminate.test.js diff --git a/.changeset/five-onions-hope.md b/.changeset/five-onions-hope.md new file mode 100644 index 000000000..89609021b --- /dev/null +++ b/.changeset/five-onions-hope.md @@ -0,0 +1,5 @@ +--- +'@lion/checkbox-group': minor +--- + +Add checkbox-indeterminate component, which is a mixed state checkbox that depends on its child checkbox states. diff --git a/packages/checkbox-group/README.md b/packages/checkbox-group/README.md index 0cd5d842b..781223745 100644 --- a/packages/checkbox-group/README.md +++ b/packages/checkbox-group/README.md @@ -173,11 +173,75 @@ export const event = () => html` ```js preview-story export const indeterminate = () => html` - + + + + + + +`; +``` + +```js preview-story +export const indeterminateSiblings = () => html` + + + + + + + + + + + +`; +``` + +```js preview-story +export const indeterminateChildren = () => html` + + + + + + + + + - - - `; ``` diff --git a/packages/checkbox-group/src/LionCheckboxIndeterminate.js b/packages/checkbox-group/src/LionCheckboxIndeterminate.js index a164ec8c4..e2ef1bf69 100644 --- a/packages/checkbox-group/src/LionCheckboxIndeterminate.js +++ b/packages/checkbox-group/src/LionCheckboxIndeterminate.js @@ -1,6 +1,28 @@ +import { html, css } from '@lion/core'; import { LionCheckbox } from './LionCheckbox.js'; +/** + * @typedef {import('./LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup + */ + +// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. export class LionCheckboxIndeterminate extends LionCheckbox { + static get styles() { + const superCtor = /** @type {typeof LionCheckbox} */ (super.prototype.constructor); + return [ + superCtor.styles ? superCtor.styles : [], + css` + :host .choice-field__nested-checkboxes { + display: block; + } + + ::slotted([slot='checkbox']) { + padding-left: 8px; + } + `, + ]; + } + static get properties() { return { /** @@ -14,52 +36,99 @@ export class LionCheckboxIndeterminate extends LionCheckbox { } get _checkboxGroupNode() { - return /** @type {import('./LionCheckboxGroup').LionCheckboxGroup} */ (this.parentElement); + return /** @type LionCheckboxGroup */ (this._parentFormGroup); } get _subCheckboxes() { - return this._checkboxGroupNode.formElements.filter(checkbox => checkbox !== this); + let checkboxes = []; + if ( + this._checkboxGroupNode && + this._checkboxGroupNode.formElements && + this._checkboxGroupNode.formElements.length > 0 + ) { + checkboxes = this._checkboxGroupNode.formElements.filter( + checkbox => checkbox !== this && this.contains(checkbox), + ); + } + return /** @type LionCheckbox[] */ (checkboxes); } - _parentModelValueChanged() { - const checkedElements = this._subCheckboxes.filter(checkbox => checkbox.checked); - switch (this._subCheckboxes.length - checkedElements.length) { + _setOwnCheckedState() { + const subCheckboxes = this._subCheckboxes; + if (!subCheckboxes.length) { + return; + } + + this.__settingOwnChecked = true; + const checkedElements = subCheckboxes.filter(checkbox => checkbox.checked); + switch (subCheckboxes.length - checkedElements.length) { // all checked case 0: this.indeterminate = false; this.checked = true; break; // none checked - case this._subCheckboxes.length: + case subCheckboxes.length: this.indeterminate = false; this.checked = false; break; default: this.indeterminate = true; + this.checked = false; } + this.updateComplete.then(() => { + this.__settingOwnChecked = false; + }); } - _ownModelValueChanged(ev) { - if (ev.target === this) { + /** + * @param {Event} ev + */ + __onModelValueChanged(ev) { + if (this.disabled) { + return; + } + + const _ev = /** @type {CustomEvent} */ (ev); + if (_ev.detail.formPath[0] === this && !this.__settingOwnChecked) { this._subCheckboxes.forEach(checkbox => { // eslint-disable-next-line no-param-reassign - checkbox.checked = this.checked; + checkbox.checked = this._inputNode.checked; }); } + this._setOwnCheckedState(); + } + + // eslint-disable-next-line class-methods-use-this + _afterTemplate() { + return html` +
+ +
+ `; + } + + _onRequestToAddFormElement() { + this._setOwnCheckedState(); } constructor() { super(); this.indeterminate = false; + this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); + this.__onModelValueChanged = this.__onModelValueChanged.bind(this); } connectedCallback() { super.connectedCallback(); - this._checkboxGroupNode.addEventListener( - 'model-value-changed', - this._parentModelValueChanged.bind(this), - ); - this.addEventListener('model-value-changed', this._ownModelValueChanged); + this.addEventListener('model-value-changed', this.__onModelValueChanged); + this.addEventListener('form-element-register', this._onRequestToAddFormElement); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('model-value-changed', this.__onModelValueChanged); + this.removeEventListener('form-element-register', this._onRequestToAddFormElement); } /** @param {import('lit-element').PropertyValues } changedProperties */ @@ -69,23 +138,4 @@ export class LionCheckboxIndeterminate extends LionCheckbox { this._inputNode.indeterminate = this.indeterminate; } } - - /** - * @override - * clicking on indeterminate status will set the status as checked - */ - __toggleChecked() { - if (this.disabled) { - return; - } - - // always turn off indeterminate - // and set checked to true - if (this.indeterminate) { - this.indeterminate = false; - this.checked = true; - } else { - this.checked = !this.checked; - } - } } diff --git a/packages/checkbox-group/test/lion-checkbox-indeterminate-integrations.test.js b/packages/checkbox-group/test/lion-checkbox-indeterminate-integrations.test.js new file mode 100644 index 000000000..8e3bb2f22 --- /dev/null +++ b/packages/checkbox-group/test/lion-checkbox-indeterminate-integrations.test.js @@ -0,0 +1,4 @@ +import '../lion-checkbox-indeterminate.js'; +import { runChoiceInputMixinSuite } from '@lion/form-core/test-suites/choice-group/ChoiceInputMixin.suite.js'; + +runChoiceInputMixinSuite({ tagString: 'lion-checkbox-indeterminate' }); diff --git a/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js b/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js new file mode 100644 index 000000000..f96123dc7 --- /dev/null +++ b/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js @@ -0,0 +1,370 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import '../lion-checkbox-indeterminate.js'; +import '../lion-checkbox-group.js'; +import '../lion-checkbox.js'; + +/** + * @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate + * @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup + */ + +describe('', () => { + it('should have type = checkbox', async () => { + // Arrange + const el = await fixture(html` + + `); + + // Assert + expect(el.getAttribute('type')).to.equal('checkbox'); + }); + + it('should not be indeterminate by default if all children are unchecked', async () => { + // Arrange + const el = await fixture(html` + + + + + + + + `); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + }); + + it('should be indeterminate if one child is checked', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ await fixture(html` + + + + + + + + `); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.true; + }); + + it('should be checked if all children are checked', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?.checked).to.be.true; + }); + + it('should become indeterminate if one child is checked', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._subCheckboxes[0].checked = true; + await el.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.true; + }); + + it('should become checked if all children are checked', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._subCheckboxes[0].checked = true; + elIndeterminate._subCheckboxes[1].checked = true; + elIndeterminate._subCheckboxes[2].checked = true; + await el.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?.checked).to.be.true; + }); + + it('should sync all children when parent is checked (from indeterminate to checked)', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?._subCheckboxes[0].hasAttribute('checked')).to.be.true; + expect(elIndeterminate?._subCheckboxes[1].hasAttribute('checked')).to.be.true; + expect(elIndeterminate?._subCheckboxes[2].hasAttribute('checked')).to.be.true; + }); + + it('should sync all children when parent is checked (from unchecked to checked)', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?._subCheckboxes[0].hasAttribute('checked')).to.be.true; + expect(elIndeterminate?._subCheckboxes[1].hasAttribute('checked')).to.be.true; + expect(elIndeterminate?._subCheckboxes[2].hasAttribute('checked')).to.be.true; + }); + + it('should sync all children when parent is checked (from checked to unchecked)', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?._subCheckboxes[0].hasAttribute('checked')).to.be.false; + expect(elIndeterminate?._subCheckboxes[1].hasAttribute('checked')).to.be.false; + expect(elIndeterminate?._subCheckboxes[2].hasAttribute('checked')).to.be.false; + }); + + it('should work as expected with siblings checkbox-indeterminate', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + + + + + `)); + const elFirstIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + '#first-checkbox-indeterminate', + )); + const elSecondIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + '#second-checkbox-indeterminate', + )); + + // Act - check the first sibling + elFirstIndeterminate._inputNode.click(); + await elFirstIndeterminate.updateComplete; + await elSecondIndeterminate.updateComplete; + + // Assert - the second sibling should not be affected + expect(elFirstIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elFirstIndeterminate?._subCheckboxes[0].hasAttribute('checked')).to.be.true; + expect(elFirstIndeterminate?._subCheckboxes[1].hasAttribute('checked')).to.be.true; + expect(elFirstIndeterminate?._subCheckboxes[2].hasAttribute('checked')).to.be.true; + expect(elSecondIndeterminate?._subCheckboxes[0].hasAttribute('checked')).to.be.false; + expect(elSecondIndeterminate?._subCheckboxes[1].hasAttribute('checked')).to.be.false; + }); + + it('should work as expected with nested indeterminate checkboxes', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + + + + + + + + + + + + `)); + const elNestedIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + '#nested-checkbox-indeterminate', + )); + const elParentIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + '#parent-checkbox-indeterminate', + )); + + // Act - check a nested checkbox + elNestedIndeterminate?._subCheckboxes[0]._inputNode.click(); + await el.updateComplete; + + // Assert + expect(elNestedIndeterminate?.hasAttribute('indeterminate')).to.be.true; + expect(elParentIndeterminate?.hasAttribute('indeterminate')).to.be.true; + + // Act - check all nested checkbox + elNestedIndeterminate?._subCheckboxes[1]._inputNode.click(); + elNestedIndeterminate?._subCheckboxes[2]._inputNode.click(); + await el.updateComplete; + + // Assert + expect(elNestedIndeterminate?.hasAttribute('checked')).to.be.true; + expect(elNestedIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elParentIndeterminate?.hasAttribute('checked')).to.be.false; + expect(elParentIndeterminate?.hasAttribute('indeterminate')).to.be.true; + + // Act - finally check all remaining checkbox + elParentIndeterminate?._subCheckboxes[0]._inputNode.click(); + elParentIndeterminate?._subCheckboxes[1]._inputNode.click(); + await el.updateComplete; + + // Assert + expect(elNestedIndeterminate?.hasAttribute('checked')).to.be.true; + expect(elNestedIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elParentIndeterminate?.hasAttribute('checked')).to.be.true; + expect(elParentIndeterminate?.hasAttribute('indeterminate')).to.be.false; + }); + + it('should work as expected if extra html', async () => { + // Arrange + const el = /** @type {LionCheckboxGroup} */ (await fixture(html` + +
+ Let's have some fun +
Hello I'm a div
+ +
useless div
+ + +
absolutely useless
+ +
+
+
Too much fun, stop it !
+
+ `)); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (el.querySelector( + 'lion-checkbox-indeterminate', + )); + + // Act + elIndeterminate._subCheckboxes[0].checked = true; + elIndeterminate._subCheckboxes[1].checked = true; + elIndeterminate._subCheckboxes[2].checked = true; + await el.updateComplete; + + // Assert + expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; + expect(elIndeterminate?.checked).to.be.true; + }); +});