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; + }); +});