From 958b01e0a102f68277ab4bd055504c4a29a93b76 Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Wed, 15 Jul 2026 15:14:24 -0700 Subject: [PATCH] feat(labs): add circular and linear progress Adds md-gb-circular-progress and md-gb-linear-progress, supporting determinate, indeterminate, four-color, and buffer (linear) modes. PiperOrigin-RevId: 948571591 --- .../progress/_circular-progress-tokens.scss | 11 + .../progress/_linear-progress-tokens.scss | 12 + .../progress/circular-progress-element.ts | 156 ++++++++++ .../progress/circular-progress.scss | 94 ++++++ .../components/progress/circular-progress.ts | 119 ++++++++ .../progress/circular-progress_test.ts | 169 +++++++++++ labs/gb/components/progress/demo/demo.ts | 32 +++ labs/gb/components/progress/demo/stories.ts | 85 ++++++ .../progress/linear-progress-element.ts | 204 +++++++++++++ .../components/progress/linear-progress.scss | 131 +++++++++ .../gb/components/progress/linear-progress.ts | 103 +++++++ .../progress/linear-progress_test.ts | 272 ++++++++++++++++++ .../progress/md-gb-circular-progress.ts | 16 ++ .../progress/md-gb-linear-progress.ts | 16 ++ 14 files changed, 1420 insertions(+) create mode 100644 labs/gb/components/progress/_circular-progress-tokens.scss create mode 100644 labs/gb/components/progress/_linear-progress-tokens.scss create mode 100644 labs/gb/components/progress/circular-progress-element.ts create mode 100644 labs/gb/components/progress/circular-progress.scss create mode 100644 labs/gb/components/progress/circular-progress.ts create mode 100644 labs/gb/components/progress/circular-progress_test.ts create mode 100644 labs/gb/components/progress/demo/demo.ts create mode 100644 labs/gb/components/progress/demo/stories.ts create mode 100644 labs/gb/components/progress/linear-progress-element.ts create mode 100644 labs/gb/components/progress/linear-progress.scss create mode 100644 labs/gb/components/progress/linear-progress.ts create mode 100644 labs/gb/components/progress/linear-progress_test.ts create mode 100644 labs/gb/components/progress/md-gb-circular-progress.ts create mode 100644 labs/gb/components/progress/md-gb-linear-progress.ts diff --git a/labs/gb/components/progress/_circular-progress-tokens.scss b/labs/gb/components/progress/_circular-progress-tokens.scss new file mode 100644 index 0000000000..6982b09f02 --- /dev/null +++ b/labs/gb/components/progress/_circular-progress-tokens.scss @@ -0,0 +1,11 @@ +// +// 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; +} diff --git a/labs/gb/components/progress/_linear-progress-tokens.scss b/labs/gb/components/progress/_linear-progress-tokens.scss new file mode 100644 index 0000000000..7a7ded100d --- /dev/null +++ b/labs/gb/components/progress/_linear-progress-tokens.scss @@ -0,0 +1,12 @@ +// +// 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); +} diff --git a/labs/gb/components/progress/circular-progress-element.ts b/labs/gb/components/progress/circular-progress-element.ts new file mode 100644 index 0000000000..ba47c34bbe --- /dev/null +++ b/labs/gb/components/progress/circular-progress-element.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {css, CSSResultOrNative, html, nothing, TemplateResult} from 'lit'; +import {property} from 'lit/decorators.js'; +import {styleMap, type StyleInfo} from 'lit/directives/style-map.js'; + +import {ARIAMixinStrict} from '../../../../internal/aria/aria.js'; +import {Progress} from '../../../../progress/internal/progress.js'; + +import {circularProgress, circularWavePath} from './circular-progress.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 = 6; + +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 + */ +export class CircularProgressElement extends Progress { + static override styles: CSSResultOrNative[] = [ + circularProgressStyles, + css` + :host { + display: inline-flex; + vertical-align: middle; + } + `, + ]; + + /** + * Whether or not to render a wavy (expressive) active indicator instead of a + * standard smooth one. + */ + @property({type: Boolean}) wavy = false; + + /** + * The amplitude of the wavy active indicator, in viewBox units. Only applies + * when `wavy` is true. + */ + @property({type: Number}) amplitude = 1.6; + + /** + * The wavelength of the wavy active indicator, in viewBox units. Only applies + * when `wavy` is true. + */ + @property({type: Number}) wavelength = 15; + + protected override render() { + // Needed for closure conformance + const {ariaLabel} = this as ARIAMixinStrict; + return html` +
${this.renderIndicator()}
+ `; + } + + protected override renderIndicator(): TemplateResult { + const amplitude = this.wavy ? this.amplitude : 0; + const activePath = circularWavePath({ + size: VIEWBOX_SIZE, + strokeWidth: STROKE_INSET, + amplitude, + wavelength: this.wavelength, + wavy: this.wavy, + }); + // The track shares the active indicator's mean radius but is always smooth. + const trackPath = circularWavePath({ + size: VIEWBOX_SIZE, + strokeWidth: STROKE_INSET, + amplitude, + wavelength: this.wavelength, + wavy: false, + }); + + if (this.indeterminate) { + return this.renderIndeterminate(trackPath, activePath); + } + return this.renderDeterminate(trackPath, activePath); + } + + private renderDeterminate( + trackPath: string, + activePath: 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( + trackPath: string, + activePath: string, + ): TemplateResult { + return html` + + + + + `; + } +} diff --git a/labs/gb/components/progress/circular-progress.scss b/labs/gb/components/progress/circular-progress.scss new file mode 100644 index 0000000000..e21ff96529 --- /dev/null +++ b/labs/gb/components/progress/circular-progress.scss @@ -0,0 +1,94 @@ +/*! + * 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; + +@layer md.sys; +@layer md.comp.circular-progress { + .circular-progress { + @include circular-progress-tokens.root; + + position: relative; + width: var(--size); + height: var(--size); + contain: strict; + content-visibility: auto; + } + + .circular-progress svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + overflow: visible; + } + + .circular-progress path { + fill: none; + stroke-linecap: round; + stroke-width: var(--active-indicator-thickness); + } + + .circular-progress-track { + stroke: var(--track-color); + } + + .circular-progress-active { + stroke: var(--active-indicator-color); + stroke-dasharray: 100; + transition: stroke-dashoffset 500ms $determinate-easing; + } + + .circular-progress.indeterminate svg { + animation: circular-rotate $rotate-duration linear infinite; + } + + .circular-progress.indeterminate .circular-progress-active { + transition: none; + animation: circular-dash $dash-duration ease-in-out infinite; + } + + @media (forced-colors: active) { + .circular-progress-active { + stroke: CanvasText; + } + } + + @media (prefers-reduced-motion: reduce) { + .circular-progress.indeterminate svg, + .circular-progress.indeterminate .circular-progress-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; + } + } +} diff --git a/labs/gb/components/progress/circular-progress.ts b/labs/gb/components/progress/circular-progress.ts new file mode 100644 index 0000000000..f9ec92d2ab --- /dev/null +++ b/labs/gb/components/progress/circular-progress.ts @@ -0,0 +1,119 @@ +/** + * @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 = { + circularProgress: 'circular-progress', + indeterminate: 'indeterminate', +} as const; + +/** The state provided to the `circularProgressClasses()` function. */ +export interface CircularProgressClassesState { + /** Whether the progress is indeterminate. */ + indeterminate?: 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, +}: CircularProgressClassesState = {}): ClassInfo { + return { + [CIRCULAR_PROGRESS_CLASSES.circularProgress]: true, + [CIRCULAR_PROGRESS_CLASSES.indeterminate]: indeterminate, + }; +} + +/** + * A Lit directive that adds circular progress root styling to its element. + * + * @example + * ```ts + * html`
`; + * ``` + */ +export const circularProgress = + createClassMapDirective({ + getClasses: circularProgressClasses, + }); + +const DEG_STEP = (2 * Math.PI) / 360; + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Options for {@link circularWavePath}. + */ +export interface CircularWaveOptions { + /** The size of the square viewBox the ring is inscribed in. */ + size: number; + /** The stroke width of the ring, used to inset the ring from the edge. */ + strokeWidth: number; + /** The wave amplitude, in viewBox units. */ + amplitude: number; + /** The wave wavelength, in viewBox units. */ + wavelength: number; + /** The phase offset of the wave, in radians. */ + phase?: number; + /** Whether to render a wave. When false, a plain ring is returned. */ + wavy?: boolean; +} + +/** + * Builds the SVG path `d` for a closed 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 `wavy` is false (or `amplitude`/ + * `wavelength` is not positive) a plain circle (sampled as a closed polygon) is + * returned at the same mean radius, so a wavy active indicator and a smooth + * track stay concentric. + */ +export function circularWavePath(options: CircularWaveOptions): string { + const { + size, + strokeWidth, + amplitude, + wavelength, + phase = 0, + wavy = true, + } = options; + const center = size / 2; + const safeAmplitude = amplitude > 0 ? amplitude : 0; + // Reserve room for the wave peaks so the smooth track and the wavy active + // indicator share the same mean radius. + const radius = center - strokeWidth / 2 - safeAmplitude; + + if (radius <= 0) { + return ''; + } + + const circumference = 2 * Math.PI * radius; + const waves = + wavy && safeAmplitude > 0 && wavelength > 0 + ? Math.max(1, Math.round(circumference / wavelength)) + : 0; + const oscillation = waves > 0 ? safeAmplitude : 0; + + const points: string[] = []; + for (let angle = 0; angle < 2 * Math.PI; angle += DEG_STEP) { + const r = radius + oscillation * 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/progress/circular-progress_test.ts b/labs/gb/components/progress/circular-progress_test.ts new file mode 100644 index 0000000000..d70a26c8ef --- /dev/null +++ b/labs/gb/components/progress/circular-progress_test.ts @@ -0,0 +1,169 @@ +/** + * @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 {circularWavePath} from './circular-progress.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('renders the circular-progress root class', async () => { + const {progressbar} = await setupTest(); + expect(progressbar.classList.contains('circular-progress')).toBeTrue(); + }); + + 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.circular-progress-active', + ); + const track = element.shadowRoot!.querySelector( + 'svg path.circular-progress-track', + ); + expect(active).not.toBeNull(); + expect(track).not.toBeNull(); + // (1 - 0.25 / 1) * 100 = 75 + expect(active!.getAttribute('stroke-dashoffset')).toBe('75'); + }); + + it('renders both the track and active paths when indeterminate', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('svg path.circular-progress-active'), + ).not.toBeNull(); + expect( + element.shadowRoot!.querySelector('svg path.circular-progress-track'), + ).not.toBeNull(); + }); + + it('draws a smooth active by default and a wavy active when wavy', async () => { + const {element: standard} = await setupTest( + html``, + ); + const {element: wavy} = await setupTest( + html``, + ); + const standardPath = standard + .shadowRoot!.querySelector('svg path.circular-progress-active')! + .getAttribute('d')!; + const wavyPath = wavy + .shadowRoot!.querySelector('svg path.circular-progress-active')! + .getAttribute('d')!; + expect(standardPath.length).toBeGreaterThan(0); + expect(wavyPath.length).toBeGreaterThan(0); + expect(standardPath).not.toEqual(wavyPath); + }); + + describe('circularWavePath', () => { + const base = {size: 48, strokeWidth: 6, wavelength: 15}; + + it('returns a closed path that starts with a move command', () => { + const path = circularWavePath({...base, amplitude: 1.6, wavy: true}); + expect(path.startsWith('M')).toBeTrue(); + expect(path.endsWith('Z')).toBeTrue(); + }); + + it('returns an empty string when the radius is not positive', () => { + expect( + circularWavePath({ + size: 4, + strokeWidth: 20, + amplitude: 0, + wavelength: 15, + }), + ).toBe(''); + }); + + it('renders a wave only when wavy, amplitude and wavelength are positive', () => { + const wavy = circularWavePath({...base, amplitude: 1.6, wavy: true}); + const flatByAmplitude = circularWavePath({ + ...base, + amplitude: 0, + wavy: true, + }); + const flatByWavy = circularWavePath({ + ...base, + amplitude: 1.6, + wavy: false, + }); + const flatByWavelength = circularWavePath({ + ...base, + amplitude: 1.6, + wavelength: 0, + wavy: true, + }); + + expect(wavy).not.toEqual(flatByAmplitude); + expect(wavy).not.toEqual(flatByWavy); + expect(wavy).not.toEqual(flatByWavelength); + // Zeroing the wavelength or disabling wavy both flatten to the same ring + // (radius still reserves the amplitude). + expect(flatByWavelength).toEqual(flatByWavy); + }); + }); +}); diff --git a/labs/gb/components/progress/demo/demo.ts b/labs/gb/components/progress/demo/demo.ts new file mode 100644 index 0000000000..14c0a45a3e --- /dev/null +++ b/labs/gb/components/progress/demo/demo.ts @@ -0,0 +1,32 @@ +/** + * @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>( + '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('indeterminate', {ui: boolInput(), defaultValue: false}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/progress/demo/stories.ts b/labs/gb/components/progress/demo/stories.ts new file mode 100644 index 0000000000..e2e5d1f913 --- /dev/null +++ b/labs/gb/components/progress/demo/stories.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/progress/md-gb-circular-progress.js'; +import '@material/web/labs/gb/components/progress/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 progress stories. */ +export interface StoryKnobs { + value: number; + max: number; + buffer: number; + indeterminate: boolean; +} + +const circular: MaterialStoryInit = { + name: 'Circular progress', + render(knobs) { + return html` +
+
+ Standard + +
+
+ Wavy + +
+
+ `; + }, +}; + +const linear: MaterialStoryInit = { + name: 'Linear progress', + render(knobs) { + return html` +
+
+ Standard + +
+
+ Wavy + +
+
+ `; + }, +}; + +/** Progress stories. */ +export const stories = [circular, linear]; diff --git a/labs/gb/components/progress/linear-progress-element.ts b/labs/gb/components/progress/linear-progress-element.ts new file mode 100644 index 0000000000..b51110488a --- /dev/null +++ b/labs/gb/components/progress/linear-progress-element.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {css, CSSResultOrNative, html, nothing, TemplateResult} from 'lit'; +import {property, state} from 'lit/decorators.js'; +import {styleMap, type StyleInfo} from 'lit/directives/style-map.js'; + +import {ARIAMixinStrict} from '../../../../internal/aria/aria.js'; +import {Progress} from '../../../../progress/internal/progress.js'; + +import {linearProgress, linearWavePath} from './linear-progress.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; +// The gap between the active indicator and the inactive track, in pixels. +const TRACK_GAP = 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 linear progress component. + * + * @cssprop --active-indicator-color + * @cssprop --active-indicator-thickness + * @cssprop --track-color + * @cssprop --track-shape + * @cssprop --container-height + */ +export class LinearProgressElement extends Progress { + static override styles: CSSResultOrNative[] = [ + linearProgressStyles, + css` + :host { + display: block; + } + :host(:dir(rtl)) { + transform: scale(-1); + } + @media (forced-colors: active) { + :host { + outline: 1px solid CanvasText; + } + } + `, + ]; + + /** + * 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; + + /** + * Whether or not to render a wavy (expressive) active indicator instead of a + * standard smooth one. + */ + @property({type: Boolean}) wavy = false; + + /** + * The amplitude of the wavy active indicator, in pixels. Only applies when + * `wavy` is true. + */ + @property({type: Number}) amplitude = 3; + + /** + * The wavelength of the wavy active indicator, in pixels. Only applies when + * `wavy` is true. + */ + @property({type: Number}) wavelength = 40; + + @state() private measuredWidth = 0; + @state() private measuredHeight = DEFAULT_HEIGHT; + + private readonly resizeObserver = new ResizeObserver(() => { + this.measure(); + }); + + override connectedCallback() { + super.connectedCallback(); + this.resizeObserver.observe(this); + this.measure(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.resizeObserver.disconnect(); + } + + 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, + wavy: this.wavy, + }); + } + + protected override render() { + // Needed for closure conformance + const {ariaLabel} = this as ARIAMixinStrict; + return html` +
${this.renderIndicator()}
+ `; + } + + 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 buffer = this.max > 0 ? clamp(bufferValue / this.max, 0, 1) : 0; + const hasBuffer = bufferValue > 0 && buffer > progress; + + // The inactive track starts after the active indicator (plus a gap) and + // ends at the buffer (if any) or the end of the track. + const trackStyles: StyleInfo = { + 'inset-inline-start': `calc(${round(progress * 100)}% + ${TRACK_GAP}px)`, + 'inset-inline-end': `${round((1 - (hasBuffer ? buffer : 1)) * 100)}%`, + }; + + // Buffer dots fill the region between the buffer and the end of the track. + const dotsStyles: StyleInfo = { + 'inset-inline-start': `${round(buffer * 100)}%`, + }; + + const hideDots = !hasBuffer || buffer >= 1 || progress >= 1; + const hideStopIndicator = progress >= 1; + + return html` +
+
+ + + +
+ `; + } + + private renderIndeterminate(): TemplateResult { + return html` +
+ + + + `; + } +} diff --git a/labs/gb/components/progress/linear-progress.scss b/labs/gb/components/progress/linear-progress.scss new file mode 100644 index 0000000000..552d3d004e --- /dev/null +++ b/labs/gb/components/progress/linear-progress.scss @@ -0,0 +1,131 @@ +/*! + * 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; + +@layer md.sys; +@layer md.comp.linear-progress { + .linear-progress { + @include linear-progress-tokens.root; + + position: relative; + direction: ltr; + min-width: 80px; + height: var(--container-height); + border-radius: var(--track-shape); + contain: strict; + content-visibility: auto; + } + + .linear-progress-inactive-track, + .linear-progress-dots, + .linear-progress-stop-indicator, + .linear-progress-indicator { + position: absolute; + } + + .linear-progress-inactive-track, + .linear-progress-dots { + top: calc(50% - var(--active-indicator-thickness) / 2); + height: var(--active-indicator-thickness); + border-radius: var(--active-indicator-thickness); + } + + .linear-progress-inactive-track { + inset-inline: 0; + background: var(--track-color); + transition: + inset-inline-start $determinate-duration $determinate-easing, + inset-inline-end $determinate-duration $determinate-easing; + } + + .linear-progress-dots { + inset-inline-end: 0; + animation: linear infinite $determinate-duration; + animation-name: buffering; + background-color: var(--active-indicator-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); + } + + .linear-progress-dots[hidden], + .linear-progress-stop-indicator[hidden] { + display: none; + } + + .linear-progress-indicator { + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + + .linear-progress-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; + } + + .linear-progress-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); + } + + .linear-progress.indeterminate .linear-progress-inactive-track { + inset-inline: 0; + transition: none; + } + + .linear-progress.indeterminate .linear-progress-active { + transition: none; + stroke-dasharray: 30 100; + animation: linear infinite $indeterminate-duration; + animation-name: linear-indeterminate; + } + + @media (forced-colors: active) { + .linear-progress-active { + stroke: CanvasText; + } + } + + @media (prefers-reduced-motion: reduce) { + .linear-progress.indeterminate .linear-progress-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}); + } + } +} diff --git a/labs/gb/components/progress/linear-progress.ts b/labs/gb/components/progress/linear-progress.ts new file mode 100644 index 0000000000..fd3c3d8d4a --- /dev/null +++ b/labs/gb/components/progress/linear-progress.ts @@ -0,0 +1,103 @@ +/** + * @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 = { + linearProgress: 'linear-progress', + indeterminate: 'indeterminate', +} as const; + +/** The state provided to the `linearProgressClasses()` function. */ +export interface LinearProgressClassesState { + /** Whether the progress is indeterminate. */ + indeterminate?: 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, +}: LinearProgressClassesState = {}): ClassInfo { + return { + [LINEAR_PROGRESS_CLASSES.linearProgress]: true, + [LINEAR_PROGRESS_CLASSES.indeterminate]: indeterminate, + }; +} + +/** + * A Lit directive that adds linear progress root styling to its element. + * + * @example + * ```ts + * html`
`; + * ``` + */ +export const linearProgress = + createClassMapDirective({ + getClasses: linearProgressClasses, + }); + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Options for {@link linearWavePath}. + */ +export interface LinearWaveOptions { + /** The width of the wave, in pixels. */ + width: number; + /** The height of the wave's viewBox, in pixels. */ + height: number; + /** The wave amplitude, in pixels. */ + amplitude: number; + /** The wave wavelength, in pixels. */ + wavelength: number; + /** The phase offset of the wave, in radians. */ + phase?: number; + /** Whether to render a wave. When false, a straight line is returned. */ + wavy?: boolean; +} + +/** + * Builds the SVG path `d` for a horizontal sine wave centered vertically. + * + * When `wavy` is false (or `amplitude`/`wavelength`/`width` is not positive) a + * straight line is returned. + */ +export function linearWavePath(options: LinearWaveOptions): string { + const { + width, + height, + amplitude, + wavelength, + phase = 0, + wavy = true, + } = options; + const center = height / 2; + + if (!wavy || 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 += 1) { + 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(' ')}`; +} diff --git a/labs/gb/components/progress/linear-progress_test.ts b/labs/gb/components/progress/linear-progress_test.ts new file mode 100644 index 0000000000..0d98c9034e --- /dev/null +++ b/labs/gb/components/progress/linear-progress_test.ts @@ -0,0 +1,272 @@ +/** + * @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 {linearWavePath} from './linear-progress.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('renders the linear-progress root class', async () => { + const {progressbar} = await setupTest(); + expect(progressbar.classList.contains('linear-progress')).toBeTrue(); + }); + + 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.linear-progress-active', + )!; + // (1 - 0.5 / 1) * 100 = 50 + expect(active.getAttribute('stroke-dashoffset')).toBe('50'); + }); + + it('measures its width and renders a sized indicator viewBox', async () => { + const {element} = await setupTest( + html``, + ); + const svg = element.shadowRoot!.querySelector( + 'svg.linear-progress-indicator', + )!; + const viewBoxWidth = Number(svg.getAttribute('viewBox')!.split(' ')[2]); + expect(viewBoxWidth).toBeGreaterThan(0); + }); + + it('renders a stop indicator when determinate', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('.linear-progress-stop-indicator'), + ).not.toBeNull(); + }); + + it('hides buffer dots when there is no buffer', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element + .shadowRoot!.querySelector('.linear-progress-dots')! + .hasAttribute('hidden'), + ).toBeTrue(); + }); + + it('shows dots and an inactive track when the buffer is set', async () => { + const {element} = await setupTest( + html``, + ); + const dots = element.shadowRoot!.querySelector('.linear-progress-dots')!; + const inactiveTrack = element.shadowRoot!.querySelector( + '.linear-progress-inactive-track', + ); + expect(dots.hasAttribute('hidden')).toBeFalse(); + expect(inactiveTrack).not.toBeNull(); + }); + + it('renders an inactive track but no dots or stop indicator when indeterminate', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element.shadowRoot!.querySelector('svg path.linear-progress-active'), + ).not.toBeNull(); + expect( + element.shadowRoot!.querySelector('.linear-progress-inactive-track'), + ).not.toBeNull(); + expect( + element.shadowRoot!.querySelector('.linear-progress-dots'), + ).toBeNull(); + expect( + element.shadowRoot!.querySelector('.linear-progress-stop-indicator'), + ).toBeNull(); + }); + + it('hides dots when the buffer reaches max', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element + .shadowRoot!.querySelector('.linear-progress-dots')! + .hasAttribute('hidden'), + ).toBeTrue(); + }); + + it('hides the dots and stop indicator when the value reaches max', async () => { + const {element} = await setupTest( + html``, + ); + expect( + element + .shadowRoot!.querySelector('.linear-progress-dots')! + .hasAttribute('hidden'), + ).toBeTrue(); + expect( + element + .shadowRoot!.querySelector('.linear-progress-stop-indicator')! + .hasAttribute('hidden'), + ).toBeTrue(); + }); + + it('draws a smooth active by default and a wavy active when wavy', async () => { + const {element: standard} = await setupTest( + html``, + ); + const {element: wavy} = await setupTest( + html``, + ); + const standardPath = standard + .shadowRoot!.querySelector('svg path.linear-progress-active')! + .getAttribute('d')!; + const wavyPath = wavy + .shadowRoot!.querySelector('svg path.linear-progress-active')! + .getAttribute('d')!; + expect(standardPath.length).toBeGreaterThan(0); + expect(wavyPath.length).toBeGreaterThan(0); + expect(standardPath).not.toEqual(wavyPath); + }); + + describe('linearWavePath', () => { + const straight = 'M0,8 L100,8'; + + it('returns a straight line when not wavy', () => { + expect( + linearWavePath({ + width: 100, + height: 16, + amplitude: 3, + wavelength: 40, + wavy: false, + }), + ).toBe(straight); + }); + + it('returns a straight line when amplitude is not positive', () => { + expect( + linearWavePath({ + width: 100, + height: 16, + amplitude: 0, + wavelength: 40, + wavy: true, + }), + ).toBe(straight); + }); + + it('returns a straight line when wavelength is not positive', () => { + expect( + linearWavePath({ + width: 100, + height: 16, + amplitude: 3, + wavelength: 0, + wavy: true, + }), + ).toBe(straight); + }); + + it('returns a zero-length line when width is not positive', () => { + expect( + linearWavePath({ + width: 0, + height: 16, + amplitude: 3, + wavelength: 40, + wavy: true, + }), + ).toBe('M0,8 L0,8'); + }); + + it('renders a wave that differs from a straight line', () => { + const wavy = linearWavePath({ + width: 100, + height: 16, + amplitude: 3, + wavelength: 40, + wavy: true, + }); + expect(wavy.startsWith('M')).toBeTrue(); + expect(wavy).not.toEqual(straight); + }); + }); +}); diff --git a/labs/gb/components/progress/md-gb-circular-progress.ts b/labs/gb/components/progress/md-gb-circular-progress.ts new file mode 100644 index 0000000000..19f7d39764 --- /dev/null +++ b/labs/gb/components/progress/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/progress/md-gb-linear-progress.ts b/labs/gb/components/progress/md-gb-linear-progress.ts new file mode 100644 index 0000000000..f10fced462 --- /dev/null +++ b/labs/gb/components/progress/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);