Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions labs/gb/components/checkbox/_checkbox-tokens.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@mixin root {
--container-color: transparent;
--container-shape: 2px;
--container-size: 18px;
--icon-color: transparent;
--icon-size: 18px;
--outline-color: var(--md-sys-color-on-surface-variant);
--outline-width: 2px;
--state-layer-color: var(--md-sys-color-on-surface);
--state-layer-shape: 50%;
--state-layer-size: 40px;
}

@mixin hovered {
--state-layer-color: var(--md-sys-color-on-surface);
--outline-color: var(--md-sys-color-on-surface);
}

@mixin focused {
--outline-color: var(--md-sys-color-on-surface);
}

@mixin pressed {
--state-layer-color: var(--md-sys-color-primary);
--outline-color: var(--md-sys-color-on-surface);
}

@mixin selected {
--outline-width: 0;
--container-color: var(--md-sys-color-primary);
--icon-color: var(--md-sys-color-on-primary);
}

@mixin selected-hovered {
--state-layer-color: var(--md-sys-color-primary);
}

@mixin selected-pressed {
--state-layer-color: var(--md-sys-color-on-surface);
}

@mixin error {
--state-layer-color: var(--md-sys-color-error);
--outline-color: var(--md-sys-color-error);
}

@mixin error-selected {
--container-color: var(--md-sys-color-error);
--icon-color: var(--md-sys-color-on-error);
}

@mixin disabled {
--outline-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
}

@mixin disabled-selected {
--container-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
--icon-color: var(--md-sys-color-surface);
}
105 changes: 105 additions & 0 deletions labs/gb/components/checkbox/checkbox.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*!
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

// go/keep-sorted start by_regex='(.+) prefix_order=sass:
@use 'checkbox-tokens';
// go/keep-sorted end

@layer md.sys, md.comp.ripple, md.comp.focus-ring;
@layer md.comp.checkbox {
.checkbox {
& {
@include checkbox-tokens.root;
}

&:is(:hover, .hover) {
@include checkbox-tokens.hovered;
}

&:is(:focus-within, .focus) {
@include checkbox-tokens.focused;
}

&:is(:active, .active) {
@include checkbox-tokens.pressed;
}

&:is(:checked, .checked, :indeterminate, .indeterminate) {
@include checkbox-tokens.selected;

&:where(:hover, .hover) {
@include checkbox-tokens.selected-hovered;
}

&:where(:active, .active) {
@include checkbox-tokens.selected-pressed;
}
}

&:is(:invalid, .invalid) {
@include checkbox-tokens.error;

&:where(:checked, .checked, :indeterminate, .indeterminate) {
@include checkbox-tokens.error-selected;
}
}

&:is(:disabled, .disabled) {
@include checkbox-tokens.disabled;

&:where(:checked, .checked, :indeterminate, .indeterminate) {
@include checkbox-tokens.disabled-selected;
}
}

& {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background-image: none;
outline: none;

&::before {
content: '';
box-sizing: border-box;
width: var(--container-size);
height: var(--container-size);
background: var(--container-color);
border-radius: var(--container-shape);
border: var(--outline-width) solid var(--outline-color);
color: var(--icon-color);
font-family: var(--md-icon-font);
font-size: var(--icon-size);
line-height: 1;
}

&::after {
content: '';
position: absolute;
aspect-ratio: 1;
background-image: var(--ripple);
color: var(--state-layer-color);
width: var(--state-layer-size);
border-radius: var(--state-layer-shape);
outline: var(--focus-ring-outline);
outline-offset: var(--focus-ring-offset);
transition: var(--ripple-transition);
animation: var(--ripple-animation), var(--focus-ring-animation);
}

&:is(:checked, .checked)::before {
content: 'check';
}

&:is(:indeterminate, .indeterminate)::before {
content: 'remove';
}
}
}
}
129 changes: 129 additions & 0 deletions labs/gb/components/checkbox/checkbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {focusRingClasses} from '@material/web/labs/gb/components/focus/focus-ring.js';
import {
rippleClasses,
setupRipple,
} from '@material/web/labs/gb/components/ripple/ripple.js';
import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js';
import {AttributePart} from 'lit';
import {Directive, directive, DirectiveParameters} from 'lit/directive.js';
import {classMap, type ClassInfo} from 'lit/directives/class-map.js';

/** Checkbox classes. */
export const CHECKBOX_CLASSES = {
checkbox: 'checkbox',
invalid: PSEUDO_CLASSES.invalid,
hover: PSEUDO_CLASSES.hover,
focus: PSEUDO_CLASSES.focus,
active: PSEUDO_CLASSES.active,
checked: PSEUDO_CLASSES.checked,
indeterminate: PSEUDO_CLASSES.indeterminate,
disabled: PSEUDO_CLASSES.disabled,
} as const;

/** The state provided to the `checkboxClasses()` function. */
export interface CheckboxClassesState {
/** Emulates `:invalid`. */
invalid?: boolean;
/** Emulates `:hover`. */
hover?: boolean;
/** Emulates `:focus`. */
focus?: boolean;
/** Emulates `:active`. */
active?: boolean;
/** Emulates `:checked`. */
checked?: boolean;
/** Emulates `:indeterminate`. */
indeterminate?: boolean;
/** Emulates `:disabled`. */
disabled?: boolean;
}

/**
* Returns the checkbox classes to apply to an element based on the given state.
*
* @param state The state of the checkbox.
* @return An object of class names and truthy values if they apply.
*/
export function checkboxClasses({
invalid = false,
hover = false,
focus = false,
active = false,
checked = false,
indeterminate = false,
disabled = false,
}: CheckboxClassesState = {}): ClassInfo {
return {
...rippleClasses(),
...focusRingClasses(),
[CHECKBOX_CLASSES.checkbox]: true,
[CHECKBOX_CLASSES.checked]: checked,
[CHECKBOX_CLASSES.indeterminate]: indeterminate,
[CHECKBOX_CLASSES.disabled]: disabled,
[CHECKBOX_CLASSES.invalid]: invalid,
[CHECKBOX_CLASSES.hover]: hover,
[CHECKBOX_CLASSES.focus]: focus,
[CHECKBOX_CLASSES.active]: active,
};
}

/**
* Sets up checkbox functionality for the given element.
*
* @param checkbox The element on which to set up checkbox functionality.
* @param opts Setup options, supports a cleanup `signal`.
*/
export function setupCheckbox(
checkbox: HTMLElement,
opts?: {signal?: AbortSignal},
): void {
setupRipple(checkbox, opts);
}

/** The state provided to the `checkbox()` directive. */
export interface CheckboxDirectiveState extends CheckboxClassesState {
/** Additional classes to apply to the element. */
classes?: ClassInfo;
}

class CheckboxDirective extends Directive {
private element?: HTMLElement;
private cleanup?: AbortController;

render(state: CheckboxDirectiveState) {
return classMap({
...(state.classes || {}),
...checkboxClasses(state),
});
}

override update(
{element}: AttributePart,
[state]: DirectiveParameters<this>,
) {
if (element !== this.element) {
this.element = element as HTMLElement;
this.cleanup?.abort();
this.cleanup = new AbortController();
setupCheckbox(this.element, {signal: this.cleanup.signal});
}

return this.render(state);
}
}

/**
* A Lit directive that adds checkbox styling and functionality to its element.
*
* @example
* ```ts
* html`<input type="checkbox" class="${checkbox()}">`;
* ```
*/
export const checkbox = directive(CheckboxDirective);
31 changes: 31 additions & 0 deletions labs/gb/components/checkbox/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import './material-collection.js';
import './index.js';

import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob} from './index.js';

import {stories, StoryKnobs} from './stories.js';

const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
'Checkbox',
[
new Knob('checked', {ui: boolInput()}),
new Knob('indeterminate', {ui: boolInput()}),
new Knob('error', {ui: boolInput()}),
],
);

collection.addStories(...materialInitsToStoryInits(stories));

setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
47 changes: 47 additions & 0 deletions labs/gb/components/checkbox/demo/stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import '@material/web/labs/gb/components/checkbox/md-checkbox.js';

import {MaterialStoryInit} from './material-collection.js';
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
import {css, html} from 'lit';

import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only
// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only

/** Knob types for checkbox stories. */
export interface StoryKnobs {
checked: boolean;
indeterminate: boolean;
error: boolean;
}

adoptStyles(document, [
m3Styles,
css`
:root {
--md-icon-font: 'Material Symbols Outlined';
}
`,
]);

const playground: MaterialStoryInit<StoryKnobs> = {
name: 'Playground',
render(knobs) {
return html`
<md-checkbox></md-checkbox>
<md-checkbox checked></md-checkbox>
<md-checkbox
.checked=${knobs.checked}
.indeterminate=${knobs.indeterminate}
.error=${knobs.error}></md-checkbox>
`;
},
};

/** Checkbox stories. */
export const stories = [playground];
Loading
Loading