diff --git a/labs/gb/components/checkbox/_checkbox-tokens.scss b/labs/gb/components/checkbox/_checkbox-tokens.scss new file mode 100644 index 0000000000..c78f19e523 --- /dev/null +++ b/labs/gb/components/checkbox/_checkbox-tokens.scss @@ -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); +} diff --git a/labs/gb/components/checkbox/checkbox.scss b/labs/gb/components/checkbox/checkbox.scss new file mode 100644 index 0000000000..379a79d6b0 --- /dev/null +++ b/labs/gb/components/checkbox/checkbox.scss @@ -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'; + } + } + } +} diff --git a/labs/gb/components/checkbox/checkbox.ts b/labs/gb/components/checkbox/checkbox.ts new file mode 100644 index 0000000000..749c204a3c --- /dev/null +++ b/labs/gb/components/checkbox/checkbox.ts @@ -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, + ) { + 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``; + * ``` + */ +export const checkbox = directive(CheckboxDirective); diff --git a/labs/gb/components/checkbox/demo/demo.ts b/labs/gb/components/checkbox/demo/demo.ts new file mode 100644 index 0000000000..e6da79c230 --- /dev/null +++ b/labs/gb/components/checkbox/demo/demo.ts @@ -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>( + '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'}); diff --git a/labs/gb/components/checkbox/demo/stories.ts b/labs/gb/components/checkbox/demo/stories.ts new file mode 100644 index 0000000000..274ab67b40 --- /dev/null +++ b/labs/gb/components/checkbox/demo/stories.ts @@ -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 = { + name: 'Playground', + render(knobs) { + return html` + + + + `; + }, +}; + +/** Checkbox stories. */ +export const stories = [playground]; diff --git a/labs/gb/components/checkbox/md-checkbox.ts b/labs/gb/components/checkbox/md-checkbox.ts new file mode 100644 index 0000000000..1ea0c83a6c --- /dev/null +++ b/labs/gb/components/checkbox/md-checkbox.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ARIAMixinStrict} from '@material/web/internal/aria/aria.js'; +import {mixinDelegatesAria} from '@material/web/internal/aria/delegate.js'; +import {redispatchEvent} from '@material/web/internal/events/redispatch-event.js'; +import { + createValidator, + getValidityAnchor, + mixinConstraintValidation, +} from '@material/web/labs/behaviors/constraint-validation.js'; +import {mixinElementInternals} from '@material/web/labs/behaviors/element-internals.js'; +import { + getFormState, + getFormValue, + mixinFormAssociated, +} from '@material/web/labs/behaviors/form-associated.js'; +import {CheckboxValidator} from '@material/web/labs/behaviors/validators/checkbox-validator.js'; +import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit'; +import {customElement, property, query} from 'lit/decorators.js'; + +import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.css' with {type: 'css'}; // github-only +// import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js'; // google3-only +import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.css' with {type: 'css'}; // github-only +// import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.cssresult.js'; // google3-only +import checkboxStyles from './checkbox.css' with {type: 'css'}; // github-only +// import checkboxStyles from './checkbox.cssresult.js'; // google3-only + +import {checkbox} from './checkbox.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design checkbox component. */ + 'md-checkbox': Checkbox; + } +} + +// Separate variable needed for closure. +const baseClass = mixinDelegatesAria( + mixinConstraintValidation( + mixinFormAssociated(mixinElementInternals(LitElement)), + ), +); + +/** + * A Material Design checkbox component. + */ +@customElement('md-checkbox') +export class Checkbox extends baseClass { + /** @nocollapse */ + static override shadowRootOptions: ShadowRootInit = { + mode: 'open', + delegatesFocus: true, + }; + + static override styles: CSSResultOrNative[] = [ + focusRingStyles, + rippleStyles, + checkboxStyles, + css` + :host { + display: inline-flex; + } + .checkbox { + flex: 1; + } + `, + ]; + + /** + * Whether or not the checkbox is invalid. + */ + @property({type: Boolean}) error = false; + + /** + * Whether or not the checkbox is selected. + */ + @property({type: Boolean}) checked = false; + + /** + * The default checked state of the checkbox. + */ + get defaultChecked(): boolean { + return this.hasAttribute('checked'); + } + set defaultChecked(value: boolean) { + this.toggleAttribute('checked', value || false); + } + + /** + * 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; + + /** + * When true, require the checkbox to be selected when participating in + * form submission. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#validation + */ + @property({type: Boolean}) required = false; + + /** + * The value of the checkbox that is submitted with a form when selected. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#value + */ + @property() value = 'on'; + + @query('input', true) + private readonly input!: HTMLInputElement | null; + private dirty = false; + + protected override render() { + // Needed for closure conformance + const {ariaLabel, ariaInvalid} = this as ARIAMixinStrict; + return html` + + `; + } + + private handleInput(event: Event) { + this.dirty = true; + const target = event.target as HTMLInputElement; + this.checked = target.checked; + this.indeterminate = target.indeterminate; + // 'input' event bubbles and is composed, don't re-dispatch it. + } + + private handleChange(event: Event) { + this.dirty = true; + // 'change' event is not composed, re-dispatch it. + redispatchEvent(this, event); + } + + override attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (name === 'checked' && this.dirty) { + // The 'checked' attribute does not update checkboxes that have been + // interacted with. + return; + } + + super.attributeChangedCallback(name, oldValue, newValue); + } + + override [getFormValue]() { + return this.checked ? this.value : null; + } + + override [getFormState]() { + return String(this.checked); + } + + override formResetCallback() { + this.dirty = false; + this.checked = this.defaultChecked; + } + + override formStateRestoreCallback(state: string) { + this.checked = state === 'true'; + } + + override [createValidator]() { + return new CheckboxValidator(() => this); + } + + override [getValidityAnchor]() { + return this.input; + } +} diff --git a/labs/gb/components/shared/pseudo-classes.ts b/labs/gb/components/shared/pseudo-classes.ts index 901aeba550..7b758afe30 100644 --- a/labs/gb/components/shared/pseudo-classes.ts +++ b/labs/gb/components/shared/pseudo-classes.ts @@ -17,8 +17,11 @@ */ export const PSEUDO_CLASSES = { active: 'active', + checked: 'checked', disabled: 'disabled', focus: 'focus', focusVisible: 'focus-visible', hover: 'hover', + indeterminate: 'indeterminate', + invalid: 'invalid', }; diff --git a/labs/gb/tsconfig.json b/labs/gb/tsconfig.json new file mode 100644 index 0000000000..d9e1f8d6d9 --- /dev/null +++ b/labs/gb/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": [ + "**/demo" + ], + "include": [ + "../../types/css-imports.d.ts", + "**/*" + ] +} diff --git a/package.json b/package.json index 1389d10d51..98940f61f6 100644 --- a/package.json +++ b/package.json @@ -87,9 +87,16 @@ ] }, "build:ts": { + "dependencies": [ + "build:ts:main", + "build:ts:labs-gb" + ] + }, + "build:ts:main": { "command": "tsc --pretty", "files": [ "tsconfig.json", + "tsconfig.base.json", "**/*.ts", "!**/*.d.ts", "!**/*-styles.ts", @@ -113,6 +120,25 @@ "build:css-to-ts" ] }, + "build:ts:labs-gb": { + "command": "tsc --pretty -p labs/gb/tsconfig.json", + "files": [ + "tsconfig.base.json", + "labs/gb/tsconfig.json", + "labs/gb/**/*.ts", + "!labs/gb/**/*.d.ts" + ], + "output": [ + ".tsbuildinfo", + "labs/gb/**/*.js", + "labs/gb/**/*.js.map", + "labs/gb/**/*.d.ts" + ], + "clean": "if-file-deleted", + "dependencies": [ + "build:css-to-ts" + ] + }, "build:css-to-ts": { "dependencies": [ "build:css-to-ts:cssresult", diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 220c38ec18..bb39cb0edb 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -4,7 +4,8 @@ "types": ["node"], "allowSyntheticDefaultImports": true, "target": "ES2022", - "module": "ES2022" + "module": "ES2022", + "moduleResolution": "node" }, "exclude": [ "catalog", diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000000..ce4bad59b4 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "declaration": true, + "declarationMap": false, + "experimentalDecorators": true, + "importHelpers": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "paths": { + "@material/web/*": ["./*"] + }, + "sourceMap": true, + "inlineSources": true, + "strict": true, + "strictNullChecks": false, + "target": "es2021", + "types": ["lit", "jasmine"] + } +} diff --git a/tsconfig.json b/tsconfig.json index 234bb6a6f2..35aaa86398 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,32 +1,9 @@ { - "compilerOptions": { - "allowUnreachableCode": false, - "declaration": true, - "declarationMap": false, - "experimentalDecorators": true, - "importHelpers": true, - "module": "nodenext", - "moduleResolution": "nodenext", - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noPropertyAccessFromIndexSignature": true, - "noUnusedLocals": true, - "paths": { - "@material/web/*": ["./*"] - }, - "sourceMap": true, - "inlineSources": true, - "strict": true, - "strictNullChecks": false, - "target": "es2021", - "types": ["lit", "jasmine"] - }, + "extends": "./tsconfig.base.json", "exclude": [ "catalog", "**/demo", - "scripts/" + "scripts/", + "labs/gb/" ] }