diff --git a/.changeset/clean-terms-give.md b/.changeset/clean-terms-give.md new file mode 100644 index 000000000..8e1c9edfc --- /dev/null +++ b/.changeset/clean-terms-give.md @@ -0,0 +1,5 @@ +--- +'@lion/checkbox-group': patch +--- + +Add mixed-state feature to checkbox indeterminate. See https://www.w3.org/TR/wai-aria-practices-1.1/examples/checkbox/checkbox-2/checkbox-2.html for the WAI ARIA pattern. diff --git a/docs/components/checkbox-group/use-cases.md b/docs/components/checkbox-group/use-cases.md index 6979a379a..515049862 100644 --- a/docs/components/checkbox-group/use-cases.md +++ b/docs/components/checkbox-group/use-cases.md @@ -219,3 +219,37 @@ export const indeterminateChildren = () => html` `; ``` + +You can also use `mixed-state` attribute so your indeterminate checkbox toggles through three states (indeterminate, checked, unchecked), where for indeterminate state the old children states are restored when you toggle back into this. + +```js preview-story +export const mixedState = () => html` + + + + + + + + + + + +`; +``` diff --git a/packages/checkbox-group/src/LionCheckboxIndeterminate.js b/packages/checkbox-group/src/LionCheckboxIndeterminate.js index 66707f3da..6fbc947ea 100644 --- a/packages/checkbox-group/src/LionCheckboxIndeterminate.js +++ b/packages/checkbox-group/src/LionCheckboxIndeterminate.js @@ -31,6 +31,11 @@ export class LionCheckboxIndeterminate extends LionCheckbox { type: Boolean, reflect: true, }, + mixedState: { + type: Boolean, + reflect: true, + attribute: 'mixed-state', + }, }; } @@ -58,6 +63,18 @@ export class LionCheckboxIndeterminate extends LionCheckbox { return /** @type LionCheckbox[] */ (checkboxes); } + _storeIndeterminateState() { + this._indeterminateSubStates = this._subCheckboxes.map(checkbox => checkbox.checked); + } + + _setOldState() { + if (this.indeterminate) { + this._oldState = 'indeterminate'; + } else { + this._oldState = this.checked ? 'checked' : 'unchecked'; + } + } + /** * @protected */ @@ -89,6 +106,27 @@ export class LionCheckboxIndeterminate extends LionCheckbox { }); } + _setBasedOnMixedState() { + switch (this._oldState) { + case 'checked': + // --> unchecked + this.checked = false; + this.indeterminate = false; + break; + case 'unchecked': + // --> indeterminate + this.checked = false; + this.indeterminate = true; + break; + case 'indeterminate': + // --> checked + this.checked = true; + this.indeterminate = false; + break; + // no default + } + } + /** * @param {Event} ev * @private @@ -97,15 +135,44 @@ export class LionCheckboxIndeterminate extends LionCheckbox { if (this.disabled) { return; } - const _ev = /** @type {CustomEvent} */ (ev); + + // If the model value change event is coming from out own _inputNode + // and we're not already setting our own (mixed) state programmatically if (_ev.detail.formPath[0] === this && !this.__settingOwnChecked) { - this._subCheckboxes.forEach(checkbox => { - // eslint-disable-next-line no-param-reassign - checkbox.checked = this._inputNode.checked; + const allEqual = (/** @type {any[]} */ arr) => arr.every(val => val === arr[0]); + // If our child checkboxes states are all the same, we shouldn't be able to reach indeterminate (mixed) state + if (this.mixedState && !allEqual(this._indeterminateSubStates)) { + this._setBasedOnMixedState(); + } + + this.__settingOwnSubs = true; + if (this.indeterminate && this.mixedState) { + this._subCheckboxes.forEach((checkbox, i) => { + // eslint-disable-next-line no-param-reassign + checkbox.checked = this._indeterminateSubStates[i]; + }); + } else { + this._subCheckboxes.forEach(checkbox => { + // eslint-disable-next-line no-param-reassign + checkbox.checked = this._inputNode.checked; + }); + } + this.updateComplete.then(() => { + this.__settingOwnSubs = false; + }); + } else { + this._setOwnCheckedState(); + this.updateComplete.then(() => { + if (!this.__settingOwnSubs && !this.__settingOwnChecked && this.mixedState) { + this._storeIndeterminateState(); + } }); } - this._setOwnCheckedState(); + + if (this.mixedState) { + this._setOldState(); + } } /** @@ -132,6 +199,9 @@ export class LionCheckboxIndeterminate extends LionCheckbox { this.indeterminate = false; this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); this.__onModelValueChanged = this.__onModelValueChanged.bind(this); + /** @type {boolean[]} */ + this._indeterminateSubStates = []; + this.mixedState = false; } connectedCallback() { @@ -146,6 +216,15 @@ export class LionCheckboxIndeterminate extends LionCheckbox { this.removeEventListener('form-element-register', this._onRequestToAddFormElement); } + /** @param {import('lit-element').PropertyValues } changedProperties */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + this._setOldState(); + if (this.indeterminate) { + this._storeIndeterminateState(); + } + } + /** @param {import('lit-element').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); diff --git a/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js b/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js index 667261ae6..9170d76f6 100644 --- a/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js +++ b/packages/checkbox-group/test/lion-checkbox-indeterminate.test.js @@ -4,6 +4,7 @@ import { getFormControlMembers } from '@lion/form-core/test-helpers'; import '@lion/checkbox-group/define'; /** + * @typedef {import('../src/LionCheckbox').LionCheckbox} LionCheckbox * @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate * @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup */ @@ -428,4 +429,153 @@ describe('', () => { expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; expect(elIndeterminate?.checked).to.be.true; }); + + // https://www.w3.org/TR/wai-aria-practices-1.1/examples/checkbox/checkbox-2/checkbox-2.html + describe('mixed-state', () => { + it('can have a mixed-state (using mixed-state attribute), none -> indeterminate -> all, cycling through', async () => { + const el = await fixture(html` + + + + + + + + `); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ ( + el.querySelector('lion-checkbox-indeterminate') + ); + + expect(elIndeterminate.mixedState).to.be.true; + expect(elIndeterminate.checked).to.be.false; + expect(elIndeterminate.indeterminate).to.be.true; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.true; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.false; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.false; + expect(elIndeterminate.indeterminate).to.be.true; + }); + + it('should reset to old child checkbox states when reaching indeterminate state', async () => { + const el = await fixture(html` + + + + + + + + `); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ ( + el.querySelector('lion-checkbox-indeterminate') + ); + const checkboxEls = /** @type {LionCheckbox[]} */ ( + Array.from(el.querySelectorAll('lion-checkbox')) + ); + + expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, false, false]); + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, true, true]); + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([false, false, false]); + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, false, false]); + }); + + it('should no longer reach indeterminate state if the child boxes are all checked or all unchecked during indeterminate state', async () => { + const el = await fixture(html` + + + + + + + + `); + const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ ( + el.querySelector('lion-checkbox-indeterminate') + ); + const checkboxEls = /** @type {LionCheckbox[]} */ ( + Array.from(el.querySelectorAll('lion-checkbox')) + ); + + // Check when all child boxes in indeterminate state are unchecked + // we don't have a tri-state, but a duo-state. + + // @ts-ignore for testing purposes, we access this protected getter + checkboxEls[0]._inputNode.click(); + await elIndeterminate.updateComplete; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.true; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.false; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.true; + expect(elIndeterminate.indeterminate).to.be.false; + + // Check when all child boxes in indeterminate state are getting checked + // we also don't have a tri-state, but a duo-state. + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); // unchecked + await elIndeterminate.updateComplete; + + for (const checkEl of checkboxEls) { + // @ts-ignore for testing purposes, we access this protected getter + checkEl._inputNode.click(); + // Give each checking of the sub checkbox a chance to finish updating + // This means indeterminate state will be true for a bit and the state gets stored + await checkEl.updateComplete; + await elIndeterminate.updateComplete; + } + + expect(elIndeterminate.checked).to.be.true; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.false; + expect(elIndeterminate.indeterminate).to.be.false; + + // @ts-ignore for testing purposes, we access this protected getter + elIndeterminate._inputNode.click(); + await elIndeterminate.updateComplete; + expect(elIndeterminate.checked).to.be.true; + expect(elIndeterminate.indeterminate).to.be.false; + }); + }); });