diff --git a/projects/core/.visual/progress-gauge.dark.png b/projects/core/.visual/progress-gauge.dark.png new file mode 100644 index 000000000..db5e281be --- /dev/null +++ b/projects/core/.visual/progress-gauge.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0236d6b78bf6e1c9b51a4c75e47da7532624294d1beb668a88ef905d5e4eb832 +size 50155 diff --git a/projects/core/.visual/progress-gauge.png b/projects/core/.visual/progress-gauge.png new file mode 100644 index 000000000..e9330b117 --- /dev/null +++ b/projects/core/.visual/progress-gauge.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b19ee9c236e3538b0c846171fc8820514b5816b4975bef61d3ea72a09ee860c1 +size 48192 diff --git a/projects/core/package.json b/projects/core/package.json index d1e78788b..5a78bf857 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -605,6 +605,18 @@ "types": "./dist/progress-bar/define.d.ts", "default": "./dist/progress-bar/define.js" }, + "./progress-gauge": { + "types": "./dist/progress-gauge/index.d.ts", + "default": "./dist/progress-gauge/index.js" + }, + "./progress-gauge/index.js": { + "types": "./dist/progress-gauge/index.d.ts", + "default": "./dist/progress-gauge/index.js" + }, + "./progress-gauge/define.js": { + "types": "./dist/progress-gauge/define.d.ts", + "default": "./dist/progress-gauge/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 b94181262..ff6c31914 100644 --- a/projects/core/src/bundle.ts +++ b/projects/core/src/bundle.ts @@ -47,6 +47,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-gauge/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'; @@ -115,6 +116,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-gauge'; 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 a493fa164..4caf2e338 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -15,7 +15,7 @@ describe('lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(130.5); + expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(132); // if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(120); @@ -62,6 +62,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-gauge/define.js'; import '@nvidia-elements/core/progress-ring/define.js'; import '@nvidia-elements/core/progressive-filter-chip/define.js'; import '@nvidia-elements/core/radio/define.js'; diff --git a/projects/core/src/progress-gauge/define.ts b/projects/core/src/progress-gauge/define.ts new file mode 100644 index 000000000..e875174c6 --- /dev/null +++ b/projects/core/src/progress-gauge/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 { ProgressGauge } from '@nvidia-elements/core/progress-gauge'; + +define(ProgressGauge); + +declare global { + interface HTMLElementTagNameMap { + 'nve-progress-gauge': ProgressGauge; + } +} diff --git a/projects/core/src/progress-gauge/index.ts b/projects/core/src/progress-gauge/index.ts new file mode 100644 index 000000000..af2979efa --- /dev/null +++ b/projects/core/src/progress-gauge/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-gauge.js'; diff --git a/projects/core/src/progress-gauge/progress-gauge.css b/projects/core/src/progress-gauge/progress-gauge.css new file mode 100644 index 000000000..04f20b580 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.css @@ -0,0 +1,134 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --color: var(--nve-sys-text-emphasis-color); + --background: var(--nve-sys-interaction-background); + --accent-color: var(--nve-sys-interaction-color); + --gauge-width: 12px; + --font-size: var(--nve-ref-font-size-400); + --gap: var(--nve-ref-space-xs); + --width: 128px; + --height: var(--width); + --_animation-duration: var(--nve-ref-animation-duration-250); + + display: inline-block; + position: relative; + width: var(--width); + height: var(--height); + container-type: inline-size; + contain: content; + text-box: trim-both cap alphabetic; +} + +:host([size='sm']) { + --font-size: var(--nve-ref-font-size-300); + --width: 96px; +} + +:host([size='lg']) { + --font-size: var(--nve-ref-font-size-500); + --width: 160px; +} + +[internal-host] { + display: grid; + place-items: center; + position: relative; + height: 100%; +} + +:host([container='half']) { + --height: calc(var(--width) / 2); +} + +:host([container='half']) [internal-host] { + place-items: end center; +} + +svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +path { + fill: none; + stroke-linecap: round; + stroke-width: var(--gauge-width); +} + +path.background { + stroke: var(--background); +} + +path.gauge { + animation: gauge-progress-in var(--_animation-duration) var(--nve-ref-animation-easing-100); + stroke: var(--accent-color); + stroke-dasharray: var(--_progress) 100; + transition: stroke-dasharray var(--_animation-duration) var(--nve-ref-animation-easing-100); + will-change: stroke-dasharray; +} + +path.gauge[empty] { + animation: none; + stroke-linecap: butt; +} + +slot { + display: flex; + flex-direction: column; + place-items: center; + justify-content: center; + height: 100%; + gap: var(--gap); + color: var(--color); + font-size: var(--font-size); + font-weight: var(--nve-ref-font-weight-medium); +} + +:host([container='half']) slot { + transform: translateY(10cqw); +} + +::slotted(*) { + color: var(--color); + font-size: var(--font-size); +} + +:host([status='success']) { + --accent-color: var(--nve-sys-support-success-emphasis-color); + --background: var(--nve-sys-support-success-muted-color); +} + +:host([status='warning']) { + --accent-color: var(--nve-sys-support-warning-emphasis-color); + --background: var(--nve-sys-support-warning-muted-color); +} + +:host([status='danger']) { + --accent-color: var(--nve-sys-support-danger-emphasis-color); + --background: var(--nve-sys-support-danger-muted-color); +} + +:host([status='accent']) { + --accent-color: var(--nve-sys-accent-secondary-background); +} + +@media (prefers-reduced-motion: reduce) { + :host { + --_animation-duration: 0s; + } +} + +@keyframes gauge-progress-in { + from { + stroke-dasharray: 0 100; + } + + to { + stroke-dasharray: var(--_progress) 100; + } +} diff --git a/projects/core/src/progress-gauge/progress-gauge.examples.ts b/projects/core/src/progress-gauge/progress-gauge.examples.ts new file mode 100644 index 000000000..65c5060d5 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.examples.ts @@ -0,0 +1,136 @@ +// 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-gauge/define.js'; + +export default { + title: 'Elements/Progress Gauge', + component: 'nve-progress-gauge', +}; + +/** + * @summary 270-degree progress gauges for displaying system resource usage. + */ +export const Default = { + render: () => html` +
+ 50% + 66% +
+`}; + +/** + * @summary Container variants compare the default 270-degree gauge with the compact half gauge for telemetry layouts with tighter vertical space. + * @tags test-case + */ +export const Container = { + render: () => html` +
+ 66% + 66% +
+`}; + +/** + * @summary Gauges with values from 0% to 100% for displaying system resource usage. + * @tags test-case + */ +export const Values = { + render: () => html` +
+ 0% + 33% + 66% + 100% +
+`}; + +/** + * @summary Progress gauges with custom max values for mission checkpoints, validation clips, and map tile processing. + * @tags test-case + */ +export const Max = { + render: () => html` +
+ 5/20 + 10/20 + 15/20 +
+`}; + +/** + * @summary Progress gauges with accent, success, warning, and danger colors for autonomous system health and readiness signals. + * @tags test-case + */ +export const Status = { + render: () => html` +
+ 50% + 75% + 75% + 2.1m + 0Hz +
+`}; + +/** + * @summary Small progress gauge paired with route-solve text for compact autonomous vehicle task rows. + * @tags test-case + */ +export const WithText = { + render: () => html` +
+ 2.4s + Route solve +
+`}; + +/** + * @summary Progress gauges in small, medium, and large sizes for dense robotics and autonomous vehicle dashboards. + * @tags test-case + */ +export const Sizing = { + render: () => html` +
+ 30Hz + 12Hz + 84% +
+`}; + +/** + * @summary Use for displaying real-time system load and performance metrics. + * @tags pattern + */ +export const Dynamic = { + render: () => html` +
+ + + 0% + GPU + + +
+ +`}; diff --git a/projects/core/src/progress-gauge/progress-gauge.test.axe.ts b/projects/core/src/progress-gauge/progress-gauge.test.axe.ts new file mode 100644 index 000000000..a66db58c8 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.test.axe.ts @@ -0,0 +1,33 @@ +// 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, beforeEach, afterEach } from 'vitest'; +import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { ProgressGauge } from '@nvidia-elements/core/progress-gauge'; +import '@nvidia-elements/core/progress-gauge/define.js'; + +describe(ProgressGauge.metadata.tag, () => { + let fixture: HTMLElement; + + beforeEach(async () => { + fixture = await createFixture(html` + + + + + `); + const elements = Array.from(fixture.querySelectorAll(ProgressGauge.metadata.tag)) as ProgressGauge[]; + await Promise.all(elements.map(gauge => elementIsStable(gauge))); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should pass axe check', async () => { + const results = await runAxe([ProgressGauge.metadata.tag]); + expect(results.violations.length).toBe(0); + }); +}); diff --git a/projects/core/src/progress-gauge/progress-gauge.test.lighthouse.ts b/projects/core/src/progress-gauge/progress-gauge.test.lighthouse.ts new file mode 100644 index 000000000..2191bc5d7 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.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 { expect, test, describe } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('progress-gauge lighthouse report', () => { + test('progress-gauge should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-progress-gauge', /* 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(15); + }); +}); diff --git a/projects/core/src/progress-gauge/progress-gauge.test.ssr.ts b/projects/core/src/progress-gauge/progress-gauge.test.ssr.ts new file mode 100644 index 000000000..c0541be94 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.test.ssr.ts @@ -0,0 +1,16 @@ +// 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 { ProgressGauge } from '@nvidia-elements/core/progress-gauge'; +import '@nvidia-elements/core/progress-gauge/define.js'; + +describe(ProgressGauge.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes('nve-progress-gauge')).toBe(true); + }); +}); diff --git a/projects/core/src/progress-gauge/progress-gauge.test.ts b/projects/core/src/progress-gauge/progress-gauge.test.ts new file mode 100644 index 000000000..9b9f0d00d --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.test.ts @@ -0,0 +1,268 @@ +// 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, beforeEach, afterEach } from 'vitest'; +import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; +import { ProgressGauge } from '@nvidia-elements/core/progress-gauge'; +import '@nvidia-elements/core/progress-gauge/define.js'; + +describe(ProgressGauge.metadata.tag, () => { + let fixture: HTMLElement; + let element: ProgressGauge; + + beforeEach(async () => { + fixture = await createFixture(html` + + `); + element = fixture.querySelector(ProgressGauge.metadata.tag); + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(ProgressGauge.metadata.tag)).toBeDefined(); + }); + + it('should set aria attributes', async () => { + element.value = 50; + element.max = 80; + await elementIsStable(element); + + expect(element._internals.role).toBe('progressbar'); + expect(element._internals.ariaValueNow).toBe('50'); + expect(element._internals.ariaValueMax).toBe('80'); + expect(element._internals.ariaLabel).toBe('information'); + + element.status = 'success'; + await elementIsStable(element); + expect(element._internals.ariaLabel).toBe('success'); + }); + + it('should default to neutral status', () => { + expect(element.status).toBe('neutral'); + }); + + it('should default to the 270-degree container', () => { + expect(element.container).toBeUndefined(); + }); + + it('should default max to 100', () => { + expect(element.max).toBe(100); + }); + + it('should default value to 0', () => { + expect(element.value).toBe(0); + }); + + it('should default gauge width to 12px', () => { + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(getComputedStyle(gauge).strokeWidth).toBe('12px'); + }); + + it.each([ + ['default', undefined, '128px'], + ['sm', 'sm', '96px'], + ['md', 'md', '128px'], + ['lg', 'lg', '160px'] + ] as const)('should set %s width', async (_, size, width) => { + if (size) { + element.size = size; + } + + await elementIsStable(element); + + expect(getComputedStyle(element).width).toBe(width); + }); + + it('should scale slotted text with size', async () => { + removeFixture(fixture); + fixture = await createFixture(html` +
+ 50% + 50% + 50% +
+ `); + + const gauges = Array.from(fixture.querySelectorAll(ProgressGauge.metadata.tag)) as ProgressGauge[]; + await Promise.all(gauges.map(gauge => elementIsStable(gauge))); + + const fontSizes = ['sm', 'md', 'lg'].map(size => + parseFloat(getComputedStyle(fixture.querySelector(`[data-size="${size}"]`) as HTMLElement).fontSize) + ); + + expect(fontSizes[0]).toBeLessThan(fontSizes[1]); + expect(fontSizes[1]).toBeLessThan(fontSizes[2]); + }); + + it('should not apply the status color to slotted text', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + 50% + + `); + element = fixture.querySelector(ProgressGauge.metadata.tag); + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + const text = fixture.querySelector('span') as HTMLSpanElement; + + expect(getComputedStyle(text).color).not.toBe(getComputedStyle(gauge).stroke); + }); + + it.each(['warning', 'success', 'danger'] as const)( + 'should leave the default slot empty for %s status', + async status => { + element.status = status; + await elementIsStable(element); + + const defaultSlot = element.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement; + expect(defaultSlot.childElementCount).toBe(0); + expect(defaultSlot.textContent.trim()).toBe(''); + } + ); + + it('should render default state as determinate 0 progress', async () => { + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(gauge.getAttribute('stroke-dasharray')).toBe('0 100'); + }); + + it('should suppress rounded foreground caps when progress is 0', async () => { + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(gauge.hasAttribute('empty')).toBe(true); + expect(getComputedStyle(gauge).strokeLinecap).toBe('butt'); + + element.value = 1; + await elementIsStable(element); + + expect(gauge.hasAttribute('empty')).toBe(false); + expect(getComputedStyle(gauge).strokeLinecap).toBe('round'); + }); + + it('should animate progress on initial render and value changes', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + `); + element = fixture.querySelector(ProgressGauge.metadata.tag); + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(gauge.style.getPropertyValue('--_progress')).toBe('50'); + expect(getComputedStyle(gauge).animationName).toBe('gauge-progress-in'); + expect(getComputedStyle(gauge).transitionProperty).toBe('stroke-dasharray'); + + element.value = 75; + await elementIsStable(element); + + expect(gauge.style.getPropertyValue('--_progress')).toBe('75'); + expect(gauge.getAttribute('stroke-dasharray')).toBe('75 100'); + }); + + it('should render a 270-degree inset arc with rounded ends by default', async () => { + element.value = 50; + await elementIsStable(element); + + const svg = element.shadowRoot.querySelector('svg') as SVGElement; + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(svg.getAttribute('viewBox')).toBe('0 0 128 128'); + expect(gauge.getAttribute('d')).toBe('M 27.23 100.77 A 52 52 0 1 1 100.77 100.77'); + expect(getComputedStyle(gauge).strokeLinecap).toBe('round'); + expect(getComputedStyle(element).height).toBe('128px'); + }); + + it('should render the half container with the semi-circular arc', async () => { + element.container = 'half'; + element.value = 50; + await elementIsStable(element); + + const svg = element.shadowRoot.querySelector('svg') as SVGElement; + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + + expect(element.getAttribute('container')).toBe('half'); + expect(svg.getAttribute('viewBox')).toBe('0 0 128 64'); + expect(gauge.getAttribute('d')).toBe('M 12 64 A 52 52 0 0 1 116 64'); + expect(getComputedStyle(gauge).strokeLinecap).toBe('round'); + expect(getComputedStyle(element).height).toBe('64px'); + }); + + it('should assign slotted content to the default slot', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + 50% + + `); + element = fixture.querySelector(ProgressGauge.metadata.tag); + await elementIsStable(element); + + const defaultSlot = element.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement; + const assigned = defaultSlot.assignedElements(); + expect(assigned).toHaveLength(1); + expect(assigned[0]).toBe(fixture.querySelector('span')); + }); + + it('should set stroke-dasharray to 0 100 when value is 0', async () => { + element.value = 0; + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + expect(gauge.getAttribute('stroke-dasharray')).toBe('0 100'); + }); + + it('should default stroke-dasharray scaling when max is omitted', async () => { + element.value = 50; + element.max = undefined; + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + expect(gauge.getAttribute('stroke-dasharray')).toBe('50 100'); + }); + + it('should scale stroke-dasharray with a custom max', async () => { + element.value = 5; + element.max = 20; + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + expect(gauge.getAttribute('stroke-dasharray')).toBe('25 100'); + }); + + it('should clamp over-max values', async () => { + element.value = 150; + element.max = 100; + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + expect(gauge.getAttribute('stroke-dasharray')).toBe('100 100'); + expect(element._internals.ariaValueNow).toBe('100'); + expect(element._internals.ariaValueMax).toBe('100'); + }); + + it.each([ + { name: 'NaN value', value: Number.NaN, max: 100, dasharray: '0 100', ariaValueNow: '0', ariaValueMax: '100' }, + { name: 'negative value', value: -1, max: 100, dasharray: '0 100', ariaValueNow: '0', ariaValueMax: '100' }, + { name: 'NaN max', value: 50, max: Number.NaN, dasharray: '50 100', ariaValueNow: '50', ariaValueMax: '100' }, + { name: 'zero max', value: 50, max: 0, dasharray: '50 100', ariaValueNow: '50', ariaValueMax: '100' }, + { name: 'negative max', value: 50, max: -1, dasharray: '50 100', ariaValueNow: '50', ariaValueMax: '100' } + ])('should normalize $name', async ({ value, max, dasharray, ariaValueNow, ariaValueMax }) => { + element.value = value; + element.max = max; + await elementIsStable(element); + + const gauge = element.shadowRoot.querySelector('.gauge') as SVGPathElement; + expect(gauge.getAttribute('stroke-dasharray')).toBe(dasharray); + expect(element._internals.ariaValueNow).toBe(ariaValueNow); + expect(element._internals.ariaValueMax).toBe(ariaValueMax); + }); +}); diff --git a/projects/core/src/progress-gauge/progress-gauge.test.visual.ts b/projects/core/src/progress-gauge/progress-gauge.test.visual.ts new file mode 100644 index 000000000..de824257d --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.test.visual.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test, describe } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('progress-gauge visual', () => { + test('progress-gauge should match visual baseline', async () => { + const report = await visualRunner.render('progress-gauge', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('progress-gauge should match visual baseline dark theme', async () => { + const report = await visualRunner.render('progress-gauge.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + + + +
+ + + + + + +
+ +
+ + + +
+ +
+ + 30Hz + + + 12Hz + + + 84% + +
+ +
+ + + + 75% + + + 100% + +
+ `; +} diff --git a/projects/core/src/progress-gauge/progress-gauge.ts b/projects/core/src/progress-gauge/progress-gauge.ts new file mode 100644 index 000000000..b40d4b638 --- /dev/null +++ b/projects/core/src/progress-gauge/progress-gauge.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import type { Size, SupportStatus } from '@nvidia-elements/core/internal'; +import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal'; +import styles from './progress-gauge.css?inline'; + +const GAUGE_GEOMETRY = { + default: { + path: 'M 27.23 100.77 A 52 52 0 1 1 100.77 100.77', + viewBox: '0 0 128 128' + }, + half: { + path: 'M 12 64 A 52 52 0 0 1 116 64', + viewBox: '0 0 128 64' + } +} as const; + +/** + * @element nve-progress-gauge + * @description Use progress gauge to show system resource usage. + * @since 2.0.2 + * @entrypoint \@nvidia-elements/core/progress-gauge + * @slot - Content to display in the gauge center. + * @cssprop --gauge-width + * @cssprop --accent-color + * @cssprop --background + * @cssprop --color + * @cssprop --width + * @cssprop --height + * @cssprop --font-size + * @aria https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/progressbar_role + * @stable false + */ +export class ProgressGauge extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-progress-gauge', + version: '0.0.0' + }; + + /** @private */ + declare _internals: ElementInternals; + + /** The current `value` of the progress gauge. */ + @property({ type: Number }) value = 0; + + /** The `max` value of the progress gauge that the `value` is proportionally scaled to. */ + @property({ type: Number }) max? = 100; + + /** Four visual treatments represent the `status` of tasks. */ + @property({ type: String, reflect: true }) status?: SupportStatus | 'neutral' = 'neutral'; + + /** Determines the gauge container shape. Set `half` for a compact semi-circular arc. */ + @property({ type: String, reflect: true }) container?: 'half'; + + /** T-shirt `size` of the progress indicator, used to scale the gauge. */ + @property({ type: String, reflect: true }) size?: Size; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + #normalizedValues() { + const sourceMax = this.max; + const max = sourceMax !== undefined && Number.isFinite(sourceMax) && sourceMax > 0 ? sourceMax : 100; + const value = Number.isFinite(this.value) ? Math.min(Math.max(this.value, 0), max) : 0; + return { value, max }; + } + + render() { + const geometry = GAUGE_GEOMETRY[this.container ?? 'default']; + const { value, max } = this.#normalizedValues(); + const progress = (value / max) * 100; + + return html` +
+ + +
+ `; + } + + connectedCallback() { + super.connectedCallback(); + attachInternals(this); + this._internals.role = 'progressbar'; + } + + updated(props: PropertyValues) { + super.updated(props); + const { value, max } = this.#normalizedValues(); + this._internals.ariaValueNow = `${value}`; + this._internals.ariaValueMax = `${max}`; + const i18nRecord = this.i18n as Record; + this._internals.ariaLabel = + (this.status && i18nRecord[this.status] && i18nRecord[this.status] !== 'neutral' + ? i18nRecord[this.status]! + : this.i18n.information) ?? null; + } +} diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index 580b90cc9..b76cba85d 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -284,6 +284,7 @@ export const renderDocsNav = data => /* html */ ` Pagination Progressive Filter Chip Progress Bar + Progress Gauge Progress Ring Password Preferences Input diff --git a/projects/site/src/docs/elements/progress-gauge.md b/projects/site/src/docs/elements/progress-gauge.md new file mode 100644 index 000000000..522b41c02 --- /dev/null +++ b/projects/site/src/docs/elements/progress-gauge.md @@ -0,0 +1,45 @@ +--- +{ + title: 'Progress Gauge', + layout: 'docs.11ty.js', + tag: 'nve-progress-gauge' +} +--- + +## Installation + +{% install 'nve-progress-gauge' %} + +## Indicating Status + +{% api 'nve-progress-gauge', 'property', 'status' %} + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Status' %} + +## Container + +{% api 'nve-progress-gauge', 'property', 'container' %} + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Container' %} + +## Sizing + +{% api 'nve-progress-gauge', 'property', 'size' %} + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Sizing' %} + +## Value + +{% api 'nve-progress-gauge', 'property', 'value' %} + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Values' %} + +## Max Value + +{% api 'nve-progress-gauge', 'property', 'max' %} + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Max' %} + +## Dynamic + +{% example '@nvidia-elements/core/progress-gauge/progress-gauge.examples.json' 'Dynamic' %}