Skip to content

Commit 0178717

Browse files
asyncLizcopybara-github
authored andcommitted
feat(checkbox): support :state(checked) and :state(indeterminate)
PiperOrigin-RevId: 694362061
1 parent d69f2f2 commit 0178717

File tree

5 files changed

+148
-11
lines changed

5 files changed

+148
-11
lines changed

checkbox/internal/checkbox.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ import {
2323
getValidityAnchor,
2424
mixinConstraintValidation,
2525
} from '../../labs/behaviors/constraint-validation.js';
26+
import {
27+
hasState,
28+
mixinCustomStateSet,
29+
toggleState,
30+
} from '../../labs/behaviors/custom-state-set.js';
2631
import {mixinElementInternals} from '../../labs/behaviors/element-internals.js';
2732
import {
2833
getFormState,
@@ -34,7 +39,7 @@ import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-valida
3439
// Separate variable needed for closure.
3540
const checkboxBaseClass = mixinDelegatesAria(
3641
mixinConstraintValidation(
37-
mixinFormAssociated(mixinElementInternals(LitElement)),
42+
mixinFormAssociated(mixinCustomStateSet(mixinElementInternals(LitElement))),
3843
),
3944
);
4045

@@ -59,14 +64,26 @@ export class Checkbox extends checkboxBaseClass {
5964
/**
6065
* Whether or not the checkbox is selected.
6166
*/
62-
@property({type: Boolean}) checked = false;
67+
@property({type: Boolean})
68+
get checked(): boolean {
69+
return this[hasState]('checked');
70+
}
71+
set checked(checked: boolean) {
72+
this[toggleState]('checked', checked);
73+
}
6374

6475
/**
6576
* Whether or not the checkbox is indeterminate.
6677
*
6778
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#indeterminate_state_checkboxes
6879
*/
69-
@property({type: Boolean}) indeterminate = false;
80+
@property({type: Boolean})
81+
get indeterminate(): boolean {
82+
return this[hasState]('indeterminate');
83+
}
84+
set indeterminate(indeterminate: boolean) {
85+
this[toggleState]('indeterminate', indeterminate);
86+
}
7087

7188
/**
7289
* When true, require the checkbox to be selected when participating in

checkbox/internal/checkbox_test.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,31 @@ describe('checkbox', () => {
152152
expect(input.checked).toEqual(true);
153153
expect(harness.element.checked).toEqual(true);
154154
});
155+
156+
it('matches :state(checked) when true', async () => {
157+
// Arrange
158+
const {harness} = await setupTest();
159+
160+
// Act
161+
harness.element.checked = true;
162+
await env.waitForStability();
163+
164+
// Assert
165+
expect(harness.element.matches(':state(checked)'))
166+
.withContext("element.matches(':state(checked)')")
167+
.toBeTrue();
168+
});
169+
170+
it('does not match :state(checked) when false', async () => {
171+
// Arrange
172+
// Act
173+
const {harness} = await setupTest();
174+
175+
// Assert
176+
expect(harness.element.matches(':state(checked)'))
177+
.withContext("element.matches(':state(checked)')")
178+
.toBeFalse();
179+
});
155180
});
156181

157182
describe('indeterminate', () => {
@@ -169,6 +194,31 @@ describe('checkbox', () => {
169194
expect(input.indeterminate).toEqual(false);
170195
expect(input.getAttribute('aria-checked')).not.toEqual('mixed');
171196
});
197+
198+
it('matches :state(indeterminate) when true', async () => {
199+
// Arrange
200+
const {harness} = await setupTest();
201+
202+
// Act
203+
harness.element.indeterminate = true;
204+
await env.waitForStability();
205+
206+
// Assert
207+
expect(harness.element.matches(':state(indeterminate)'))
208+
.withContext("element.matches(':state(indeterminate)')")
209+
.toBeTrue();
210+
});
211+
212+
it('does not match :state(indeterminate) when false', async () => {
213+
// Arrange
214+
// Act
215+
const {harness} = await setupTest();
216+
217+
// Assert
218+
expect(harness.element.matches(':state(indeterminate)'))
219+
.withContext("element.matches(':state(indeterminate)')")
220+
.toBeFalse();
221+
});
172222
});
173223

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

187237
describe('form submission', () => {
188238
async function setupFormTest(propsInit: Partial<Checkbox> = {}) {
189-
return await setupTest(html` <form>
190-
<md-test-checkbox
191-
.checked=${propsInit.checked === true}
192-
.disabled=${propsInit.disabled === true}
193-
.name=${propsInit.name ?? ''}
194-
.value=${propsInit.value ?? ''}></md-test-checkbox>
195-
</form>`);
239+
return await setupTest(
240+
html`<form>
241+
<md-test-checkbox
242+
.checked=${propsInit.checked === true}
243+
.disabled=${propsInit.disabled === true}
244+
.name=${propsInit.name ?? ''}
245+
.value=${propsInit.value ?? ''}></md-test-checkbox>
246+
</form>`,
247+
);
196248
}
197249

198250
it('does not submit if not checked', async () => {

docs/components/checkbox.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,10 @@ Token | Default value
209209
<!-- mdformat on(autogenerated might break rendering in catalog) -->
210210

211211
<!-- auto-generated API docs end -->
212+
213+
#### States
214+
215+
| State | Description |
216+
| --- | --- |
217+
| `:state(checked)` | Matches when the checkbox is checked. |
218+
| `:state(indeterminate)` | Matches when the checkbox is indeterminate. |

labs/behaviors/custom-state-set.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,18 @@ export function mixinCustomStateSet<
128128
}
129129

130130
[toggleState](state: string, isActive: boolean) {
131+
if (this[hasState](state) === isActive) {
132+
return;
133+
}
134+
131135
state = this[privateGetStateIdentifier](state);
132136
if (isActive) {
133137
this[internals].states.add(state);
134138
} else {
135139
this[internals].states.delete(state);
136140
}
141+
142+
this.requestUpdate();
137143
}
138144

139145
[privateUseDashedIdentifier]: boolean | null = null;

labs/behaviors/custom-state-set_test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ import {mixinElementInternals} from './element-internals.js';
1515
@customElement('test-custom-state-set')
1616
class TestCustomStateSet extends mixinCustomStateSet(
1717
mixinElementInternals(LitElement),
18-
) {}
18+
) {
19+
renderCount = 0;
20+
21+
override render() {
22+
this.renderCount++;
23+
return null;
24+
}
25+
}
1926

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

35+
let renderedElement: HTMLElement | null = null;
36+
37+
async function renderElement() {
38+
renderedElement?.remove();
39+
const element = new TestCustomStateSet();
40+
renderedElement = element;
41+
document.body.appendChild(element);
42+
await element.updateComplete;
43+
return element;
44+
}
45+
2846
beforeAll(() => {
2947
if (testWithPolyfill) {
3048
// A more reliable test would use `forceElementInternalsPolyfill()` from
@@ -75,6 +93,11 @@ for (const testWithPolyfill of [false, true]) {
7593
}
7694
});
7795

96+
afterEach(() => {
97+
renderedElement?.remove();
98+
renderedElement = null;
99+
});
100+
78101
afterAll(() => {
79102
if (testWithPolyfill) {
80103
HTMLElement.prototype.attachInternals = nativeAttachInternals;
@@ -161,6 +184,38 @@ for (const testWithPolyfill of [false, true]) {
161184
.withContext(`element.matches('${fooStateSelector}')`)
162185
.toBeFalse();
163186
});
187+
188+
it('triggers a LitElement update when the state changes', async () => {
189+
// Arrange
190+
const element = await renderElement();
191+
const initialRenderCount = element.renderCount;
192+
193+
// Act
194+
element[toggleState]('foo', true);
195+
await element.updateComplete;
196+
197+
// Assert
198+
const timesRendered = element.renderCount - initialRenderCount;
199+
expect(timesRendered)
200+
.withContext('times rendered after toggleState (change)')
201+
.toBe(1);
202+
});
203+
204+
it('does not trigger a LitElement update when the state does not change', async () => {
205+
// Arrange
206+
const element = await renderElement();
207+
const initialRenderCount = element.renderCount;
208+
209+
// Act
210+
element[toggleState]('foo', false);
211+
await element.updateComplete;
212+
213+
// Assert
214+
const timesRendered = element.renderCount - initialRenderCount;
215+
expect(timesRendered)
216+
.withContext('times rendered after toggleState (no change)')
217+
.toBe(0);
218+
});
164219
});
165220
});
166221
}

0 commit comments

Comments
 (0)