diff --git a/labs/gb/components/button/_button-tokens.scss b/labs/gb/components/button/_button-tokens.scss new file mode 100644 index 0000000000..24b248c942 --- /dev/null +++ b/labs/gb/components/button/_button-tokens.scss @@ -0,0 +1,192 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin root { + --container-color: transparent; + --container-height: auto; + --container-elevation: var(--md-sys-elevation-shadow-0); + --container-shape: calc(var(--container-height) / 2); + --outline-width: 0; + --outline-color: transparent; + --icon-label-space: 8px; + --icon-color: currentColor; + --icon-size: 20px; + --label-text: var(--md-sys-typescale-label-lg); + --label-text-tracking: var(--md-sys-typescale-label-lg-tracking); + --label-text-color: currentColor; + --leading-space: 0; + --state-layer-color: transparent; + --trailing-space: 0; +} + +@mixin selected { + --container-shape: var(--md-sys-shape-corner-md); +} + +@mixin square { + --container-shape: var(--md-sys-shape-corner-md); +} + +@mixin selected-square { + --container-shape: calc(var(--container-height) / 2); +} + +@mixin pressed { + --container-shape: var(--md-sys-shape-corner-sm); +} + +@mixin filled { + --container-color: var(--md-sys-color-primary); + --icon-color: var(--md-sys-color-on-primary); + --label-text-color: var(--md-sys-color-on-primary); +} + +@mixin filled-unselected { + --container-color: var(--md-sys-color-surface-container); + --icon-color: var(--md-sys-color-on-surface-variant); + --label-text-color: var(--md-sys-color-on-surface-variant); +} + +@mixin filled-selected { + --container-color: var(--md-sys-color-primary); + --icon-color: var(--md-sys-color-on-primary); + --label-text-color: var(--md-sys-color-on-primary); +} + +@mixin elevated { + --container-color: var(--md-sys-color-surface-container-low); + --container-elevation: var(--md-sys-elevation-shadow-1); + --icon-color: var(--md-sys-color-primary); + --label-text-color: var(--md-sys-color-primary); +} + +@mixin elevated-selected { + --container-color: var(--md-sys-color-primary); + --icon-color: var(--md-sys-color-on-primary); + --label-text-color: var(--md-sys-color-on-primary); +} + +@mixin tonal { + --container-color: var(--md-sys-color-secondary-container); + --icon-color: var(--md-sys-color-on-secondary-container); + --label-text-color: var(--md-sys-color-on-secondary-container); +} + +@mixin tonal-selected { + --container-color: var(--md-sys-color-secondary); + --icon-color: var(--md-sys-color-on-secondary); + --label-text-color: var(--md-sys-color-on-secondary); +} + +@mixin outlined { + --icon-color: var(--md-sys-color-on-surface-variant); + --label-text-color: var(--md-sys-color-on-surface-variant); + --outline-color: var(--md-sys-color-outline-variant); + --outline-width: 1px; +} + +@mixin outlined-lg { + --outline-width: 2px; +} + +@mixin outlined-xl { + --outline-width: 3px; +} + +@mixin outlined-selected { + --container-color: var(--md-sys-color-inverse-surface); + --icon-color: var(--md-sys-color-inverse-on-surface); + --label-text-color: var(--md-sys-color-inverse-on-surface); +} + +@mixin text { + --label-text-color: var(--md-sys-color-primary); + --icon-color: var(--md-sys-color-primary); + --state-layer-color: var(--md-sys-color-primary); +} + +@mixin xs { + --container-height: 32px; + --leading-space: 12px; + --trailing-space: 12px; +} + +@mixin sm { + --container-height: 40px; + --leading-space: 16px; + --trailing-space: 16px; +} + +@mixin md { + --container-height: 56px; + --leading-space: 24px; + --trailing-space: 24px; + --icon-size: 24px; + --label-text: var(--md-sys-typescale-title-md); + --label-text-tracking: var(--md-sys-typescale-title-md-tracking); +} + +@mixin md-selected { + --container-shape: var(--md-sys-shape-corner-lg); +} + +@mixin md-square { + --container-shape: var(--md-sys-shape-corner-lg); +} + +@mixin md-pressed { + --container-shape: var(--md-sys-shape-corner-md); +} + +@mixin lg { + --container-height: 96px; + --icon-label-space: 12px; + --icon-size: 32px; + --leading-space: 48px; + --trailing-space: 48px; + --label-text: var(--md-sys-typescale-headline-sm); + --label-text-tracking: var(--md-sys-typescale-headline-sm-tracking); +} + +@mixin lg-selected { + --container-shape: var(--md-sys-shape-corner-xl); +} + +@mixin lg-square { + --container-shape: var(--md-sys-shape-corner-xl); +} + +@mixin lg-pressed { + --container-shape: var(--md-sys-shape-corner-lg); +} + +@mixin xl { + --container-height: 136px; + --icon-label-space: 16px; + --icon-size: 40px; + --leading-space: 64px; + --trailing-space: 64px; + --label-text: var(--md-sys-typescale-headline-lg); + --label-text-tracking: var(--md-sys-typescale-headline-lg-tracking); +} + +@mixin xl-selected { + --container-shape: var(--md-sys-shape-corner-xl); +} + +@mixin xl-square { + --container-shape: var(--md-sys-shape-corner-xl); +} + +@mixin xl-pressed { + --container-shape: var(--md-sys-shape-corner-lg); +} + +@mixin disabled { + --container-color: hsl(from var(--md-sys-color-on-surface) h s l / 10%); + --container-elevation: var(--md-sys-elevation-shadow-0); + --icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); +} diff --git a/labs/gb/components/button/button.scss b/labs/gb/components/button/button.scss new file mode 100644 index 0000000000..cba6626854 --- /dev/null +++ b/labs/gb/components/button/button.scss @@ -0,0 +1,132 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'button-tokens'; +// go/keep-sorted end + +@layer md.sys, md.comp.ripple, md.comp.focus-ring; +@layer md.comp.button { + .btn { + @include button-tokens.root; + &:is(.btn-selected, [aria-pressed='true']) { + @include button-tokens.selected; + } + &.btn-square { + @include button-tokens.square; + } + &:is(.btn-selected, [aria-pressed='true']):where(.btn-square) { + @include button-tokens.selected-square; + } + &:is(:active, .active):where(:not(:disabled, .disabled)) { + @include button-tokens.pressed; + } + &.btn-filled { + @include button-tokens.filled; + &:where(.btn-unselected, [aria-pressed='false']) { + @include button-tokens.filled-unselected; + } + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.filled-selected; + } + } + &.btn-elevated { + @include button-tokens.elevated; + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.elevated-selected; + } + } + &.btn-tonal { + @include button-tokens.tonal; + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.tonal-selected; + } + } + &.btn-outlined { + @include button-tokens.outlined; + &:where(.btn-lg) { + @include button-tokens.outlined-lg; + } + &:where(.btn-xl) { + @include button-tokens.outlined-xl; + } + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.outlined-selected; + } + } + &.btn-text { + @include button-tokens.text; + } + &.btn-xs { + @include button-tokens.xs; + } + &.btn-sm { + @include button-tokens.sm; + } + &.btn-md { + @include button-tokens.md; + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.md-selected; + } + &:where(.btn-square) { + @include button-tokens.md-square; + } + &:where(:is(:active, .active):not(:disabled, .disabled)) { + @include button-tokens.md-pressed; + } + } + &.btn-lg { + @include button-tokens.lg; + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.lg-selected; + } + &:where(.btn-square) { + @include button-tokens.lg-square; + } + &:where(:is(:active, .active):not(:disabled, .disabled)) { + @include button-tokens.lg-pressed; + } + } + &.btn-xl { + @include button-tokens.xl; + &:where(.btn-selected, [aria-pressed='true']) { + @include button-tokens.xl-selected; + } + &:where(.btn-square) { + @include button-tokens.xl-square; + } + &:where(:is(:active, .active):not(:disabled, .disabled)) { + @include button-tokens.xl-pressed; + } + } + &:is(:disabled, .disabled) { + @include button-tokens.disabled; + } + + & { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--container-height); + gap: var(--icon-label-space); + padding-inline: var(--leading-space) var(--trailing-space); + border: var(--outline-width) solid var(--outline-color); + border-radius: var(--container-shape); + box-shadow: var(--container-elevation); + background-color: var(--container-color); + color: var(--label-text-color); + font: var(--label-text); + letter-spacing: var(--label-text-tracking); + transition: + border-radius 350ms cubic-bezier(0.42, 1.67, 0.21, 0.9), + var(--ripple-transition); + --md-icon-color: var(--icon-color); + --md-icon-size: var(--icon-size); + } + &:not(:disabled, .disabled) { + cursor: pointer; + } + } +} diff --git a/labs/gb/components/button/button.ts b/labs/gb/components/button/button.ts new file mode 100644 index 0000000000..521807e05c --- /dev/null +++ b/labs/gb/components/button/button.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + afterDispatch, + setupDispatchHooks, +} from '@material/web/internal/events/dispatch-hooks.js'; +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'; + +/** Button color configuration types. */ +export type ButtonColor = 'filled' | 'elevated' | 'tonal' | 'outlined' | 'text'; + +/** Button color configurations. */ +export const BUTTON_COLORS = { + filled: 'filled', + elevated: 'elevated', + tonal: 'tonal', + outlined: 'outlined', + text: 'text', +} as const; + +/** Button size configuration types. */ +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +/** Button size configurations. */ +export const BUTTON_SIZES = { + xs: 'xs', + sm: 'sm', + md: 'md', + lg: 'lg', + xl: 'xl', +} as const; + +/** Button classes. */ +export const BUTTON_CLASSES = { + btn: 'btn', + btnFilled: 'btn-filled', + btnElevated: 'btn-elevated', + btnTonal: 'btn-tonal', + btnOutlined: 'btn-outlined', + btnText: 'btn-text', + btnXs: 'btn-xs', + btnSm: 'btn-sm', + btnMd: 'btn-md', + btnLg: 'btn-lg', + btnXl: 'btn-xl', + btnSquare: 'btn-square', + btnUnselected: 'btn-unselected', + btnSelected: 'btn-selected', + active: PSEUDO_CLASSES.active, + disabled: PSEUDO_CLASSES.disabled, +}; + +/** The state provided to the `buttonClasses()` function. */ +export interface ButtonClassesState { + /** The color of the button. */ + color?: ButtonColor; + /** The size of the button. */ + size?: ButtonSize; + /** Whether the button is a square shape. */ + square?: boolean; + /** Whether the toggle button is selected, if not undefined. */ + selected?: boolean; + /** Emulates `:active`. */ + active?: boolean; + /** Emulates `:disabled`. */ + disabled?: boolean; +} + +/** + * Returns the button classes to apply to an element based on the given state. + * + * @param state The state of the button. + * @return An object of class names and truthy values if they apply. + */ +export function buttonClasses({ + color, + size, + square = false, + selected, + active = false, + disabled = false, +}: ButtonClassesState = {}): ClassInfo { + return { + ...rippleClasses(), + ...focusRingClasses(), + [BUTTON_CLASSES.btn]: true, + [BUTTON_CLASSES.btnFilled]: color === BUTTON_COLORS.filled, + [BUTTON_CLASSES.btnElevated]: color === BUTTON_COLORS.elevated, + [BUTTON_CLASSES.btnTonal]: color === BUTTON_COLORS.tonal, + [BUTTON_CLASSES.btnOutlined]: color === BUTTON_COLORS.outlined, + [BUTTON_CLASSES.btnText]: color === BUTTON_COLORS.text || !color, + [BUTTON_CLASSES.btnXs]: size === BUTTON_SIZES.xs, + [BUTTON_CLASSES.btnSm]: size === BUTTON_SIZES.sm || !size, + [BUTTON_CLASSES.btnMd]: size === BUTTON_SIZES.md, + [BUTTON_CLASSES.btnLg]: size === BUTTON_SIZES.lg, + [BUTTON_CLASSES.btnXl]: size === BUTTON_SIZES.xl, + [BUTTON_CLASSES.btnSquare]: square, + [BUTTON_CLASSES.btnUnselected]: selected === false, + [BUTTON_CLASSES.btnSelected]: selected === true, + [BUTTON_CLASSES.active]: active, + [BUTTON_CLASSES.disabled]: disabled, + }; +} + +/** + * Sets up button functionality for the given element. + * + * @param button The element on which to set up button functionality. + * @param opts Setup options, supports a cleanup `signal`. + */ +export function setupButton( + button: HTMLElement, + opts?: {signal?: AbortSignal}, +): void { + setupDispatchHooks(button, 'click'); + setupRipple(button, opts); + button.addEventListener( + 'click', + (event) => { + // When disabled, explicitly prevent the click from propagating to other + // event listeners as well as prevent the default action. This is because + // the underlying element may not actually be `:disabled`, such as an + // anchor tag or a soft-disabled button. + if ( + button.matches(`.${BUTTON_CLASSES.disabled},[aria-disabled="true"]`) + ) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + + afterDispatch(event, () => { + const isToggle = + button.hasAttribute('aria-pressed') || + button.matches( + `.${BUTTON_CLASSES.btnSelected},.${BUTTON_CLASSES.btnUnselected}`, + ); + if (event.defaultPrevented || !isToggle) { + return; + } + + const isPressed = button.ariaPressed === 'true'; + button.ariaPressed = String(!isPressed); + // Mimic native browser input and change event behavior. + button.dispatchEvent( + new InputEvent('input', {bubbles: true, composed: true}), + ); + button.dispatchEvent(new Event('change', {bubbles: true})); + }); + }, + opts, + ); +} + +/** The state provided to the `button()` directive. */ +export interface ButtonDirectiveState extends ButtonClassesState { + /** Additional classes to apply to the element. */ + classes?: ClassInfo; +} + +class ButtonDirective extends Directive { + private element?: HTMLElement; + private cleanup?: AbortController; + + render(state: ButtonDirectiveState) { + return classMap({ + ...(state.classes || {}), + ...buttonClasses(state), + }); + } + + override update( + {element}: AttributePart, + [state]: DirectiveParameters, + ) { + if (element !== this.element) { + this.element = element as HTMLElement; + this.cleanup?.abort(); + this.cleanup = new AbortController(); + setupButton(this.element, {signal: this.cleanup.signal}); + } + + return this.render(state); + } +} + +/** + * A Lit directive that adds button styling and functionality to its element. + * + * @example + * ```ts + * html``; + * ``` + */ +export const button = directive(ButtonDirective); diff --git a/labs/gb/components/button/demo/demo.ts b/labs/gb/components/button/demo/demo.ts new file mode 100644 index 0000000000..7ad7204ba6 --- /dev/null +++ b/labs/gb/components/button/demo/demo.ts @@ -0,0 +1,54 @@ +/** + * @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, selectDropdown} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Button', + [ + new Knob('color', { + ui: selectDropdown({ + options: [ + {value: 'filled', label: 'Filled'}, + {value: 'elevated', label: 'Elevated'}, + {value: 'tonal', label: 'Tonal'}, + {value: 'outlined', label: 'Outlined'}, + {value: 'text', label: 'Text'}, + ], + }), + }), + new Knob('size', { + ui: selectDropdown({ + options: [ + {value: 'xs', label: 'X-Small'}, + {value: 'sm', label: 'Small'}, + {value: 'md', label: 'Medium'}, + {value: 'lg', label: 'Large'}, + {value: 'xl', label: 'X-Large'}, + ], + }), + }), + new Knob('square', {ui: boolInput()}), + new Knob('disabled', {ui: boolInput()}), + new Knob('softDisabled', {ui: boolInput()}), + new Knob('toggle', {ui: boolInput()}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/button/demo/stories.ts b/labs/gb/components/button/demo/stories.ts new file mode 100644 index 0000000000..bfb7bdbd0c --- /dev/null +++ b/labs/gb/components/button/demo/stories.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/button/md-button.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import { + ButtonColor, + ButtonSize, +} from '@material/web/labs/gb/components/button/button.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; +import {html, nothing} from 'lit'; + +/** Knob types for button stories. */ +export interface StoryKnobs { + color?: ButtonColor; + size?: ButtonSize; + square: boolean; + disabled: boolean; + softDisabled: boolean; + toggle: boolean; +} + +adoptStyles(document, [m3Styles]); + +const playground: MaterialStoryInit = { + name: 'Playground', + render(knobs) { + return html` + + Label + + `; + }, +}; + +const colors: MaterialStoryInit = { + name: 'Colors', + render(knobs) { + return html` + + Filled + + + Elevated + + + Outlined + + + Tonal + + + Text + + `; + }, +}; + +const sizes: MaterialStoryInit = { + name: 'Sizes', + render(knobs) { + return html` + + XS + + + SM + + + MD + + + LG + + + XL + + `; + }, +}; + +/** Button stories. */ +export const stories = [playground, colors, sizes]; diff --git a/labs/gb/components/button/md-button.ts b/labs/gb/components/button/md-button.ts new file mode 100644 index 0000000000..255ccec957 --- /dev/null +++ b/labs/gb/components/button/md-button.ts @@ -0,0 +1,177 @@ +/** + * @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 {mixinElementInternals} from '@material/web/labs/behaviors/element-internals.js'; +import {mixinFormAssociated} from '@material/web/labs/behaviors/form-associated.js'; +import {mixinFormSubmitter} from '@material/web/labs/behaviors/form-submitter.js'; +import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit'; +import {customElement, property} 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 buttonStyles from './button.css' with {type: 'css'}; // github-only +// import buttonStyles from './button.cssresult.js'; // google3-only + +import {button} from './button.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design button. */ + 'md-button': Button; + } +} + +// Separate variable needed for closure. +const baseClass = mixinDelegatesAria( + mixinFormSubmitter(mixinFormAssociated(mixinElementInternals(LitElement))), +); + +/** + * A Material Design button. + */ +@customElement('md-button') +export class Button extends baseClass { + /** @nocollapse */ + static override shadowRootOptions: ShadowRootInit = { + mode: 'open', + delegatesFocus: true, + }; + + static override styles: CSSResultOrNative[] = [ + focusRingStyles, + rippleStyles, + buttonStyles, + css` + :host { + display: inline-flex; + } + .btn { + flex: 1; + } + `, + ]; + + /** + * The color of the button. + */ + @property() + color: 'filled' | 'elevated' | 'tonal' | 'outlined' | 'text' = 'text'; + + /** + * The size of the button. + */ + @property() size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'; + + /** + * Changes the shape of the button to be square. + */ + @property({type: Boolean}) square = false; + + /** + * A string indicating the behavior of the button. + * + * - "submit" (default): A button that submits its associated form. + * - "reset": A button that resets its associated form. + * - "button": A normal button. + * - "toggle": A toggle button using the `selected` property. + * - "link": An anchor link (``). Type is always "link" when `href` is set. + */ + @property({noAccessor: true}) + override get type(): string { + return this.href ? 'link' : super.type; + } + override set type(type: string) { + if (this.href && type !== 'link') { + return; + } + super.type = type; + } + + /** + * Whether or not the button is "soft-disabled" (disabled but still + * focusable). + * + * Use this when a button needs increased visibility when disabled. See + * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls + * for more guidance on when this is needed. + */ + @property({type: Boolean, attribute: 'soft-disabled', reflect: true}) + softDisabled = false; + + /** + * Whether or not the button is selected, when `type="toggle"`. + */ + @property({type: Boolean}) selected = false; + + /** + * The URL that the link button points to. + */ + @property() href = ''; + + /** + * The filename to use when downloading the linked resource. + * If not specified, the browser will determine a filename. + * This is only applicable when the button is used as a link (`href` is set). + */ + @property() download = ''; + + /** + * Where to display the linked `href` URL for a link button. Common options + * include `_blank` to open in a new tab. + */ + @property() target: '_blank' | '_parent' | '_self' | '_top' | '' = ''; + + protected override render() { + const classes = button({ + color: this.color, + size: this.size, + square: this.square, + // Emulate `:disabled` when soft-disabled + disabled: this.softDisabled, + }); + + // Needed for closure conformance + const {ariaLabel, ariaHasPopup, ariaExpanded} = this as ARIAMixinStrict; + if (this.type === 'link') { + return html` + + `; + } + + return html``; + } + + private handleChange(event: Event) { + this.selected = (event.target as HTMLElement).ariaPressed === 'true'; + redispatchEvent(this, event); + } +} diff --git a/tsconfig.json b/tsconfig.json index 1884d269e0..234bb6a6f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,12 @@ { "compilerOptions": { "allowUnreachableCode": false, - "baseUrl": ".", "declaration": true, "declarationMap": false, - "downlevelIteration": true, "experimentalDecorators": true, "importHelpers": true, - "module": "es2015", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitOverride": true, diff --git a/types/css-imports.d.ts b/types/css-imports.d.ts new file mode 100644 index 0000000000..0c65f739a2 --- /dev/null +++ b/types/css-imports.d.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Support CSS import attributes for GitHub builds: +// `import styles from './styles.css' with {type: 'css'};` +declare module '*.css' { + const styles: CSSStyleSheet; + export default styles; +}