diff --git a/labs/aria/progress/demo/demo.ts b/labs/aria/progress/demo/demo.ts new file mode 100644 index 0000000000..27f98530ad --- /dev/null +++ b/labs/aria/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>( + 'ARIA Progress', + [ + new Knob('indeterminate', {ui: boolInput(), defaultValue: false}), + new Knob('value', {ui: numberInput({step: 1}), defaultValue: 50}), + new Knob('min', {ui: numberInput(), defaultValue: 0}), + new Knob('max', {ui: numberInput(), defaultValue: 100}), + ], +); + +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..d7f5547d31 --- /dev/null +++ b/labs/aria/progress/demo/stories.ts @@ -0,0 +1,134 @@ +/** + * @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; + min?: 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(--progress) - 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..22d5cf9044 --- /dev/null +++ b/labs/aria/progress/progress.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {LitElement, css, html} from 'lit'; +import {property} from 'lit/decorators.js'; +import {StyleInfo, styleMap} from 'lit/directives/style-map.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: + * + * - `--progress-value` - The current value, clamped to + * [`--progress-min`, `--progress-max`]. + * - `--progress-min` - The minimum value. + * - `--progress-max` - The maximum value, or `--progress-min` if `max` is + * less than `min`. + * - `--progress` - The current position, from 0 to 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-flex; + position: relative; + block-size: 1em; + inline-size: 10em; + vertical-align: -0.2em; + overflow: hidden; + container: size; + } + + slot { + /* Defaults until overridden by inline styles; --progress-value is + not set inline when indeterminate. */ + --progress-value: 0; + --progress-min: 0; + --progress-max: 100; + --progress: indeterminate; + + :host(:not(:state(indeterminate))) & { + --progress: progress( + var(--progress-value), + var(--progress-min), + var(--progress-max) + ); + } + } + + #track, + #bar { + display: block; + position: absolute; + + inset: 0.25em 0; + border-radius: 50cqmin; + } + + #track { + border: 1px solid currentColor; + } + + #bar { + forced-color-adjust: preserve-parent-color; + background: currentColor; + + :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(--progress)); + } + } + `; + + /** + * The current value, or `null` if indeterminate. Clamped to [`min`, `max`] + * when used. + */ + @property({type: Number, reflect: true, useDefault: true}) + value: number | null = null; + + /** The minimum value. */ + @property({type: Number, reflect: true, useDefault: true}) + min = 0; + + /** The maximum value. Treated as `min` when less than `min`. */ + @property({type: Number, reflect: true, useDefault: true}) + max = 100; + + constructor() { + super(); + + this[internals].role = 'progressbar'; + this[toggleState]('indeterminate', true); + } + + /** + * Updates ARIA properties and returns a `StyleInfo` containing computed + * `--progress-*` properties to be applied to this element's ``. + * + * This method must be called by any override for `value` / `min` / `max` to + * be correctly synced to ARIA properties and the entries it returns must be + * merged with any additional entries produced by the override for the + * `--progress-*` properties to be updated. New entries produced by the + * override do not affect ARIA or the indeterminate state. + */ + protected getProgressStyles(): StyleInfo { + const styles: StyleInfo = {}; + + const {value, min, max} = this; + const indeterminate = value === null; + const usedMin = min; + const usedMax = Math.max(min, max); + const usedValue = indeterminate + ? null + : Math.min(usedMax, Math.max(usedMin, value)); + + if (indeterminate) { + this[internals].ariaValueNow = null; + this[toggleState]('indeterminate', true); + } else { + this[internals].ariaValueNow = String(usedValue); + styles['--progress-value'] = String(usedValue); + this[toggleState]('indeterminate', false); + } + + this[internals].ariaValueMin = String(usedMin); + styles['--progress-min'] = String(usedMin); + + this[internals].ariaValueMax = String(usedMax); + styles['--progress-max'] = String(usedMax); + + return styles; + } + + 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..102671312a --- /dev/null +++ b/labs/aria/progress/progress_test.ts @@ -0,0 +1,531 @@ +/** + * @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}; + } + + describe('properties', () => { + describe('value', () => { + it('is null when the `value` attribute is not set', async () => { + const {progress} = await setUpTest(); + + expect(progress.value).toBeNull(); + }); + + it('is parsed from the `value` attribute', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.value).toBe(25); + }); + + it('is not clamped to `max`', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.value).toBe(20); + }); + + it('is not clamped to `min`', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.value).toBe(-1); + }); + + it('reflects to the `value` attribute when set', async () => { + const {progress} = await setUpTest(); + + expect(progress.getAttribute('value')).toBeNull(); + + progress.value = 50; + await env.waitForStability(); + + expect(progress.getAttribute('value')).toBe('50'); + }); + + it('removes the `value` attribute when set to null', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.value = null; + await env.waitForStability(); + + expect(progress.hasAttribute('value')).toBeFalse(); + }); + + it('returns to null when the `value` attribute is removed', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.removeAttribute('value'); + await env.waitForStability(); + + expect(progress.value).toBeNull(); + }); + }); + + describe('min', () => { + it('is 0 when the `min` attribute is not set', async () => { + const {progress} = await setUpTest(); + + expect(progress.min).toBe(0); + }); + + it('does not reflect its default value', async () => { + const {progress} = await setUpTest(); + + expect(progress.hasAttribute('min')).toBeFalse(); + }); + + it('is parsed from the `min` attribute', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.min).toBe(10); + }); + + it('reflects to the `min` attribute when set', async () => { + const {progress} = await setUpTest(); + + progress.min = 5; + await env.waitForStability(); + + expect(progress.getAttribute('min')).toBe('5'); + }); + + it('returns to 0 when the `min` attribute is removed', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.removeAttribute('min'); + await env.waitForStability(); + + expect(progress.min).toBe(0); + }); + }); + + describe('max', () => { + it('is 100 when the `max` attribute is not set', async () => { + const {progress} = await setUpTest(); + + expect(progress.max).toBe(100); + }); + + it('does not reflect its default value', async () => { + const {progress} = await setUpTest(); + + expect(progress.hasAttribute('max')).toBeFalse(); + }); + + it('is parsed from the `max` attribute', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.max).toBe(10); + }); + + it('reflects to the `max` attribute when set', async () => { + const {progress} = await setUpTest(); + + progress.max = 2; + await env.waitForStability(); + + expect(progress.getAttribute('max')).toBe('2'); + }); + + it('returns to 100 when the `max` attribute is removed', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.removeAttribute('max'); + await env.waitForStability(); + + expect(progress.max).toBe(100); + }); + + it('is not modified when less than `min`', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress.max).toBe(2); + expect(progress.getAttribute('max')).toBe('2'); + }); + }); + }); + + describe('ARIA', () => { + it('sets role to "progressbar"', async () => { + const {progress} = await setUpTest(); + + expect(progress[internals].role).toBe('progressbar'); + }); + + it('sets ariaValueNow, ariaValueMin, and ariaValueMax when determinate', async () => { + const {progress} = await setUpTest(html` +
+ +
+ `); + + expect(progress[internals].ariaValueNow).toBe('3'); + expect(progress[internals].ariaValueMin).toBe('1'); + expect(progress[internals].ariaValueMax).toBe('10'); + }); + + it('defaults ariaValueMin to 0 and ariaValueMax to 100', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress[internals].ariaValueMin).toBe('0'); + expect(progress[internals].ariaValueMax).toBe('100'); + }); + + it('clamps ariaValueNow to max', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress[internals].ariaValueNow).toBe('10'); + }); + + it('clamps ariaValueNow to min', async () => { + const {progress} = await setUpTest(html` +
+ `); + + expect(progress[internals].ariaValueNow).toBe('10'); + }); + + it('updates the clamped ariaValueNow when `min` and `max` change', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.max = 20; + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBe('20'); + + progress.max = 100; + progress.min = 60; + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBe('60'); + }); + + 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 = 25; + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBe('25'); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(progress[internals].ariaValueNow).toBeNull(); + }); + + it('updates ariaValueMin and ariaValueMax when `min` and `max` change', async () => { + const {progress} = await setUpTest(); + + progress.min = 10; + progress.max = 20; + await env.waitForStability(); + + expect(progress[internals].ariaValueMin).toBe('10'); + expect(progress[internals].ariaValueMax).toBe('20'); + }); + + it('raises ariaValueMax to min when max is less than min', async () => { + const {progress} = await setUpTest(html` +
+ +
+ `); + + expect(progress[internals].ariaValueMin).toBe('10'); + expect(progress[internals].ariaValueMax).toBe('10'); + expect(progress[internals].ariaValueNow).toBe('10'); + }); + }); + + 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('stops matching when the `value` property is set', async () => { + const {progress} = await setUpTest(); + + progress.value = 50; + await env.waitForStability(); + + expect(progress.matches(':state(indeterminate)')).toBeFalse(); + }); + + it('matches when the `value` property is set to null', async () => { + const {progress} = await setUpTest(html` +
+ `); + + progress.value = null; + await env.waitForStability(); + + expect(progress.matches(':state(indeterminate)')).toBeTrue(); + }); + + it('toggles when the `value` attribute is added and removed', async () => { + const {progress} = await setUpTest(); + + progress.setAttribute('value', '50'); + 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; + min?: number; + max?: number; + }) { + const {root, progress} = await setUpTest(); + for (const name of ['value', 'min', 'max'] as const) { + const value = attributes[name]; + if (value !== undefined) { + progress.setAttribute(name, String(value)); + } + } + + // Custom content uses `--progress` to size itself, as in the demo's + // custom progress story. + const content = document.createElement('div'); + content.style.setProperty('inline-size', 'calc(100px * var(--progress))'); + progress.append(content); + await env.waitForStability(); + + return {root, progress, content}; + } + + describe('--progress-value', () => { + it('is the current value', async () => { + const {content} = await setUpCustomContentTest({value: 3, max: 10}); + + expect(getCustomProperty(content, '--progress-value')).toBe('3'); + }); + + it('is clamped to max', async () => { + const {content} = await setUpCustomContentTest({value: 20, max: 10}); + + expect(getCustomProperty(content, '--progress-value')).toBe('10'); + }); + + it('is clamped to min', async () => { + const {content} = await setUpCustomContentTest({value: 5, min: 10}); + + expect(getCustomProperty(content, '--progress-value')).toBe('10'); + }); + + it('defaults to 0 when indeterminate', async () => { + const {content} = await setUpCustomContentTest({}); + + expect(getCustomProperty(content, '--progress-value')).toBe('0'); + }); + + it('updates when `value` changes', async () => { + const {progress, content} = await setUpCustomContentTest({value: 1}); + + progress.value = 25; + await env.waitForStability(); + expect(getCustomProperty(content, '--progress-value')).toBe('25'); + + progress.removeAttribute('value'); + await env.waitForStability(); + expect(getCustomProperty(content, '--progress-value')).toBe('0'); + }); + }); + + describe('--progress-min', () => { + it('is the current min', async () => { + const {content} = await setUpCustomContentTest({value: 3, min: 2}); + + expect(getCustomProperty(content, '--progress-min')).toBe('2'); + }); + + it('defaults to 0', async () => { + const {content} = await setUpCustomContentTest({value: 50}); + + expect(getCustomProperty(content, '--progress-min')).toBe('0'); + }); + + it('updates when `min` changes', async () => { + const {progress, content} = await setUpCustomContentTest({value: 50}); + + progress.min = 10; + await env.waitForStability(); + + expect(getCustomProperty(content, '--progress-min')).toBe('10'); + }); + }); + + describe('--progress-max', () => { + it('is the current max', async () => { + const {content} = await setUpCustomContentTest({value: 3, max: 10}); + + expect(getCustomProperty(content, '--progress-max')).toBe('10'); + }); + + it('defaults to 100', async () => { + const {content} = await setUpCustomContentTest({value: 50}); + + expect(getCustomProperty(content, '--progress-max')).toBe('100'); + }); + + it('updates when `max` changes', async () => { + const {progress, content} = await setUpCustomContentTest({value: 50}); + + progress.max = 200; + await env.waitForStability(); + + expect(getCustomProperty(content, '--progress-max')).toBe('200'); + }); + + it('is raised to min when max is less than min', async () => { + const {content} = await setUpCustomContentTest({ + value: 5, + min: 10, + max: 2, + }); + + expect(getCustomProperty(content, '--progress-max')).toBe('10'); + }); + }); + + describe('--progress', () => { + it('is "indeterminate" when indeterminate', async () => { + const {content} = await setUpCustomContentTest({}); + + expect(getCustomProperty(content, '--progress')).toBe('indeterminate'); + }); + + it('is the progress of value between the default min and max', async () => { + const {content} = await setUpCustomContentTest({value: 25}); + + expect(content.getBoundingClientRect().width).toBe(25); + }); + + it('is the progress of value between 0 and max', async () => { + const {content} = await setUpCustomContentTest({value: 3, max: 8}); + + expect(content.getBoundingClientRect().width).toBe(37.5); + }); + + it('is the progress of value between min and max', async () => { + const {content} = await setUpCustomContentTest({ + value: 30, + min: 20, + max: 60, + }); + + expect(content.getBoundingClientRect().width).toBe(25); + }); + + it('supports a negative min', async () => { + const {content} = await setUpCustomContentTest({ + value: 0, + min: -10, + max: 10, + }); + + expect(content.getBoundingClientRect().width).toBe(50); + }); + + it('is clamped to 0 when value is below min', async () => { + const {content} = await setUpCustomContentTest({value: 5, min: 10}); + + 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`, `min`, and `max` change', async () => { + const {progress, content} = await setUpCustomContentTest({value: 0}); + + progress.value = 3; + progress.min = 2; + progress.max = 6; + await env.waitForStability(); + + expect(content.getBoundingClientRect().width).toBe(25); + }); + }); + }); +});