diff --git a/projects/core/eslint-suppressions.json b/projects/core/eslint-suppressions.json index fa9bb4a206..bf013b95c3 100644 --- a/projects/core/eslint-suppressions.json +++ b/projects/core/eslint-suppressions.json @@ -11,7 +11,7 @@ }, "src/format-datetime/format-datetime.test.ts": { "@typescript-eslint/no-unnecessary-type-assertion": { - "count": 27 + "count": 26 } }, "src/format-number/format-number.test.ts": { @@ -21,7 +21,7 @@ }, "src/format-relative-time/format-relative-time.test.ts": { "@typescript-eslint/no-unnecessary-type-assertion": { - "count": 40 + "count": 39 } }, "src/internal/controllers/audit.controller.ts": { diff --git a/projects/core/src/button-group/button-group.ts b/projects/core/src/button-group/button-group.ts index 1d874beb97..b521f0bb8d 100644 --- a/projects/core/src/button-group/button-group.ts +++ b/projects/core/src/button-group/button-group.ts @@ -25,7 +25,7 @@ import globalStyles from './button-group.global.css?inline'; * @documentation https://nvidia.github.io/elements/docs/elements/button-group/ * @since 0.16.0 * @entrypoint \@nvidia-elements/core/button-group - * @slot - default slot for `nve-button` or `nve-icon-button` + * @slot - default slot for `nve-button`, `nve-icon-button`, `nve-divider`, `nve-media-seek-button`, or `nve-media-pause-button` * @cssprop --background * @cssprop --border-radius * @cssprop --padding @@ -66,7 +66,7 @@ export class ButtonGroup extends LitElement { static readonly metadata = { tag: 'nve-button-group', version: '0.0.0', - children: ['nve-button', 'nve-icon-button', 'nve-divider'] + children: ['nve-button', 'nve-icon-button', 'nve-divider', 'nve-media-seek-button', 'nve-media-pause-button'] }; /** @private */ diff --git a/projects/core/src/card/card.ts b/projects/core/src/card/card.ts index 39310ba3a6..ac3b119e5f 100644 --- a/projects/core/src/card/card.ts +++ b/projects/core/src/card/card.ts @@ -62,7 +62,6 @@ export class Card extends LitElement implements ContainerElement { * @cssprop --gap * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardHeader extends LitElement { static styles = useStyles([cardHeaderStyleSheet]); @@ -96,7 +95,6 @@ export class CardHeader extends LitElement { * @cssprop --padding * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardContent extends LitElement { static styles = useStyles([cardContentStyleSheet]); @@ -124,7 +122,6 @@ export class CardContent extends LitElement { * @cssprop --border-top * @aria https://github.com/w3c/aria-practices/issues */ -@audit() export class CardFooter extends LitElement { static styles = useStyles([cardFooterStyleSheet]); diff --git a/projects/core/src/dialog/dialog-footer.ts b/projects/core/src/dialog/dialog-footer.ts index bd2197fc49..041df46510 100644 --- a/projects/core/src/dialog/dialog-footer.ts +++ b/projects/core/src/dialog/dialog-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dialog-footer.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './dialog-footer.css?inline'; * @cssprop --gap * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DialogFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dialog/dialog-header.ts b/projects/core/src/dialog/dialog-header.ts index 655e4a21e4..d2e503451c 100644 --- a/projects/core/src/dialog/dialog-header.ts +++ b/projects/core/src/dialog/dialog-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dialog-header.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './dialog-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DialogHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-content.ts b/projects/core/src/drawer/drawer-content.ts index 5ef196130e..6786f11e21 100644 --- a/projects/core/src/drawer/drawer-content.ts +++ b/projects/core/src/drawer/drawer-content.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-content.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './drawer-content.css?inline'; * @cssprop --width * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerContent extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-footer.ts b/projects/core/src/drawer/drawer-footer.ts index 4050d11198..73a49006c4 100644 --- a/projects/core/src/drawer/drawer-footer.ts +++ b/projects/core/src/drawer/drawer-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-footer.css?inline'; /** @@ -18,7 +18,6 @@ import styles from './drawer-footer.css?inline'; * @cssprop --min-height * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/drawer/drawer-header.ts b/projects/core/src/drawer/drawer-header.ts index f3e259b66d..9fedf619b9 100644 --- a/projects/core/src/drawer/drawer-header.ts +++ b/projects/core/src/drawer/drawer-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './drawer-header.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './drawer-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ */ -@audit() export class DrawerHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dropdown/dropdown-footer.ts b/projects/core/src/dropdown/dropdown-footer.ts index 90fcda8794..58c9afee09 100644 --- a/projects/core/src/dropdown/dropdown-footer.ts +++ b/projects/core/src/dropdown/dropdown-footer.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dropdown-footer.css?inline'; /** @@ -17,7 +17,6 @@ import styles from './dropdown-footer.css?inline'; * @cssprop --gap * @aria https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ */ -@audit() export class DropdownFooter extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/dropdown/dropdown-header.ts b/projects/core/src/dropdown/dropdown-header.ts index c267282e13..56286546be 100644 --- a/projects/core/src/dropdown/dropdown-header.ts +++ b/projects/core/src/dropdown/dropdown-header.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { html, LitElement } from 'lit'; -import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; import styles from './dropdown-header.css?inline'; /** @@ -16,7 +16,6 @@ import styles from './dropdown-header.css?inline'; * @cssprop --padding * @aria https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ */ -@audit() export class DropdownHeader extends LitElement { static styles = useStyles([styles]); diff --git a/projects/core/src/format-bytes/format-bytes.test.ts b/projects/core/src/format-bytes/format-bytes.test.ts index c3d20814a2..a51a4fc6f3 100644 --- a/projects/core/src/format-bytes/format-bytes.test.ts +++ b/projects/core/src/format-bytes/format-bytes.test.ts @@ -228,51 +228,81 @@ describe(FormatBytes.metadata.tag, () => { ['display', 'invalid'], ['unit', 'invalid'], ['unit-display', 'invalid'] - ])('should preserve input for an invalid %s option', async (attribute, value) => { + ])('should preserve input without warning 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(); + expect(warn).not.toHaveBeenCalled(); }); - 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); + it.each(['not-a-number', 'NaN', 'Infinity', '-Infinity', '1e309'])( + 'should preserve invalid numeric input %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.textContent = value; + await elementIsStable(element); - expect(renderedText(element)).toBe('not-a-number'); - expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "not-a-number"'); - }); + expect(renderedText(element)).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); - it('should preserve and warn for an invalid value attribute', async () => { + it('should preserve an invalid value attribute without warning', 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"'); + expect(warn).not.toHaveBeenCalled(); }); - it('should preserve and warn for a NaN value property', async () => { + it('should preserve a NaN value property without warning', async () => { const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.value = Number.NaN; await elementIsStable(element); expect(renderedData(element)?.getAttribute('value')).toBe('NaN'); expect(renderedText(element)).toBe('NaN'); - expect(warn).toHaveBeenCalledWith('format-bytes: invalid numeric value "NaN"'); + expect(warn).not.toHaveBeenCalled(); }); - it('should preserve input for invalid fraction digit options', async () => { + it('should preserve input silently 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(); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['minimum-fraction-digits', '-1'], + ['maximum-fraction-digits', '101'], + ['maximum-fraction-digits', 'NaN'], + ['maximum-fraction-digits', 'Infinity'] + ])('should preserve input without warning for invalid %s', 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).not.toHaveBeenCalled(); + }); + + it('should resume formatting after correcting an invalid locale', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.locale = 'en_US'; + await elementIsStable(element); + expect(renderedText(element)).toBe('1048576'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(renderedText(element)).toBe('1.05 MB'); + expect(warn).not.toHaveBeenCalled(); }); }); diff --git a/projects/core/src/format-bytes/format-bytes.ts b/projects/core/src/format-bytes/format-bytes.ts index 344cf8e8a8..85feaa291a 100644 --- a/projects/core/src/format-bytes/format-bytes.ts +++ b/projects/core/src/format-bytes/format-bytes.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { LogService, typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import { typeSSR, useStyles } from '@nvidia-elements/core/internal'; import styles from './format-bytes.css?inline'; export type FormatBytesDisplay = 'decimal' | 'binary'; @@ -122,7 +122,6 @@ export class FormatBytes extends LitElement { const numericValue = Number(rawValue); if (Number.isFinite(numericValue)) return numericValue; - LogService.warn(`format-bytes: invalid numeric value "${rawValue}"`); return null; } @@ -144,24 +143,8 @@ export class FormatBytes extends LitElement { 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; + return isDisplay(this.display) && isUnitDisplay(this.unitDisplay) && (this.unit === undefined || isUnit(this.unit)); } get #numberFormatOptions(): Intl.NumberFormatOptions { @@ -201,9 +184,7 @@ export class FormatBytes extends LitElement { 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}`); + } catch { return rawValue; } } diff --git a/projects/core/src/format-datetime/format-datetime.test.ts b/projects/core/src/format-datetime/format-datetime.test.ts index 21ef10e4a5..bb66afeff8 100644 --- a/projects/core/src/format-datetime/format-datetime.test.ts +++ b/projects/core/src/format-datetime/format-datetime.test.ts @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { html } from 'lit'; -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; import { FormatDatetime } from '@nvidia-elements/core/format-datetime'; +import { LogService } from '@nvidia-elements/core/internal'; import '@nvidia-elements/core/format-datetime/define.js'; describe(FormatDatetime.metadata.tag, () => { @@ -24,6 +25,7 @@ describe(FormatDatetime.metadata.tag, () => { afterEach(() => { document.documentElement.lang = originalDocumentLang; removeFixture(fixture); + vi.restoreAllMocks(); }); it('should define element', () => { @@ -315,18 +317,93 @@ describe(FormatDatetime.metadata.tag, () => { expect(time!.textContent!.trim()).toBe('7/28/23'); }); - it('should show raw string for invalid date', async () => { - element.date = 'not-a-date'; + it.each(['not-a-date', '2026-13-01', '2026-10-26T25:00:00Z', '+999999-01-01'])( + 'should preserve invalid date %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe(value); + expect(time?.textContent.trim()).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid date attributes without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute('date', 'not-a-date'); await elementIsStable(element); + const time = element.shadowRoot!.querySelector('time'); - expect(time!.textContent!.trim()).toBe('not-a-date'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); }); - it('should fall back to raw string for invalid Intl options', async () => { + it('should preserve invalid text content without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.textContent = 'not-a-date'; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should fall back silently to raw string for invalid Intl options', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute('date-style', 'banana'); await elementIsStable(element); const time = element.shadowRoot!.querySelector('time'); expect(time!.textContent!.trim()).toBe('2023-07-28T04:20:17.434Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['locale', ''], + ['time-zone', 'Invalid/Zone'], + ['time-zone', '+24:00'], + ['time-zone', ' '] + ])('should preserve input without warning for invalid %s=%s', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + const time = element.shadowRoot?.querySelector('time'); + expect(time?.textContent.trim()).toBe('2023-07-28T04:20:17.434Z'); + expect(time?.getAttribute('datetime')).toBe('2023-07-28T04:20:17.434Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US', 'en-US'], + ['time-zone', 'Invalid/Zone', 'UTC'] + ])('should resume formatting after correcting %s', async (attribute, invalid, valid) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, invalid); + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('2023-07-28T04:20:17.434Z'); + + element.setAttribute(attribute, valid); + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('July 28, 2023'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should use the default time zone for an empty time zone option', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.timeZone = ''; + await elementIsStable(element); + const expected = new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format( + new Date('2023-07-28T04:20:17.434Z') + ); + + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe(expected); + expect(warn).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { diff --git a/projects/core/src/format-datetime/format-datetime.ts b/projects/core/src/format-datetime/format-datetime.ts index 4795ad826f..a89412464c 100644 --- a/projects/core/src/format-datetime/format-datetime.ts +++ b/projects/core/src/format-datetime/format-datetime.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-datetime.css?inline'; /** @@ -111,8 +111,7 @@ export class FormatDatetime extends LitElement { try { return new Intl.DateTimeFormat(this.#resolvedLocale, this.#formatOptions).format(date); - } catch (e) { - LogService.warn(`format-datetime: ${(e as Error).message}`); + } catch { return iso; } } @@ -155,7 +154,6 @@ export class FormatDatetime extends LitElement { const date = new Date(iso); if (!isNaN(date.getTime())) return date; - LogService.warn(`format-datetime: invalid date value "${iso}"`); return null; } diff --git a/projects/core/src/format-number/format-number.test.ts b/projects/core/src/format-number/format-number.test.ts index 7ea24d4d22..1e3b42f93c 100644 --- a/projects/core/src/format-number/format-number.test.ts +++ b/projects/core/src/format-number/format-number.test.ts @@ -290,26 +290,38 @@ describe(FormatNumber.metadata.tag, () => { warnSpy.mockRestore(); }); - it('should show raw string for invalid number input', async () => { - element.number = 'not-a-number'; + it.each(['not-a-number', 'NaN', 'Infinity', '-Infinity', '1e309'])( + 'should preserve invalid number %s without warning', + async value => { + element.number = value; + await elementIsStable(element); + + const data = element.shadowRoot!.querySelector('data'); + expect(data!.textContent!.trim()).toBe(value); + expect(warnSpy).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid text content without warning', async () => { + element.textContent = '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')); + expect(data!.textContent.trim()).toBe('not-a-number'); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back for invalid Intl options', async () => { + it('should fall back silently 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(); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back when currency is missing', async () => { + it('should fall back silently when currency is missing', async () => { element.number = '1234.56'; element.formatStyle = 'currency'; element.currency = undefined; @@ -317,10 +329,10 @@ describe(FormatNumber.metadata.tag, () => { const data = element.shadowRoot!.querySelector('data'); expect(data!.textContent!.trim()).toBe('1234.56'); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); }); - it('should fall back when unit is missing', async () => { + it('should fall back silently when unit is missing', async () => { element.number = '12'; element.formatStyle = 'unit'; element.unit = undefined; @@ -328,7 +340,46 @@ describe(FormatNumber.metadata.tag, () => { const data = element.shadowRoot!.querySelector('data'); expect(data!.textContent!.trim()).toBe('12'); - expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['currency', 'US'], + ['unit', 'invalid'], + ['minimum-fraction-digits', '-1'], + ['maximum-fraction-digits', '101'], + ['maximum-fraction-digits', 'NaN'], + ['minimum-integer-digits', '0'] + ])('should preserve input without warning for invalid %s', async (attribute, value) => { + element.number = '1234.56'; + element.setAttribute(attribute, value); + await elementIsStable(element); + + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should preserve input silently for reversed fraction digit bounds', async () => { + element.number = '1234.56'; + element.minimumFractionDigits = 3; + element.maximumFractionDigits = 2; + await elementIsStable(element); + + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should resume formatting after correcting an invalid locale', async () => { + element.number = '1234.56'; + element.locale = 'en_US'; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1234.56'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(element.shadowRoot!.querySelector('data')!.textContent.trim()).toBe('1,234.56'); + expect(warnSpy).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { diff --git a/projects/core/src/format-number/format-number.ts b/projects/core/src/format-number/format-number.ts index c28efa7a8d..77d4c00578 100644 --- a/projects/core/src/format-number/format-number.ts +++ b/projects/core/src/format-number/format-number.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-number.css?inline'; export type FormatNumberStyle = 'decimal' | 'currency' | 'percent' | 'unit'; @@ -114,12 +114,12 @@ export class FormatNumber extends LitElement { useGrouping?: 'auto' | 'always' | 'min2' | 'true' | 'false' | boolean; /** - * Pad fraction output to at least this many digits (0-20). + * Pad fraction output to at least this many digits (0-100). */ @property({ type: Number, attribute: 'minimum-fraction-digits' }) minimumFractionDigits?: number; /** - * Round fraction output to at most this many digits (0-20). + * Round fraction output to at most this many digits (0-100). */ @property({ type: Number, attribute: 'maximum-fraction-digits' }) maximumFractionDigits?: number; @@ -143,7 +143,6 @@ export class FormatNumber extends LitElement { const numericValue = Number(raw); if (Number.isFinite(numericValue)) return numericValue; - LogService.warn(`format-number: invalid numeric value "${raw}"`); return null; } @@ -177,8 +176,7 @@ export class FormatNumber extends LitElement { try { return new Intl.NumberFormat(this.#resolvedLocale, this.#formatOptions).format(numericValue); - } catch (e) { - LogService.warn(`format-number: ${(e as Error).message}`); + } catch { return raw; } } diff --git a/projects/core/src/format-relative-time/format-relative-time.test.ts b/projects/core/src/format-relative-time/format-relative-time.test.ts index 970a616be1..47aa799f8f 100644 --- a/projects/core/src/format-relative-time/format-relative-time.test.ts +++ b/projects/core/src/format-relative-time/format-relative-time.test.ts @@ -5,6 +5,7 @@ import { html } from 'lit'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; import { FormatRelativeTime } from '@nvidia-elements/core/format-relative-time'; +import { LogService } from '@nvidia-elements/core/internal'; import '@nvidia-elements/core/format-relative-time/define.js'; const NOW = new Date('2023-07-28T12:00:00.000Z'); @@ -30,6 +31,7 @@ describe(FormatRelativeTime.metadata.tag, () => { vi.useRealTimers(); document.documentElement.lang = originalDocumentLang; removeFixture(fixture); + vi.restoreAllMocks(); }); it('should define element', () => { @@ -384,11 +386,41 @@ describe(FormatRelativeTime.metadata.tag, () => { }); describe('fallback', () => { - it('should show raw string for invalid date', async () => { - element.date = 'not-a-date'; + it.each(['not-a-date', '2026-13-01', '2026-10-26T25:00:00Z', '+999999-01-01'])( + 'should preserve invalid date %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe(value); + expect(time?.textContent.trim()).toBe(value); + expect(warn).not.toHaveBeenCalled(); + } + ); + + it('should preserve invalid date attributes without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute('date', 'not-a-date'); + await elementIsStable(element); + + const time = element.shadowRoot!.querySelector('time'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should preserve invalid text content without warning', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = undefined; + element.textContent = 'not-a-date'; await elementIsStable(element); + const time = element.shadowRoot!.querySelector('time'); - expect(time!.textContent!.trim()).toBe('not-a-date'); + expect(time?.getAttribute('datetime')).toBe('not-a-date'); + expect(time?.textContent.trim()).toBe('not-a-date'); + expect(warn).not.toHaveBeenCalled(); }); it('should render empty for no content', async () => { @@ -402,13 +434,55 @@ describe(FormatRelativeTime.metadata.tag, () => { expect(time!.textContent!.trim()).toBe(''); }); - it('should fall back to raw string for invalid options', async () => { + it('should fall back silently to raw string for invalid options', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); element.setAttribute('format-style', 'banana'); await elementIsStable(element); const time = element.shadowRoot!.querySelector('time'); expect(time!.textContent!.trim()).toBe('2023-07-27T12:00:00.000Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + ['locale', 'en_US'], + ['locale', ''], + ['numeric', 'invalid'], + ['unit', 'invalid'] + ])('should preserve input without warning for invalid %s=%s', async (attribute, value) => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.setAttribute(attribute, value); + await elementIsStable(element); + + const time = element.shadowRoot?.querySelector('time'); + expect(time?.textContent.trim()).toBe('2023-07-27T12:00:00.000Z'); + expect(time?.getAttribute('datetime')).toBe('2023-07-27T12:00:00.000Z'); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should resume relative formatting after correcting an invalid locale', async () => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.locale = 'en_US'; + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('2023-07-27T12:00:00.000Z'); + + element.locale = 'en-US'; + await elementIsStable(element); + expect(element.shadowRoot?.querySelector('time')?.textContent.trim()).toBe('1 day ago'); + expect(warn).not.toHaveBeenCalled(); }); + it.each(['-271821-04-20T00:00:00.000Z', '+275760-09-13T00:00:00.000Z'])( + 'should format the valid date boundary %s without warning', + async value => { + const warn = vi.spyOn(LogService, 'warn').mockImplementation(() => undefined); + element.date = value; + await elementIsStable(element); + + expect(element.shadowRoot?.querySelector('time')?.textContent).toContain('years'); + expect(warn).not.toHaveBeenCalled(); + } + ); + it('should fall back to raw string for relative values outside Intl range', async () => { vi.useRealTimers(); const now = vi.spyOn(Date, 'now').mockReturnValue(Number.NEGATIVE_INFINITY); diff --git a/projects/core/src/format-relative-time/format-relative-time.ts b/projects/core/src/format-relative-time/format-relative-time.ts index 03d98b9836..c3433ecf40 100644 --- a/projects/core/src/format-relative-time/format-relative-time.ts +++ b/projects/core/src/format-relative-time/format-relative-time.ts @@ -3,7 +3,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { useStyles, typeSSR, LogService } from '@nvidia-elements/core/internal'; +import { useStyles, typeSSR } from '@nvidia-elements/core/internal'; import styles from './format-relative-time.css?inline'; type TimeUnitOption = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; @@ -117,7 +117,6 @@ export class FormatRelativeTime extends LitElement { const target = new Date(iso); if (isNaN(target.getTime())) { - LogService.warn(`format-relative-time: invalid date value "${iso}"`); return iso; } @@ -128,8 +127,7 @@ export class FormatRelativeTime extends LitElement { numeric: this.numeric, style: this.formatStyle }).format(value, resolvedUnit); - } catch (e) { - LogService.warn(`format-relative-time: ${(e as Error).message}`); + } catch { return iso; } } diff --git a/projects/core/src/icon/icon.test.visual.ts b/projects/core/src/icon/icon.test.visual.ts index 64f53af30d..1a914b411c 100644 --- a/projects/core/src/icon/icon.test.visual.ts +++ b/projects/core/src/icon/icon.test.visual.ts @@ -7,12 +7,12 @@ import { ICON_NAMES, ICON_NAMES_SOLID } from '@nvidia-elements/core/icon'; describe('icon visual', () => { test('icon should match visual baseline', async () => { - const report = await visualRunner.render('icon', template(), { network: true }); + const report = await visualRunner.render('icon', template(), { waitFor: waitForIcons }); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); test('icon should match visual baseline dark theme', async () => { - const report = await visualRunner.render('icon.dark', template('dark'), { network: true }); + const report = await visualRunner.render('icon.dark', template('dark'), { waitFor: waitForIcons }); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -57,7 +57,7 @@ describe('icon visual', () => { .join('')} `, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -84,7 +84,7 @@ describe('icon visual', () => { ${ICONS_WITH_OPTICAL_SPACING.map(i => ``).join('')} `, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); @@ -111,12 +111,18 @@ describe('icon visual', () => { ${ICON_NAMES_SOLID.map(i => ``).join('')} `, - { network: true } + { waitFor: waitForIcons } ); expect(report.maxDiffPercentage).toBeLessThan(0.1); }); }); +async function waitForIcons(waitForFunction: (...args: unknown[]) => Promise) { + await waitForFunction(() => + Array.from(document.querySelectorAll('nve-icon')).every(icon => icon.shadowRoot?.querySelector('svg')) + ); +} + function template(theme: '' | 'dark' = '') { return /* html */ `