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>
|
</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,
|
type: Boolean,
|
||||||
reflect: true,
|
reflect: true,
|
||||||
},
|
},
|
||||||
|
mixedState: {
|
||||||
|
type: Boolean,
|
||||||
|
reflect: true,
|
||||||
|
attribute: 'mixed-state',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +63,18 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
|
||||||
return /** @type LionCheckbox[] */ (checkboxes);
|
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
|
* @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
|
* @param {Event} ev
|
||||||
* @private
|
* @private
|
||||||
|
|
@ -97,15 +135,44 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _ev = /** @type {CustomEvent} */ (ev);
|
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) {
|
if (_ev.detail.formPath[0] === this && !this.__settingOwnChecked) {
|
||||||
|
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 => {
|
this._subCheckboxes.forEach(checkbox => {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
checkbox.checked = this._inputNode.checked;
|
checkbox.checked = this._inputNode.checked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
this.__settingOwnSubs = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
this._setOwnCheckedState();
|
this._setOwnCheckedState();
|
||||||
|
this.updateComplete.then(() => {
|
||||||
|
if (!this.__settingOwnSubs && !this.__settingOwnChecked && this.mixedState) {
|
||||||
|
this._storeIndeterminateState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mixedState) {
|
||||||
|
this._setOldState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -132,6 +199,9 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
|
||||||
this.indeterminate = false;
|
this.indeterminate = false;
|
||||||
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
|
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
|
||||||
this.__onModelValueChanged = this.__onModelValueChanged.bind(this);
|
this.__onModelValueChanged = this.__onModelValueChanged.bind(this);
|
||||||
|
/** @type {boolean[]} */
|
||||||
|
this._indeterminateSubStates = [];
|
||||||
|
this.mixedState = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -146,6 +216,15 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
|
||||||
this.removeEventListener('form-element-register', this._onRequestToAddFormElement);
|
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 */
|
/** @param {import('lit-element').PropertyValues } changedProperties */
|
||||||
updated(changedProperties) {
|
updated(changedProperties) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { getFormControlMembers } from '@lion/form-core/test-helpers';
|
||||||
import '@lion/checkbox-group/define';
|
import '@lion/checkbox-group/define';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @typedef {import('../src/LionCheckbox').LionCheckbox} LionCheckbox
|
||||||
* @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate
|
* @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate
|
||||||
* @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup
|
* @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup
|
||||||
*/
|
*/
|
||||||
|
|
@ -428,4 +429,153 @@ describe('<lion-checkbox-indeterminate>', () => {
|
||||||
expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false;
|
expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false;
|
||||||
expect(elIndeterminate?.checked).to.be.true;
|
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