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:
parent
b291e60794
commit
4129786909
4 changed files with 273 additions and 5 deletions
5
.changeset/clean-terms-give.md
Normal file
5
.changeset/clean-terms-give.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
`;
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue