diff --git a/projects/core/.visual/progress-dot.dark.png b/projects/core/.visual/progress-dot.dark.png new file mode 100644 index 000000000..196cd1c5a --- /dev/null +++ b/projects/core/.visual/progress-dot.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1da5934dee0d82c7b5995ed6e2311eb1001bb2c43f307f95905d9aec7b0dde60 +size 888 diff --git a/projects/core/.visual/progress-dot.png b/projects/core/.visual/progress-dot.png new file mode 100644 index 000000000..1474c9d30 --- /dev/null +++ b/projects/core/.visual/progress-dot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35795c878ec87f08b16a00cda467f0df2755ed4c8f651a1e0f1c9532a1f9dcc0 +size 855 diff --git a/projects/core/package.json b/projects/core/package.json index 8b450d894..391c01c4a 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -663,6 +663,18 @@ "types": "./dist/progress-bar/define.d.ts", "default": "./dist/progress-bar/define.js" }, + "./progress-dot": { + "types": "./dist/progress-dot/index.d.ts", + "default": "./dist/progress-dot/index.js" + }, + "./progress-dot/index.js": { + "types": "./dist/progress-dot/index.d.ts", + "default": "./dist/progress-dot/index.js" + }, + "./progress-dot/define.js": { + "types": "./dist/progress-dot/define.d.ts", + "default": "./dist/progress-dot/define.js" + }, "./progress-ring": { "types": "./dist/progress-ring/index.d.ts", "default": "./dist/progress-ring/index.js" diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts index fb8c672ed..11fdec6f0 100644 --- a/projects/core/src/bundle.ts +++ b/projects/core/src/bundle.ts @@ -51,6 +51,7 @@ import '@nvidia-elements/core/panel/define.js'; import '@nvidia-elements/core/password/define.js'; import '@nvidia-elements/core/preferences-input/define.js'; import '@nvidia-elements/core/progress-bar/define.js'; +import '@nvidia-elements/core/progress-dot/define.js'; import '@nvidia-elements/core/progress-ring/define.js'; import '@nvidia-elements/core/progressive-filter-chip/define.js'; import '@nvidia-elements/core/pulse/define.js'; @@ -123,6 +124,7 @@ export * from '@nvidia-elements/core/panel'; export * from '@nvidia-elements/core/password'; export * from '@nvidia-elements/core/preferences-input'; export * from '@nvidia-elements/core/progress-bar'; +export * from '@nvidia-elements/core/progress-dot'; export * from '@nvidia-elements/core/progress-ring'; export * from '@nvidia-elements/core/progressive-filter-chip'; export * from '@nvidia-elements/core/pulse'; diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index 3ed158ce1..04ce6c67a 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -76,6 +76,7 @@ describe('lighthouse report', () => { import '@nvidia-elements/core/password/define.js'; import '@nvidia-elements/core/preferences-input/define.js'; import '@nvidia-elements/core/progress-bar/define.js'; + import '@nvidia-elements/core/progress-dot/define.js'; import '@nvidia-elements/core/progress-ring/define.js'; import '@nvidia-elements/core/progressive-filter-chip/define.js'; import '@nvidia-elements/core/pulse/define.js'; diff --git a/projects/core/src/progress-dot/define.ts b/projects/core/src/progress-dot/define.ts new file mode 100644 index 000000000..61cef7cb3 --- /dev/null +++ b/projects/core/src/progress-dot/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { ProgressDot } from '@nvidia-elements/core/progress-dot'; + +define(ProgressDot); + +declare global { + interface HTMLElementTagNameMap { + 'nve-progress-dot': ProgressDot; + } +} diff --git a/projects/core/src/progress-dot/index.ts b/projects/core/src/progress-dot/index.ts new file mode 100644 index 000000000..c59967153 --- /dev/null +++ b/projects/core/src/progress-dot/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './progress-dot.js'; diff --git a/projects/core/src/progress-dot/progress-dot.css b/projects/core/src/progress-dot/progress-dot.css new file mode 100644 index 000000000..4b2300198 --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.css @@ -0,0 +1,72 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --color: currentColor; + --width: var(--nve-ref-size-500); + --height: var(--nve-ref-size-500); + --animation-duration: calc(var(--nve-ref-animation-duration-400) * 1.5); + display: inline-flex; + color: inherit; +} + +:host([size='sm']) { + --width: var(--nve-ref-size-400); + --height: var(--nve-ref-size-400); +} + +:host([size='lg']) { + --width: var(--nve-ref-size-600); + --height: var(--nve-ref-size-600); +} + +[internal-host] { + width: var(--width); + height: var(--height); + display: flex; + align-items: center; + justify-content: center; +} + +svg { + width: 100%; + height: 100%; + fill: var(--color); +} + +circle { + opacity: 1; + animation: fade var(--animation-duration) ease-in-out infinite; +} + +circle:nth-child(2) { + animation-delay: calc(var(--animation-duration) / 3); +} + +circle:nth-child(3) { + animation-delay: calc(var(--animation-duration) * 2 / 3); +} + +@keyframes fade { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: var(--nve-ref-opacity-200); + } +} + +@media (prefers-reduced-motion: reduce) { + circle { + animation: none; + } +} + +/* A zero short-duration token means the active theme suppresses motion. */ +@container style(--nve-ref-animation-duration-150: 0) { + circle { + animation: none; + } +} diff --git a/projects/core/src/progress-dot/progress-dot.examples.ts b/projects/core/src/progress-dot/progress-dot.examples.ts new file mode 100644 index 000000000..467388aca --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.examples.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import '@nvidia-elements/core/progress-dot/define.js'; + +export default { + title: 'Elements/Progress Dot', + component: 'nve-progress-dot' +}; + +/** + * @summary Indeterminate dots communicate ongoing work when a completion percentage is unavailable. + */ +export const Default = { + render: () => html`` +}; + +/** + * @summary Three indicator sizes fit loading feedback into compact rows or spacious panels. + */ +export const Size = { + render: () => html` +
+ + + +
+ ` +}; + +/** + * @summary An adjacent label describes the pending task and supplies the indicator's accessible name. + */ +export const WithText = { + render: () => html` +
+ + Generating response… +
+ ` +}; + +/** + * @summary Button indicators inherit text color to keep loading feedback visible across button treatments. + */ +export const WithButton = { + render: () => html` +
+ + + Saving… + + + + Saving… + +
+ ` +}; + +/** + * @summary Custom color, dimensions, and timing adapt loading feedback to its surrounding interface. + * @tags theme + */ +export const CustomStyles = { + render: () => html` + + ` +}; + +/** + * @summary Static dots keep loading feedback visible when animation is unnecessary or distracting. + * @tags test-case + */ +export const Static = { + render: () => html` + + ` +}; diff --git a/projects/core/src/progress-dot/progress-dot.test.axe.ts b/projects/core/src/progress-dot/progress-dot.test.axe.ts new file mode 100644 index 000000000..416db3838 --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.test.axe.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { ProgressDot } from '@nvidia-elements/core/progress-dot'; +import '@nvidia-elements/core/progress-dot/define.js'; + +describe(ProgressDot.metadata.tag, () => { + let fixture: HTMLElement; + + beforeEach(async () => { + fixture = await createFixture(html` + + + Generating response + + + + `); + await Promise.all([...fixture.querySelectorAll('nve-progress-dot')].map(element => elementIsStable(element))); + }); + + afterEach(() => removeFixture(fixture)); + + it('should pass axe for all sizes and label sources', async () => { + const results = await runAxe([ProgressDot.metadata.tag]); + expect(results.violations.length).toBe(0); + }); +}); diff --git a/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts b/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts new file mode 100644 index 000000000..9acb04ff2 --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('progress-dot lighthouse report', () => { + test('should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-progress-dot', /* html */ ` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(12); + }); +}); diff --git a/projects/core/src/progress-dot/progress-dot.test.ssr.ts b/projects/core/src/progress-dot/progress-dot.test.ssr.ts new file mode 100644 index 000000000..43db0b156 --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.test.ssr.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { ProgressDot } from '@nvidia-elements/core/progress-dot'; +import '@nvidia-elements/core/progress-dot/define.js'; + +describe(ProgressDot.metadata.tag, () => { + it('should render three decorative dots and preserve author configuration', async () => { + const result = await ssrRunner.render(html` + + `); + expect(result).toContain('shadowroot="open"'); + expect(result).toContain('nve-progress-dot'); + expect(result).toContain('size="sm"'); + expect(result).toContain('aria-label="Loading results"'); + expect(result).toContain('aria-hidden="true"'); + expect(result.match(/ { + let fixture: HTMLElement; + let element: ProgressDot; + let loadingLabel: string; + let themeStyles: HTMLStyleElement; + + beforeEach(async () => { + loadingLabel = I18nService.i18n.loading; + themeStyles = document.createElement('style'); + themeStyles.textContent = reducedMotionStyles; + document.head.append(themeStyles); + fixture = await createFixture(html``); + element = fixture.querySelector('nve-progress-dot'); + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + themeStyles.remove(); + I18nService.update({ loading: loadingLabel }); + }); + + it('should register an indeterminate progress indicator with a default label', () => { + expect(customElements.get(ProgressDot.metadata.tag)).toBe(ProgressDot); + expect(element.size).toBe('md'); + expect(element.getAttribute('size')).toBe('md'); + expect(element._internals.role).toBe('progressbar'); + expect(element._internals.ariaLabel).toBe(loadingLabel); + expect(element._internals.ariaValueNow).toBeNull(); + expect(element.hasAttribute('aria-valuenow')).toBe(false); + expect(element.hasAttribute('tabindex')).toBe(false); + expect(element.shadowRoot.querySelector('svg').getAttribute('aria-hidden')).toBe('true'); + expect(element.shadowRoot.querySelectorAll('circle')).toHaveLength(3); + }); + + it.each([ + ['sm', 16], + ['md', 20], + ['lg', 24] + ] as const)('should reflect and render the %s size', async (size, dimension) => { + element.size = size; + await elementIsStable(element); + + expect(element.getAttribute('size')).toBe(size); + expect(element.getBoundingClientRect().width).toBe(dimension); + expect(element.getBoundingClientRect().height).toBe(dimension); + + element.setAttribute('size', 'sm'); + await elementIsStable(element); + expect(element.size).toBe('sm'); + }); + + it('should inherit surrounding text color', () => { + fixture.style.color = 'rgb(12, 34, 56)'; + expect(getComputedStyle(element.shadowRoot.querySelector('svg')).fill).toBe('rgb(12, 34, 56)'); + }); + + it('should allow CSS overrides across sizes', async () => { + element.style.cssText = '--width: 60px; --height: 40px; --color: rgb(56, 34, 12)'; + element.size = 'lg'; + await elementIsStable(element); + + expect(element.getBoundingClientRect().width).toBe(60); + expect(element.getBoundingClientRect().height).toBe(40); + expect(getComputedStyle(element.shadowRoot.querySelector('svg')).fill).toBe('rgb(56, 34, 12)'); + }); + + it('should scale animation delays with the cycle duration', () => { + element.style.setProperty('--animation-duration', '2.4s'); + const dots = [...element.shadowRoot.querySelectorAll('circle')]; + expect(dots.map(dot => getComputedStyle(dot).animationDuration)).toEqual(['2.4s', '2.4s', '2.4s']); + expect(dots.map(dot => getComputedStyle(dot).animationDelay)).toEqual(['0s', '0.8s', '1.6s']); + }); + + it('should visibly fade the dots during the default animation', () => { + // Loading a theme registers its availability without selecting that theme. + expect(getComputedStyle(element).getPropertyValue('--nve-config-reduced-motion')).toBe('true'); + const dot = element.shadowRoot.querySelector('circle'); + const animations = dot.getAnimations(); + expect(animations).toHaveLength(1); + const animation = animations[0]; + expect(animation.playState).toBe('running'); + const duration = Number(animation.effect.getComputedTiming().duration); + expect(duration).toBeGreaterThan(0); + animation.pause(); + animation.currentTime = 0; + expect(getComputedStyle(dot).opacity).toBe('1'); + animation.currentTime = duration / 2; + expect(Number(getComputedStyle(dot).opacity)).toBeLessThan(0.3); + }); + + it('should show static visible dots when the duration is zero', () => { + element.style.setProperty('--animation-duration', '0s'); + for (const dot of element.shadowRoot.querySelectorAll('circle')) { + expect(getComputedStyle(dot).animationDuration).toBe('0s'); + expect(getComputedStyle(dot).opacity).toBe('1'); + } + }); + + it('should stop animation for the reduced-motion theme', () => { + fixture.setAttribute('nve-theme', 'reduced-motion'); + element.style.setProperty('--animation-duration', '2s'); + for (const dot of element.shadowRoot.querySelectorAll('circle')) { + expect(getComputedStyle(dot).animationName).toBe('none'); + expect(getComputedStyle(dot).opacity).toBe('1'); + } + + fixture.removeAttribute('nve-theme'); + for (const dot of element.shadowRoot.querySelectorAll('circle')) { + expect(getComputedStyle(dot).animationName).toBe('fade'); + expect(getComputedStyle(dot).animationDuration).toBe('2s'); + } + }); + + it('should honor native hidden without changing loading semantics', () => { + element.hidden = true; + expect(getComputedStyle(element).display).toBe('none'); + element.hidden = false; + expect(getComputedStyle(element).display).toBe('inline-flex'); + expect(element._internals.role).toBe('progressbar'); + }); + + it('should update the fallback label from global translations', async () => { + I18nService.update({ loading: 'chargement' }); + await elementIsStable(element); + expect(element._internals.ariaLabel).toBe('chargement'); + }); + + it('should preserve instance translations across global updates', async () => { + element.i18n = { loading: 'Generating response' }; + await elementIsStable(element); + I18nService.update({ loading: 'chargement' }); + await elementIsStable(element); + + expect(element._internals.ariaLabel).toBe('Generating response'); + expect(element.hasAttribute('i18n')).toBe(false); + }); + + it('should preserve author labels when translations change', async () => { + element.setAttribute('aria-label', 'Loading results'); + element.setAttribute('aria-labelledby', 'results-label'); + element.i18n = { loading: 'chargement' }; + await elementIsStable(element); + + expect(element.getAttribute('aria-label')).toBe('Loading results'); + expect(element.getAttribute('aria-labelledby')).toBe('results-label'); + expect(element._internals.ariaLabel).toBe('chargement'); + }); +}); diff --git a/projects/core/src/progress-dot/progress-dot.test.visual.ts b/projects/core/src/progress-dot/progress-dot.test.visual.ts new file mode 100644 index 000000000..f2c5c7fc2 --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.test.visual.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('progress-dot visual', () => { + test.each(['', 'dark'] as const)('should match the %s theme baseline', async theme => { + const report = await visualRunner.render(theme ? 'progress-dot.dark' : 'progress-dot', template(theme)); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + + +
+ `; +} diff --git a/projects/core/src/progress-dot/progress-dot.ts b/projects/core/src/progress-dot/progress-dot.ts new file mode 100644 index 000000000..89d3e3a3a --- /dev/null +++ b/projects/core/src/progress-dot/progress-dot.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import type { Size } from '@nvidia-elements/core/internal'; +import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal'; +import styles from './progress-dot.css?inline'; + +/** + * @element nve-progress-dot + * @description An inline visual indicator that shows the progress of an ongoing task with an unknown completion percentage. + * @documentation https://nvidia.github.io/elements/docs/elements/progress-dot/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/core/progress-dot + * @cssprop --color - Dot color. Defaults to the inherited text color. + * @cssprop --width - Indicator width. + * @cssprop --height - Indicator height. + * @cssprop --animation-duration - Duration of one animation cycle. Set to 0s for static dots. + * @aria https://www.w3.org/TR/wai-aria-1.2/#progressbar + */ +export class ProgressDot extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-progress-dot', + version: '0.0.0' + }; + + /** @private */ + declare _internals: ElementInternals; + + /** Controls the indicator size. */ + @property({ type: String, reflect: true }) size: Size = 'md'; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating the default loading label for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + render() { + return html` +
+ +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + attachInternals(this); + this._internals.role = 'progressbar'; + } + + updated(props: PropertyValues) { + super.updated(props); + this._internals.ariaLabel = this.i18n.loading ?? null; + } +} diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index f6f41e3a1..54f67381b 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -347,6 +347,7 @@ export const renderDocsNav = data => /* html */ ` Pagination Progressive Filter Chip Progress Bar + Progress Dot Progress Ring Password Preferences Input diff --git a/projects/site/src/docs/elements/progress-dot.md b/projects/site/src/docs/elements/progress-dot.md new file mode 100644 index 000000000..dad4ac62f --- /dev/null +++ b/projects/site/src/docs/elements/progress-dot.md @@ -0,0 +1,25 @@ +--- +{ + title: 'Progress Dot', + layout: 'docs.11ty.js', + tag: 'nve-progress-dot' +} +--- + +## Installation + +{% install 'nve-progress-dot' %} + +## Sizing + +{% api 'nve-progress-dot' 'property' 'size' %} + +{% example '@nvidia-elements/core/progress-dot/progress-dot.examples.json' 'Size' %} + +## Accessible Labels + +The indicator provides a localized loading label by default. Set `aria-label` to describe the task, or use `aria-labelledby` to reference a visible label. Set `aria-busy="true"` on the region that loads, then remove or hide the indicator when the task finishes. + +{% example '@nvidia-elements/core/progress-dot/progress-dot.examples.json' 'WithText' %} + +{% api 'nve-progress-dot' 'property' 'i18n' %}