From b57257b3cef97d150fb30d40e98fa9ce25b0c9dc Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Fri, 31 Jul 2026 17:51:33 -0500 Subject: [PATCH] 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 | 27 +++ .../format-bytes.test.lighthouse.ts | 21 ++ .../src/format-bytes/format-bytes.test.ssr.ts | 18 ++ .../src/format-bytes/format-bytes.test.ts | 215 ++++++++++++++++++ .../format-bytes/format-bytes.test.visual.ts | 33 +++ .../core/src/format-bytes/format-bytes.ts | 206 +++++++++++++++++ projects/core/src/format-bytes/index.ts | 4 + projects/core/src/index.test.lighthouse.ts | 2 +- projects/site/src/_11ty/layouts/common.js | 1 + .../site/src/docs/elements/format-bytes.md | 43 ++++ 17 files changed, 710 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..7765296bb4 --- /dev/null +++ b/projects/core/.visual/format-bytes.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:839759336d5205be122856fcd784e71a1cc0890bd60acd3b93596160764388e1 +size 7217 diff --git a/projects/core/.visual/format-bytes.png b/projects/core/.visual/format-bytes.png new file mode 100644 index 0000000000..c394314955 --- /dev/null +++ b/projects/core/.visual/format-bytes.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5335be8f45db8e866ef98449d92077ed4d8755a0d819c7bfb2fc216f501a28b +size 7183 diff --git a/projects/core/package.json b/projects/core/package.json index ba28fcaf2c..4eb8ff15af 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..36b9ffcc06 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.test.axe.ts @@ -0,0 +1,27 @@ +// 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 { 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, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + 1048576 + 1048576 + 1048576 + `); + + try { + await elementIsStable(fixture.querySelector(FormatBytes.metadata.tag)); + const results = await runAxe([FormatBytes.metadata.tag]); + expect(results.violations.length).toBe(0); + } finally { + removeFixture(fixture); + } + }); +}); 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..aa31379a38 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.test.ts @@ -0,0 +1,215 @@ +// 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 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 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 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..6c20453789 --- /dev/null +++ b/projects/core/src/format-bytes/format-bytes.ts @@ -0,0 +1,206 @@ +// 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 { + if (this.value === undefined) return this.textContent?.trim() ?? ''; + if (Number.isNaN(this.value)) return this.getAttribute('value') ?? String(this.value); + return String(this.value); + } + + 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, rawValue: string): string { + LogService.warn(`format-bytes: invalid ${name} value "${String(value)}"`); + return rawValue; + } + + #hasValidConfiguration(rawValue: string): boolean { + if (!isDisplay(this.display)) { + this.#warnInvalidOption('display', this.display, rawValue); + return false; + } + if (!isUnitDisplay(this.unitDisplay)) { + this.#warnInvalidOption('unit-display', this.unitDisplay, rawValue); + return false; + } + if (this.unit !== undefined && !isUnit(this.unit)) { + this.#warnInvalidOption('unit', this.unit, rawValue); + return false; + } + return true; + } + + #formatNumber(value: number): string { + const effectiveMaximumFractionDigits = this.maximumFractionDigits ?? Math.max(this.minimumFractionDigits ?? 0, 2); + return new Intl.NumberFormat(this.#resolvedLocale, { + minimumFractionDigits: this.minimumFractionDigits, + maximumFractionDigits: effectiveMaximumFractionDigits + }).format(value); + } + + get #formattedBytes(): string { + const rawValue = this.#rawValue; + if (!rawValue) return ''; + + const numericValue = this.#parsedValue; + if (numericValue === null) return rawValue; + if (!this.#hasValidConfiguration(rawValue)) 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 { + return `${this.#formatNumber(convertedValue)} ${this.#formatLabel(resolvedUnit, convertedValue)}`; + } 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..0896abab42 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); 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' %}