diff --git a/projects/core/eslint.config.js b/projects/core/eslint.config.js index 3273083c7..c722b12f4 100644 --- a/projects/core/eslint.config.js +++ b/projects/core/eslint.config.js @@ -30,7 +30,11 @@ export default [ } }, { - files: ['src/format-datetime/format-datetime.ts', 'src/format-relative-time/format-relative-time.ts'], + files: [ + 'src/format-datetime/format-datetime.ts', + 'src/format-number/format-number.ts', + 'src/format-relative-time/format-relative-time.ts' + ], rules: { 'local/require-test-completeness': ['error', { skipSuffixes: ['.test.visual.ts'] }] } diff --git a/projects/core/package.json b/projects/core/package.json index 84f7310ad..e4002a141 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -381,6 +381,18 @@ "types": "./dist/format-datetime/define.d.ts", "default": "./dist/format-datetime/define.js" }, + "./format-number": { + "types": "./dist/format-number/index.d.ts", + "default": "./dist/format-number/index.js" + }, + "./format-number/index.js": { + "types": "./dist/format-number/index.d.ts", + "default": "./dist/format-number/index.js" + }, + "./format-number/define.js": { + "types": "./dist/format-number/define.d.ts", + "default": "./dist/format-number/define.js" + }, "./format-relative-time": { "types": "./dist/format-relative-time/index.d.ts", "default": "./dist/format-relative-time/index.js" diff --git a/projects/core/src/bundle.ts b/projects/core/src/bundle.ts index c35b589bb..7c1b320df 100644 --- a/projects/core/src/bundle.ts +++ b/projects/core/src/bundle.ts @@ -28,6 +28,7 @@ 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-datetime/define.js'; +import '@nvidia-elements/core/format-number/define.js'; import '@nvidia-elements/core/format-relative-time/define.js'; import '@nvidia-elements/core/forms/define.js'; import '@nvidia-elements/core/grid/define.js'; @@ -81,6 +82,7 @@ export * from '@nvidia-elements/core/dropdown'; export * from '@nvidia-elements/core/dropzone'; export * from '@nvidia-elements/core/file'; export * from '@nvidia-elements/core/format-datetime'; +export * from '@nvidia-elements/core/format-number'; export * from '@nvidia-elements/core/format-relative-time'; export * from '@nvidia-elements/core/forms'; export * from '@nvidia-elements/core/grid'; diff --git a/projects/core/src/format-number/define.ts b/projects/core/src/format-number/define.ts new file mode 100644 index 000000000..074bb3d77 --- /dev/null +++ b/projects/core/src/format-number/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 { FormatNumber } from '@nvidia-elements/core/format-number'; + +define(FormatNumber); + +declare global { + interface HTMLElementTagNameMap { + 'nve-format-number': FormatNumber; + } +} diff --git a/projects/core/src/format-number/format-number.css b/projects/core/src/format-number/format-number.css new file mode 100644 index 000000000..5843acc02 --- /dev/null +++ b/projects/core/src/format-number/format-number.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-number/format-number.examples.ts b/projects/core/src/format-number/format-number.examples.ts new file mode 100644 index 000000000..5990217c8 --- /dev/null +++ b/projects/core/src/format-number/format-number.examples.ts @@ -0,0 +1,120 @@ +// 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-number/define.js'; + +export default { + title: 'Elements/FormatNumber', + component: 'nve-format-number' +}; + +/** + * @summary Basic decimal formatting with localized grouping separators. Use for inline counts and metrics. + */ +export const Default = { + render: () => html` + 1234567.89 + ` +}; + +/** + * @summary Currency formatting for monetary values with locale-aware symbols and separators. Use for prices, budgets, and financial totals. + */ +export const Currency = { + render: () => html` +
+ 1234.56 + 1234.56 + 1234 +
+ ` +}; + +/** + * @summary Percent formatting for ratios and completion values. Source values should already represent a fraction (such as 0.85 for 85 percent). + */ +export const Percent = { + render: () => html` +
+ 0.85 + 0.126 +
+ ` +}; + +/** + * @summary Unit formatting for measurements and quantities. Use for distances, storage sizes, or other numeric labels that need a localized unit suffix. + */ +export const Unit = { + render: () => html` +
+ 1234.56 + 2048 + 22 +
+ ` +}; + +/** + * @summary Notation presets for scientific, engineering, and compact display. Use compact notation in dashboards or cards where space matters. + */ +export const Notation = { + render: () => html` +
+ 1234567 + 1234567 + 1234567 + 1234567 +
+ ` +}; + +/** + * @summary Sign display options for controlling positive and negative indicators. Use 'always' for delta values or 'exceptZero' for change indicators. + */ +export const SignDisplay = { + render: () => html` +
+ 42 + -42 + 0 + -42 +
+ ` +}; + +/** + * @summary Fraction digit control for tuning decimal precision. Use to enforce fixed decimal places in financial or scientific contexts. + */ +export const FractionDigits = { + render: () => html` +
+ 1.5 + 1.567 + 3 +
+ ` +}; + +/** + * @summary Explicit locale settings for internationalized number output. Use when the target audience locale differs from the document language or browser default. + */ +export const Locale = { + render: () => html` +
+ 1234.56 + 1234 + 1234567.89 +
+ ` +}; + +/** + * @summary Number attribute input for values supplied by JavaScript or bound data. By default, the component formats text content, which also serves as the SSR fallback, and `number` wins when both are present. + */ +export const NumberAttribute = { + render: () => html` + + ` +}; \ No newline at end of file diff --git a/projects/core/src/format-number/format-number.test.axe.ts b/projects/core/src/format-number/format-number.test.axe.ts new file mode 100644 index 000000000..28454bebc --- /dev/null +++ b/projects/core/src/format-number/format-number.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 { FormatNumber } from '@nvidia-elements/core/format-number'; +import '@nvidia-elements/core/format-number/define.js'; + +describe(FormatNumber.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + 1234567 + 1234.56 + 1234.56 + `); + + try { + await elementIsStable(fixture.querySelector(FormatNumber.metadata.tag)); + const results = await runAxe([FormatNumber.metadata.tag]); + expect(results.violations.length).toBe(0); + } finally { + removeFixture(fixture); + } + }); +}); diff --git a/projects/core/src/format-number/format-number.test.lighthouse.ts b/projects/core/src/format-number/format-number.test.lighthouse.ts new file mode 100644 index 000000000..aa40cdf00 --- /dev/null +++ b/projects/core/src/format-number/format-number.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('format-number lighthouse report', () => { + test('format-number should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-format-number', /* html */` + 1234.56 + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(12); + }); +}); \ No newline at end of file diff --git a/projects/core/src/format-number/format-number.test.ssr.ts b/projects/core/src/format-number/format-number.test.ssr.ts new file mode 100644 index 000000000..33e212c58 --- /dev/null +++ b/projects/core/src/format-number/format-number.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 { FormatNumber } from '@nvidia-elements/core/format-number'; +import '@nvidia-elements/core/format-number/define.js'; + +describe(FormatNumber.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render( + html`1234.56` + ); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes('nve-format-number')).toBe(true); + }); +}); diff --git a/projects/core/src/format-number/format-number.test.ts b/projects/core/src/format-number/format-number.test.ts new file mode 100644 index 000000000..3bc2fd1e1 --- /dev/null +++ b/projects/core/src/format-number/format-number.test.ts @@ -0,0 +1,316 @@ +// 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, vi, type MockInstance } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { LogService } from '@nvidia-elements/core/internal'; +import { FormatNumber } from '@nvidia-elements/core/format-number'; +import '@nvidia-elements/core/format-number/define.js'; + +describe(FormatNumber.metadata.tag, () => { + let fixture: HTMLElement; + let element: FormatNumber; + let originalDocumentLang: string; + + beforeEach(async () => { + originalDocumentLang = document.documentElement.lang; + fixture = await createFixture(html`1234567`); + element = fixture.querySelector(FormatNumber.metadata.tag); + await elementIsStable(element); + }); + + afterEach(() => { + document.documentElement.lang = originalDocumentLang; + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(FormatNumber.metadata.tag)).toBeDefined(); + }); + + it('should render formatted number from slot content', async () => { + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1,234,567'); + }); + + it('should render semantic data element with value attribute', async () => { + const data = element.shadowRoot!.querySelector('data'); + expect(data).toBeTruthy(); + expect(data!.getAttribute('value')).toBe('1234567'); + }); + + it('should use number attribute over slot content', async () => { + element.number = '9999'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.getAttribute('value')).toBe('9999'); + expect(data!.textContent!.trim()).toBe('9,999'); + }); + + it('should re-render when slot content changes', async () => { + element.textContent = '2048'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.getAttribute('value')).toBe('2048'); + expect(data!.textContent!.trim()).toBe('2,048'); + }); + + describe('format-style', () => { + it('should format as decimal by default', async () => { + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1,234,567'); + }); + + it('should format as currency', async () => { + element.number = '1234.56'; + element.formatStyle = 'currency'; + element.currency = 'USD'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('$1,234.56'); + }); + + it('should format as percent', async () => { + element.number = '0.85'; + element.formatStyle = 'percent'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('85%'); + }); + + it('should format as unit', async () => { + element.number = '12'; + element.formatStyle = 'unit'; + element.unit = 'kilometer'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('km'); + }); + }); + + describe('currency options', () => { + it('should display currency code', async () => { + element.number = '1234.56'; + element.formatStyle = 'currency'; + element.currency = 'USD'; + element.currencyDisplay = 'code'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('USD'); + }); + + it('should display currency name', async () => { + element.number = '1234.56'; + element.formatStyle = 'currency'; + element.currency = 'USD'; + element.currencyDisplay = 'name'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('US dollars'); + }); + + it('should use accounting sign for negative values', async () => { + element.number = '-1234.56'; + element.formatStyle = 'currency'; + element.currency = 'USD'; + element.currencySign = 'accounting'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('($1,234.56)'); + }); + }); + + describe('notation', () => { + it('should format with compact notation short', async () => { + element.number = '1234567'; + element.notation = 'compact'; + element.compactDisplay = 'short'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1.2M'); + }); + + it('should format with compact notation long', async () => { + element.number = '1234567'; + element.notation = 'compact'; + element.compactDisplay = 'long'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('million'); + }); + + it('should format with scientific notation', async () => { + element.number = '1234567'; + element.notation = 'scientific'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('E'); + }); + }); + + describe('sign-display', () => { + it('should show sign always', async () => { + element.number = '12'; + element.signDisplay = 'always'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('+12'); + }); + + it('should hide sign with never', async () => { + element.number = '-12'; + element.signDisplay = 'never'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('12'); + }); + }); + + describe('unit-display', () => { + it('should display unit long', async () => { + element.number = '12'; + element.formatStyle = 'unit'; + element.unit = 'kilometer'; + element.unitDisplay = 'long'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toContain('kilometers'); + }); + }); + + describe('use-grouping', () => { + it('should suppress grouping with false', async () => { + element.number = '1234567'; + element.useGrouping = 'false'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1234567'); + }); + }); + + describe('fraction digits', () => { + it('should pad with minimum-fraction-digits', async () => { + element.number = '1.5'; + element.minimumFractionDigits = 4; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1.5000'); + }); + + it('should round with maximum-fraction-digits', async () => { + element.number = '1.567'; + element.maximumFractionDigits = 0; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('2'); + }); + }); + + describe('locale', () => { + it('should fall back to document lang when locale is omitted', async () => { + document.documentElement.lang = 'de-DE'; + removeFixture(fixture); + + fixture = await createFixture(html`1234.56`); + element = fixture.querySelector(FormatNumber.metadata.tag); + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1.234,56'); + }); + + it('should format with explicit de-DE locale', async () => { + removeFixture(fixture); + + fixture = await createFixture(html`1234.56`); + element = fixture.querySelector(FormatNumber.metadata.tag); + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1.234,56'); + }); + }); + + describe('fallback', () => { + let warnSpy: MockInstance; + + beforeEach(() => { + warnSpy = vi.spyOn(LogService, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should show raw string for invalid number input', async () => { + element.number = 'not-a-number'; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('not-a-number'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid numeric value')); + }); + + it('should fall back for invalid Intl options', async () => { + element.number = '1234.56'; + element.setAttribute('format-style', 'banana'); + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1234.56'); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('should fall back when currency is missing', async () => { + element.number = '1234.56'; + element.formatStyle = 'currency'; + element.currency = undefined; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('1234.56'); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('should fall back when unit is missing', async () => { + element.number = '12'; + element.formatStyle = 'unit'; + element.unit = undefined; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe('12'); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('should render empty for no content', async () => { + removeFixture(fixture); + + fixture = await createFixture(html``); + element = fixture.querySelector(FormatNumber.metadata.tag); + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe(''); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/core/src/format-number/format-number.ts b/projects/core/src/format-number/format-number.ts new file mode 100644 index 000000000..db433811f --- /dev/null +++ b/projects/core/src/format-number/format-number.ts @@ -0,0 +1,190 @@ +// 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 { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import styles from './format-number.css?inline'; + +export type FormatNumberStyle = 'decimal' | 'currency' | 'percent' | 'unit'; +export type CurrencyDisplayOption = 'symbol' | 'code' | 'name' | 'narrowSymbol'; +export type CurrencySignOption = 'standard' | 'accounting'; +export type NotationOption = 'standard' | 'scientific' | 'engineering' | 'compact'; +export type CompactDisplayOption = 'short' | 'long'; +export type UnitDisplayOption = 'short' | 'long' | 'narrow'; +export type SignDisplayOption = 'auto' | 'never' | 'always' | 'exceptZero'; + +const STRING_KEYS: [keyof FormatNumber, string][] = [ + ['currency', 'currency'], + ['currencyDisplay', 'currencyDisplay'], + ['currencySign', 'currencySign'], + ['notation', 'notation'], + ['compactDisplay', 'compactDisplay'], + ['unit', 'unit'], + ['unitDisplay', 'unitDisplay'], + ['signDisplay', 'signDisplay'] +]; + +const NUMBER_KEYS: [keyof FormatNumber, string][] = [ + ['minimumFractionDigits', 'minimumFractionDigits'], + ['maximumFractionDigits', 'maximumFractionDigits'], + ['minimumIntegerDigits', 'minimumIntegerDigits'] +]; + +/** + * @element nve-format-number + * @description A localized number formatter for currencies, percentages, units, and compact notation, backed by Intl.NumberFormat. + * Provide a `currency` attribute when `formatStyle` is `currency`, and a `unit` attribute when `formatStyle` is `unit`. + * @since 0.0.0 + * @entrypoint \@nvidia-elements/core/format-number + * @slot - Numeric string to format (such as 1234567 or 1234.56). Serves as fallback before hydration. + */ +@typeSSR() +export class FormatNumber extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-format-number', + version: '0.0.0' + }; + + /** + * Optional numeric string 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: String }) number?: string; + + /** + * Language tag (such as en-US, de-DE). Defaults to document.documentElement.lang or browser default. + */ + @property({ type: String }) locale?: string; + + /** + * Formatting style: 'decimal' | 'currency' | 'percent' | 'unit'. + */ + @property({ type: String, attribute: 'format-style' }) formatStyle: FormatNumberStyle = 'decimal'; + + /** + * ISO 4217 currency code (such as USD or EUR). Required when formatStyle is currency. + */ + @property({ type: String }) currency?: string; + + /** + * Currency sign style: 'standard' | 'accounting'. + */ + @property({ type: String, attribute: 'currency-sign' }) currencySign?: CurrencySignOption; + + /** + * Currency display style: 'symbol' | 'code' | 'name' | 'narrowSymbol'. + */ + @property({ type: String, attribute: 'currency-display' }) currencyDisplay?: CurrencyDisplayOption; + + /** + * Unit identifier (such as kilometer or byte). Required when formatStyle is unit. + */ + @property({ type: String }) unit?: string; + + /** + * Unit display style: 'short' | 'long' | 'narrow'. + */ + @property({ type: String, attribute: 'unit-display' }) unitDisplay?: UnitDisplayOption; + + /** + * Number notation: 'standard' | 'scientific' | 'engineering' | 'compact'. + */ + @property({ type: String }) notation?: NotationOption; + + /** + * Compact notation display: 'short' | 'long'. Only applies when notation is compact. + */ + @property({ type: String, attribute: 'compact-display' }) compactDisplay?: CompactDisplayOption; + + /** + * Sign display: 'auto' | 'never' | 'always' | 'exceptZero'. + */ + @property({ type: String, attribute: 'sign-display' }) signDisplay?: SignDisplayOption; + + /** + * Grouping separators: 'auto' | 'always' | 'min2' | 'true' | 'false'. + */ + @property({ type: String, attribute: 'use-grouping' }) useGrouping?: string; + + /** + * Pad fraction output to at least this many digits (0-20). + */ + @property({ type: Number, attribute: 'minimum-fraction-digits' }) minimumFractionDigits?: number; + + /** + * Round fraction output to at most this many digits (0-20). + */ + @property({ type: Number, attribute: 'maximum-fraction-digits' }) maximumFractionDigits?: number; + + /** + * Pad integer output to at least this many digits (1-21). + */ + @property({ type: Number, attribute: 'minimum-integer-digits' }) minimumIntegerDigits?: number; + + get #rawValue(): string { + return this.number ?? this.textContent?.trim() ?? ''; + } + + get #resolvedLocale(): string | undefined { + return this.locale ?? (globalThis.document?.documentElement?.lang || undefined); + } + + get #parsedNumber(): number | null { + const raw = this.#rawValue; + if (!raw) return null; + + const numericValue = Number(raw); + if (Number.isFinite(numericValue)) return numericValue; + + LogService.warn(`format-number: invalid numeric value "${raw}"`); + return null; + } + + get #formatOptions(): Intl.NumberFormatOptions { + const options: Intl.NumberFormatOptions = { style: this.formatStyle }; + + for (const [prop, key] of STRING_KEYS) { + const value = this[prop] as string | undefined; + if (value !== undefined) (options as Record)[key] = value; + } + + for (const [prop, key] of NUMBER_KEYS) { + const value = this[prop] as number | undefined; + if (value !== undefined) (options as Record)[key] = value; + } + + if (this.useGrouping !== undefined) { + const grouping = this.useGrouping === 'false' ? false : this.useGrouping === 'true' ? true : this.useGrouping; + (options as Record).useGrouping = grouping; + } + + return options; + } + + get #formattedNumber(): string { + const raw = this.#rawValue; + if (!raw) return ''; + + const numericValue = this.#parsedNumber; + if (numericValue === null) return raw; + + try { + return new Intl.NumberFormat(this.#resolvedLocale, this.#formatOptions).format(numericValue); + } catch (e) { + LogService.warn(`format-number: ${(e as Error).message}`); + return raw; + } + } + + render() { + return html`${this.#formattedNumber}`; + } + + #onSlotChange() { + this.requestUpdate(); + } +} diff --git a/projects/core/src/format-number/index.ts b/projects/core/src/format-number/index.ts new file mode 100644 index 000000000..9c479cab6 --- /dev/null +++ b/projects/core/src/format-number/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-number.js'; diff --git a/projects/internals/eslint/src/configs/lit.js b/projects/internals/eslint/src/configs/lit.js index f3ddf3d17..6969cbb2c 100644 --- a/projects/internals/eslint/src/configs/lit.js +++ b/projects/internals/eslint/src/configs/lit.js @@ -148,6 +148,7 @@ export const litConfig = [ 'dropdown-group', 'progressive-filter-chip', 'format-datetime', + 'format-number', 'format-relative-time' ] } diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index ad35b98ea..28171c993 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -224,6 +224,7 @@ export const renderDocsNav = data => /* html */ ` Dropzone File Format Datetime + Format Number Format Relative Time Forms diff --git a/projects/site/src/docs/elements/format-number.md b/projects/site/src/docs/elements/format-number.md new file mode 100644 index 000000000..e4d08d2aa --- /dev/null +++ b/projects/site/src/docs/elements/format-number.md @@ -0,0 +1,47 @@ +--- +{ + title: 'Format Number', + layout: 'docs.11ty.js', + tag: 'nve-format-number' +} +--- + +## Installation + +{% install 'nve-format-number' %} + +## Default + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Default' %} + +## Currency + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Currency' %} + +## Percent + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Percent' %} + +## Unit + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Unit' %} + +## Notation + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Notation' %} + +## Sign Display + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'SignDisplay' %} + +## Fraction Digits + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'FractionDigits' %} + +## Locale + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'Locale' %} + +## Number Attribute + +{% example '@nvidia-elements/core/format-number/format-number.examples.json', 'NumberAttribute' %}