From c3881b8c11d66a32311576bd37a4821b769ec809 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Fri, 31 Jul 2026 17:51:33 -0500 Subject: [PATCH 1/2] feat(core): format-bytes - Introduced `nve-format-bytes` component to convert byte counts into human-readable decimal or binary units Signed-off-by: Cory Rylan --- projects/core/.visual/format-bytes.dark.png | 3 + projects/core/.visual/format-bytes.png | 3 + projects/core/package.json | 12 + projects/core/src/bundle.ts | 2 + projects/core/src/format-bytes/define.ts | 13 + .../core/src/format-bytes/format-bytes.css | 14 + .../src/format-bytes/format-bytes.examples.ts | 94 +++++++ .../src/format-bytes/format-bytes.test.axe.ts | 32 +++ .../format-bytes.test.lighthouse.ts | 21 ++ .../src/format-bytes/format-bytes.test.ssr.ts | 18 ++ .../src/format-bytes/format-bytes.test.ts | 246 ++++++++++++++++++ .../format-bytes/format-bytes.test.visual.ts | 33 +++ .../core/src/format-bytes/format-bytes.ts | 218 ++++++++++++++++ projects/core/src/format-bytes/index.ts | 4 + projects/core/src/index.test.lighthouse.ts | 3 +- projects/site/src/_11ty/layouts/common.js | 1 + .../site/src/docs/elements/format-bytes.md | 43 +++ 17 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 projects/core/.visual/format-bytes.dark.png create mode 100644 projects/core/.visual/format-bytes.png create mode 100644 projects/core/src/format-bytes/define.ts create mode 100644 projects/core/src/format-bytes/format-bytes.css create mode 100644 projects/core/src/format-bytes/format-bytes.examples.ts create mode 100644 projects/core/src/format-bytes/format-bytes.test.axe.ts create mode 100644 projects/core/src/format-bytes/format-bytes.test.lighthouse.ts create mode 100644 projects/core/src/format-bytes/format-bytes.test.ssr.ts create mode 100644 projects/core/src/format-bytes/format-bytes.test.ts create mode 100644 projects/core/src/format-bytes/format-bytes.test.visual.ts create mode 100644 projects/core/src/format-bytes/format-bytes.ts create mode 100644 projects/core/src/format-bytes/index.ts create mode 100644 projects/site/src/docs/elements/format-bytes.md diff --git a/projects/core/.visual/format-bytes.dark.png b/projects/core/.visual/format-bytes.dark.png new file mode 100644 index 0000000000..ee24d5f338 --- /dev/null +++ b/projects/core/.visual/format-bytes.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:706ff483db971ca309d32dc42c28afeebe3982e0b965fc137cb44de621a3212d +size 8122 diff --git a/projects/core/.visual/format-bytes.png b/projects/core/.visual/format-bytes.png new file mode 100644 index 0000000000..be082e3b2f --- /dev/null +++ b/projects/core/.visual/format-bytes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4ad01781c3eacad2a5fb1541a346ffb54a9e35437f31c1a98b05e5cafd81ef3 +size 8253 diff --git a/projects/core/package.json b/projects/core/package.json index c6e7a177c4..e785615be7 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -378,6 +378,18 @@ "types": "./dist/file/define.d.ts", "default": "./dist/file/define.js" }, + "./format-bytes": { + "types": "./dist/format-bytes/index.d.ts", + "default": "./dist/format-bytes/index.js" + }, + "./format-bytes/index.js": { + "types": "./dist/format-bytes/index.d.ts", + "default": "./dist/format-bytes/index.js" + }, + "./format-bytes/define.js": { + "types": "./dist/format-bytes/define.d.ts", + "default": "./dist/format-bytes/define.js" + }, "./format-datetime": { "types": "./dist/format-datetime/index.d.ts", "default": "./dist/format-datetime/index.js" diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts index 8d15c1e5ec..de28bb4e1c 100644 --- a/projects/core/src/bundle.ts +++ b/projects/core/src/bundle.ts @@ -27,6 +27,7 @@ import '@nvidia-elements/core/dropdown/define.js'; import '@nvidia-elements/core/dropdown-group/define.js'; import '@nvidia-elements/core/dropzone/define.js'; import '@nvidia-elements/core/file/define.js'; +import '@nvidia-elements/core/format-bytes/define.js'; import '@nvidia-elements/core/format-datetime/define.js'; import '@nvidia-elements/core/format-number/define.js'; import '@nvidia-elements/core/format-relative-time/define.js'; @@ -96,6 +97,7 @@ export * from '@nvidia-elements/core/dropdown'; export * from '@nvidia-elements/core/dropdown-group'; export * from '@nvidia-elements/core/dropzone'; export * from '@nvidia-elements/core/file'; +export * from '@nvidia-elements/core/format-bytes'; export * from '@nvidia-elements/core/format-datetime'; export * from '@nvidia-elements/core/format-number'; export * from '@nvidia-elements/core/format-relative-time'; diff --git a/projects/core/src/format-bytes/define.ts b/projects/core/src/format-bytes/define.ts new file mode 100644 index 0000000000..74b3aa8568 --- /dev/null +++ b/projects/core/src/format-bytes/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 { FormatBytes } from '@nvidia-elements/core/format-bytes'; + +define(FormatBytes); + +declare global { + interface HTMLElementTagNameMap { + 'nve-format-bytes': FormatBytes; + } +} diff --git a/projects/core/src/format-bytes/format-bytes.css b/projects/core/src/format-bytes/format-bytes.css new file mode 100644 index 0000000000..5843acc026 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.css @@ -0,0 +1,14 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + display: inline; +} + +[internal-host] { + color: var(--nve-sys-text-color, inherit); +} + +slot { + display: none; +} diff --git a/projects/core/src/format-bytes/format-bytes.examples.ts b/projects/core/src/format-bytes/format-bytes.examples.ts new file mode 100644 index 0000000000..d71c4cb1c7 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.examples.ts @@ -0,0 +1,94 @@ +// 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/format-bytes/define.js'; + +export default { + title: 'Elements/FormatBytes', + component: 'nve-format-bytes' +}; + +/** + * @summary Automatic decimal conversion for concise file sizes and storage metrics. The component selects the appropriate unit from the byte count. + */ +export const Default = { + render: () => html` +
+ 1024 + 1048576 + 1073741824 +
+ ` +}; + +/** + * @summary Forced unit magnitudes for comparing byte counts on a consistent scale. Use when values need the same unit across a table or chart. + */ +export const Unit = { + render: () => html` +
+ 1048576 + 1048576 + 1048576 +
+ ` +}; + +/** + * @summary Short and long unit labels for compact metrics or explanatory text. Long labels improve clarity when space allows. + */ +export const UnitDisplay = { + render: () => html` +
+ 1048576 + 1048576 + 1048576 +
+ ` +}; + +/** + * @summary Fraction digit controls for matching the precision of storage measurements. Use fixed digits when values must align visually. + */ +export const Precision = { + render: () => html` +
+ 1234567 + 1234567 + 1234567 +
+ ` +}; + +/** + * @summary Explicit locale formatting for audiences whose numeric separators differ from the document language. Unit labels remain lowercase English. + */ +export const Locale = { + render: () => html` +
+ 1048576 + 1048576 + 1048576 +
+ ` +}; + +/** + * @summary Value attribute input for JavaScript or bound data. It takes precedence over text content while the text remains an SSR fallback. + */ +export const Value = { + render: () => html`1024` +}; + +/** + * @summary Decimal and binary conversion for matching SI or IEC storage conventions. Use the convention expected by the surrounding product. + */ +export const Display = { + render: () => html` +
+ 1024 + 1024 +
+ ` +}; diff --git a/projects/core/src/format-bytes/format-bytes.test.axe.ts b/projects/core/src/format-bytes/format-bytes.test.axe.ts new file mode 100644 index 0000000000..89c2867a5e --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.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 { beforeEach, afterEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { FormatBytes } from '@nvidia-elements/core/format-bytes'; +import '@nvidia-elements/core/format-bytes/define.js'; + +describe(FormatBytes.metadata.tag, () => { + let fixture: HTMLElement; + let element: FormatBytes; + + beforeEach(async () => { + fixture = await createFixture(html` + 1048576 + `); + element = fixture.querySelector(FormatBytes.metadata.tag); + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should pass axe check', async () => { + await elementIsStable(element); + const results = await runAxe([FormatBytes.metadata.tag]); + expect(results.violations.length).toBe(0); + }); +}); diff --git a/projects/core/src/format-bytes/format-bytes.test.lighthouse.ts b/projects/core/src/format-bytes/format-bytes.test.lighthouse.ts new file mode 100644 index 0000000000..0005f478f9 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.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('format-bytes lighthouse report', () => { + test('format-bytes should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-format-bytes', /* html */ ` + 1048576 + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(11); + }); +}); diff --git a/projects/core/src/format-bytes/format-bytes.test.ssr.ts b/projects/core/src/format-bytes/format-bytes.test.ssr.ts new file mode 100644 index 0000000000..6db4ac007e --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.test.ssr.ts @@ -0,0 +1,18 @@ +// 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 { FormatBytes } from '@nvidia-elements/core/format-bytes'; +import '@nvidia-elements/core/format-bytes/define.js'; + +describe(FormatBytes.metadata.tag, () => { + it('should render formatted semantic output during ssr', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes('')).toBe(true); + expect(result.includes('1.05 mb')).toBe(true); + expect(result.includes('nve-format-bytes')).toBe(true); + }); +}); diff --git a/projects/core/src/format-bytes/format-bytes.test.ts b/projects/core/src/format-bytes/format-bytes.test.ts new file mode 100644 index 0000000000..2e0bcb8e5d --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.test.ts @@ -0,0 +1,246 @@ +// 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, vi } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { FormatBytes, type FormatBytesUnit } from '@nvidia-elements/core/format-bytes'; +import { LogService } from '@nvidia-elements/core/internal'; +import '@nvidia-elements/core/format-bytes/define.js'; + +function renderedData(element: FormatBytes): HTMLDataElement | null { + return element.shadowRoot?.querySelector('data') ?? null; +} + +function renderedText(element: FormatBytes): string { + return renderedData(element)?.textContent?.trim() ?? ''; +} + +describe(FormatBytes.metadata.tag, () => { + let fixture: HTMLElement; + let element: FormatBytes; + let originalDocumentLang: string; + + beforeEach(async () => { + originalDocumentLang = document.documentElement.lang; + fixture = await createFixture(html`1048576`); + element = fixture.querySelector(FormatBytes.metadata.tag); + await elementIsStable(element); + }); + + afterEach(() => { + document.documentElement.lang = originalDocumentLang; + removeFixture(fixture); + vi.restoreAllMocks(); + }); + + it('should define element', () => { + expect(customElements.get(FormatBytes.metadata.tag)).toBeDefined(); + }); + + it('should render semantic data with the raw byte count', () => { + expect(renderedData(element)?.getAttribute('value')).toBe('1048576'); + expect(renderedText(element)).toBe('1.05 mb'); + }); + + it('should use value over slot content', async () => { + element.value = 1024; + await elementIsStable(element); + + expect(renderedData(element)?.getAttribute('value')).toBe('1024'); + expect(renderedText(element)).toBe('1.02 kb'); + }); + + it('should use slot content when value attribute is removed', async () => { + element.setAttribute('value', '1024'); + await elementIsStable(element); + + element.removeAttribute('value'); + await elementIsStable(element); + + expect(renderedData(element)?.getAttribute('value')).toBe('1048576'); + expect(renderedText(element)).toBe('1.05 mb'); + }); + + it('should re-render when slot content changes', async () => { + element.textContent = '1073741824'; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1.07 gb'); + }); + + it.each([ + ['999', '999 b'], + ['1000', '1 kb'], + ['1024', '1.02 kb'], + ['1048576', '1.05 mb'], + ['1073741824', '1.07 gb'] + ])('should automatically format decimal bytes %s as %s', async (value, expected) => { + element.textContent = value; + await elementIsStable(element); + + expect(renderedText(element)).toBe(expected); + }); + + it.each([ + ['1023', '1,023 b'], + ['1024', '1 kib'], + ['1048576', '1 mib'], + ['1073741824', '1 gib'] + ])('should automatically format binary bytes %s as %s', async (value, expected) => { + element.display = 'binary'; + element.textContent = value; + await elementIsStable(element); + + expect(renderedText(element)).toBe(expected); + }); + + it.each<[FormatBytesUnit, string]>([ + ['kb', '1,048.58 kb'], + ['mb', '1.05 mb'], + ['gb', '0 gb'] + ])('should force the %s unit', async (unit, expected) => { + element.unit = unit; + await elementIsStable(element); + + expect(renderedText(element)).toBe(expected); + }); + + it('should use the forced magnitude with binary labels', async () => { + element.display = 'binary'; + element.unit = 'kb'; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1,024 kib'); + }); + + it.each([ + [1000000, '1 megabyte'], + [1500000, '1.5 megabytes'], + [-1000000, '-1 megabyte'] + ])('should format decimal long labels for %s bytes', async (value, expected) => { + element.value = value; + element.unitDisplay = 'long'; + await elementIsStable(element); + + expect(renderedText(element)).toBe(expected); + }); + + it('should format binary long labels', async () => { + element.display = 'binary'; + element.unitDisplay = 'long'; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1 mebibyte'); + }); + + it('should select a long label from the rounded value', async () => { + element.value = 999999; + element.unit = 'mb'; + element.unitDisplay = 'long'; + element.maximumFractionDigits = 0; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1 megabyte'); + }); + + it('should format with maximum fraction digits', async () => { + element.textContent = '1234567'; + element.maximumFractionDigits = 0; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1 mb'); + }); + + it('should format with fixed fraction digits', async () => { + element.textContent = '1234567'; + element.minimumFractionDigits = 3; + element.maximumFractionDigits = 3; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1.235 mb'); + }); + + it('should expand the effective default maximum for minimum fraction digits', async () => { + element.textContent = '1234567'; + element.minimumFractionDigits = 3; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1.235 mb'); + }); + + it('should use the configured locale', async () => { + element.locale = 'de-DE'; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1,05 mb'); + }); + + it('should use the document locale by default', async () => { + element.locale = undefined; + document.documentElement.lang = 'de-DE'; + element.requestUpdate(); + await elementIsStable(element); + + expect(renderedText(element)).toBe('1,05 mb'); + }); + + it('should preserve zero and negative values', async () => { + element.value = 0; + await elementIsStable(element); + expect(renderedText(element)).toBe('0 b'); + + element.value = -1000; + await elementIsStable(element); + expect(renderedText(element)).toBe('-1 kb'); + }); + + it('should cap automatic conversion at petabytes', async () => { + element.value = 1e18; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1,000 pb'); + }); + + it.each([ + ['display', 'invalid'], + ['unit', 'invalid'], + ['unit-display', 'invalid'] + ])('should preserve input for an invalid %s option', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + expect(renderedText(element)).toBe('1048576'); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('should preserve and warn for invalid numeric input', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.textContent = 'not-a-number'; + await elementIsStable(element); + + expect(renderedText(element)).toBe('not-a-number'); + expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"'); + }); + + it('should preserve and warn for an invalid value attribute', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute('value', 'not-a-number'); + await elementIsStable(element); + + expect(renderedData(element)?.getAttribute('value')).toBe('not-a-number'); + expect(renderedText(element)).toBe('not-a-number'); + expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"'); + }); + + it('should preserve input for invalid fraction digit options', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.minimumFractionDigits = 3; + element.maximumFractionDigits = 2; + await elementIsStable(element); + + expect(renderedText(element)).toBe('1048576'); + expect(warn).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/core/src/format-bytes/format-bytes.test.visual.ts b/projects/core/src/format-bytes/format-bytes.test.visual.ts new file mode 100644 index 0000000000..2507788d75 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.test.visual.ts @@ -0,0 +1,33 @@ +// 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('format-bytes visual', () => { + test('format-bytes should match visual baseline', async () => { + const report = await visualRunner.render('format-bytes', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('format-bytes should match visual baseline dark theme', async () => { + const report = await visualRunner.render('format-bytes.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ 1024 + 1048576 + 1048576 + 1048576 + 1048576 +
+ `; +} diff --git a/projects/core/src/format-bytes/format-bytes.ts b/projects/core/src/format-bytes/format-bytes.ts new file mode 100644 index 0000000000..cdd0943d40 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { LogService, typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import styles from './format-bytes.css?inline'; + +export type FormatBytesDisplay = 'decimal' | 'binary'; +export type FormatBytesUnit = 'b' | 'kb' | 'mb' | 'gb' | 'tb' | 'pb'; +export type FormatBytesUnitDisplay = 'short' | 'long'; + +interface UnitLabels { + short: string; + singular: string; + plural: string; +} + +const UNITS: readonly FormatBytesUnit[] = ['b', 'kb', 'mb', 'gb', 'tb', 'pb']; +const DISPLAYS: readonly FormatBytesDisplay[] = ['decimal', 'binary']; +const UNIT_DISPLAYS: readonly FormatBytesUnitDisplay[] = ['short', 'long']; + +const DECIMAL_LABELS: Record = { + b: { short: 'b', singular: 'byte', plural: 'bytes' }, + kb: { short: 'kb', singular: 'kilobyte', plural: 'kilobytes' }, + mb: { short: 'mb', singular: 'megabyte', plural: 'megabytes' }, + gb: { short: 'gb', singular: 'gigabyte', plural: 'gigabytes' }, + tb: { short: 'tb', singular: 'terabyte', plural: 'terabytes' }, + pb: { short: 'pb', singular: 'petabyte', plural: 'petabytes' } +}; + +const BINARY_LABELS: Record = { + b: { short: 'b', singular: 'byte', plural: 'bytes' }, + kb: { short: 'kib', singular: 'kibibyte', plural: 'kibibytes' }, + mb: { short: 'mib', singular: 'mebibyte', plural: 'mebibytes' }, + gb: { short: 'gib', singular: 'gibibyte', plural: 'gibibytes' }, + tb: { short: 'tib', singular: 'tebibyte', plural: 'tebibytes' }, + pb: { short: 'pib', singular: 'pebibyte', plural: 'pebibytes' } +}; + +function isDisplay(value: unknown): value is FormatBytesDisplay { + return DISPLAYS.some(display => display === value); +} + +function isUnit(value: unknown): value is FormatBytesUnit { + return UNITS.some(unit => unit === value); +} + +function isUnitDisplay(value: unknown): value is FormatBytesUnitDisplay { + return UNIT_DISPLAYS.some(unitDisplay => unitDisplay === value); +} + +/** + * @element nve-format-bytes + * @description Formats a byte count as localized, human-readable decimal or binary units. + * @documentation https://nvidia.github.io/elements/docs/elements/format-bytes/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/core/format-bytes + * @slot - Numeric byte count to format (such as 1048576). Serves as fallback before hydration. + * @aria https://developer.mozilla.org/en-US/docs/Web/HTML/Element/data + */ +@typeSSR() +export class FormatBytes extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-format-bytes', + version: '0.0.0' + }; + + /** + * Optional byte count for values supplied by JavaScript or bound data. + * By default, the component formats the element's text content, which also serves as the SSR fallback. + * When both are present, this property takes precedence. + */ + @property({ type: Number }) value?: number; + + /** + * Unit system: 'decimal' uses powers of 1000 and 'binary' uses powers of 1024. + */ + @property({ type: String }) display: FormatBytesDisplay = 'decimal'; + + /** + * Optional unit magnitude. When omitted, the component selects a unit from the byte count. + */ + @property({ type: String }) unit?: FormatBytesUnit; + + /** + * Unit label length: 'short' renders labels such as 'mb'; 'long' renders labels such as 'megabytes'. + */ + @property({ type: String, attribute: 'unit-display' }) unitDisplay: FormatBytesUnitDisplay = 'short'; + + /** + * Language tag (such as en-US or de-DE) used to format the number. + * Defaults to document.documentElement.lang or the runtime default. + */ + @property({ type: String }) locale?: string; + + /** + * Pad fraction output to at least this many digits. + */ + @property({ type: Number, attribute: 'minimum-fraction-digits' }) minimumFractionDigits?: number; + + /** + * Round fraction output to at most this many digits. Defaults to two effective digits after the decimal point. + */ + @property({ type: Number, attribute: 'maximum-fraction-digits' }) maximumFractionDigits?: number; + + get #rawValue(): string { + const value = Number.isNaN(this.value) ? (this.getAttribute('value') ?? this.value) : this.value; + return String(value ?? this.textContent?.trim() ?? ''); + } + + get #resolvedLocale(): string | undefined { + return this.locale ?? (globalThis.document?.documentElement?.lang || undefined); + } + + get #parsedValue(): number | null { + const rawValue = this.#rawValue; + if (!rawValue) return null; + + const numericValue = Number(rawValue); + if (Number.isFinite(numericValue)) return numericValue; + + LogService.warn(`format-bytes: invalid numeric value "${rawValue}"`); + return null; + } + + #resolveAutoUnit(value: number, display: FormatBytesDisplay): FormatBytesUnit { + const base = display === 'binary' ? 1024 : 1000; + const absoluteValue = Math.abs(value); + + for (let index = UNITS.length - 1; index > 0; index--) { + const unit = UNITS[index]; + if (unit && absoluteValue >= base ** index) return unit; + } + + return 'b'; + } + + #formatLabel(unit: FormatBytesUnit, convertedValue: number): string { + const labels = this.display === 'binary' ? BINARY_LABELS[unit] : DECIMAL_LABELS[unit]; + if (this.unitDisplay === 'short') return labels.short; + return Math.abs(convertedValue) === 1 ? labels.singular : labels.plural; + } + + #warnInvalidOption(name: string, value: unknown): void { + LogService.warn(`format-bytes: invalid ${name} value "${String(value)}"`); + } + + #hasValidConfiguration(): boolean { + if (!isDisplay(this.display)) { + this.#warnInvalidOption('display', this.display); + return false; + } + if (!isUnitDisplay(this.unitDisplay)) { + this.#warnInvalidOption('unit-display', this.unitDisplay); + return false; + } + if (this.unit !== undefined && !isUnit(this.unit)) { + this.#warnInvalidOption('unit', this.unit); + return false; + } + return true; + } + + get #numberFormatOptions(): Intl.NumberFormatOptions { + const effectiveMaximumFractionDigits = this.maximumFractionDigits ?? Math.max(this.minimumFractionDigits ?? 0, 2); + return { + minimumFractionDigits: this.minimumFractionDigits, + maximumFractionDigits: effectiveMaximumFractionDigits + }; + } + + #roundNumber(value: number): number { + return Number( + new Intl.NumberFormat('en-US-u-nu-latn', { + ...this.#numberFormatOptions, + useGrouping: false + }).format(value) + ); + } + + #formatNumber(value: number): string { + return new Intl.NumberFormat(this.#resolvedLocale, this.#numberFormatOptions).format(value); + } + + get #formattedBytes(): string { + const rawValue = this.#rawValue; + if (!rawValue) return ''; + + const numericValue = this.#parsedValue; + if (numericValue === null) return rawValue; + if (!this.#hasValidConfiguration()) return rawValue; + + const resolvedUnit = this.unit ?? this.#resolveAutoUnit(numericValue, this.display); + const unitIndex = UNITS.indexOf(resolvedUnit); + const base = this.display === 'binary' ? 1024 : 1000; + const convertedValue = numericValue / base ** unitIndex; + + try { + const roundedValue = this.#roundNumber(convertedValue); + return `${this.#formatNumber(roundedValue)} ${this.#formatLabel(resolvedUnit, roundedValue)}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + LogService.warn(`format-bytes: ${message}`); + return rawValue; + } + } + + render() { + return html`${this.#formattedBytes}`; + } + + #onSlotChange() { + this.requestUpdate(); + } +} diff --git a/projects/core/src/format-bytes/index.ts b/projects/core/src/format-bytes/index.ts new file mode 100644 index 0000000000..b315b510e8 --- /dev/null +++ b/projects/core/src/format-bytes/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 './format-bytes.js'; diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index 5e235dbf60..89e1864d76 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(133.6); + expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(134.5); // 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); @@ -45,6 +45,7 @@ describe('lighthouse report', () => { import '@nvidia-elements/core/drawer/define.js'; import '@nvidia-elements/core/dropdown/define.js'; import '@nvidia-elements/core/file/define.js'; + import '@nvidia-elements/core/format-bytes/define.js'; import '@nvidia-elements/core/forms/define.js'; import '@nvidia-elements/core/gauge/define.js'; import '@nvidia-elements/core/grid/define.js'; diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index de8027b741..86ab92590e 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -270,6 +270,7 @@ export const renderDocsNav = data => /* html */ ` Dropdown Group Dropzone File + Format Bytes Format Datetime Format Number Format Relative Time diff --git a/projects/site/src/docs/elements/format-bytes.md b/projects/site/src/docs/elements/format-bytes.md new file mode 100644 index 0000000000..c502538812 --- /dev/null +++ b/projects/site/src/docs/elements/format-bytes.md @@ -0,0 +1,43 @@ +--- +{ + title: 'Format Bytes', + layout: 'docs.11ty.js', + tag: 'nve-format-bytes' +} +--- + +## Installation + +{% install 'nve-format-bytes' %} + +Format Bytes converts byte counts into readable decimal or binary units. Supply the byte count as text content to provide an SSR fallback, or set the `value` property or attribute for JavaScript and bound data. If both are present, `value` takes precedence. + +The `locale` property controls number formatting. Unit labels remain lowercase English strings in every locale. + +## Default + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Default' %} + +## Unit + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Unit' %} + +## Unit Display + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'UnitDisplay' %} + +## Precision + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Precision' %} + +## Locale + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Locale' %} + +## Value + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Value' %} + +## Display + +{% example '@nvidia-elements/core/format-bytes/format-bytes.examples.json', 'Display' %} From e7f5d5c6a6981c8a7d789636053f621720690957 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 5 Aug 2026 17:45:18 -0500 Subject: [PATCH 2/2] chore(ci): lint rules and skill for component registration and testing - Updated the component creation guide to include new steps for updating shared entry points and measuring payloads. - Modified ESLint rules to enforce bundle registration and added support for Lighthouse benchmark integration. - Expanded Lighthouse test imports to include additional components and adjusted payload size expectations. - Removed deprecated component from vocabulary list and updated related documentation. Signed-off-by: Cory Rylan --- .agents/skills/component-creation/SKILL.md | 7 +- .../config/vocabularies/Elements/accept.txt | 1 - knip.config.js | 1 - mise.toml | 2 +- projects/code/eslint.config.js | 6 + projects/core/eslint.config.js | 12 ++ projects/core/src/index.test.lighthouse.ts | 11 +- .../core/src/internal/utils/keynav.test.ts | 13 +- projects/internals/eslint/README.md | 1 + projects/internals/eslint/src/configs/lit.js | 9 +- .../local/no-missing-bundle-registration.js | 33 +--- .../no-missing-bundle-registration.test.js | 24 +++ .../src/local/no-missing-bundle-test.js | 183 ++++++++++++++++++ .../src/local/no-missing-bundle-test.test.js | 160 +++++++++++++++ projects/internals/eslint/src/local/utils.js | 46 +++-- projects/monaco/eslint.config.js | 6 + .../site/src/docs/metrics/api-status.11ty.js | 2 +- 17 files changed, 459 insertions(+), 58 deletions(-) create mode 100644 projects/internals/eslint/src/local/no-missing-bundle-test.js create mode 100644 projects/internals/eslint/src/local/no-missing-bundle-test.test.js diff --git a/.agents/skills/component-creation/SKILL.md b/.agents/skills/component-creation/SKILL.md index de15f81ac3..874f603a2b 100644 --- a/.agents/skills/component-creation/SKILL.md +++ b/.agents/skills/component-creation/SKILL.md @@ -35,8 +35,11 @@ Read the [component creation guide](projects/site/src/docs/internal/guidelines/c - `component-name.test.lighthouse.ts`:lighthouse tests - `define.ts`:registration using `define()` helper with `HTMLElementTagNameMap` - `index.ts`:side-effect-free export -4. **Update bundle**: add `import '@nvidia-elements/core//define.js'` to `projects/core/src/bundle.ts` in alphabetical order so the bundle registers the component. -5. **Verify**: confirm all files follow the templates in the component creation guide, run `pnpm run lint` and `pnpm run test` from the elements project. +4. **Update shared entry points**: + - Add only `import '@nvidia-elements/core//define.js'` to `projects/core/src/bundle.ts` in alphabetical order. Keep this registration bundle import-only. Export the public API from the component `index.ts` and expose its entry point through the package `exports` map. + - Add the definition import to the `js-modules` list in `projects/core/src/index.test.lighthouse.ts` so the combined direct-import benchmark measures the component. +5. **Measure payloads**: run `mise exec -- pnpm run test:lighthouse` from `projects/core`. Increase a bundle-size limit only when the measured payload exceeds the current limit. +6. **Verify**: confirm all files follow the templates in the component creation guide, then run `mise exec -- pnpm run lint` and `mise exec -- pnpm run test` from `projects/core`. ## References diff --git a/config/vale/styles/config/vocabularies/Elements/accept.txt b/config/vale/styles/config/vocabularies/Elements/accept.txt index 20b0ac8cd7..35733c840c 100644 --- a/config/vale/styles/config/vocabularies/Elements/accept.txt +++ b/config/vale/styles/config/vocabularies/Elements/accept.txt @@ -255,7 +255,6 @@ nve-grid nve-icon nve-icon-button nve-input -nve-json-viewer nve-logo nve-menu nve-month diff --git a/knip.config.js b/knip.config.js index b4b13eb440..4864acf0eb 100644 --- a/knip.config.js +++ b/knip.config.js @@ -32,7 +32,6 @@ export default { '@semantic-release/github', '@semantic-release/npm', '@semantic-release/release-notes-generator', - '@typescript-eslint/parser', '@typescript/lib-dom', 'adm-zip', 'archiver', diff --git a/mise.toml b/mise.toml index cd62829f88..bd126af82e 100644 --- a/mise.toml +++ b/mise.toml @@ -20,7 +20,7 @@ run = [ "mise install", "mise exec -- git lfs install --skip-repo", "mise exec -- git lfs pull", - "mise exec -- pnpm i --frozen-lockfile --prefer-offline", + "mise exec -- pnpm i --frozen-lockfile --prefer-offline --config.confirmModulesPurge=false", ] [tasks.setup] diff --git a/projects/code/eslint.config.js b/projects/code/eslint.config.js index 77c1959d58..5d4d793be1 100644 --- a/projects/code/eslint.config.js +++ b/projects/code/eslint.config.js @@ -8,6 +8,12 @@ export default [ ...libraryConfig, ...litConfig, ...jsonConfig, + { + files: ['src/bundle.ts'], + rules: { + 'local/no-missing-bundle-registration': ['error'] + } + }, { files: ['src/**/*.ts'], ignores: ['**/*.test.ts', '**/*.test.*.ts', '**/*.examples.ts'], diff --git a/projects/core/eslint.config.js b/projects/core/eslint.config.js index e0afa8b857..efae7b3c35 100644 --- a/projects/core/eslint.config.js +++ b/projects/core/eslint.config.js @@ -17,6 +17,18 @@ export default [ ...litConfig, ...cssConfig, ...jsonConfig, + { + files: ['src/bundle.ts'], + rules: { + 'local/no-missing-bundle-registration': ['error'], + 'local/no-missing-bundle-test': [ + 'error', + { + lighthouseTestFile: 'index.test.lighthouse.ts' + } + ] + } + }, // Disable no-missing-popover-trigger globally, only enable for examples { rules: { diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index 89e1864d76..b7d6883ddd 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -26,7 +26,7 @@ describe('lighthouse report', () => { + \`); + `; + await writeFile(join(sourceDirectory, 'index.test.lighthouse.ts'), source); + } + + return join(sourceDirectory, 'bundle.ts'); +} + +test('defines rule metadata', () => { + assert.equal(noMissingBundleTest.meta.type, 'problem'); + assert.equal(noMissingBundleTest.meta.name, 'no-missing-bundle-test'); + assert.ok(noMissingBundleTest.meta.messages['missing-bundle-test']); + assert.ok(noMissingBundleTest.meta.messages['missing-lighthouse-test']); + assert.deepEqual(noMissingBundleTest.meta.schema[0].required, ['lighthouseTestFile']); +}); + +test('invalid: reports a missing configured Lighthouse test file', async () => { + const filename = await createBundleFixture(); + + tester.run('no-missing-bundle-test', noMissingBundleTest, { + valid: [], + invalid: [ + { + filename, + code: "import '@nvidia-elements/core/button/define.js';", + options: [{ lighthouseTestFile: 'index.test.lighthouse.ts' }], + errors: [ + { + messageId: 'missing-lighthouse-test', + data: { testFile: 'index.test.lighthouse.ts' } + } + ] + } + ] + }); +}); + +test('valid: allows bundle registrations measured by the aggregate Lighthouse test', async () => { + const filename = await createBundleFixture(['button']); + + tester.run('no-missing-bundle-test', noMissingBundleTest, { + valid: [ + { + filename, + code: "import '@nvidia-elements/core/button/define.js';", + options: [{ lighthouseTestFile: 'index.test.lighthouse.ts' }] + } + ], + invalid: [] + }); +}); + +test('invalid: reports bundle registrations missing from the aggregate Lighthouse test', async () => { + const filename = await createBundleFixture(['button']); + + tester.run('no-missing-bundle-test', noMissingBundleTest, { + valid: [], + invalid: [ + { + filename, + code: ` + import '@nvidia-elements/core/button/define.js'; + import '@nvidia-elements/core/card/define.js'; + `, + options: [{ lighthouseTestFile: 'index.test.lighthouse.ts' }], + errors: [ + { + messageId: 'missing-bundle-test', + data: { + component: 'card', + prefix: '@nvidia-elements/core', + testFile: 'index.test.lighthouse.ts' + } + } + ] + } + ] + }); +}); + +test('invalid: ignores matching lighthouse paths in module-script comments and string literals', async () => { + const filename = await createBundleFixture( + [], + ` + const report = lighthouseRunner.getReport('js-modules', /* html */\` + + \`); + ` + ); + + tester.run('no-missing-bundle-test', noMissingBundleTest, { + valid: [], + invalid: [ + { + filename, + code: ` + import '@nvidia-elements/core/button/define.js'; + import '@nvidia-elements/core/card/define.js'; + `, + options: [{ lighthouseTestFile: 'index.test.lighthouse.ts' }], + errors: [ + { + messageId: 'missing-bundle-test', + data: { + component: 'button', + prefix: '@nvidia-elements/core', + testFile: 'index.test.lighthouse.ts' + } + }, + { + messageId: 'missing-bundle-test', + data: { + component: 'card', + prefix: '@nvidia-elements/core', + testFile: 'index.test.lighthouse.ts' + } + } + ] + } + ] + }); +}); diff --git a/projects/internals/eslint/src/local/utils.js b/projects/internals/eslint/src/local/utils.js index 1e389d8e1c..41ba165b28 100644 --- a/projects/internals/eslint/src/local/utils.js +++ b/projects/internals/eslint/src/local/utils.js @@ -1,12 +1,38 @@ -/** - * Shared helpers for the listener/observer/timer cleanup rules. - * Extracted so walker and class-scope logic stay consistent across rules. - */ - -/** - * Recursive AST walker that visits every node under `node`. Safe against the - * `.parent` back-pointer (skipped) and descends into arrays of child nodes. - */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export const DEFAULT_IMPORT_PREFIX = '@nvidia-elements/core'; + +export function getPackageName(startDirectory) { + let directory = startDirectory; + + while (true) { + const packageJsonPath = join(directory, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return typeof packageJson.name === 'string' ? packageJson.name : undefined; + } catch { + return undefined; + } + } + + const parentDirectory = dirname(directory); + if (parentDirectory === directory) return undefined; + directory = parentDirectory; + } +} + +export function getBundleImportPattern(prefix) { + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedPrefix}/([^/]+)/define\\.js$`); +} + +export function getBundleExportPattern(prefix) { + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedPrefix}/([^/]+)$`); +} + export function walk(node, visit) { if (!node || typeof node !== 'object') { return; @@ -27,12 +53,10 @@ export function walk(node, visit) { } } -/** Collapse internal whitespace so `this.shadowRoot` matches across formatting. */ export function normalize(text) { return text.replace(/\s+/g, ' ').trim(); } -/** Walk up `.parent` looking for the enclosing class. Returns `null` for module-level nodes. */ export function findEnclosingClass(node) { let current = node.parent; while (current) { diff --git a/projects/monaco/eslint.config.js b/projects/monaco/eslint.config.js index 02a681e31b..4ab83fbdcb 100644 --- a/projects/monaco/eslint.config.js +++ b/projects/monaco/eslint.config.js @@ -8,6 +8,12 @@ export default [ ...libraryConfig, ...litConfig, ...jsonConfig, + { + files: ['src/bundle.ts'], + rules: { + 'local/no-missing-bundle-registration': ['error'] + } + }, { files: ['src/**/*.ts'], ignores: ['**/*.test.ts', '**/*.test.*.ts', '**/*.examples.ts'], diff --git a/projects/site/src/docs/metrics/api-status.11ty.js b/projects/site/src/docs/metrics/api-status.11ty.js index 51a52ad6d2..09665240d8 100644 --- a/projects/site/src/docs/metrics/api-status.11ty.js +++ b/projects/site/src/docs/metrics/api-status.11ty.js @@ -22,7 +22,7 @@ const reportDate = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeS new Date(tests.created) ); const elements = siteData.elements - .filter(element => !element.name.includes('internal') && !element.name.includes('json-viewer')) + .filter(element => !element.name.includes('internal')) .sort((a, b) => a.name.localeCompare(b.name)); export function render() {