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