feat: add mixed-state indeterminate checkbox feature (#1669)

* fix: don't switch to tri-state if all child boxes are same checked state

Co-authored-by: gerjanvangeest <gerjanvangeest@users.noreply.github.com>
This commit is contained in:
Joren Broekema 2022-05-03 13:34:24 +02:00 committed by GitHub
parent b291e60794
commit 4129786909
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 273 additions and 5 deletions

View file

@ -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.

View file

@ -219,3 +219,37 @@ export const indeterminateChildren = () => html`
</lion-checkbox-group>
`;
```
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`
<lion-checkbox-group name="scientists[]" label="Favorite scientists">
<lion-checkbox-indeterminate mixed-state label="Scientists">
<lion-checkbox
slot="checkbox"
label="Isaac Newton"
.choiceValue=${'Isaac Newton'}
></lion-checkbox>
<lion-checkbox
slot="checkbox"
label="Galileo Galilei"
.choiceValue=${'Galileo Galilei'}
></lion-checkbox>
<lion-checkbox-indeterminate mixed-state slot="checkbox" label="Old Greek scientists">
<lion-checkbox
slot="checkbox"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox slot="checkbox" label="Plato" .choiceValue=${'Plato'}></lion-checkbox>
<lion-checkbox
slot="checkbox"
label="Pythagoras"
.choiceValue=${'Pythagoras'}
></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`;
```

View file

@ -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);

View file

@ -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('<lion-checkbox-indeterminate>', () => {
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`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
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`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
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`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
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;
});
});
});