From 9a11ebebf1326e65bb3439986990dc693521b44b Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 29 Sep 2026 15:21:43 -0700 Subject: [PATCH] feat(labs): add aria progress component PiperOrigin-RevId: 990564343 --- labs/aria/progress/demo/demo.ts | 31 ++ labs/aria/progress/demo/stories.ts | 133 +++++++ labs/aria/progress/md-aria-progress.ts | 15 + labs/aria/progress/progress.ts | 216 +++++++++++ labs/aria/progress/progress_test.ts | 496 +++++++++++++++++++++++++ 5 files changed, 891 insertions(+) create mode 100644 labs/aria/progress/demo/demo.ts create mode 100644 labs/aria/progress/demo/stories.ts create mode 100644 labs/aria/progress/md-aria-progress.ts create mode 100644 labs/aria/progress/progress.ts create mode 100644 labs/aria/progress/progress_test.ts diff --git a/labs/aria/progress/demo/demo.ts b/labs/aria/progress/demo/demo.ts new file mode 100644 index 0000000000..521b7513a8 --- /dev/null +++ b/labs/aria/progress/demo/demo.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import {boolInput, Knob, numberInput} from './index.js'; + +import {stories, StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'ARIA Progress', + [ + new Knob('indeterminate', {ui: boolInput(), defaultValue: false}), + new Knob('value', {ui: numberInput({step: 0.1}), defaultValue: 0.5}), + new Knob('max', {ui: numberInput(), defaultValue: 1}), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection); diff --git a/labs/aria/progress/demo/stories.ts b/labs/aria/progress/demo/stories.ts new file mode 100644 index 0000000000..9abc05b930 --- /dev/null +++ b/labs/aria/progress/demo/stories.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/aria/progress/md-aria-progress.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import {css, html, nothing} from 'lit'; + +/** Knob types for ARIA Progress stories. */ +export interface StoryKnobs { + indeterminate?: boolean; + value?: number; + max?: number; +} + +const progress: MaterialStoryInit = { + name: 'progress', + render(knobs) { + return html` + + `; + }, +}; + +const progressCustom: MaterialStoryInit = { + name: 'custom progress', + styles: css` + /* |@property| would be nice here... */ + @keyframes --linear-time { + 0% { + --time: 0; + } + 10% { + --time: 0.1; + } + 20% { + --time: 0.2; + } + 30% { + --time: 0.3; + } + 40% { + --time: 0.4; + } + 50% { + --time: 0.5; + } + 60% { + --time: 0.6; + } + 70% { + --time: 0.7; + } + 80% { + --time: 0.8; + } + 90% { + --time: 0.9; + } + 100% { + --time: 1; + } + } + + #track, + #bar { + --track-border-width: 1px; + position: absolute; + inset: 0px; + border-radius: 10000px; + --bg: peachpuff; + --fg: coral; + background-color: var(--bg); + + @media (forced-colors: active) { + --fg: CanvasText; + } + } + + #track { + border: var(--track-border-width) solid var(--fg); + } + + #bar { + --bar-inset: calc(var(--track-border-width) + 1px); + inset: var(--bar-inset); + background-color: var(--fg); + + md-aria-progress:state(indeterminate) & { + forced-color-adjust: none; + background-color: transparent; + background-image: repeating-linear-gradient( + to right, + transparent 0px, + transparent clamp(0px, calc(-5px + 10px * var(--time)), 10px), + var(--fg) clamp(0px, calc(-5px + 10px * var(--time)), 10px), + var(--fg) clamp(0px, calc(0px + 10px * var(--time)), 10px), + transparent clamp(0px, calc(0px + 10px * var(--time)), 10px), + transparent clamp(0px, calc(5px + 10px * var(--time)), 10px), + var(--fg) clamp(0px, calc(5px + 10px * var(--time)), 10px), + var(--fg) 10px + ); + animation: --linear-time 1s infinite linear; + } + + md-aria-progress:not(:state(indeterminate)) & { + width: calc( + 100% * var(--md-aria-progress-position) - 2 * var(--bar-inset) + ); + } + } + `, + render(knobs) { + return html` + +
+
+
+ `; + }, +}; + +/** ARIA Progress stories. */ +export const stories = [progress, progressCustom]; diff --git a/labs/aria/progress/md-aria-progress.ts b/labs/aria/progress/md-aria-progress.ts new file mode 100644 index 0000000000..080fdf6013 --- /dev/null +++ b/labs/aria/progress/md-aria-progress.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AriaProgressElement} from './progress.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-aria-progress': AriaProgressElement; + } +} + +customElements.define('md-aria-progress', AriaProgressElement); diff --git a/labs/aria/progress/progress.ts b/labs/aria/progress/progress.ts new file mode 100644 index 0000000000..86044f6ef2 --- /dev/null +++ b/labs/aria/progress/progress.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LitElement, PropertyValues, css, html, isServer} from 'lit'; +import {state} from 'lit/decorators.js'; +import { + mixinCustomStateSet, + toggleState, +} from '../../behaviors/custom-state-set.js'; +import { + internals, + mixinElementInternals, +} from '../../behaviors/element-internals.js'; + +const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement)); + +/** + * An ARIA progress element. + * + * @cssstate indeterminate - True when the progress has no `value`. + * @slot - Use this slot to replace the default rendered content of this + * component. These CSS properties are available to slotted content: + * + * - `--md-aria-progress-value` - The current value, or `indeterminate`. + * - `--md-aria-progress-max` - The maximum value. + * - `--md-aria-progress-position` - The current position (value / max, + * clamped to [0, 1]), or `indeterminate`. + */ +export class AriaProgressElement extends baseClass { + static override styles = css` + @keyframes --_indeterminate { + from { + inset-inline-start: 0%; + } + to { + inset-inline-start: 80%; + } + } + + :host { + display: inline-block; + position: relative; + block-size: 1em; + inline-size: 10em; + vertical-align: -0.2em; + overflow: hidden; + + /* These defaults are immediately overridden by inline styles. */ + --md-aria-progress-value: indeterminate; + --md-aria-progress-max: 1; + --md-aria-progress-position: indeterminate; + } + + :host(:not(:state(indeterminate))) { + --md-aria-progress-position: clamp( + 0, + calc(var(--md-aria-progress-value) / var(--md-aria-progress-max)), + 1 + ); + } + + #track, + #bar { + display: block; + position: absolute; + + inset: 0.25em 0px; + border-radius: 10000px; + + background: lightgray; + } + + #track { + @media (forced-colors: active) { + border: 1px solid transparent; + } + } + + #bar { + background: AccentColor; + + @media (forced-colors: active) { + background: CanvasText; + } + + :host(:state(indeterminate)) & { + inset-inline: unset; + inline-size: 20%; + animation: --_indeterminate 2s ease-in-out infinite alternate; + } + + :host(:not(:state(indeterminate))) & { + inline-size: calc(100% * var(--md-aria-progress-position)); + } + } + `; + + static override get observedAttributes() { + return [...super.observedAttributes, 'value', 'max']; + } + + @state() + private internalValue: number | null = null; + + @state() + private internalMax = 1; + + constructor() { + super(); + if (isServer) return; + + this[internals].role = 'progressbar'; + this[toggleState]('indeterminate', true); + } + + /** + * The current value of the progress bar. Must be non-negative and is clamped + * between 0 and `max`. + */ + get value(): number { + const {internalValue, internalMax} = this; + if (internalValue === null) { + return 0; + } + + return Math.max(0, Math.min(internalValue, internalMax)); + } + set value(value: number) { + if (value < 0) { + value = 0; + } + + this.setAttribute('value', String(value)); + } + + /** The maximum value of the progress bar. Must be positive. */ + get max(): number { + return this.internalMax; + } + set max(value: number) { + if (value <= 0) { + return; + } + + this.setAttribute('max', String(value)); + } + + /** The ratio of the current to maximum values. */ + get position(): number { + const {internalValue, internalMax} = this; + + if (internalValue === null) { + return -1; + } + + return Math.min(internalValue, internalMax) / internalMax; + } + + override attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (name === 'value') { + if (newValue === null) { + this.internalValue = null; + } else { + const parsed = Number(newValue); + this.internalValue = isNaN(parsed) || parsed < 0 ? 0 : parsed; + } + } + + if (name === 'max') { + if (newValue === null) { + this.internalMax = 1; + } else { + const parsed = Number(newValue); + this.internalMax = isNaN(parsed) || parsed <= 0 ? 1 : parsed; + } + } + } + + protected override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + const {internalValue, internalMax} = this; + + if (internalValue === null) { + this[internals].ariaValueNow = null; + this[toggleState]('indeterminate', true); + this.style.setProperty('--md-aria-progress-value', 'indeterminate'); + } else { + const usedValue = Math.min(internalValue, internalMax); + this[internals].ariaValueNow = String(usedValue); + this[toggleState]('indeterminate', false); + this.style.setProperty('--md-aria-progress-value', String(usedValue)); + } + + this[internals].ariaValueMax = String(internalMax); + this.style.setProperty('--md-aria-progress-max', String(internalMax)); + } + + protected override render() { + return html` + +
+
+
+ `; + } +} diff --git a/labs/aria/progress/progress_test.ts b/labs/aria/progress/progress_test.ts new file mode 100644 index 0000000000..774630459a --- /dev/null +++ b/labs/aria/progress/progress_test.ts @@ -0,0 +1,496 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) +import './md-aria-progress.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../testing/environment.js'; +import {internals} from '../../behaviors/element-internals.js'; + +function getCustomProperty(element: Element, name: string) { + return getComputedStyle(element).getPropertyValue(name).trim(); +} + +describe('md-aria-progress', () => { + const env = new Environment(); + + async function setupTest( + // This wrapper `
` is used so that test styles don't apply `display: + // block;` directly to the component. + template = html`
`, + ) { + const root = env.render(template); + await env.waitForStability(); + const progress = root.querySelector('md-aria-progress')!; + return {root, progress}; + } + + it('has computed display inline-block by default', async () => { + const {progress} = await setupTest(); + expect(getComputedStyle(progress).display).toBe('inline-block'); + }); + + describe('properties', () => { + describe('value', () => { + it('is 0 when the `value` attribute is not set', async () => { + const {progress} = await setupTest(); + + expect(progress.value).toBe(0); + }); + + it('is parsed from the `value` attribute', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.value).toBe(0.25); + }); + + it('is 0 when the `value` attribute is not a number', async () => { + const {progress} = await setupTest(); + progress.setAttribute('value', 'abc'); + await env.waitForStability(); + + expect(progress.value).toBe(0); + }); + + it('is clamped to 0 when the `value` attribute is negative', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.value).toBe(0); + }); + + it('is clamped to `max` when the `value` attribute exceeds it', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.value).toBe(10); + }); + + it('sets the `value` attribute when set', async () => { + const {progress} = await setupTest(); + + progress.value = 0.5; + await env.waitForStability(); + + expect(progress.getAttribute('value')).toBe('0.5'); + expect(progress.value).toBe(0.5); + }); + + it('sets the `value` attribute to 0 when set to a negative number', async () => { + const {progress} = await setupTest(); + + progress.value = -1; + await env.waitForStability(); + + expect(progress.getAttribute('value')).toBe('0'); + expect(progress.value).toBe(0); + }); + + it('sets the `value` attribute to a value above `max` without clamping', async () => { + const {progress} = await setupTest(); + + progress.value = 5; + await env.waitForStability(); + + expect(progress.getAttribute('value')).toBe('5'); + expect(progress.value).toBe(1); + }); + + it('is clamped when `max` is reduced below it', async () => { + const {progress} = await setupTest(html` +
+ `); + + progress.max = 2; + await env.waitForStability(); + + expect(progress.value).toBe(2); + expect(progress.getAttribute('value')).toBe('5'); + }); + }); + + describe('max', () => { + it('is 1 when the `max` attribute is not set', async () => { + const {progress} = await setupTest(); + + expect(progress.max).toBe(1); + }); + + it('is parsed from the `max` attribute', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.max).toBe(10); + }); + + for (const invalidMax of ['abc', '0', '-1']) { + it(`is 1 when the \`max\` attribute is "${invalidMax}"`, async () => { + const {progress} = await setupTest(); + progress.setAttribute('max', invalidMax); + await env.waitForStability(); + + expect(progress.max).toBe(1); + }); + } + + it('returns to 1 when the `max` attribute is removed', async () => { + const {progress} = await setupTest(html` +
+ `); + + progress.removeAttribute('max'); + await env.waitForStability(); + + expect(progress.max).toBe(1); + }); + + it('sets the `max` attribute when set', async () => { + const {progress} = await setupTest(); + + progress.max = 2; + await env.waitForStability(); + + expect(progress.getAttribute('max')).toBe('2'); + expect(progress.max).toBe(2); + }); + + for (const invalidMax of [0, -1]) { + it(`ignores being set to ${invalidMax}`, async () => { + const {progress} = await setupTest(html` +
+ `); + + progress.max = invalidMax; + await env.waitForStability(); + + expect(progress.getAttribute('max')).toBe('10'); + expect(progress.max).toBe(10); + }); + } + }); + + describe('position', () => { + it('is -1 when indeterminate', async () => { + const {progress} = await setupTest(); + + expect(progress.position).toBe(-1); + }); + + it('is the ratio of value to the default max', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.position).toBe(0.25); + }); + + it('is the ratio of value to max', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.position).toBe(0.375); + }); + + it('is 0 when the `value` attribute is negative', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.position).toBe(0); + }); + + it('is 1 when value exceeds max', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.position).toBe(1); + }); + + it('updates when the `value` attribute is added and removed', async () => { + const {progress} = await setupTest(); + + progress.setAttribute('value', '0.5'); + await env.waitForStability(); + expect(progress.position).toBe(0.5); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(progress.position).toBe(-1); + }); + }); + }); + + describe('ARIA', () => { + it('sets role to "progressbar"', async () => { + const {progress} = await setupTest(); + + expect(progress[internals].role).toBe('progressbar'); + }); + + it('sets ariaValueNow and ariaValueMax when determinate', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress[internals].ariaValueNow).toBe('3'); + expect(progress[internals].ariaValueMax).toBe('10'); + }); + + it('defaults ariaValueMax to 1', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress[internals].ariaValueMax).toBe('1'); + }); + + it('clamps ariaValueNow to max', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress[internals].ariaValueNow).toBe('10'); + }); + + it('clears ariaValueNow when indeterminate', async () => { + const {progress} = await setupTest(); + + expect(progress[internals].ariaValueNow).toBeNull(); + }); + + it('updates ariaValueNow when `value` changes', async () => { + const {progress} = await setupTest(); + + progress.value = 0.25; + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBe('0.25'); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBeNull(); + }); + }); + + describe(':state(indeterminate)', () => { + it('matches when `value` is not set', async () => { + const {progress} = await setupTest(); + + expect(progress.matches(':state(indeterminate)')).toBeTrue(); + }); + + it('does not match when `value` is set', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + }); + + it('does not match when `value` is 0', async () => { + const {progress} = await setupTest(html` +
+ `); + + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + }); + + it('does not match when the `value` attribute is not a number', async () => { + const {progress} = await setupTest(); + progress.setAttribute('value', 'abc'); + await env.waitForStability(); + + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + }); + + it('stops matching when the `value` property is set', async () => { + const {progress} = await setupTest(); + + progress.value = 0.5; + await env.waitForStability(); + + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + }); + + it('toggles when the `value` attribute is added and removed', async () => { + const {progress} = await setupTest(); + + progress.setAttribute('value', '0.5'); + await env.waitForStability(); + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(progress.matches(':state(indeterminate)')).toBeTrue(); + }); + }); + + describe('custom properties', () => { + async function setupCustomContentTest(attributes: { + value?: number; + max?: number; + }) { + const {root, progress} = await setupTest(); + if (attributes.value !== undefined) { + progress.setAttribute('value', String(attributes.value)); + } + if (attributes.max !== undefined) { + progress.setAttribute('max', String(attributes.max)); + } + + // Custom content uses `--md-aria-progress-position` to size itself, as + // in the demo's custom progress story. + const content = document.createElement('div'); + content.style.setProperty( + 'inline-size', + 'calc(100px * var(--md-aria-progress-position))', + ); + progress.append(content); + await env.waitForStability(); + + return {root, progress, content}; + } + + describe('--md-aria-progress-value', () => { + it('is the current value when determinate', async () => { + const {content} = await setupCustomContentTest({value: 3, max: 10}); + + expect(getCustomProperty(content, '--md-aria-progress-value')).toBe( + '3', + ); + }); + + it('is clamped to max', async () => { + const {content} = await setupCustomContentTest({value: 20, max: 10}); + + expect(getCustomProperty(content, '--md-aria-progress-value')).toBe( + '10', + ); + }); + + it('is "indeterminate" when indeterminate', async () => { + const {content} = await setupCustomContentTest({}); + + expect(getCustomProperty(content, '--md-aria-progress-value')).toBe( + 'indeterminate', + ); + }); + + it('updates when `value` changes', async () => { + const {progress, content} = await setupCustomContentTest({value: 1}); + + progress.value = 0.25; + await env.waitForStability(); + expect(getCustomProperty(content, '--md-aria-progress-value')).toBe( + '0.25', + ); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(getCustomProperty(content, '--md-aria-progress-value')).toBe( + 'indeterminate', + ); + }); + }); + + describe('--md-aria-progress-max', () => { + it('is the current max', async () => { + const {content} = await setupCustomContentTest({value: 3, max: 10}); + + expect(getCustomProperty(content, '--md-aria-progress-max')).toBe('10'); + }); + + it('defaults to 1', async () => { + const {content} = await setupCustomContentTest({value: 0.5}); + + expect(getCustomProperty(content, '--md-aria-progress-max')).toBe('1'); + }); + + it('is 1 when the `max` attribute is invalid', async () => { + const {content} = await setupCustomContentTest({value: 0.5, max: 0}); + + expect(getCustomProperty(content, '--md-aria-progress-max')).toBe('1'); + }); + }); + + describe('--md-aria-progress-position', () => { + it('is "indeterminate" when indeterminate', async () => { + const {content} = await setupCustomContentTest({}); + + expect(getCustomProperty(content, '--md-aria-progress-position')).toBe( + 'indeterminate', + ); + }); + + it('is the ratio of value to the default max', async () => { + const {content} = await setupCustomContentTest({value: 0.25}); + + expect(content.getBoundingClientRect().width).toBe(25); + }); + + it('is the ratio of value to max', async () => { + const {content} = await setupCustomContentTest({value: 3, max: 8}); + + expect(content.getBoundingClientRect().width).toBe(37.5); + }); + + it('is clamped to 0 when value is below 0', async () => { + const {content} = await setupCustomContentTest({value: -1}); + + expect(content.getBoundingClientRect().width).toBe(0); + }); + + it('is clamped to 1 when value is above max', async () => { + const {content} = await setupCustomContentTest({value: 20, max: 10}); + + expect(content.getBoundingClientRect().width).toBe(100); + }); + + it('updates when `value` and `max` change', async () => { + const {progress, content} = await setupCustomContentTest({value: 0}); + + progress.value = 2; + progress.max = 4; + await env.waitForStability(); + + expect(content.getBoundingClientRect().width).toBe(50); + }); + }); + }); + + describe('slotted content', () => { + it('renders default track and bar when no content is slotted', async () => { + const {progress} = await setupTest(); + + const slot = progress.shadowRoot!.querySelector('slot')!; + expect(slot.assignedNodes()).toEqual([]); + expect(progress.shadowRoot!.querySelector('#track')).not.toBeNull(); + expect(progress.shadowRoot!.querySelector('#bar')).not.toBeNull(); + }); + + it('assigns custom content to the default slot', async () => { + const {progress} = await setupTest(html` +
+
+
+ `); + + const slot = progress.shadowRoot!.querySelector('slot')!; + expect(slot.name).toBe(''); + expect(slot.assignedElements()).toEqual([ + progress.querySelector('#custom')!, + ]); + }); + }); +});