Skip to content

Commit

Permalink
feat(checkbox): support :state(checked) and :state(indeterminate)
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 694362061
  • Loading branch information
asyncLiz authored and copybara-github committed Dec 10, 2024
1 parent d69f2f2 commit 0178717
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 11 deletions.
23 changes: 20 additions & 3 deletions checkbox/internal/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import {
getValidityAnchor,
mixinConstraintValidation,
} from '../../labs/behaviors/constraint-validation.js';
import {
hasState,
mixinCustomStateSet,
toggleState,
} from '../../labs/behaviors/custom-state-set.js';
import {mixinElementInternals} from '../../labs/behaviors/element-internals.js';
import {
getFormState,
Expand All @@ -34,7 +39,7 @@ import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-valida
// Separate variable needed for closure.
const checkboxBaseClass = mixinDelegatesAria(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
mixinFormAssociated(mixinCustomStateSet(mixinElementInternals(LitElement))),
),
);

Expand All @@ -59,14 +64,26 @@ export class Checkbox extends checkboxBaseClass {
/**
* Whether or not the checkbox is selected.
*/
@property({type: Boolean}) checked = false;
@property({type: Boolean})
get checked(): boolean {
return this[hasState]('checked');
}
set checked(checked: boolean) {
this[toggleState]('checked', checked);
}

/**
* Whether or not the checkbox is indeterminate.
*
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#indeterminate_state_checkboxes
*/
@property({type: Boolean}) indeterminate = false;
@property({type: Boolean})
get indeterminate(): boolean {
return this[hasState]('indeterminate');
}
set indeterminate(indeterminate: boolean) {
this[toggleState]('indeterminate', indeterminate);
}

/**
* When true, require the checkbox to be selected when participating in
Expand Down
66 changes: 59 additions & 7 deletions checkbox/internal/checkbox_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,31 @@ describe('checkbox', () => {
expect(input.checked).toEqual(true);
expect(harness.element.checked).toEqual(true);
});

it('matches :state(checked) when true', async () => {
// Arrange
const {harness} = await setupTest();

// Act
harness.element.checked = true;
await env.waitForStability();

// Assert
expect(harness.element.matches(':state(checked)'))
.withContext("element.matches(':state(checked)')")
.toBeTrue();
});

it('does not match :state(checked) when false', async () => {
// Arrange
// Act
const {harness} = await setupTest();

// Assert
expect(harness.element.matches(':state(checked)'))
.withContext("element.matches(':state(checked)')")
.toBeFalse();
});
});

describe('indeterminate', () => {
Expand All @@ -169,6 +194,31 @@ describe('checkbox', () => {
expect(input.indeterminate).toEqual(false);
expect(input.getAttribute('aria-checked')).not.toEqual('mixed');
});

it('matches :state(indeterminate) when true', async () => {
// Arrange
const {harness} = await setupTest();

// Act
harness.element.indeterminate = true;
await env.waitForStability();

// Assert
expect(harness.element.matches(':state(indeterminate)'))
.withContext("element.matches(':state(indeterminate)')")
.toBeTrue();
});

it('does not match :state(indeterminate) when false', async () => {
// Arrange
// Act
const {harness} = await setupTest();

// Assert
expect(harness.element.matches(':state(indeterminate)'))
.withContext("element.matches(':state(indeterminate)')")
.toBeFalse();
});
});

describe('disabled', () => {
Expand All @@ -186,13 +236,15 @@ describe('checkbox', () => {

describe('form submission', () => {
async function setupFormTest(propsInit: Partial<Checkbox> = {}) {
return await setupTest(html` <form>
<md-test-checkbox
.checked=${propsInit.checked === true}
.disabled=${propsInit.disabled === true}
.name=${propsInit.name ?? ''}
.value=${propsInit.value ?? ''}></md-test-checkbox>
</form>`);
return await setupTest(
html`<form>
<md-test-checkbox
.checked=${propsInit.checked === true}
.disabled=${propsInit.disabled === true}
.name=${propsInit.name ?? ''}
.value=${propsInit.value ?? ''}></md-test-checkbox>
</form>`,
);
}

it('does not submit if not checked', async () => {
Expand Down
7 changes: 7 additions & 0 deletions docs/components/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,10 @@ Token | Default value
<!-- mdformat on(autogenerated might break rendering in catalog) -->

<!-- auto-generated API docs end -->

#### States

| State | Description |
| --- | --- |
| `:state(checked)` | Matches when the checkbox is checked. |
| `:state(indeterminate)` | Matches when the checkbox is indeterminate. |
6 changes: 6 additions & 0 deletions labs/behaviors/custom-state-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,18 @@ export function mixinCustomStateSet<
}

[toggleState](state: string, isActive: boolean) {
if (this[hasState](state) === isActive) {
return;
}

state = this[privateGetStateIdentifier](state);
if (isActive) {
this[internals].states.add(state);
} else {
this[internals].states.delete(state);
}

this.requestUpdate();
}

[privateUseDashedIdentifier]: boolean | null = null;
Expand Down
57 changes: 56 additions & 1 deletion labs/behaviors/custom-state-set_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ import {mixinElementInternals} from './element-internals.js';
@customElement('test-custom-state-set')
class TestCustomStateSet extends mixinCustomStateSet(
mixinElementInternals(LitElement),
) {}
) {
renderCount = 0;

override render() {
this.renderCount++;
return null;
}
}

for (const testWithPolyfill of [false, true]) {
const describeSuffix = testWithPolyfill
Expand All @@ -25,6 +32,17 @@ for (const testWithPolyfill of [false, true]) {
describe(`mixinCustomStateSet()${describeSuffix}`, () => {
const nativeAttachInternals = HTMLElement.prototype.attachInternals;

let renderedElement: HTMLElement | null = null;

async function renderElement() {
renderedElement?.remove();
const element = new TestCustomStateSet();
renderedElement = element;
document.body.appendChild(element);
await element.updateComplete;
return element;
}

beforeAll(() => {
if (testWithPolyfill) {
// A more reliable test would use `forceElementInternalsPolyfill()` from
Expand Down Expand Up @@ -75,6 +93,11 @@ for (const testWithPolyfill of [false, true]) {
}
});

afterEach(() => {
renderedElement?.remove();
renderedElement = null;
});

afterAll(() => {
if (testWithPolyfill) {
HTMLElement.prototype.attachInternals = nativeAttachInternals;
Expand Down Expand Up @@ -161,6 +184,38 @@ for (const testWithPolyfill of [false, true]) {
.withContext(`element.matches('${fooStateSelector}')`)
.toBeFalse();
});

it('triggers a LitElement update when the state changes', async () => {
// Arrange
const element = await renderElement();
const initialRenderCount = element.renderCount;

// Act
element[toggleState]('foo', true);
await element.updateComplete;

// Assert
const timesRendered = element.renderCount - initialRenderCount;
expect(timesRendered)
.withContext('times rendered after toggleState (change)')
.toBe(1);
});

it('does not trigger a LitElement update when the state does not change', async () => {
// Arrange
const element = await renderElement();
const initialRenderCount = element.renderCount;

// Act
element[toggleState]('foo', false);
await element.updateComplete;

// Assert
const timesRendered = element.renderCount - initialRenderCount;
expect(timesRendered)
.withContext('times rendered after toggleState (no change)')
.toBe(0);
});
});
});
}

0 comments on commit 0178717

Please sign in to comment.