Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix indeterminate checkbox edge cases #2458

Merged
merged 10 commits into from
Feb 4, 2025
5 changes: 5 additions & 0 deletions .changeset/good-squids-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lion/ui': patch
---

[lion-checkbox-indeterminate] Fix bugs regarding disabled and pre-checked children
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,7 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
* @protected
*/
get _subCheckboxes() {
let checkboxes = [];
if (
this._checkboxGroupNode &&
this._checkboxGroupNode.formElements &&
this._checkboxGroupNode.formElements.length > 0
) {
checkboxes = this._checkboxGroupNode.formElements.filter(
checkbox => checkbox !== this && this.contains(checkbox),
);
}
return /** @type LionCheckbox[] */ (checkboxes);
return /** @type LionCheckbox[] */ (this.__subCheckboxes);
}

_storeIndeterminateState() {
Expand Down Expand Up @@ -97,9 +87,12 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
this.indeterminate = false;
this.checked = false;
break;
default:
default: {
this.indeterminate = true;
this.checked = false;
const disabledElements = subCheckboxes.filter(checkbox => checkbox.disabled);
this.checked =
subCheckboxes.length - checkedElements.length - disabledElements.length === 0;
okadurin marked this conversation as resolved.
Show resolved Hide resolved
}
}
this.updateComplete.then(() => {
this.__settingOwnChecked = false;
Expand Down Expand Up @@ -155,13 +148,12 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
subCheckboxes.length > 0 && subCheckboxes.length === checkedElements.length;
const allDisabled =
subCheckboxes.length > 0 && subCheckboxes.length === disabledElements.length;
const hasDisabledElements = disabledElements.length > 0;

if (allDisabled) {
this.checked = allChecked;
}

if (this.indeterminate && (this.mixedState || hasDisabledElements)) {
if (this.indeterminate && this.mixedState) {
this._subCheckboxes.forEach((checkbox, i) => {
// eslint-disable-next-line no-param-reassign
checkbox.checked = this._indeterminateSubStates[i];
Expand Down Expand Up @@ -211,6 +203,7 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
if (!(/** @type {HTMLElement} */ (ev.target).hasAttribute('role'))) {
/** @type {HTMLElement} */ (ev.target)?.setAttribute('role', 'listitem');
}
this.__addToSubCheckboxes(/** @type {CustomEvent} */ (ev).detail.element);
this._setOwnCheckedState();
}

Expand All @@ -223,13 +216,35 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
if (/** @type {HTMLElement} */ (ev.target).getAttribute('role') === 'listitem') {
/** @type {HTMLElement} */ (ev.target)?.removeAttribute('role');
}
this.__removeFromSubCheckboxes(/** @type {CustomEvent} */ (ev).detail.element);
}

/**
* @param {HTMLElement} element
*/
__addToSubCheckboxes(element) {
if (element !== this && this.contains(element)) {
this.__subCheckboxes.push(element);
}
}

/**
* @param {HTMLElement} element
*/
__removeFromSubCheckboxes(element) {
const index = this.__subCheckboxes.indexOf(element);
if (index !== -1) {
this.__subCheckboxes.splice(index, 1);
}
}

constructor() {
super();
this.indeterminate = false;
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.__onModelValueChanged = this.__onModelValueChanged.bind(this);
/** @type {HTMLElement[]} */
this.__subCheckboxes = [];
/** @type {boolean[]} */
this._indeterminateSubStates = [];
this.mixedState = false;
Expand Down Expand Up @@ -259,7 +274,13 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
/** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('indeterminate')) {

// When 1. sub checkboxes have disabled elements and 2. some elements are checked while the others are unchecked
// both this._inputNode.indeterminate and this.indeterminate are already true.
// If user clicks the input node, this._inputNode.indeterminate is turned to false by the browser
// while this.indeterminate is still true and the 'indeterminate' is not in the changedProperties
// because it hasn't been updated (true -> true) but checked would have been updated (false -> true).
if (changedProperties.has('indeterminate') || changedProperties.has('checked')) {
this._inputNode.indeterminate = this.indeterminate;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,65 @@ export function runCheckboxIndeterminateSuite(customConfig) {

// Assert
expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.true;
expect(elIndeterminate?.checked).to.be.false;
expect(elIndeterminate?.checked).to.be.true;
});

it('should be checked when all children are prechecked', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ await fixture(html`
<${groupTag} name="scientists[]">
<${tag} label="Favorite scientists">
<${childTag} checked label="Archimedes"></${childTag}>
<${childTag} checked label="Francis Bacon"></${childTag}>
<${childTag} checked label="Marie Curie"></${childTag}>
</${tag}>
</${groupTag}>
`);

const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector(`${cfg.tagString}`)
);

// Assert
expect(elIndeterminate?.hasAttribute('checked')).to.be.true;
});

it('should be checked when it has one prechecked child', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ await fixture(html`
<${groupTag} name="scientists[]">
<${tag} label="Favorite scientists">
<${childTag} checked label="Archimedes"></${childTag}>
</${tag}>
</${groupTag}>
`);

const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector(`${cfg.tagString}`)
);

// Assert
expect(elIndeterminate?.hasAttribute('checked')).to.be.true;
});

it('should be indeterminated when some of the children are prechecked', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ await fixture(html`
<${groupTag} name="scientists[]">
<${tag} label="Favorite scientists">
<${childTag} checked label="Archimedes"></${childTag}>
<${childTag} checked label="Francis Bacon"></${childTag}>
<${childTag} label="Marie Curie"></${childTag}>
</${tag}>
</${groupTag}>
`);

const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector(`${cfg.tagString}`)
);

// Assert
expect(elIndeterminate.hasAttribute('indeterminate')).to.be.true;
});

it('should sync all children when parent is checked (from indeterminate to checked)', async () => {
Expand Down Expand Up @@ -384,6 +442,65 @@ export function runCheckboxIndeterminateSuite(customConfig) {
expect(_subCheckboxes[2].hasAttribute('checked')).to.be.false;
});

it('should sync all prechecked children when parent is indeterminate and some of the children are disabled (from checked to unchecked)', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ (
await fixture(html`
<${groupTag} name="scientists[]">
<${tag} label="Favorite scientists">
<${childTag} checked label="Archimedes"></${childTag}>
<${childTag} disabled label="Francis Bacon"></${childTag}>
<${childTag} checked label="Marie Curie"></${childTag}>
</${tag}>
</${groupTag}>
`)
);
const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector(`${cfg.tagString}`)
);
const { _subCheckboxes, _inputNode } = getCheckboxIndeterminateMembers(elIndeterminate);

// Act
_inputNode.click();
await elIndeterminate.updateComplete;

// Assert
expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false;
expect(_inputNode?.hasAttribute('indeterminate')).to.be.false;
expect(_subCheckboxes[0].hasAttribute('checked')).to.be.false;
expect(_subCheckboxes[1].hasAttribute('checked')).to.be.false;
expect(_subCheckboxes[2].hasAttribute('checked')).to.be.false;
});

it('should stay as indeterminated after it is clicked, when it is interminated already and some children are disabled', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ (
await fixture(html`
<${groupTag} name="scientists[]">
<${tag} label="Favorite scientists">
<${childTag} checked label="Archimedes"></${childTag}>
<${childTag} disabled label="Francis Bacon"></${childTag}>
<${childTag} label="Marie Curie"></${childTag}>
</${tag}>
</${groupTag}>
`)
);
const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector(`${cfg.tagString}`)
);
const { _subCheckboxes, _inputNode } = getCheckboxIndeterminateMembers(elIndeterminate);

// Act
_inputNode.click();
await elIndeterminate.updateComplete;

// Assert
expect(elIndeterminate.hasAttribute('indeterminate')).to.be.true;
expect(_inputNode.indeterminate).to.be.true;
expect(_subCheckboxes[0].hasAttribute('checked')).to.be.true;
expect(_subCheckboxes[2].hasAttribute('checked')).to.be.true;
});

it('should work as expected with siblings checkbox-indeterminate', async () => {
// Arrange
const el = /** @type {LionCheckboxGroup} */ (
Expand Down