diff --git a/labs/gb/components/circularprogress/_circular-progress-tokens.scss b/labs/gb/components/circularprogress/_circular-progress-tokens.scss new file mode 100644 index 0000000000..7318186a96 --- /dev/null +++ b/labs/gb/components/circularprogress/_circular-progress-tokens.scss @@ -0,0 +1,19 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin root { + --active-indicator-color: var(--md-sys-color-primary); + --active-indicator-thickness: 4px; + --track-color: var(--md-sys-color-secondary-container); + --size: 48px; + --four-color-active-indicator-one-color: var(--md-sys-color-primary); + --four-color-active-indicator-two-color: var( + --md-sys-color-primary-container + ); + --four-color-active-indicator-three-color: var(--md-sys-color-tertiary); + --four-color-active-indicator-four-color: var( + --md-sys-color-tertiary-container + ); +} diff --git a/labs/gb/components/circularprogress/circular-progress-element.ts b/labs/gb/components/circularprogress/circular-progress-element.ts new file mode 100644 index 0000000000..e10eebe4a2 --- /dev/null +++ b/labs/gb/components/circularprogress/circular-progress-element.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CSSResultOrNative, html, TemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import {styleMap, type StyleInfo} from 'lit/directives/style-map.js'; + +import {ProgressElement} from '../shared/progress-base.js'; +import {circularWavePath} from '../shared/wave-path.js'; + +import circularProgressStyles from './circular-progress.css' with {type: 'css'}; // github-only +// import {styles as circularProgressStyles} from './circular-progress.cssresult.js'; // google3-only + +const VIEWBOX_SIZE = 48; +const STROKE_INSET = 6; +const GAP_PERCENT = 4; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * A Material Design circular progress component. + * + * @cssprop --active-indicator-color + * @cssprop --active-indicator-thickness + * @cssprop --track-color + * @cssprop --size + * @cssprop --four-color-active-indicator-one-color + * @cssprop --four-color-active-indicator-two-color + * @cssprop --four-color-active-indicator-three-color + * @cssprop --four-color-active-indicator-four-color + */ +export class CircularProgressElement extends ProgressElement { + static override styles: CSSResultOrNative[] = [circularProgressStyles]; + + /** + * The amplitude of the wavy active indicator, in viewBox units. A value of 0 + * renders a smooth (non-wavy) indicator. + */ + @property({type: Number}) amplitude = 2; + + /** + * The wavelength of the wavy active indicator, in viewBox units. + */ + @property({type: Number}) wavelength = 15; + + protected override renderIndicator(): TemplateResult { + const path = circularWavePath({ + size: VIEWBOX_SIZE, + strokeWidth: STROKE_INSET, + amplitude: this.amplitude, + wavelength: this.wavelength, + }); + + if (this.indeterminate) { + return this.renderIndeterminate(path); + } + return this.renderDeterminate(path); + } + + private renderDeterminate(path: string): TemplateResult { + const progress = this.max > 0 ? clamp(this.value / this.max, 0, 1) : 0; + const activeOffset = round((1 - progress) * 100); + const trackStart = progress * 100 + GAP_PERCENT; + const trackLength = Math.max(0, 100 - progress * 100 - 2 * GAP_PERCENT); + const trackTail = Math.max(0, 100 - trackStart - trackLength); + const trackStyles: StyleInfo = { + 'stroke-dasharray': `0 ${round(trackStart)} ${round(trackLength)} ${round( + trackTail, + )}`, + }; + + return html` + + + + + `; + } + + private renderIndeterminate(path: string): TemplateResult { + return html` + + + + `; + } +} diff --git a/labs/gb/components/circularprogress/circular-progress.scss b/labs/gb/components/circularprogress/circular-progress.scss new file mode 100644 index 0000000000..ce3d1d612c --- /dev/null +++ b/labs/gb/components/circularprogress/circular-progress.scss @@ -0,0 +1,128 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'circular-progress-tokens'; +// go/keep-sorted end + +$determinate-easing: cubic-bezier(0, 0, 0.2, 1); +$rotate-duration: 1568ms; +$dash-duration: 1333ms; +$four-color-duration: 4s; + +@layer md.sys; +@layer md.comp.circular-progress { + :host { + @include circular-progress-tokens.root; + + display: inline-flex; + vertical-align: middle; + width: var(--size); + height: var(--size); + position: relative; + align-items: center; + justify-content: center; + contain: strict; + content-visibility: auto; + } + + .progress { + position: absolute; + inset: 0; + } + + svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + overflow: visible; + } + + path { + fill: none; + stroke-linecap: round; + stroke-width: var(--active-indicator-thickness); + } + + .track { + stroke: var(--track-color); + } + + .active { + stroke: var(--active-indicator-color); + stroke-dasharray: 100; + transition: stroke-dashoffset 500ms $determinate-easing; + } + + .progress.indeterminate svg { + animation: circular-rotate $rotate-duration linear infinite; + } + + .progress.indeterminate .active { + transition: none; + animation: circular-dash $dash-duration ease-in-out infinite; + } + + .progress.indeterminate.four-color .active { + animation: + circular-dash $dash-duration ease-in-out infinite, + circular-four-color $four-color-duration ease-in-out infinite; + } + + @media (forced-colors: active) { + .active { + stroke: CanvasText; + } + } + + @media (prefers-reduced-motion: reduce) { + .progress.indeterminate svg, + .progress.indeterminate .active { + animation-duration: 6s; + } + } + + @keyframes circular-rotate { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } + } + + @keyframes circular-dash { + 0% { + stroke-dasharray: 2 100; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 55 100; + stroke-dashoffset: -20; + } + 100% { + stroke-dasharray: 2 100; + stroke-dashoffset: -100; + } + } + + @keyframes circular-four-color { + 0% { + stroke: var(--four-color-active-indicator-one-color); + } + 25% { + stroke: var(--four-color-active-indicator-two-color); + } + 50% { + stroke: var(--four-color-active-indicator-three-color); + } + 75% { + stroke: var(--four-color-active-indicator-four-color); + } + 100% { + stroke: var(--four-color-active-indicator-one-color); + } + } +} diff --git a/labs/gb/components/circularprogress/circular-progress.ts b/labs/gb/components/circularprogress/circular-progress.ts new file mode 100644 index 0000000000..e97cebbe6e --- /dev/null +++ b/labs/gb/components/circularprogress/circular-progress.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type ClassInfo} from 'lit/directives/class-map.js'; +import {createClassMapDirective} from '../shared/directives.js'; + +/** Circular progress classes. */ +export const CIRCULAR_PROGRESS_CLASSES = { + progress: 'progress', + indeterminate: 'indeterminate', + fourColor: 'four-color', +} as const; + +/** The state provided to the `circularProgressClasses()` function. */ +export interface CircularProgressClassesState { + /** Whether the progress is indeterminate. */ + indeterminate?: boolean; + /** Whether indeterminate mode cycles through 4 colors. */ + fourColor?: boolean; +} + +/** + * Returns the circular progress root classes to apply to an element. + * + * @param state The state of the circular progress. + * @return An object of class names and truthy values if they apply. + */ +export function circularProgressClasses({ + indeterminate = false, + fourColor = false, +}: CircularProgressClassesState = {}): ClassInfo { + return { + [CIRCULAR_PROGRESS_CLASSES.progress]: true, + [CIRCULAR_PROGRESS_CLASSES.indeterminate]: indeterminate, + [CIRCULAR_PROGRESS_CLASSES.fourColor]: fourColor, + }; +} + +/** + * A Lit directive that adds circular progress root styling to its element. + * + * @example + * ```ts + * html`
`; + * ``` + */ +export const circularProgress = + createClassMapDirective({ + getClasses: circularProgressClasses, + }); diff --git a/labs/gb/components/circularprogress/circular-progress_test.ts b/labs/gb/components/circularprogress/circular-progress_test.ts new file mode 100644 index 0000000000..6fc272d731 --- /dev/null +++ b/labs/gb/components/circularprogress/circular-progress_test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) +import './md-gb-circular-progress.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../../testing/environment.js'; + +import {CircularProgressElement} from './circular-progress-element.js'; + +describe('', () => { + const env = new Environment(); + + async function setupTest( + template = html``, + ) { + const root = env.render(template); + const element = root.querySelector('md-gb-circular-progress'); + if (!(element instanceof CircularProgressElement)) { + throw new Error('Could not find CircularProgressElement'); + } + await env.waitForStability(); + const progressbar = element.shadowRoot!.querySelector( + '[role="progressbar"]', + )!; + return {element, progressbar}; + } + + it('renders a progressbar with default ARIA values', async () => { + const {progressbar} = await setupTest(); + expect(progressbar.getAttribute('aria-valuemin')).toBe('0'); + expect(progressbar.getAttribute('aria-valuemax')).toBe('1'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('reflects value and max to ARIA', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.getAttribute('aria-valuemax')).toBe('2'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('0.5'); + }); + + it('removes aria-valuenow when indeterminate', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.hasAttribute('aria-valuenow')).toBeFalse(); + }); + + it('delegates host aria-label to the progressbar', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.getAttribute('aria-label')).toBe('Loading'); + }); + + it('reveals the active path via stroke-dashoffset when determinate', async () => { + const {element} = await setupTest( + html``, + ); + const active = element.shadowRoot!.querySelector('svg path.active'); + const track = element.shadowRoot!.querySelector('svg path.track'); + expect(active).not.toBeNull(); + expect(track).not.toBeNull(); + // (1 - 0.25 / 1) * 100 = 75 + expect(active!.getAttribute('stroke-dashoffset')).toBe('75'); + }); + + it('renders only the active path and no track when indeterminate', async () => { + const {element} = await setupTest( + html``, + ); + expect(element.shadowRoot!.querySelector('svg path.active')).not.toBeNull(); + expect(element.shadowRoot!.querySelector('svg path.track')).toBeNull(); + }); + + it('draws a wavy path by default and a smoother path when amplitude is 0', async () => { + const {element: wavy} = await setupTest( + html``, + ); + const {element: smooth} = await setupTest( + html``, + ); + const wavyPath = wavy + .shadowRoot!.querySelector('svg path.active')! + .getAttribute('d')!; + const smoothPath = smooth + .shadowRoot!.querySelector('svg path.active')! + .getAttribute('d')!; + expect(wavyPath.length).toBeGreaterThan(0); + expect(smoothPath.length).toBeGreaterThan(0); + expect(wavyPath).not.toEqual(smoothPath); + }); +}); diff --git a/labs/gb/components/circularprogress/demo/demo.ts b/labs/gb/components/circularprogress/demo/demo.ts new file mode 100644 index 0000000000..1b9480bc36 --- /dev/null +++ b/labs/gb/components/circularprogress/demo/demo.ts @@ -0,0 +1,34 @@ +/** + * @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, numberInput} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Circular Progress (gBreeze)', + [ + new Knob('value', {ui: numberInput({step: 0.1}), defaultValue: 0.5}), + new Knob('max', {ui: numberInput(), defaultValue: 1}), + new Knob('amplitude', {ui: numberInput({step: 0.5}), defaultValue: 2}), + new Knob('wavelength', {ui: numberInput({step: 1}), defaultValue: 15}), + new Knob('indeterminate', {ui: boolInput(), defaultValue: false}), + new Knob('fourColor', {ui: boolInput(), defaultValue: false}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/circularprogress/demo/stories.ts b/labs/gb/components/circularprogress/demo/stories.ts new file mode 100644 index 0000000000..204e77da77 --- /dev/null +++ b/labs/gb/components/circularprogress/demo/stories.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/circularprogress/md-gb-circular-progress.js'; + +import {MaterialStoryInit} from './material-collection.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} from 'lit'; + +adoptStyles(document, [m3Styles]); + +/** Knob types for circular progress stories. */ +export interface StoryKnobs { + value: number; + max: number; + amplitude: number; + wavelength: number; + indeterminate: boolean; + fourColor: boolean; +} + +const playground: MaterialStoryInit = { + name: 'Playground', + render(knobs) { + return html` + + `; + }, +}; + +/** Circular progress stories. */ +export const stories = [playground]; diff --git a/labs/gb/components/circularprogress/md-gb-circular-progress.ts b/labs/gb/components/circularprogress/md-gb-circular-progress.ts new file mode 100644 index 0000000000..19f7d39764 --- /dev/null +++ b/labs/gb/components/circularprogress/md-gb-circular-progress.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CircularProgressElement} from './circular-progress-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design circular progress component. */ + 'md-gb-circular-progress': CircularProgressElement; + } +} + +customElements.define('md-gb-circular-progress', CircularProgressElement); diff --git a/labs/gb/components/linearprogress/_linear-progress-tokens.scss b/labs/gb/components/linearprogress/_linear-progress-tokens.scss new file mode 100644 index 0000000000..4d9ddb41c6 --- /dev/null +++ b/labs/gb/components/linearprogress/_linear-progress-tokens.scss @@ -0,0 +1,20 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@mixin root { + --active-indicator-color: var(--md-sys-color-primary); + --active-indicator-thickness: 4px; + --container-height: 16px; + --track-color: var(--md-sys-color-secondary-container); + --track-shape: var(--md-sys-shape-corner-full); + --four-color-active-indicator-one-color: var(--md-sys-color-primary); + --four-color-active-indicator-two-color: var( + --md-sys-color-primary-container + ); + --four-color-active-indicator-three-color: var(--md-sys-color-tertiary); + --four-color-active-indicator-four-color: var( + --md-sys-color-tertiary-container + ); +} diff --git a/labs/gb/components/linearprogress/demo/demo.ts b/labs/gb/components/linearprogress/demo/demo.ts new file mode 100644 index 0000000000..7a59fb6602 --- /dev/null +++ b/labs/gb/components/linearprogress/demo/demo.ts @@ -0,0 +1,35 @@ +/** + * @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, numberInput} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Linear Progress (gBreeze)', + [ + new Knob('value', {ui: numberInput({step: 0.1}), defaultValue: 0.5}), + new Knob('max', {ui: numberInput(), defaultValue: 1}), + new Knob('buffer', {ui: numberInput({step: 0.1}), defaultValue: 0.8}), + new Knob('amplitude', {ui: numberInput({step: 0.5}), defaultValue: 3}), + new Knob('wavelength', {ui: numberInput({step: 1}), defaultValue: 40}), + new Knob('indeterminate', {ui: boolInput(), defaultValue: false}), + new Knob('fourColor', {ui: boolInput(), defaultValue: false}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/linearprogress/demo/stories.ts b/labs/gb/components/linearprogress/demo/stories.ts new file mode 100644 index 0000000000..3de7decc7b --- /dev/null +++ b/labs/gb/components/linearprogress/demo/stories.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/linearprogress/md-gb-linear-progress.js'; + +import {MaterialStoryInit} from './material-collection.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} from 'lit'; + +adoptStyles(document, [m3Styles]); + +/** Knob types for linear progress stories. */ +export interface StoryKnobs { + value: number; + max: number; + buffer: number; + amplitude: number; + wavelength: number; + indeterminate: boolean; + fourColor: boolean; +} + +const playground: MaterialStoryInit = { + name: 'Playground', + render(knobs) { + return html` +
+ +
+ `; + }, +}; + +/** Linear progress stories. */ +export const stories = [playground]; diff --git a/labs/gb/components/linearprogress/linear-progress-element.ts b/labs/gb/components/linearprogress/linear-progress-element.ts new file mode 100644 index 0000000000..902316a3ef --- /dev/null +++ b/labs/gb/components/linearprogress/linear-progress-element.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CSSResultOrNative, html, TemplateResult} from 'lit'; +import {property, state} from 'lit/decorators.js'; +import {styleMap, type StyleInfo} from 'lit/directives/style-map.js'; + +import {ProgressElement} from '../shared/progress-base.js'; +import {linearWavePath} from '../shared/wave-path.js'; + +import linearProgressStyles from './linear-progress.css' with {type: 'css'}; // github-only +// import {styles as linearProgressStyles} from './linear-progress.cssresult.js'; // google3-only + +const DEFAULT_HEIGHT = 16; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * A Material Design linear progress component. + * + * @cssprop --active-indicator-color + * @cssprop --active-indicator-thickness + * @cssprop --track-color + * @cssprop --track-shape + * @cssprop --four-color-active-indicator-one-color + * @cssprop --four-color-active-indicator-two-color + * @cssprop --four-color-active-indicator-three-color + * @cssprop --four-color-active-indicator-four-color + */ +export class LinearProgressElement extends ProgressElement { + static override styles: CSSResultOrNative[] = [linearProgressStyles]; + + /** + * Buffer amount to display, a fraction between 0 and `max`. + * If the value is 0 or negative, the buffer is not displayed. + */ + @property({type: Number}) buffer = 0; + + /** + * The amplitude of the wavy active indicator, in pixels. A value of 0 renders + * a smooth (non-wavy) indicator. + */ + @property({type: Number}) amplitude = 3; + + /** + * The wavelength of the wavy active indicator, in pixels. + */ + @property({type: Number}) wavelength = 40; + + @state() private measuredWidth = 0; + @state() private measuredHeight = DEFAULT_HEIGHT; + + private resizeObserver?: ResizeObserver; + + override connectedCallback() { + super.connectedCallback(); + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + this.measure(); + }); + this.resizeObserver.observe(this); + } + this.measure(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + } + + private measure() { + const rect = this.getBoundingClientRect(); + if (rect.width > 0) { + this.measuredWidth = rect.width; + } + if (rect.height > 0) { + this.measuredHeight = rect.height; + } + } + + private wavePath(): string { + return linearWavePath({ + width: this.measuredWidth, + height: this.measuredHeight, + amplitude: this.amplitude, + wavelength: this.wavelength, + }); + } + + protected override renderIndicator(): TemplateResult { + if (this.indeterminate) { + return this.renderIndeterminate(); + } + return this.renderDeterminate(); + } + + private renderDeterminate(): TemplateResult { + const progress = this.max > 0 ? clamp(this.value / this.max, 0, 1) : 0; + const activeOffset = round((1 - progress) * 100); + + const bufferValue = this.buffer ?? 0; + const hasBuffer = bufferValue > 0; + const dotSize = + this.indeterminate || !hasBuffer + ? 1 + : clamp(bufferValue / this.max, 0, 1); + const dotStyles: StyleInfo = { + transform: `scaleX(${dotSize * 100}%)`, + }; + + const hideDots = + !hasBuffer || bufferValue >= this.max || this.value >= this.max; + + return html` +
+
+ + + +
+ `; + } + + private renderIndeterminate(): TemplateResult { + return html` + + + + `; + } +} diff --git a/labs/gb/components/linearprogress/linear-progress.scss b/labs/gb/components/linearprogress/linear-progress.scss new file mode 100644 index 0000000000..f0b3114295 --- /dev/null +++ b/labs/gb/components/linearprogress/linear-progress.scss @@ -0,0 +1,168 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'linear-progress-tokens'; +// go/keep-sorted end + +$determinate-duration: 400ms; +$determinate-easing: cubic-bezier(0, 0, 0.2, 1); +$indeterminate-duration: 2s; +$four-color-duration: 4s; + +@layer md.sys; +@layer md.comp.linear-progress { + :host { + @include linear-progress-tokens.root; + + border-radius: var(--track-shape); + display: block; + position: relative; + min-width: 80px; + height: var(--container-height); + content-visibility: auto; + contain: strict; + } + + .progress { + position: absolute; + inset: 0; + direction: ltr; + border-radius: inherit; + } + + .dots, + .inactive-track, + .stop-indicator, + .indicator { + position: absolute; + } + + .inactive-track, + .dots { + inset-inline: 0; + top: calc(50% - var(--active-indicator-thickness) / 2); + height: var(--active-indicator-thickness); + border-radius: var(--active-indicator-thickness); + } + + .inactive-track { + background: var(--track-color); + transform-origin: left center; + transition: transform $determinate-duration $determinate-easing; + } + + .dots { + animation: linear infinite $determinate-duration; + animation-name: buffering; + background-color: var(--track-color); + background-repeat: repeat-x; + $svg: "data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 5 2' preserveAspectRatio='xMinYMin slice'%3E%3Ccircle cx='1' cy='1' r='1'/%3E%3C/svg%3E"; + -webkit-mask-image: url($svg); + mask-image: url($svg); + } + + .dots[hidden] { + display: none; + } + + .indicator { + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + + .active { + fill: none; + stroke: var(--active-indicator-color); + stroke-linecap: round; + stroke-width: var(--active-indicator-thickness); + stroke-dasharray: 100; + transition: stroke-dashoffset $determinate-duration $determinate-easing; + } + + .stop-indicator { + inset-inline-end: 0; + top: calc(50% - var(--active-indicator-thickness) / 2); + width: var(--active-indicator-thickness); + height: var(--active-indicator-thickness); + border-radius: 50%; + background: var(--active-indicator-color); + } + + .progress.indeterminate .dots, + .progress.indeterminate .inactive-track, + .progress.indeterminate .stop-indicator { + display: none; + } + + .progress.indeterminate .active { + transition: none; + stroke-dasharray: 30 100; + animation: linear infinite $indeterminate-duration; + animation-name: linear-indeterminate; + } + + .progress.indeterminate.four-color .active { + animation-name: linear-indeterminate, linear-four-color; + animation-duration: $indeterminate-duration, $four-color-duration; + } + + :host(:dir(rtl)) { + transform: scale(-1); + } + + @media (forced-colors: active) { + :host { + outline: 1px solid CanvasText; + } + + .active { + stroke: CanvasText; + } + } + + @media (prefers-reduced-motion: reduce) { + .progress.indeterminate .active { + animation-duration: 4s; + } + } + + @keyframes linear-indeterminate { + 0% { + stroke-dashoffset: 40; + } + 100% { + stroke-dashoffset: -130; + } + } + + @keyframes buffering { + 0% { + $_dot-size: calc(var(--active-indicator-thickness)); + $_dot-background-width: calc($_dot-size * 2.5); + transform: translateX(#{$_dot-background-width}); + } + } + + @keyframes linear-four-color { + 0% { + stroke: var(--four-color-active-indicator-one-color); + } + 25% { + stroke: var(--four-color-active-indicator-two-color); + } + 50% { + stroke: var(--four-color-active-indicator-three-color); + } + 75% { + stroke: var(--four-color-active-indicator-four-color); + } + 100% { + stroke: var(--four-color-active-indicator-one-color); + } + } +} diff --git a/labs/gb/components/linearprogress/linear-progress.ts b/labs/gb/components/linearprogress/linear-progress.ts new file mode 100644 index 0000000000..2adf8c4cee --- /dev/null +++ b/labs/gb/components/linearprogress/linear-progress.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type ClassInfo} from 'lit/directives/class-map.js'; +import {createClassMapDirective} from '../shared/directives.js'; + +/** Linear progress classes. */ +export const LINEAR_PROGRESS_CLASSES = { + progress: 'progress', + indeterminate: 'indeterminate', + fourColor: 'four-color', +} as const; + +/** The state provided to the `linearProgressClasses()` function. */ +export interface LinearProgressClassesState { + /** Whether the progress is indeterminate. */ + indeterminate?: boolean; + /** Whether indeterminate mode cycles through 4 colors. */ + fourColor?: boolean; +} + +/** + * Returns the linear progress root classes to apply to an element. + * + * @param state The state of the linear progress. + * @return An object of class names and truthy values if they apply. + */ +export function linearProgressClasses({ + indeterminate = false, + fourColor = false, +}: LinearProgressClassesState = {}): ClassInfo { + return { + [LINEAR_PROGRESS_CLASSES.progress]: true, + [LINEAR_PROGRESS_CLASSES.indeterminate]: indeterminate, + [LINEAR_PROGRESS_CLASSES.fourColor]: fourColor, + }; +} + +/** + * A Lit directive that adds linear progress root styling to its element. + * + * @example + * ```ts + * html`
`; + * ``` + */ +export const linearProgress = + createClassMapDirective({ + getClasses: linearProgressClasses, + }); diff --git a/labs/gb/components/linearprogress/linear-progress_test.ts b/labs/gb/components/linearprogress/linear-progress_test.ts new file mode 100644 index 0000000000..6f897fd373 --- /dev/null +++ b/labs/gb/components/linearprogress/linear-progress_test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) +import './md-gb-linear-progress.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../../testing/environment.js'; + +import {LinearProgressElement} from './linear-progress-element.js'; + +describe('', () => { + const env = new Environment(); + + async function setupTest( + template = html``, + ) { + const root = env.render(template); + const element = root.querySelector('md-gb-linear-progress'); + if (!(element instanceof LinearProgressElement)) { + throw new Error('Could not find LinearProgressElement'); + } + await env.waitForStability(); + const progressbar = element.shadowRoot!.querySelector( + '[role="progressbar"]', + )!; + return {element, progressbar}; + } + + it('renders a progressbar with default ARIA values', async () => { + const {progressbar} = await setupTest(); + expect(progressbar.getAttribute('aria-valuemin')).toBe('0'); + expect(progressbar.getAttribute('aria-valuemax')).toBe('1'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('reflects value and max to ARIA', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.getAttribute('aria-valuemax')).toBe('2'); + expect(progressbar.getAttribute('aria-valuenow')).toBe('0.5'); + }); + + it('removes aria-valuenow when indeterminate', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.hasAttribute('aria-valuenow')).toBeFalse(); + }); + + it('delegates host aria-label to the progressbar', async () => { + const {progressbar} = await setupTest( + html``, + ); + expect(progressbar.getAttribute('aria-label')).toBe('Loading'); + }); + + it('reveals the active path via stroke-dashoffset when determinate', async () => { + const {element} = await setupTest( + html``, + ); + const active = element.shadowRoot!.querySelector('svg path.active')!; + // (1 - 0.5 / 1) * 100 = 50 + expect(active.getAttribute('stroke-dashoffset')).toBe('50'); + }); + + it('renders a stop indicator when determinate', async () => { + const {element} = await setupTest( + html``, + ); + expect(element.shadowRoot!.querySelector('.stop-indicator')).not.toBeNull(); + }); + + it('hides buffer dots when there is no buffer', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'), + ).toBeTrue(); + }); + + it('shows dots and scales inactive-track when buffer is set', async () => { + const {element} = await setupTest( + html``, + ); + const dots = element.shadowRoot!.querySelector('.dots')!; + const inactiveTrack = + element.shadowRoot!.querySelector('.inactive-track')!; + expect(dots.hasAttribute('hidden')).toBeFalse(); + expect(inactiveTrack.style.transform).toContain('scaleX(0.6)'); + }); + + it('renders only the active path and no track chrome when indeterminate', async () => { + const {element} = await setupTest( + html``, + ); + expect(element.shadowRoot!.querySelector('svg path.active')).not.toBeNull(); + expect(element.shadowRoot!.querySelector('.dots')).toBeNull(); + expect(element.shadowRoot!.querySelector('.inactive-track')).toBeNull(); + expect(element.shadowRoot!.querySelector('.stop-indicator')).toBeNull(); + }); + + it('hides dots when the buffer reaches max', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'), + ).toBeTrue(); + }); + + it('hides dots when the value reaches max', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('.dots')!.hasAttribute('hidden'), + ).toBeTrue(); + }); +}); diff --git a/labs/gb/components/linearprogress/md-gb-linear-progress.ts b/labs/gb/components/linearprogress/md-gb-linear-progress.ts new file mode 100644 index 0000000000..f10fced462 --- /dev/null +++ b/labs/gb/components/linearprogress/md-gb-linear-progress.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LinearProgressElement} from './linear-progress-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design linear progress component. */ + 'md-gb-linear-progress': LinearProgressElement; + } +} + +customElements.define('md-gb-linear-progress', LinearProgressElement); diff --git a/labs/gb/components/shared/progress-base.ts b/labs/gb/components/shared/progress-base.ts new file mode 100644 index 0000000000..9ec6c59aeb --- /dev/null +++ b/labs/gb/components/shared/progress-base.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html, LitElement, nothing, TemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import {classMap, type ClassInfo} from 'lit/directives/class-map.js'; + +import {ARIAMixinStrict} from '../../../../internal/aria/aria.js'; +import {mixinDelegatesAria} from '../../../../internal/aria/delegate.js'; + +// Separate variable needed for closure. +const progressBaseClass = mixinDelegatesAria(LitElement); + +/** + * A shared base class for progress indicators (circular and linear). + * + * Owns the common reactive state (`value`, `max`, `indeterminate`, + * `fourColor`), the `role="progressbar"` container and the ARIA wiring. + * Subclasses implement `renderIndicator()` to render the actual indicator DOM. + */ +export abstract class ProgressElement extends progressBaseClass { + /** + * Progress to display, a fraction between 0 and `max`. + */ + @property({type: Number}) value = 0; + + /** + * Maximum progress to display, defaults to 1. + */ + @property({type: Number}) max = 1; + + /** + * Whether or not to display indeterminate progress, which gives no indication + * to how long an activity will take. + */ + @property({type: Boolean}) indeterminate = false; + + /** + * Whether or not to render indeterminate mode using 4 colors instead of one. + */ + @property({type: Boolean, attribute: 'four-color'}) fourColor = false; + + protected override render() { + // Needed for closure conformance + const {ariaLabel} = this as ARIAMixinStrict; + return html` +
${this.renderIndicator()}
+ `; + } + + protected getRenderClasses(): ClassInfo { + return { + 'indeterminate': this.indeterminate, + 'four-color': this.fourColor, + }; + } + + protected abstract renderIndicator(): TemplateResult; +} diff --git a/labs/gb/components/shared/wave-path.ts b/labs/gb/components/shared/wave-path.ts new file mode 100644 index 0000000000..2fa6867453 --- /dev/null +++ b/labs/gb/components/shared/wave-path.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const DEG_STEP = (2 * Math.PI) / 360; + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Options for {@link linearWavePath}. + */ +export interface LinearWaveOptions { + width: number; + height: number; + amplitude: number; + wavelength: number; + phase?: number; + resolution?: number; +} + +/** + * Builds the SVG path `d` for a horizontal sine wave centered vertically. + * + * When `amplitude` or `wavelength` is not positive a straight line is returned. + */ +export function linearWavePath(options: LinearWaveOptions): string { + const {width, height, amplitude, wavelength, phase = 0} = options; + const resolution = + options.resolution && options.resolution > 0 ? options.resolution : 1; + const center = height / 2; + + if (amplitude <= 0 || wavelength <= 0 || width <= 0) { + return `M0,${round(center)} L${round(Math.max(width, 0))},${round(center)}`; + } + + const k = (2 * Math.PI) / wavelength; + const points: string[] = []; + for (let x = 0; x < width; x += resolution) { + const y = center + amplitude * Math.sin(k * x + phase); + points.push(`${round(x)},${round(y)}`); + } + const endY = center + amplitude * Math.sin(k * width + phase); + points.push(`${round(width)},${round(endY)}`); + + return `M${points[0]} L${points.slice(1).join(' ')}`; +} + +/** + * Options for {@link circularWavePath}. + */ +export interface CircularWaveOptions { + size: number; + strokeWidth: number; + amplitude: number; + wavelength: number; + phase?: number; +} + +/** + * Builds the SVG path `d` for a closed wavy ring inscribed in a square viewBox + * of `size` units. + * + * The number of wave cycles is rounded to an integer so the wave closes on + * itself without a visible seam. When `amplitude` or `wavelength` is not + * positive a plain circle (sampled as a closed polygon) is returned. + */ +export function circularWavePath(options: CircularWaveOptions): string { + const {size, strokeWidth, amplitude, wavelength, phase = 0} = options; + const center = size / 2; + const safeAmplitude = amplitude > 0 ? amplitude : 0; + const radius = center - strokeWidth / 2 - safeAmplitude; + + if (radius <= 0) { + return ''; + } + + const circumference = 2 * Math.PI * radius; + const waves = + safeAmplitude > 0 && wavelength > 0 + ? Math.max(1, Math.round(circumference / wavelength)) + : 0; + + const points: string[] = []; + for (let angle = 0; angle < 2 * Math.PI; angle += DEG_STEP) { + const r = radius + safeAmplitude * Math.sin(waves * angle + phase); + const x = center + r * Math.cos(angle); + const y = center + r * Math.sin(angle); + points.push(`${round(x)},${round(y)}`); + } + + return `M${points[0]} L${points.slice(1).join(' ')} Z`; +} diff --git a/labs/gb/components/shared/wave-path_test.ts b/labs/gb/components/shared/wave-path_test.ts new file mode 100644 index 0000000000..5fa409dc5b --- /dev/null +++ b/labs/gb/components/shared/wave-path_test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {circularWavePath, linearWavePath} from './wave-path.js'; + +interface Point { + x: number; + y: number; +} + +function parsePoints(path: string): Point[] { + return path + .replace(/[MLZ]/g, ' ') + .trim() + .split(/\s+/) + .filter((pair) => pair.includes(',')) + .map((pair) => { + const [x, y] = pair.split(',').map(Number); + return {x, y}; + }); +} + +describe('linearWavePath', () => { + it('returns a straight centered line when amplitude is 0', () => { + const path = linearWavePath({ + width: 100, + height: 8, + amplitude: 0, + wavelength: 40, + }); + expect(path).toBe('M0,4 L100,4'); + }); + + it('returns a straight line when wavelength is not positive', () => { + const path = linearWavePath({ + width: 100, + height: 8, + amplitude: 3, + wavelength: 0, + }); + expect(path).toBe('M0,4 L100,4'); + }); + + it('starts at x=0 and ends at x=width', () => { + const points = parsePoints( + linearWavePath({width: 120, height: 10, amplitude: 3, wavelength: 40}), + ); + expect(points[0].x).toBe(0); + expect(points[points.length - 1].x).toBe(120); + }); + + it('keeps all x values monotonically non-decreasing within bounds', () => { + const points = parsePoints( + linearWavePath({width: 120, height: 10, amplitude: 3, wavelength: 40}), + ); + for (let i = 1; i < points.length; i++) { + expect(points[i].x).toBeGreaterThanOrEqual(points[i - 1].x); + expect(points[i].x).toBeLessThanOrEqual(120); + } + }); + + it('oscillates within +/- amplitude of the vertical center', () => { + const amplitude = 3; + const center = 5; + const points = parsePoints( + linearWavePath({width: 200, height: 10, amplitude, wavelength: 40}), + ); + let maxDeviation = 0; + for (const {y} of points) { + maxDeviation = Math.max(maxDeviation, Math.abs(y - center)); + expect(Math.abs(y - center)).toBeLessThanOrEqual(amplitude + 0.01); + } + expect(maxDeviation).toBeGreaterThan(amplitude - 0.5); + }); +}); + +describe('circularWavePath', () => { + it('returns a closed path', () => { + const path = circularWavePath({ + size: 48, + strokeWidth: 4, + amplitude: 2, + wavelength: 15, + }); + expect(path.startsWith('M')).toBeTrue(); + expect(path.endsWith('Z')).toBeTrue(); + }); + + it('returns an empty string when the radius collapses', () => { + const path = circularWavePath({ + size: 4, + strokeWidth: 8, + amplitude: 2, + wavelength: 15, + }); + expect(path).toBe(''); + }); + + it('samples a constant radius when amplitude is 0', () => { + const size = 48; + const strokeWidth = 4; + const center = size / 2; + const expectedRadius = center - strokeWidth / 2; + const points = parsePoints( + circularWavePath({size, strokeWidth, amplitude: 0, wavelength: 15}), + ); + for (const {x, y} of points) { + const r = Math.hypot(x - center, y - center); + expect(Math.abs(r - expectedRadius)).toBeLessThan(0.05); + } + }); + + it('keeps every point within +/- amplitude of the midline radius', () => { + const size = 48; + const strokeWidth = 4; + const amplitude = 2; + const center = size / 2; + const midlineRadius = center - strokeWidth / 2 - amplitude; + const points = parsePoints( + circularWavePath({size, strokeWidth, amplitude, wavelength: 15}), + ); + let maxRadius = 0; + for (const {x, y} of points) { + const r = Math.hypot(x - center, y - center); + expect(r).toBeGreaterThanOrEqual(midlineRadius - amplitude - 0.05); + expect(r).toBeLessThanOrEqual(midlineRadius + amplitude + 0.05); + maxRadius = Math.max(maxRadius, r); + } + expect(maxRadius).toBeLessThanOrEqual(center - strokeWidth / 2 + 0.05); + }); +});