Skip to content

Commit 81807b2

Browse files
asynclizcopybara-github
authored andcommitted
feat(labs): add checkbox utility class component
PiperOrigin-RevId: 897363391
1 parent b39e13e commit 81807b2

12 files changed

Lines changed: 638 additions & 27 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// Copyright 2026 Google LLC
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
6+
@mixin root {
7+
--container-color: transparent;
8+
--container-shape: 2px;
9+
--container-size: 18px;
10+
--icon-color: transparent;
11+
--icon-size: 18px;
12+
--outline-color: var(--md-sys-color-on-surface-variant);
13+
--outline-width: 2px;
14+
--state-layer-color: var(--md-sys-color-on-surface);
15+
--state-layer-shape: 50%;
16+
--state-layer-size: 40px;
17+
}
18+
19+
@mixin hovered {
20+
--state-layer-color: var(--md-sys-color-on-surface);
21+
--outline-color: var(--md-sys-color-on-surface);
22+
}
23+
24+
@mixin focused {
25+
--outline-color: var(--md-sys-color-on-surface);
26+
}
27+
28+
@mixin pressed {
29+
--state-layer-color: var(--md-sys-color-primary);
30+
--outline-color: var(--md-sys-color-on-surface);
31+
}
32+
33+
@mixin selected {
34+
--outline-width: 0;
35+
--container-color: var(--md-sys-color-primary);
36+
--icon-color: var(--md-sys-color-on-primary);
37+
}
38+
39+
@mixin selected-hovered {
40+
--state-layer-color: var(--md-sys-color-primary);
41+
}
42+
43+
@mixin selected-pressed {
44+
--state-layer-color: var(--md-sys-color-on-surface);
45+
}
46+
47+
@mixin error {
48+
--state-layer-color: var(--md-sys-color-error);
49+
--outline-color: var(--md-sys-color-error);
50+
}
51+
52+
@mixin error-selected {
53+
--container-color: var(--md-sys-color-error);
54+
--icon-color: var(--md-sys-color-on-error);
55+
}
56+
57+
@mixin disabled {
58+
--outline-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
59+
}
60+
61+
@mixin disabled-selected {
62+
--container-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
63+
--icon-color: var(--md-sys-color-surface);
64+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*!
2+
* Copyright 2026 Google LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
// go/keep-sorted start by_regex='(.+) prefix_order=sass:
7+
@use 'checkbox-tokens';
8+
// go/keep-sorted end
9+
10+
@layer md.sys, md.comp.ripple, md.comp.focus-ring;
11+
@layer md.comp.checkbox {
12+
.checkbox {
13+
& {
14+
@include checkbox-tokens.root;
15+
}
16+
17+
&:is(:hover, .hover) {
18+
@include checkbox-tokens.hovered;
19+
}
20+
21+
&:is(:focus-within, .focus) {
22+
@include checkbox-tokens.focused;
23+
}
24+
25+
&:is(:active, .active) {
26+
@include checkbox-tokens.pressed;
27+
}
28+
29+
&:is(:checked, .checked, :indeterminate, .indeterminate) {
30+
@include checkbox-tokens.selected;
31+
32+
&:where(:hover, .hover) {
33+
@include checkbox-tokens.selected-hovered;
34+
}
35+
36+
&:where(:active, .active) {
37+
@include checkbox-tokens.selected-pressed;
38+
}
39+
}
40+
41+
&:is(:invalid, .invalid) {
42+
@include checkbox-tokens.error;
43+
44+
&:where(:checked, .checked, :indeterminate, .indeterminate) {
45+
@include checkbox-tokens.error-selected;
46+
}
47+
}
48+
49+
&:is(:disabled, .disabled) {
50+
@include checkbox-tokens.disabled;
51+
52+
&:where(:checked, .checked, :indeterminate, .indeterminate) {
53+
@include checkbox-tokens.disabled-selected;
54+
}
55+
}
56+
57+
& {
58+
appearance: none;
59+
position: relative;
60+
display: inline-flex;
61+
align-items: center;
62+
justify-content: center;
63+
width: 48px;
64+
height: 48px;
65+
background-image: none;
66+
outline: none;
67+
68+
&::before {
69+
content: '';
70+
box-sizing: border-box;
71+
width: var(--container-size);
72+
height: var(--container-size);
73+
background: var(--container-color);
74+
border-radius: var(--container-shape);
75+
border: var(--outline-width) solid var(--outline-color);
76+
color: var(--icon-color);
77+
font-family: var(--md-icon-font);
78+
font-size: var(--icon-size);
79+
line-height: 1;
80+
}
81+
82+
&::after {
83+
content: '';
84+
position: absolute;
85+
aspect-ratio: 1;
86+
background-image: var(--ripple);
87+
color: var(--state-layer-color);
88+
width: var(--state-layer-size);
89+
border-radius: var(--state-layer-shape);
90+
outline: var(--focus-ring-outline);
91+
outline-offset: var(--focus-ring-offset);
92+
transition: var(--ripple-transition);
93+
animation: var(--ripple-animation), var(--focus-ring-animation);
94+
}
95+
96+
&:is(:checked, .checked)::before {
97+
content: 'check';
98+
}
99+
100+
&:is(:indeterminate, .indeterminate)::before {
101+
content: 'remove';
102+
}
103+
}
104+
}
105+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {focusRingClasses} from '@material/web/labs/gb/components/focus/focus-ring.js';
8+
import {
9+
rippleClasses,
10+
setupRipple,
11+
} from '@material/web/labs/gb/components/ripple/ripple.js';
12+
import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js';
13+
import {AttributePart} from 'lit';
14+
import {Directive, directive, DirectiveParameters} from 'lit/directive.js';
15+
import {classMap, type ClassInfo} from 'lit/directives/class-map.js';
16+
17+
/** Checkbox classes. */
18+
export const CHECKBOX_CLASSES = {
19+
checkbox: 'checkbox',
20+
invalid: PSEUDO_CLASSES.invalid,
21+
hover: PSEUDO_CLASSES.hover,
22+
focus: PSEUDO_CLASSES.focus,
23+
active: PSEUDO_CLASSES.active,
24+
checked: PSEUDO_CLASSES.checked,
25+
indeterminate: PSEUDO_CLASSES.indeterminate,
26+
disabled: PSEUDO_CLASSES.disabled,
27+
} as const;
28+
29+
/** The state provided to the `checkboxClasses()` function. */
30+
export interface CheckboxClassesState {
31+
/** Emulates `:invalid`. */
32+
invalid?: boolean;
33+
/** Emulates `:hover`. */
34+
hover?: boolean;
35+
/** Emulates `:focus`. */
36+
focus?: boolean;
37+
/** Emulates `:active`. */
38+
active?: boolean;
39+
/** Emulates `:checked`. */
40+
checked?: boolean;
41+
/** Emulates `:indeterminate`. */
42+
indeterminate?: boolean;
43+
/** Emulates `:disabled`. */
44+
disabled?: boolean;
45+
}
46+
47+
/**
48+
* Returns the checkbox classes to apply to an element based on the given state.
49+
*
50+
* @param state The state of the checkbox.
51+
* @return An object of class names and truthy values if they apply.
52+
*/
53+
export function checkboxClasses({
54+
invalid = false,
55+
hover = false,
56+
focus = false,
57+
active = false,
58+
checked = false,
59+
indeterminate = false,
60+
disabled = false,
61+
}: CheckboxClassesState = {}): ClassInfo {
62+
return {
63+
...rippleClasses(),
64+
...focusRingClasses(),
65+
[CHECKBOX_CLASSES.checkbox]: true,
66+
[CHECKBOX_CLASSES.checked]: checked,
67+
[CHECKBOX_CLASSES.indeterminate]: indeterminate,
68+
[CHECKBOX_CLASSES.disabled]: disabled,
69+
[CHECKBOX_CLASSES.invalid]: invalid,
70+
[CHECKBOX_CLASSES.hover]: hover,
71+
[CHECKBOX_CLASSES.focus]: focus,
72+
[CHECKBOX_CLASSES.active]: active,
73+
};
74+
}
75+
76+
/**
77+
* Sets up checkbox functionality for the given element.
78+
*
79+
* @param checkbox The element on which to set up checkbox functionality.
80+
* @param opts Setup options, supports a cleanup `signal`.
81+
*/
82+
export function setupCheckbox(
83+
checkbox: HTMLElement,
84+
opts?: {signal?: AbortSignal},
85+
): void {
86+
setupRipple(checkbox, opts);
87+
}
88+
89+
/** The state provided to the `checkbox()` directive. */
90+
export interface CheckboxDirectiveState extends CheckboxClassesState {
91+
/** Additional classes to apply to the element. */
92+
classes?: ClassInfo;
93+
}
94+
95+
class CheckboxDirective extends Directive {
96+
private element?: HTMLElement;
97+
private cleanup?: AbortController;
98+
99+
render(state: CheckboxDirectiveState) {
100+
return classMap({
101+
...(state.classes || {}),
102+
...checkboxClasses(state),
103+
});
104+
}
105+
106+
override update(
107+
{element}: AttributePart,
108+
[state]: DirectiveParameters<this>,
109+
) {
110+
if (element !== this.element) {
111+
this.element = element as HTMLElement;
112+
this.cleanup?.abort();
113+
this.cleanup = new AbortController();
114+
setupCheckbox(this.element, {signal: this.cleanup.signal});
115+
}
116+
117+
return this.render(state);
118+
}
119+
}
120+
121+
/**
122+
* A Lit directive that adds checkbox styling and functionality to its element.
123+
*
124+
* @example
125+
* ```ts
126+
* html`<input type="checkbox" class="${checkbox()}">`;
127+
* ```
128+
*/
129+
export const checkbox = directive(CheckboxDirective);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import './material-collection.js';
8+
import './index.js';
9+
10+
import {
11+
KnobTypesToKnobs,
12+
MaterialCollection,
13+
materialInitsToStoryInits,
14+
setUpDemo,
15+
} from './material-collection.js';
16+
import {boolInput, Knob} from './index.js';
17+
18+
import {stories, StoryKnobs} from './stories.js';
19+
20+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
21+
'Checkbox',
22+
[
23+
new Knob('checked', {ui: boolInput()}),
24+
new Knob('indeterminate', {ui: boolInput()}),
25+
new Knob('error', {ui: boolInput()}),
26+
],
27+
);
28+
29+
collection.addStories(...materialInitsToStoryInits(stories));
30+
31+
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import '@material/web/labs/gb/components/checkbox/md-checkbox.js';
8+
9+
import {MaterialStoryInit} from './material-collection.js';
10+
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
11+
import {css, html} from 'lit';
12+
13+
import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only
14+
// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only
15+
16+
/** Knob types for checkbox stories. */
17+
export interface StoryKnobs {
18+
checked: boolean;
19+
indeterminate: boolean;
20+
error: boolean;
21+
}
22+
23+
adoptStyles(document, [
24+
m3Styles,
25+
css`
26+
:root {
27+
--md-icon-font: 'Material Symbols Outlined';
28+
}
29+
`,
30+
]);
31+
32+
const playground: MaterialStoryInit<StoryKnobs> = {
33+
name: 'Playground',
34+
render(knobs) {
35+
return html`
36+
<md-checkbox></md-checkbox>
37+
<md-checkbox checked></md-checkbox>
38+
<md-checkbox
39+
.checked=${knobs.checked}
40+
.indeterminate=${knobs.indeterminate}
41+
.error=${knobs.error}></md-checkbox>
42+
`;
43+
},
44+
};
45+
46+
/** Checkbox stories. */
47+
export const stories = [playground];

0 commit comments

Comments
 (0)