diff --git a/projects/core/src/accordion/accordion.ts b/projects/core/src/accordion/accordion.ts index 6810c03136..f46aac59bb 100644 --- a/projects/core/src/accordion/accordion.ts +++ b/projects/core/src/accordion/accordion.ts @@ -8,7 +8,6 @@ import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js import type { ContainerElement, Container } from '@nvidia-elements/core/internal'; import { stateExpanded, - I18nController, TypeExpandableController, useStyles, attachInternals, @@ -18,12 +17,18 @@ import { audit, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import accordionStyleSheet from './accordion.css?inline'; import accordionHeaderStyleSheet from './accordion-header.css?inline'; import accordionContentStyleSheet from './accordion-content.css?inline'; import accordionGroupStyleSheet from './accordion-group.css?inline'; +const i18nDefaults = { + close: 'close', + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-accordion-header * @description Provides the clickable heading region of an accordion that toggles the visibility of associated content. @@ -133,7 +138,7 @@ export class Accordion extends LitElement implements ContainerElement { /** @private */ declare _internals: ElementInternals; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); #typeExpandableController = new TypeExpandableController(this); /** diff --git a/projects/core/src/alert/alert.ts b/projects/core/src/alert/alert.ts index 7646631505..322784a25f 100644 --- a/projects/core/src/alert/alert.ts +++ b/projects/core/src/alert/alert.ts @@ -9,7 +9,6 @@ import { IconButton } from '@nvidia-elements/core/icon-button'; import type { SupportStatus, TaskStatus } from '@nvidia-elements/core/internal'; import { attachInternals, - I18nController, statusIcons, TypeClosableController, useStyles, @@ -18,9 +17,18 @@ import { appendRootNodeStyle, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import globalStyles from './alert.global.css?inline'; import styles from './alert.css?inline'; +const i18nDefaults = { + close: 'close', + danger: 'danger', + information: 'information', + success: 'success', + warning: 'warning' +} satisfies I18nDefaults; + /** * @element nve-alert * @description Alert is an element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task. @@ -70,7 +78,7 @@ export class Alert extends LitElement { #typeClosableController = new TypeClosableController(this); - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/badge/badge.test.ts b/projects/core/src/badge/badge.test.ts index e998321d22..51148fe1ad 100644 --- a/projects/core/src/badge/badge.test.ts +++ b/projects/core/src/badge/badge.test.ts @@ -158,16 +158,6 @@ describe(Badge.metadata.tag, () => { }); }); - describe('i18n property', () => { - it('should have default i18n values', () => { - expect(element.i18n).toBeDefined(); - expect(typeof element.i18n.trend).toBe('string'); - expect(typeof element.i18n.up).toBe('string'); - expect(typeof element.i18n.down).toBe('string'); - expect(typeof element.i18n.neutral).toBe('string'); - }); - }); - describe('status property', () => { it('should set a default aria role of status', async () => { await elementIsStable(element); diff --git a/projects/core/src/badge/badge.ts b/projects/core/src/badge/badge.ts index c5dd77b480..4a92f60f78 100644 --- a/projects/core/src/badge/badge.ts +++ b/projects/core/src/badge/badge.ts @@ -13,7 +13,6 @@ import { supportStateStyles, colorStateStyles, attachInternals, - I18nController, typeSSR, scopedRegistry } from '@nvidia-elements/core/internal'; @@ -81,13 +80,6 @@ export class Badge extends LitElement { /** @private */ declare _internals: ElementInternals; - #i18nController: I18nController = new I18nController(this); - - /** - * Enables updating internal string values for internationalization. - */ - @property({ type: Object }) i18n = this.#i18nController.i18n; - render() { return html`
diff --git a/projects/core/src/color/color.ts b/projects/core/src/color/color.ts index b5ea0778cf..1117ff7f57 100644 --- a/projects/core/src/color/color.ts +++ b/projects/core/src/color/color.ts @@ -9,8 +9,13 @@ import { Control } from '@nvidia-elements/core/forms'; import { IconButton } from '@nvidia-elements/core/icon-button'; import { inputStyles } from '@nvidia-elements/core/input'; import globalStyles from './color.global.css?inline'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './color.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-color * @description A color picker is a control that enables users to choose a color value. @@ -23,6 +28,10 @@ import styles from './color.css?inline'; */ @scopedRegistry() export class Color extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/combobox/combobox.test.lighthouse.ts b/projects/core/src/combobox/combobox.test.lighthouse.ts index c41f0aa44c..621a8aaa9f 100644 --- a/projects/core/src/combobox/combobox.test.lighthouse.ts +++ b/projects/core/src/combobox/combobox.test.lighthouse.ts @@ -25,7 +25,7 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(37.1); + expect(report.payload.javascript.kb).toBeLessThan(36.49); }); test('combobox multi select with large dataset should meet lighthouse benchmarks', async () => { @@ -42,6 +42,6 @@ describe('combobox lighthouse report', () => { expect(report.scores.performance).toBeGreaterThanOrEqual(96); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(36.5); + expect(report.payload.javascript.kb).toBeLessThan(35.89); }); }); diff --git a/projects/core/src/combobox/combobox.ts b/projects/core/src/combobox/combobox.ts index e615308e93..0a024df859 100644 --- a/projects/core/src/combobox/combobox.ts +++ b/projects/core/src/combobox/combobox.ts @@ -6,6 +6,7 @@ import { html, isServer, nothing } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import type { ContainerElement } from '@nvidia-elements/core/internal'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { createLightDismiss, focusElementTimeout, @@ -24,6 +25,13 @@ import { Icon } from '@nvidia-elements/core/icon'; import { Checkbox } from '@nvidia-elements/core/checkbox'; import styles from './combobox.css?inline'; +const comboboxI18nDefaults = { + select: 'select', + selected: 'selected', + noResults: 'no results', + create: 'create' +} satisfies I18nDefaults; + /** * @element nve-combobox * @description An editable combobox with autocomplete behavior and selection support. @@ -60,6 +68,10 @@ import styles from './combobox.css?inline'; */ @scopedRegistry() export class Combobox extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return comboboxI18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/copy-button/copy-button.test.lighthouse.ts b/projects/core/src/copy-button/copy-button.test.lighthouse.ts index d0fd9a2fbe..ff90fcef77 100644 --- a/projects/core/src/copy-button/copy-button.test.lighthouse.ts +++ b/projects/core/src/copy-button/copy-button.test.lighthouse.ts @@ -19,6 +19,6 @@ describe('copy-button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(26.76); + expect(report.payload.javascript.kb).toBeLessThan(26.5); }); }); diff --git a/projects/core/src/copy-button/copy-button.ts b/projects/core/src/copy-button/copy-button.ts index 1b6835e19b..9e0765c52d 100644 --- a/projects/core/src/copy-button/copy-button.ts +++ b/projects/core/src/copy-button/copy-button.ts @@ -4,13 +4,19 @@ import { html, nothing } from 'lit'; import { property } from 'lit/decorators/property.js'; import { state } from 'lit/decorators/state.js'; -import { useStyles, I18nController, scopedRegistry } from '@nvidia-elements/core/internal'; +import { useStyles, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Button } from '@nvidia-elements/core/button'; import { Icon } from '@nvidia-elements/core/icon'; import { Toast } from '@nvidia-elements/core/toast'; import { Tooltip } from '@nvidia-elements/core/tooltip'; import styles from './copy-button.css?inline'; +const i18nDefaults = { + copy: 'copy', + copied: 'copied' +} satisfies I18nDefaults; + /** * @element nve-copy-button * @description A copy button is a button that easily enables the copy to clipboard pattern. @@ -41,7 +47,7 @@ export class CopyButton extends Button { @state() private showTooltip = false; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables internal string values to update for internationalization. diff --git a/projects/core/src/date/date.ts b/projects/core/src/date/date.ts index 864448e3a5..ff2fbfe49f 100644 --- a/projects/core/src/date/date.ts +++ b/projects/core/src/date/date.ts @@ -8,8 +8,13 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { inputStyles } from '@nvidia-elements/core/input'; import { IconButton } from '@nvidia-elements/core/icon-button'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './date.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-date * @description A date picker is a control that enables users to choose a date value. @@ -34,6 +39,10 @@ import styles from './date.css?inline'; */ @scopedRegistry() export class Date extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/datetime/datetime.ts b/projects/core/src/datetime/datetime.ts index 306152b246..7efd489fe7 100644 --- a/projects/core/src/datetime/datetime.ts +++ b/projects/core/src/datetime/datetime.ts @@ -8,8 +8,13 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { inputStyles } from '@nvidia-elements/core/input'; import { IconButton } from '@nvidia-elements/core/icon-button'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './datetime.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-datetime * @description A datetime picker is a control that enables users to choose a datetime value. @@ -34,6 +39,10 @@ import styles from './datetime.css?inline'; */ @scopedRegistry() export class Datetime extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/dialog/dialog.ts b/projects/core/src/dialog/dialog.ts index 975286e538..5db27666d2 100644 --- a/projects/core/src/dialog/dialog.ts +++ b/projects/core/src/dialog/dialog.ts @@ -7,7 +7,6 @@ import type { PopoverAlign, PopoverPosition, PopoverType, Size } from '@nvidia-e import { audit, excessiveInstanceLimit, - I18nController, popoverStyles, TypeNativePopoverController, useStyles, @@ -15,10 +14,15 @@ import { appendRootNodeStyle, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import globalStyles from './dialog.global.css?inline'; import styles from './dialog.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-dialog * @description Dialog is a component that appears above main content. A modal dialog displays critical information that requires user attention and interrupts user flow. @@ -95,7 +99,7 @@ export class Dialog extends LitElement { */ @property({ type: Boolean, reflect: true, attribute: 'behavior-trigger' }) behaviorTrigger: boolean; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables internal string values to update for internationalization. diff --git a/projects/core/src/drawer/drawer.ts b/projects/core/src/drawer/drawer.ts index f0b1e2068c..20882f2d89 100644 --- a/projects/core/src/drawer/drawer.ts +++ b/projects/core/src/drawer/drawer.ts @@ -6,16 +6,20 @@ import { property } from 'lit/decorators/property.js'; import type { PopoverType, Size } from '@nvidia-elements/core/internal'; import { createGhostElement, - I18nController, popoverStyles, scopedRegistry, TypeNativePopoverController, typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import styles from './drawer.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-drawer * @description A drawer displays content separate from the rest of the page, such as notifications, navigation, and settings. @@ -90,7 +94,7 @@ export class Drawer extends LitElement { */ @property({ type: Boolean, reflect: true, attribute: 'behavior-trigger' }) behaviorTrigger: boolean; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables internal string values to update for internationalization. diff --git a/projects/core/src/dropdown/dropdown.ts b/projects/core/src/dropdown/dropdown.ts index f8bb7cddb4..5213bfdbaa 100644 --- a/projects/core/src/dropdown/dropdown.ts +++ b/projects/core/src/dropdown/dropdown.ts @@ -9,14 +9,18 @@ import type { PopoverAlign, PopoverPosition, PopoverType } from '@nvidia-element import { audit, excessiveInstanceLimit, - I18nController, popoverStyles, scopedRegistry, TypeNativePopoverController, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './dropdown.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-dropdown * @description Generic dropdown element for rendering a variety of different content such as interactive navigation or form controls. @@ -106,7 +110,7 @@ export class Dropdown extends LitElement { /** @private */ @query('.arrow') popoverArrow: HTMLElement; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); protected typeNativePopoverController = new TypeNativePopoverController(this); diff --git a/projects/core/src/dropzone/dropzone.test.lighthouse.ts b/projects/core/src/dropzone/dropzone.test.lighthouse.ts index bc0e434215..96d1afc5ae 100644 --- a/projects/core/src/dropzone/dropzone.test.lighthouse.ts +++ b/projects/core/src/dropzone/dropzone.test.lighthouse.ts @@ -19,6 +19,6 @@ describe('dropzone lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(19.48); + expect(report.payload.javascript.kb).toBeLessThan(19.25); }); }); diff --git a/projects/core/src/dropzone/dropzone.ts b/projects/core/src/dropzone/dropzone.ts index 3944e352d2..02da7552f6 100644 --- a/projects/core/src/dropzone/dropzone.ts +++ b/projects/core/src/dropzone/dropzone.ts @@ -4,18 +4,21 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { query } from 'lit/decorators/query.js'; -import { - formatFileSize, - useStyles, - removeEmptyTextNode, - I18nController, - scopedRegistry -} from '@nvidia-elements/core/internal'; +import { formatFileSize, useStyles, removeEmptyTextNode, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import styles from './dropzone.css?inline'; import { FormControlMixin } from '@nvidia-elements/forms/mixins'; import { fileTypeValidator, fileSizeValidator, getFileTypeSpecifiers } from './dropzone.util'; +const i18nDefaults = { + or: 'or', + files: 'files', + dragAndDrop: 'drag & drop', + browseFiles: 'browse files', + maxFileSize: 'max file size' +} satisfies I18nDefaults; + /* eslint-disable jsdoc/no-types */ // Explicit JSDoc type because the CEM analyzer loses the inherited generic specialization. @@ -48,7 +51,7 @@ export class Dropzone extends FormControlMixin(LitEle @property({ attribute: 'max-file-size', type: Number }) maxFileSize: number = 2 * 1024 ** 2; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables internal string values to update for internationalization. diff --git a/projects/core/src/forms/control/control.ts b/projects/core/src/forms/control/control.ts index d0fbc95eba..4751661e08 100644 --- a/projects/core/src/forms/control/control.ts +++ b/projects/core/src/forms/control/control.ts @@ -12,10 +12,10 @@ import { associateDataList, appendRootNodeStyle, getAttributeListChanges, - I18nController, hostAttr, typeSSR } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import type { ControlMessage } from '../control-message/control-message.js'; import { setupControlValidationStates, @@ -74,7 +74,12 @@ export class Control extends LitElement { /** Sets the input to match native default content block */ @property({ type: Boolean, reflect: true, attribute: 'fit-content' }) fitContent = false; - #i18nController: I18nController = new I18nController(this); + /** Component-specific defaults supplied by a derived control. */ + protected get i18nDefaults(): I18nDefaults { + return {}; + } + + #i18nController: I18nController = new I18nController(this, this.i18nDefaults); /** Enables internal string values to update for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/gauge/gauge.ts b/projects/core/src/gauge/gauge.ts index 0b3d9f2d1c..c68b959499 100644 --- a/projects/core/src/gauge/gauge.ts +++ b/projects/core/src/gauge/gauge.ts @@ -6,7 +6,8 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { styleMap } from 'lit/directives/style-map.js'; import type { Size, SupportStatus } from '@nvidia-elements/core/internal'; -import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './gauge.css?inline'; const GAUGE_GEOMETRY = { @@ -38,6 +39,13 @@ const thumbStyle = (thumb: 'dot' | 'needle', progressAngle: number, geometry: Ga [`--_${thumb}-start-angle`]: `${geometry.startAngle}deg` }); +const i18nDefaults = { + danger: 'danger', + information: 'information', + success: 'success', + warning: 'warning' +} satisfies I18nDefaults; + /** * @element nve-gauge * @description Use a gauge to show system resource usage. @@ -88,7 +96,7 @@ export class Gauge extends LitElement { /** T-shirt `size` of the gauge. */ @property({ type: String, reflect: true }) size?: Size; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/internal/controllers/i18n.controller.examples.ts b/projects/core/src/internal/controllers/i18n.controller.examples.ts deleted file mode 100644 index a59bd77d5b..0000000000 --- a/projects/core/src/internal/controllers/i18n.controller.examples.ts +++ /dev/null @@ -1,76 +0,0 @@ -// 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 { I18nController } from '@nvidia-elements/core/internal'; -import { I18nService } from '@nvidia-elements/core'; -import '@nvidia-elements/core/card/define.js'; -import '@nvidia-elements/core/button/define.js'; - -export default { - title: 'Internal/Controllers' -} - -class I18nItem extends LitElement { - #i18nController: I18nController = new I18nController(this); - @property({ type: Object }) i18n = this.#i18nController.i18n; - - render() { - return html`
${JSON.stringify(this.i18n, null, 2)}
` - } -} -customElements.get('i18n-item') || customElements.define('i18n-item', I18nItem); - -class I18nDemo extends LitElement { - render() { - return html` -
-
- this.#english()}>English - this.#french()}>French -
- -
- - -
-
- ` - } - - createRenderRoot() { - return this; - } - - #french() { - I18nService.update({ - close: 'fermer', - expand: 'étendre', - sort: 'classer', - show: 'montrer', - hide: 'cacher', - loading: 'bourrage' - }); - } - - #english() { - I18nService.update({ - close: 'close', - expand: 'expand', - sort: 'sort', - show: 'show', - hide: 'hide', - loading: 'loading' - }); - } -} -customElements.get('i18n-demo') || customElements.define('i18n-demo', I18nDemo); - -/** - * @summary Internationalization controller with dynamic language switching between English and French. - * @tags test-case - */ -export const I18nControllerDemo = { - render: () => html`` -} diff --git a/projects/core/src/internal/controllers/i18n.controller.test.ts b/projects/core/src/internal/controllers/i18n.controller.test.ts deleted file mode 100644 index 164807a513..0000000000 --- a/projects/core/src/internal/controllers/i18n.controller.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; -import { property } from 'lit/decorators/property.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { i18n, I18nController } from '@nvidia-elements/core/internal'; -import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; - -@i18n() -@customElement('i18n-test-element') -class I18nTestElement extends LitElement { - #i18nController: I18nController = new I18nController(this); - - @property({ type: Object }) i18n = this.#i18nController.i18n; - - render() { - return html`${JSON.stringify(this.i18n, null, 2)}`; - } -} - -describe('i18n.controller', () => { - let element: I18nTestElement; - let fixture: HTMLElement; - - beforeEach(async () => { - fixture = await createFixture(html``); - element = fixture.querySelector('i18n-test-element'); - }); - - afterEach(() => { - removeFixture(fixture); - }); - - it('should initialize i18n state', async () => { - await elementIsStable(element); - - expect(element.i18n).toStrictEqual({ - __set: false, - close: 'close', - create: 'create', - currentPage: 'current page', - expand: 'expand', - select: 'select', - selected: 'selected', - sort: 'sort', - show: 'show', - hide: 'hide', - loading: 'loading', - previous: 'previous', - next: 'next', - start: 'start', - end: 'end', - noResults: 'no results', - status: 'status', - information: 'information', - warning: 'warning', - success: 'success', - danger: 'danger', - trend: 'trend', - down: 'down', - up: 'up', - neutral: 'neutral', - copy: 'copy', - copied: 'copied', - of: 'of', - theme: 'theme', - scale: 'scale', - reducedMotion: 'reduced motion', - colorScheme: 'color scheme', - resize: 'resize', - or: 'or', - files: 'files', - dragAndDrop: 'drag & drop', - browseFiles: 'browse files', - maxFileSize: 'max file size', - currentTime: 'current time', - enableLoop: 'enable looping', - disableLoop: 'disable looping', - enterFullscreen: 'enter full screen', - enterPip: 'enter picture in picture', - exitFullscreen: 'exit full screen', - exitPip: 'exit picture in picture', - muteMedia: 'mute media', - pauseMedia: 'pause media', - playMedia: 'play media', - playbackRate: 'playback rate', - playbackRateOption: '{rate}x', - seekBackward: 'seek backward {value} seconds', - seekForward: 'seek forward {value} seconds', - seekToEnd: 'seek to end', - seekToStart: 'seek to start', - unmuteMedia: 'unmute media', - volume: 'volume' - }); - }); - - it('should update i18n when global NVE_ELEMENTS_I18N_UPDATE event is dispatched', async () => { - await elementIsStable(element); - const initialI18n = { ...element.i18n }; - - document.dispatchEvent(new Event('NVE_ELEMENTS_I18N_UPDATE')); - await elementIsStable(element); - - expect(element.i18n.__set).toBe(true); - expect(element.i18n.close).toBe(initialI18n.close); - }); - - it('should merge global default and element instance override', async () => { - element.i18n = { close: 'close override' }; - await elementIsStable(element); - - expect(element.i18n).toStrictEqual({ - __set: true, - close: 'close override', - create: 'create', - currentPage: 'current page', - expand: 'expand', - select: 'select', - selected: 'selected', - sort: 'sort', - show: 'show', - hide: 'hide', - loading: 'loading', - previous: 'previous', - next: 'next', - start: 'start', - end: 'end', - noResults: 'no results', - status: 'status', - information: 'information', - warning: 'warning', - success: 'success', - danger: 'danger', - trend: 'trend', - down: 'down', - up: 'up', - neutral: 'neutral', - copy: 'copy', - copied: 'copied', - of: 'of', - theme: 'theme', - scale: 'scale', - reducedMotion: 'reduced motion', - colorScheme: 'color scheme', - resize: 'resize', - or: 'or', - files: 'files', - dragAndDrop: 'drag & drop', - browseFiles: 'browse files', - maxFileSize: 'max file size', - currentTime: 'current time', - enableLoop: 'enable looping', - disableLoop: 'disable looping', - enterFullscreen: 'enter full screen', - enterPip: 'enter picture in picture', - exitFullscreen: 'exit full screen', - exitPip: 'exit picture in picture', - muteMedia: 'mute media', - pauseMedia: 'pause media', - playMedia: 'play media', - playbackRate: 'playback rate', - playbackRateOption: '{rate}x', - seekBackward: 'seek backward {value} seconds', - seekForward: 'seek forward {value} seconds', - seekToEnd: 'seek to end', - seekToStart: 'seek to start', - unmuteMedia: 'unmute media', - volume: 'volume' - }); - }); -}); diff --git a/projects/core/src/internal/index.ts b/projects/core/src/internal/index.ts index fd48d20568..561b52c02c 100644 --- a/projects/core/src/internal/index.ts +++ b/projects/core/src/internal/index.ts @@ -6,7 +6,6 @@ */ export * from './controllers/audit.controller.js'; export * from './controllers/gesture.controller.js'; -export * from './controllers/i18n.controller.js'; export * from './controllers/key-state.controller.js'; export * from './controllers/keynav-grid.controller.js'; export * from './controllers/keynav-list.controller.js'; @@ -25,7 +24,7 @@ export * from './decorators/host-attr.js'; export * from './decorators/scoped-registry.js'; export * from './styles/index.js'; export * from './styles/popover.js'; -export * from './services/i18n.service.js'; +export * from '@nvidia-elements/foundations/internal/i18n'; export * from './services/log.service.js'; export * from './services/transition.service.js'; export * from './types/index.js'; diff --git a/projects/core/src/internal/services/i18n.service.test.ts b/projects/core/src/internal/services/i18n.service.test.ts deleted file mode 100644 index eec1bb7209..0000000000 --- a/projects/core/src/internal/services/i18n.service.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { beforeEach, describe, expect, it } from 'vitest'; -import { I18nService, I18nService_ } from './i18n.service.js'; - -describe('I18nService', () => { - beforeEach(() => { - window.NVE_ELEMENTS.state.versions = ['0.0.0']; - window.NVE_ELEMENTS.state.elementRegistry = {}; - }); - - it('should provide an intial i18n state', () => { - expect(I18nService.i18n).toStrictEqual({ - close: 'close', - copy: 'copy', - copied: 'copied', - expand: 'expand', - select: 'select', - selected: 'selected', - sort: 'sort', - show: 'show', - hide: 'hide', - loading: 'loading', - previous: 'previous', - next: 'next', - start: 'start', - end: 'end', - currentPage: 'current page', - noResults: 'no results', - status: 'status', - information: 'information', - warning: 'warning', - success: 'success', - danger: 'danger', - trend: 'trend', - down: 'down', - up: 'up', - neutral: 'neutral', - of: 'of', - theme: 'theme', - scale: 'scale', - reducedMotion: 'reduced motion', - colorScheme: 'color scheme', - resize: 'resize', - or: 'or', - files: 'files', - dragAndDrop: 'drag & drop', - browseFiles: 'browse files', - maxFileSize: 'max file size', - create: 'create', - currentTime: 'current time', - enableLoop: 'enable looping', - disableLoop: 'disable looping', - enterFullscreen: 'enter full screen', - enterPip: 'enter picture in picture', - exitFullscreen: 'exit full screen', - exitPip: 'exit picture in picture', - muteMedia: 'mute media', - pauseMedia: 'pause media', - playMedia: 'play media', - playbackRate: 'playback rate', - playbackRateOption: '{rate}x', - seekBackward: 'seek backward {value} seconds', - seekForward: 'seek forward {value} seconds', - seekToEnd: 'seek to end', - seekToStart: 'seek to start', - unmuteMedia: 'unmute media', - volume: 'volume' - }); - }); - - it('should update i18n state', () => { - const original = { ...I18nService.i18n }; - const translations = { - close: 'fermer', - copy: 'copie', - copied: 'copié', - expand: 'étendre', - select: 'choisir', - selected: 'choisir', - sort: 'classer', - show: 'montrer', - hide: 'cacher', - loading: 'bourrage', - previous: 'précédente', - next: 'suivante', - start: 'page de démarrage', - end: 'page de fin', - currentPage: 'page actuelle', - noResults: 'aucun résultat', - status: 'statut', - information: 'information', - warning: 'avertissement', - success: 'succès', - danger: 'danger', - trend: "s'orienter", - down: 'vers le bas', - up: 'en haut', - neutral: 'neutre', - of: 'de', - theme: 'thème', - scale: 'échelle', - reducedMotion: 'mouvement réduit', - colorScheme: 'schéma de couleur', - resize: 'redimensionner', - or: 'or', - files: 'files', - dragAndDrop: 'drag & drop', - browseFiles: 'browse files', - maxFileSize: 'max file size', - create: 'créer' - }; - - I18nService.update(translations); - - expect(I18nService.i18n).toStrictEqual({ ...original, ...translations }); - - I18nService.update(original); - }); - - it('should not emit event if already initialized', () => { - let event = 0; - globalThis.document.addEventListener('NVE_ELEMENTS_I18N_UPDATE', () => event++); - new I18nService_(); - expect(event).toBe(0); - }); -}); diff --git a/projects/core/src/internal/services/i18n.service.ts b/projects/core/src/internal/services/i18n.service.ts deleted file mode 100644 index b01ffcea96..0000000000 --- a/projects/core/src/internal/services/i18n.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { GlobalStateService } from '@nvidia-elements/foundations/internal/state'; -import type { ElementsGlobal } from '@nvidia-elements/foundations/internal/state'; - -export interface I18nStrings { - close: string; - copy: string; - copied: string; - expand: string; - select: string; - selected: string; - sort: string; - show: string; - hide: string; - loading: string; - previous: string; - next: string; - start: string; - end: string; - currentPage: string; - noResults: string; - status: string; - information: string; - warning: string; - success: string; - danger: string; - trend: string; - down: string; - up: string; - neutral: string; - of: string; - theme: string; - scale: string; - reducedMotion: string; - colorScheme: string; - resize: string; - or: string; - files: string; - dragAndDrop: string; - browseFiles: string; - maxFileSize: string; - create: string; - currentTime: string; - enableLoop: string; - disableLoop: string; - enterFullscreen: string; - enterPip: string; - exitFullscreen: string; - exitPip: string; - muteMedia: string; - pauseMedia: string; - playMedia: string; - playbackRate: string; - playbackRateOption: string; - seekBackward: string; - seekForward: string; - seekToEnd: string; - seekToStart: string; - unmuteMedia: string; - volume: string; -} - -const i18nRegistry = { - close: 'close', - copy: 'copy', - copied: 'copied', - expand: 'expand', - select: 'select', - selected: 'selected', - sort: 'sort', - show: 'show', - hide: 'hide', - loading: 'loading', - previous: 'previous', - next: 'next', - start: 'start', - end: 'end', - currentPage: 'current page', - noResults: 'no results', - status: 'status', - information: 'information', - warning: 'warning', - success: 'success', - danger: 'danger', - trend: 'trend', - down: 'down', - up: 'up', - neutral: 'neutral', - of: 'of', - theme: 'theme', - scale: 'scale', - reducedMotion: 'reduced motion', - colorScheme: 'color scheme', - resize: 'resize', - or: 'or', - files: 'files', - dragAndDrop: 'drag & drop', - browseFiles: 'browse files', - maxFileSize: 'max file size', - create: 'create', - currentTime: 'current time', - enableLoop: 'enable looping', - disableLoop: 'disable looping', - enterFullscreen: 'enter full screen', - enterPip: 'enter picture in picture', - exitFullscreen: 'exit full screen', - exitPip: 'exit picture in picture', - muteMedia: 'mute media', - pauseMedia: 'pause media', - playMedia: 'play media', - playbackRate: 'playback rate', - playbackRateOption: '{rate}x', - seekBackward: 'seek backward {value} seconds', - seekForward: 'seek forward {value} seconds', - seekToEnd: 'seek to end', - seekToStart: 'seek to start', - unmuteMedia: 'unmute media', - volume: 'volume' -}; - -export class I18nService_ { - get i18n(): Partial { - return { ...GlobalStateService.state.i18nRegistry }; - } - - constructor() { - if (Object.keys(GlobalStateService.state.i18nRegistry).length === 0) { - GlobalStateService.dispatch('NVE_ELEMENTS_I18N_UPDATE', { i18nRegistry }); - } - } - - update(registry: Partial) { - GlobalStateService.dispatch('NVE_ELEMENTS_I18N_UPDATE', { i18nRegistry: registry } as Partial< - typeof globalThis.NVE_ELEMENTS.state - >); - } -} - -export const I18nService = new I18nService_(); diff --git a/projects/core/src/internal/types/index.ts b/projects/core/src/internal/types/index.ts index f9f4365deb..a68afeb81b 100644 --- a/projects/core/src/internal/types/index.ts +++ b/projects/core/src/internal/types/index.ts @@ -3,7 +3,7 @@ import type { TemplateResult } from 'lit'; import type { IconName } from './icons.js'; -import type { I18nStrings } from '../services/i18n.service.js'; +import type { I18nStrings } from '@nvidia-elements/foundations/internal/i18n'; export type Color = | 'red-cardinal' diff --git a/projects/core/src/month/month.ts b/projects/core/src/month/month.ts index 8d23d0289e..803f56c634 100644 --- a/projects/core/src/month/month.ts +++ b/projects/core/src/month/month.ts @@ -8,8 +8,13 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { inputStyles } from '@nvidia-elements/core/input'; import { IconButton } from '@nvidia-elements/core/icon-button'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './month.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-month * @description A month picker is a control that enables users to choose a month value. @@ -28,6 +33,10 @@ import styles from './month.css?inline'; */ @scopedRegistry() export class Month extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/notification/notification.ts b/projects/core/src/notification/notification.ts index 77e2146f88..634074eee6 100644 --- a/projects/core/src/notification/notification.ts +++ b/projects/core/src/notification/notification.ts @@ -12,12 +12,20 @@ import { popoverStyles, statusIcons, useStyles, - I18nController, TypeNativePopoverController, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './notification.css?inline'; +const i18nDefaults = { + close: 'close', + danger: 'danger', + information: 'information', + success: 'success', + warning: 'warning' +} satisfies I18nDefaults; + /** * @element nve-notification * @description Displays real time updates without interrupting the user's workflow to communicate an important message or status. @@ -98,7 +106,7 @@ export class Notification extends LitElement { */ @property({ type: String, reflect: true }) container?: 'flat'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables updating internal string values for internationalization. diff --git a/projects/core/src/page/page-panel/page-panel.ts b/projects/core/src/page/page-panel/page-panel.ts index 77c99f7f06..57f213b69f 100644 --- a/projects/core/src/page/page-panel/page-panel.ts +++ b/projects/core/src/page/page-panel/page-panel.ts @@ -7,7 +7,6 @@ import type { Size } from '@nvidia-elements/core/internal'; import { appendRootNodeStyle, attachInternals, - I18nController, TypeExpandableController, useStyles } from '@nvidia-elements/core/internal'; @@ -46,13 +45,6 @@ export class PagePanel extends LitElement { */ @property({ type: String, reflect: true }) size?: Size; - #i18nController: I18nController = new I18nController(this); - - /** - * Enables updating internal string values for internationalization. - */ - @property({ type: Object }) i18n = this.#i18nController.i18n; - static styles = useStyles([styles]); static readonly metadata = { diff --git a/projects/core/src/pagination/pagination.test.lighthouse.ts b/projects/core/src/pagination/pagination.test.lighthouse.ts index 0478a2a532..d21f31424d 100644 --- a/projects/core/src/pagination/pagination.test.lighthouse.ts +++ b/projects/core/src/pagination/pagination.test.lighthouse.ts @@ -19,6 +19,6 @@ describe('pagination lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(39.5); + expect(report.payload.javascript.kb).toBeLessThan(39); }); }); diff --git a/projects/core/src/pagination/pagination.ts b/projects/core/src/pagination/pagination.ts index b1888b11aa..3ccb21fe05 100644 --- a/projects/core/src/pagination/pagination.ts +++ b/projects/core/src/pagination/pagination.ts @@ -7,17 +7,26 @@ import { FormControlMixin } from '@nvidia-elements/forms/mixins'; import type { KeynavListConfig } from '@nvidia-elements/core/internal'; import { attachInternals, - I18nController, formatStandardNumber, keyNavigationList, typeSSR, useStyles, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import { Select } from '@nvidia-elements/core/select'; import styles from './pagination.css?inline'; +const i18nDefaults = { + of: 'of', + previous: 'previous', + next: 'next', + start: 'start', + end: 'end', + currentPage: 'current page' +} satisfies I18nDefaults; + /* eslint-disable jsdoc/no-types */ // explicit jsdoc annotation due to inheritance context lost with CEM generator @@ -79,7 +88,7 @@ export class Pagination extends FormControlMixin(LitE */ @property({ type: String, reflect: true }) container?: 'flat' | 'inline'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables updating internal string values for internationalization. diff --git a/projects/core/src/panel/panel.ts b/projects/core/src/panel/panel.ts index 265200c107..808830433c 100644 --- a/projects/core/src/panel/panel.ts +++ b/projects/core/src/panel/panel.ts @@ -6,18 +6,25 @@ import { property } from 'lit/decorators/property.js'; import { when } from 'lit/directives/when.js'; import { stateExpanded, - I18nController, TypeExpandableController, useStyles, attachInternals, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import panelStyleSheet from './panel.css?inline'; import panelHeaderStyleSheet from './panel-header.css?inline'; import panelContentStyleSheet from './panel-content.css?inline'; import panelFooterStyleSheet from './panel-footer.css?inline'; +const i18nDefaults = { + close: 'close', + expand: 'expand', + hide: 'hide', + show: 'show' +} satisfies I18nDefaults; + /** * @element nve-panel-header * @description Displays the title, subtitle, and optional action controls at the top of a panel. @@ -180,7 +187,7 @@ export class Panel extends LitElement { /** @private */ declare _internals: ElementInternals; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); #typeExpandableController = new TypeExpandableController(this); /** diff --git a/projects/core/src/password/password.ts b/projects/core/src/password/password.ts index d0de8e15ab..5f0ced679e 100644 --- a/projects/core/src/password/password.ts +++ b/projects/core/src/password/password.ts @@ -9,8 +9,14 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { inputStyles } from '@nvidia-elements/core/input'; import { IconButton } from '@nvidia-elements/core/icon-button'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './password.css?inline'; +const i18nDefaults = { + hide: 'hide', + show: 'show' +} satisfies I18nDefaults; + /** * @element nve-password * @description A password is a control that enables users to enter password text. @@ -29,6 +35,10 @@ import styles from './password.css?inline'; */ @scopedRegistry() export class Password extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts b/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts index cfdfa4726a..8ded85b9d9 100644 --- a/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts +++ b/projects/core/src/preferences-input/preferences-input.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('preferences-input lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(33.1); + expect(report.payload.javascript.kb).toBeLessThan(32.9); }); }); diff --git a/projects/core/src/preferences-input/preferences-input.ts b/projects/core/src/preferences-input/preferences-input.ts index c7189654cf..fb079ced1d 100644 --- a/projects/core/src/preferences-input/preferences-input.ts +++ b/projects/core/src/preferences-input/preferences-input.ts @@ -3,7 +3,8 @@ import { html, isServer, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; -import { I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { state } from 'lit/decorators/state.js'; import { FormControlMixin } from '@nvidia-elements/forms/mixins'; import { type IconName, Icon } from '@nvidia-elements/core/icon'; @@ -22,6 +23,12 @@ export type Variant = 'reduced-motion'; const colorSchemes: ColorScheme[] = ['auto', 'light', 'dark', 'high-contrast']; const scales: Scale[] = ['default', 'compact']; +const i18nDefaults = { + theme: 'theme', + scale: 'scale', + reducedMotion: 'reduced motion', + colorScheme: 'color scheme' +} satisfies I18nDefaults; const colorSchemesIcons = { auto: 'computer', light: 'sun', @@ -71,7 +78,7 @@ export interface PreferencesInputValue { */ @scopedRegistry() export class PreferencesInput extends FormControlMixin(LitElement) { - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Enables updating internal string values for internationalization. diff --git a/projects/core/src/progress-dot/progress-dot.test.ts b/projects/core/src/progress-dot/progress-dot.test.ts index 472ff84cde..60461207f5 100644 --- a/projects/core/src/progress-dot/progress-dot.test.ts +++ b/projects/core/src/progress-dot/progress-dot.test.ts @@ -16,13 +16,13 @@ describe(ProgressDot.metadata.tag, () => { let themeStyles: HTMLStyleElement; beforeEach(async () => { - loadingLabel = I18nService.i18n.loading; themeStyles = document.createElement('style'); themeStyles.textContent = reducedMotionStyles; document.head.append(themeStyles); fixture = await createFixture(html``); element = fixture.querySelector('nve-progress-dot'); await elementIsStable(element); + loadingLabel = I18nService.i18n.loading; }); afterEach(() => { diff --git a/projects/core/src/progress-dot/progress-dot.ts b/projects/core/src/progress-dot/progress-dot.ts index 89d3e3a3a9..3c127a119d 100644 --- a/projects/core/src/progress-dot/progress-dot.ts +++ b/projects/core/src/progress-dot/progress-dot.ts @@ -4,9 +4,14 @@ import { html, LitElement, type PropertyValues } from 'lit'; import { property } from 'lit/decorators/property.js'; import type { Size } from '@nvidia-elements/core/internal'; -import { attachInternals, I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './progress-dot.css?inline'; +const i18nDefaults = { + loading: 'loading' +} satisfies I18nDefaults; + /** * @element nve-progress-dot * @description An inline visual indicator that shows the progress of an ongoing task with an unknown completion percentage. @@ -33,7 +38,7 @@ export class ProgressDot extends LitElement { /** Controls the indicator size. */ @property({ type: String, reflect: true }) size: Size = 'md'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating the default loading label for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/progress-ring/progress-ring.ts b/projects/core/src/progress-ring/progress-ring.ts index 5cb2f71b1d..625b96945e 100644 --- a/projects/core/src/progress-ring/progress-ring.ts +++ b/projects/core/src/progress-ring/progress-ring.ts @@ -5,16 +5,18 @@ import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import type { Size, SupportStatus } from '@nvidia-elements/core/internal'; -import { - attachInternals, - I18nController, - scopedRegistry, - statusIcons, - useStyles -} from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, statusIcons, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import styles from './progress-ring.css?inline'; +const i18nDefaults = { + danger: 'danger', + information: 'information', + success: 'success', + warning: 'warning' +} satisfies I18nDefaults; + /** * @element nve-progress-ring * @description The `progress-ring` component shows the status of a pending task. It also serves the basis of the page loading element. @@ -61,7 +63,7 @@ export class ProgressRing extends LitElement { /** T-shirt `size` of the progress indicator, used to scale the ring. */ @property({ type: String, reflect: true }) size?: Size | 'xxs' | 'xs' | 'xl'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts b/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts index 9c56242ce6..eb00254b38 100644 --- a/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts +++ b/projects/core/src/progressive-filter-chip/progressive-filter-chip.ts @@ -8,12 +8,12 @@ import { appendRootNodeStyle, attachInternals, generateId, - I18nController, scopedRegistry, TypeClosableController, typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Input } from '@nvidia-elements/core/input'; import { Select } from '@nvidia-elements/core/select'; import { Date } from '@nvidia-elements/core/date'; @@ -21,6 +21,10 @@ import { IconButton } from '@nvidia-elements/core/icon-button'; import globalStyles from './progressive-filter-chip.global.css?inline'; import styles from './progressive-filter-chip.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-progressive-filter-chip * @description A filter chip is a control that enables users to select many options from a set of choices. @@ -60,7 +64,7 @@ export class ProgressiveFilterChip extends LitElement { /** Determines if a close button should render within filter chip */ @property({ type: Boolean }) closable: boolean; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts index d9c8d6c6ee..673d391ed6 100644 --- a/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts +++ b/projects/core/src/resize-handle/resize-handle.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('resize-handle lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(15.6); + expect(report.payload.javascript.kb).toBeLessThan(15.2); }); }); diff --git a/projects/core/src/resize-handle/resize-handle.ts b/projects/core/src/resize-handle/resize-handle.ts index 7ba3beea1d..35dd3f1075 100644 --- a/projects/core/src/resize-handle/resize-handle.ts +++ b/projects/core/src/resize-handle/resize-handle.ts @@ -5,15 +5,14 @@ import { html, LitElement, type PropertyValues } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { property } from 'lit/decorators/property.js'; import { FormControlMixin } from '@nvidia-elements/forms/mixins'; -import { - GestureController, - I18nController, - useStyles, - type Gesture, - type GestureInput -} from '@nvidia-elements/core/internal'; +import { GestureController, useStyles, type Gesture, type GestureInput } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './resize-handle.css?inline'; +const i18nDefaults = { + resize: 'resize' +} satisfies I18nDefaults; + /** * @element nve-resize-handle * @description A resize-handle slider is a control that enables users to resize views or panels vertically or horizontally. @@ -64,7 +63,7 @@ export class ResizeHandle extends FormControlMixin(Li } }; - readonly #i18nController: I18nController = new I18nController(this); + readonly #i18nController: I18nController = new I18nController(this, i18nDefaults); readonly #gestureController: GestureController; diff --git a/projects/core/src/select/select.ts b/projects/core/src/select/select.ts index 527b9f3907..e1104bbdc1 100644 --- a/projects/core/src/select/select.ts +++ b/projects/core/src/select/select.ts @@ -9,13 +9,12 @@ import { focusElementTimeout, onListboxActivate, useStyles, - i18n, - I18nController, onChildListMutation, getElementUpdate, scopedRegistry, appendRootNodeStyle } from '@nvidia-elements/core/internal'; +import { i18n, I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Control } from '@nvidia-elements/core/forms'; import { Icon } from '@nvidia-elements/core/icon'; import { Menu, MenuItem } from '@nvidia-elements/core/menu'; @@ -24,6 +23,11 @@ import { Tag } from '@nvidia-elements/core/tag'; import styles from './select.css?inline'; import globalStyles from './select.global.css?inline'; +const i18nDefaults = { + select: 'select', + selected: 'selected' +} satisfies I18nDefaults; + /** * @element nve-select * @description A select is a control that enables users to select an option from a list of options. @@ -79,7 +83,7 @@ export class Select extends Control { /** Flat container option applies when embedding the component within another containing element */ @property({ type: String, reflect: true }) container?: 'flat' | 'inline'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Updates internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/sort-button/sort-button.test.lighthouse.ts b/projects/core/src/sort-button/sort-button.test.lighthouse.ts index 0b41b114d8..61c266287c 100644 --- a/projects/core/src/sort-button/sort-button.test.lighthouse.ts +++ b/projects/core/src/sort-button/sort-button.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('sort-button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(19.3); + expect(report.payload.javascript.kb).toBeLessThan(19.03); }); }); diff --git a/projects/core/src/sort-button/sort-button.ts b/projects/core/src/sort-button/sort-button.ts index 93ea1331ae..78de5fd0e2 100644 --- a/projects/core/src/sort-button/sort-button.ts +++ b/projects/core/src/sort-button/sort-button.ts @@ -5,9 +5,14 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; import { Icon } from '@nvidia-elements/core/icon'; -import { useStyles, I18nController, scopedRegistry } from '@nvidia-elements/core/internal'; +import { useStyles, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './sort-button.css?inline'; +const i18nDefaults = { + sort: 'sort' +} satisfies I18nDefaults; + const nextSort = { none: 'ascending', ascending: 'descending', @@ -46,7 +51,7 @@ export class SortButton extends ButtonFormControlMixin(LitElement) { */ @property({ type: String, reflect: true }) sort: 'ascending' | 'descending' | 'none' = 'none'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Updates internal string values for internationalization. diff --git a/projects/core/src/tag/tag.ts b/projects/core/src/tag/tag.ts index d35a1cfd47..8942d7877e 100644 --- a/projects/core/src/tag/tag.ts +++ b/projects/core/src/tag/tag.ts @@ -6,16 +6,15 @@ import { property } from 'lit/decorators/property.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; import type { Color, Prominence } from '@nvidia-elements/core/internal'; -import { - I18nController, - TypeClosableController, - useStyles, - colorStateStyles, - scopedRegistry -} from '@nvidia-elements/core/internal'; +import { TypeClosableController, useStyles, colorStateStyles, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import styles from './tag.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-tag * @description A interactive element that represents a category or group of content. Typically used to filter or organize content for one to many relations. @@ -63,7 +62,7 @@ export class Tag extends ButtonFormControlMixin(LitElement) { /** Determines the visual prominence or weight */ @property({ type: String, reflect: true }) prominence?: Extract; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Updates internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/core/src/time/time.ts b/projects/core/src/time/time.ts index 23616d6277..526207a5ff 100644 --- a/projects/core/src/time/time.ts +++ b/projects/core/src/time/time.ts @@ -8,8 +8,13 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { IconButton } from '@nvidia-elements/core/icon-button'; import { inputStyles } from '@nvidia-elements/core/input'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './time.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-time * @description A time picker is a control that enables users to choose a time value. @@ -28,6 +33,10 @@ import styles from './time.css?inline'; */ @scopedRegistry() export class Time extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/core/src/toast/toast.ts b/projects/core/src/toast/toast.ts index e7e4b9f064..ecbaf551c5 100644 --- a/projects/core/src/toast/toast.ts +++ b/projects/core/src/toast/toast.ts @@ -9,16 +9,24 @@ import { attachInternals, audit, excessiveInstanceLimit, - I18nController, popoverStyles, scopedRegistry, statusIcons, TypeNativePopoverController, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon, type IconName } from '@nvidia-elements/core/icon'; import styles from './toast.css?inline'; +const i18nDefaults = { + close: 'close', + danger: 'danger', + information: 'information', + success: 'success', + warning: 'warning' +} satisfies I18nDefaults; + /** * @element nve-toast * @description A contextual popup that displays a status. Toasts are [triggered](https://w3c.github.io/aria/#tooltip) by clicking, focusing, or tapping an element and cannot have interactive elements within them. @@ -110,7 +118,7 @@ export class Toast extends LitElement { */ @property({ type: String, reflect: true }) status: SupportStatus; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Updates internal string values for internationalization. diff --git a/projects/core/src/toggletip/toggletip.ts b/projects/core/src/toggletip/toggletip.ts index c844c2fc08..5d45e7cd30 100644 --- a/projects/core/src/toggletip/toggletip.ts +++ b/projects/core/src/toggletip/toggletip.ts @@ -10,16 +10,20 @@ import { attachInternals, audit, excessiveInstanceLimit, - I18nController, popoverStyles, TypeNativePopoverController, useStyles, appendRootNodeStyle, scopedRegistry } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './toggletip.css?inline'; import globalStyles from './toggletip.global.css?inline'; +const i18nDefaults = { + close: 'close' +} satisfies I18nDefaults; + /** * @element nve-toggletip * @description Generic toggletip element for rendering a variety of different interactive content. [MDN Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) @@ -111,7 +115,7 @@ export class Toggletip extends LitElement { protected typeNativePopoverController = new TypeNativePopoverController(this); - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Updates internal string values for internationalization. diff --git a/projects/core/src/tree/tree-node.ts b/projects/core/src/tree/tree-node.ts index a64901cc6f..d1f9cf8b38 100644 --- a/projects/core/src/tree/tree-node.ts +++ b/projects/core/src/tree/tree-node.ts @@ -6,7 +6,6 @@ import { property } from 'lit/decorators/property.js'; import { state } from 'lit/decorators/state.js'; import { queryAssignedElements } from 'lit/decorators/query-assigned-elements.js'; import { - I18nController, TypeExpandableController, TypeSelectableController, attachInternals, @@ -22,6 +21,7 @@ import { typeSSR, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { IconButton } from '@nvidia-elements/core/icon-button'; import { Icon } from '@nvidia-elements/core/icon'; import { Checkbox } from '@nvidia-elements/core/checkbox'; @@ -30,6 +30,10 @@ import type { Tree } from './tree.js'; import styles from './tree-node.css?inline'; import { updateNodeSelection } from './utils.js'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-tree-node * @description A tree view widget presents a hierarchical list. Any item in the hierarchy may have child items, and items that have children can expand or collapse to show or hide the children. @@ -106,7 +110,7 @@ export class TreeNode extends LitElement { */ @property({ type: Boolean }) highlighted = false; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** * Updates internal string values for internationalization. diff --git a/projects/core/src/week/week.ts b/projects/core/src/week/week.ts index 56c7a14697..03cbc12ea7 100644 --- a/projects/core/src/week/week.ts +++ b/projects/core/src/week/week.ts @@ -8,8 +8,13 @@ import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; import { Control } from '@nvidia-elements/core/forms'; import { IconButton } from '@nvidia-elements/core/icon-button'; import { inputStyles } from '@nvidia-elements/core/input'; +import type { I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import styles from './week.css?inline'; +const i18nDefaults = { + expand: 'expand' +} satisfies I18nDefaults; + /** * @element nve-week * @description A week picker is a control that enables users to choose a week value. @@ -28,6 +33,10 @@ import styles from './week.css?inline'; */ @scopedRegistry() export class Week extends Control implements ContainerElement { + protected override get i18nDefaults(): I18nDefaults { + return i18nDefaults; + } + /** * Reduces the visual container for a minimal borderless appearance while preserving whitespace bounds. * Use when embedding within another container such as a toolbar. diff --git a/projects/foundations/package.json b/projects/foundations/package.json index de12e9a585..f7f69930d3 100644 --- a/projects/foundations/package.json +++ b/projects/foundations/package.json @@ -94,6 +94,22 @@ "./internal/state/index.js": { "types": "./dist/internal/state/index.d.ts", "default": "./dist/internal/state/index.js" + }, + "./internal/i18n": { + "types": "./dist/internal/i18n/index.d.ts", + "default": "./dist/internal/i18n/index.js" + }, + "./internal/i18n/index.js": { + "types": "./dist/internal/i18n/index.d.ts", + "default": "./dist/internal/i18n/index.js" + }, + "./i18n": { + "types": "./dist/i18n/index.d.ts", + "default": "./dist/i18n/index.js" + }, + "./i18n/index.js": { + "types": "./dist/i18n/index.d.ts", + "default": "./dist/i18n/index.js" } }, "sideEffects": [ diff --git a/projects/foundations/src/i18n/index.ts b/projects/foundations/src/i18n/index.ts new file mode 100644 index 0000000000..95d5aa837a --- /dev/null +++ b/projects/foundations/src/i18n/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type { I18n } from '../internal/i18n/i18n.controller.js'; +export { I18nService } from '../internal/i18n/i18n.service.js'; +export type { I18nDefaults, I18nStrings } from '../internal/i18n/i18n.service.js'; diff --git a/projects/foundations/src/internal/i18n/i18n.controller.test.ts b/projects/foundations/src/internal/i18n/i18n.controller.test.ts new file mode 100644 index 0000000000..ec55f507ee --- /dev/null +++ b/projects/foundations/src/internal/i18n/i18n.controller.test.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { i18n, I18nController, I18nService, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; +import { createFixture, removeFixture, elementIsStable } from '@internals/testing'; + +const i18nDefaults = { + close: 'close', + previous: 'previous' +} satisfies I18nDefaults; + +@i18n() +@customElement('i18n-test-element') +class I18nTestElement extends LitElement { + #i18nController: I18nController = new I18nController(this, i18nDefaults); + + @property({ type: Object }) i18n = this.#i18nController.i18n; + + render() { + return html`${JSON.stringify(this.i18n, null, 2)}`; + } +} + +describe('i18n.controller', () => { + let element: I18nTestElement; + let fixture: HTMLElement; + + beforeEach(async () => { + fixture = await createFixture(html``); + element = fixture.querySelector('i18n-test-element'); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should initialize i18n state', async () => { + await elementIsStable(element); + + expect(element.i18n).toMatchObject({ + __set: false, + close: 'close', + previous: 'previous' + }); + }); + + it('should update i18n when global NVE_ELEMENTS_I18N_UPDATE event is dispatched', async () => { + await elementIsStable(element); + const initialI18n = { ...element.i18n }; + + document.dispatchEvent(new Event('NVE_ELEMENTS_I18N_UPDATE')); + await elementIsStable(element); + + expect(element.i18n.__set).toBe(true); + expect(element.i18n.close).toBe(initialI18n.close); + }); + + it('should merge global default and element instance override', async () => { + element.i18n = { close: 'close override' }; + await elementIsStable(element); + + expect(element.i18n).toMatchObject({ + __set: true, + close: 'close override', + previous: 'previous' + }); + }); + + it('should preserve global translations when a component registers defaults', async () => { + I18nService.update({ close: 'fermer' }); + const translatedFixture = await createFixture(html``); + try { + const translatedElement = translatedFixture.querySelector('i18n-test-element'); + await elementIsStable(translatedElement); + + expect(translatedElement.i18n.close).toBe('fermer'); + expect(I18nService.i18n.close).toBe('fermer'); + } finally { + removeFixture(translatedFixture); + I18nService.update({ close: 'close' }); + } + }); +}); diff --git a/projects/core/src/internal/controllers/i18n.controller.ts b/projects/foundations/src/internal/i18n/i18n.controller.ts similarity index 55% rename from projects/core/src/internal/controllers/i18n.controller.ts rename to projects/foundations/src/internal/i18n/i18n.controller.ts index 2d8d566a28..0aa1ef4021 100644 --- a/projects/core/src/internal/controllers/i18n.controller.ts +++ b/projects/foundations/src/internal/i18n/i18n.controller.ts @@ -2,24 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 import type { ReactiveController, ReactiveElement } from 'lit'; -import type { LegacyDecoratorTarget } from '../types/index.js'; -import type { I18nStrings } from '../services/i18n.service.js'; -import { I18nService } from '../services/i18n.service.js'; +import type { I18nDefaults, I18nStrings } from './i18n.service.js'; +import { I18nService } from './i18n.service.js'; -export function i18n(): ClassDecorator { - return (target: LegacyDecoratorTarget) => target.addInitializer!((instance: T) => new I18nController(instance)); +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type I18nDecoratorTarget = Function & { + addInitializer?: (initializer: (instance: T) => void) => void; +}; + +export function i18n(defaults?: I18nDefaults): ClassDecorator { + return (target: I18nDecoratorTarget) => + target.addInitializer!((instance: T) => new I18nController(instance, defaults)); } export type I18n = ReactiveElement & { i18n: Partial & { __set?: boolean } }; export class I18nController implements ReactiveController { get i18n(): Partial { - return { ...I18nService.i18n, ...this.host.i18n, __set: false }; + return { ...this.defaults, ...I18nService.i18n, ...this.host.i18n, __set: false }; } #overrides: Partial = {}; - constructor(private host: T) { + constructor( + private host: T, + private readonly defaults: I18nDefaults = {} + ) { + I18nService.registerDefaults(defaults); this.host.addController(this); } @@ -40,6 +49,6 @@ export class I18nController implements ReactiveController { } #update = () => { - this.host.i18n = { ...I18nService.i18n, ...this.#overrides, __set: true }; + this.host.i18n = { ...this.defaults, ...I18nService.i18n, ...this.#overrides, __set: true }; }; } diff --git a/projects/foundations/src/internal/i18n/i18n.service.test.ts b/projects/foundations/src/internal/i18n/i18n.service.test.ts new file mode 100644 index 0000000000..5ced70fc51 --- /dev/null +++ b/projects/foundations/src/internal/i18n/i18n.service.test.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nService, I18nService_ } from './i18n.service.js'; + +describe('I18nService', () => { + beforeEach(() => { + window.NVE_ELEMENTS.state.versions = ['0.0.0']; + window.NVE_ELEMENTS.state.elementRegistry = {}; + (window.NVE_ELEMENTS.state as { i18nRegistry: Record }).i18nRegistry = {}; + new I18nService_(); + }); + + it('should start with an empty i18n registry', () => { + expect(I18nService.i18n).toStrictEqual({}); + }); + + it('should silently register missing defaults without overwriting translations', () => { + I18nService.update({ previous: 'précédente' }); + const listener = vi.fn(); + globalThis.document.addEventListener('NVE_ELEMENTS_I18N_UPDATE', listener); + + I18nService.registerDefaults({ previous: 'previous', next: 'next' }); + + expect(I18nService.i18n.previous).toBe('précédente'); + expect(I18nService.i18n.next).toBe('next'); + expect(listener).not.toHaveBeenCalled(); + globalThis.document.removeEventListener('NVE_ELEMENTS_I18N_UPDATE', listener); + }); + + it('should update i18n state', () => { + const original = { ...I18nService.i18n }; + const translations = { + close: 'fermer', + copy: 'copie', + copied: 'copié', + expand: 'étendre', + select: 'choisir', + selected: 'choisir', + sort: 'classer', + show: 'montrer', + hide: 'cacher', + loading: 'bourrage', + previous: 'précédente', + next: 'suivante', + start: 'page de démarrage', + end: 'page de fin', + currentPage: 'page actuelle', + noResults: 'aucun résultat', + status: 'statut', + information: 'information', + warning: 'avertissement', + success: 'succès', + danger: 'danger', + trend: "s'orienter", + down: 'vers le bas', + up: 'en haut', + neutral: 'neutre', + of: 'de', + theme: 'thème', + scale: 'échelle', + reducedMotion: 'mouvement réduit', + colorScheme: 'schéma de couleur', + resize: 'redimensionner', + or: 'or', + files: 'files', + dragAndDrop: 'drag & drop', + browseFiles: 'browse files', + maxFileSize: 'max file size', + create: 'créer' + }; + + I18nService.update(translations); + + expect(I18nService.i18n).toStrictEqual({ ...original, ...translations }); + + I18nService.update(original); + }); + + it('should not emit event if already initialized', () => { + const listener = vi.fn(); + globalThis.document.addEventListener('NVE_ELEMENTS_I18N_UPDATE', listener); + new I18nService_(); + expect(listener).not.toHaveBeenCalled(); + globalThis.document.removeEventListener('NVE_ELEMENTS_I18N_UPDATE', listener); + }); +}); diff --git a/projects/foundations/src/internal/i18n/i18n.service.ts b/projects/foundations/src/internal/i18n/i18n.service.ts new file mode 100644 index 0000000000..833ae23df1 --- /dev/null +++ b/projects/foundations/src/internal/i18n/i18n.service.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GlobalStateService } from '../state/index.js'; +import type { ElementsGlobal } from '../state/index.js'; + +export interface I18nStrings { + close: string; + copy: string; + copied: string; + expand: string; + select: string; + selected: string; + sort: string; + show: string; + hide: string; + loading: string; + previous: string; + next: string; + start: string; + end: string; + currentPage: string; + noResults: string; + status: string; + information: string; + warning: string; + success: string; + danger: string; + trend: string; + down: string; + up: string; + neutral: string; + of: string; + theme: string; + scale: string; + reducedMotion: string; + colorScheme: string; + resize: string; + or: string; + files: string; + dragAndDrop: string; + browseFiles: string; + maxFileSize: string; + create: string; + currentTime: string; + enableLoop: string; + disableLoop: string; + enterFullscreen: string; + enterPip: string; + exitFullscreen: string; + exitPip: string; + muteMedia: string; + pauseMedia: string; + playMedia: string; + playbackRate: string; + playbackRateOption: string; + seekBackward: string; + seekForward: string; + seekToEnd: string; + seekToStart: string; + unmuteMedia: string; + volume: string; +} + +export type I18nDefaults = Readonly>; + +export class I18nService_ { + get i18n(): Partial { + return { ...GlobalStateService.state.i18nRegistry }; + } + + registerDefaults(registry: I18nDefaults) { + const current = GlobalStateService.state.i18nRegistry; + const missing = Object.entries(registry).reduce>((result, [key, value]) => { + if (value !== undefined && current[key] === undefined) { + result[key] = value; + } + return result; + }, {}); + + if (Object.keys(missing).length > 0) { + GlobalStateService.merge({ i18nRegistry: missing }); + } + } + + update(registry: Partial) { + GlobalStateService.dispatch('NVE_ELEMENTS_I18N_UPDATE', { i18nRegistry: registry } as Partial< + typeof globalThis.NVE_ELEMENTS.state + >); + } +} + +export const I18nService = new I18nService_(); diff --git a/projects/foundations/src/internal/i18n/index.ts b/projects/foundations/src/internal/i18n/index.ts new file mode 100644 index 0000000000..fdacf5a6e7 --- /dev/null +++ b/projects/foundations/src/internal/i18n/index.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { i18n, I18nController } from './i18n.controller.js'; +export type { I18n } from './i18n.controller.js'; +export { I18nService, I18nService_ } from './i18n.service.js'; +export type { I18nDefaults, I18nStrings } from './i18n.service.js'; diff --git a/projects/foundations/src/internal/state/state.test.ts b/projects/foundations/src/internal/state/state.test.ts index 106f00d63b..ad83ca585c 100644 --- a/projects/foundations/src/internal/state/state.test.ts +++ b/projects/foundations/src/internal/state/state.test.ts @@ -109,6 +109,31 @@ describe('GlobalStateService', () => { expect(service.state.elementRegistry).toEqual({}); }); + it('should initialize i18nRegistry when prior state did not define it', () => { + vi.stubGlobal('NVE_ELEMENTS', { + ...globalThis.NVE_ELEMENTS, + state: { ...globalThis.NVE_ELEMENTS.state } + }); + Reflect.deleteProperty(globalThis.NVE_ELEMENTS.state, 'i18nRegistry'); + + const service = new GlobalState(); + + expect(service.state.i18nRegistry).toEqual({}); + }); + + it('should preserve existing translations when another state service initializes', () => { + const registry = { close: 'fermer' }; + vi.stubGlobal('NVE_ELEMENTS', { + ...globalThis.NVE_ELEMENTS, + state: { ...globalThis.NVE_ELEMENTS.state, i18nRegistry: registry } + }); + + const service = new GlobalState(); + + expect(service.state.i18nRegistry).toBe(registry); + expect(service.state.i18nRegistry.close).toBe('fermer'); + }); + it('should initialize scopedRegistry when prior version did not define it', () => { delete (window.NVE_ELEMENTS.state as any).scopedRegistry; // eslint-disable-line @typescript-eslint/no-explicit-any const instance = new GlobalState(); diff --git a/projects/foundations/src/internal/state/state.ts b/projects/foundations/src/internal/state/state.ts index f03abb07ed..c0df759057 100644 --- a/projects/foundations/src/internal/state/state.ts +++ b/projects/foundations/src/internal/state/state.ts @@ -32,6 +32,7 @@ export class GlobalState { } }; globalThis.NVE_ELEMENTS.state.elementRegistry ??= {}; + globalThis.NVE_ELEMENTS.state.i18nRegistry ??= {}; globalThis.NVE_ELEMENTS.state.versions = Array.from(new Set([...globalThis.NVE_ELEMENTS.state.versions, '0.0.0'])); globalThis.NVE_ELEMENTS.state.scopedRegistry ??= {}; // if older versions used, they may not have a scopedRegistry globalThis.NVE_ELEMENTS.state.scopedRegistry['0.0.0'] ??= createScopedRegistry(); @@ -53,8 +54,12 @@ export class GlobalState { return (this.state.scopedRegistry[version] ??= createScopedRegistry()); } - dispatch(type: string, state: Partial) { + merge(state: Partial) { globalThis.NVE_ELEMENTS.state = deepMerge(globalThis.NVE_ELEMENTS.state, state); + } + + dispatch(type: string, state: Partial) { + this.merge(state); globalThis?.document?.dispatchEvent(new CustomEvent(type, { detail: globalThis.NVE_ELEMENTS.state })); } } diff --git a/projects/internals/metadata/static/lighthouse.json b/projects/internals/metadata/static/lighthouse.json index 8e7541d235..5cdb2d7208 100644 --- a/projects/internals/metadata/static/lighthouse.json +++ b/projects/internals/metadata/static/lighthouse.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c93b46003efc0fa2b88be6d6c95174f770a1c34f98f519d4cb22bb27d956352 -size 74704 +oid sha256:76556241cf74eda39009812be91ad6a988c3f607d759f9cc23077a8460010e50 +size 74680 diff --git a/projects/internals/vite/src/runners/playwright.js b/projects/internals/vite/src/runners/playwright.js index 8b43a251fc..80bbab6955 100644 --- a/projects/internals/vite/src/runners/playwright.js +++ b/projects/internals/vite/src/runners/playwright.js @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { setTimeout } from 'node:timers/promises'; import { globSync } from 'glob'; import { chromium } from 'playwright'; import { preview, build } from 'vite'; @@ -115,8 +116,8 @@ export class VitePlaywrightRunner { const args = this.#browserServer.process().spawnargs; const userDataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice('--user-data-dir='.length); if (!userDataDir) throw new Error('Chromium did not expose its isolated profile directory'); - const port = fs.readFileSync(path.join(userDataDir, 'DevToolsActivePort'), 'utf8').split('\n')[0]; - fs.writeFileSync(`${this.#portFile}-debugging`, port); + const port = await waitForDebuggingPort(userDataDir); + fs.writeFileSync(`${this.#portFile}-debugging`, String(port)); } else { this.#browser = await chromium.launch({ args: this.#chromiumArgs }); } @@ -167,6 +168,26 @@ export class VitePlaywrightRunner { } } +/** Waits for Chromium to finish publishing its automatically assigned debugging port. */ +export async function waitForDebuggingPort(userDataDir, { timeoutMs = 10000 } = {}) { + const portFile = path.join(userDataDir, 'DevToolsActivePort'); + const deadline = Date.now() + timeoutMs; + + while (true) { + try { + const contents = await fs.promises.readFile(portFile, 'utf8'); + const port = Number(contents.split('\n')[0]); + if (contents.includes('\n') && Number.isInteger(port) && port > 0 && port <= 65535) return port; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`Timed out waiting for Chromium debugging port in ${portFile}`); + await setTimeout(Math.min(50, remaining)); + } +} + /** Collects individual runner reports without re-reading the aggregate report. */ export function collectReports(root) { return globSync(path.resolve(root, 'dist/*/report.json')) diff --git a/projects/internals/vite/src/runners/playwright.test.js b/projects/internals/vite/src/runners/playwright.test.js new file mode 100644 index 0000000000..d5cae4a4c4 --- /dev/null +++ b/projects/internals/vite/src/runners/playwright.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { waitForDebuggingPort } from './playwright.js'; + +async function createProfile(t) { + const directory = await mkdtemp(join(tmpdir(), 'elements-chromium-port-')); + t.after(() => rm(directory, { recursive: true, force: true })); + return { directory, portFile: join(directory, 'DevToolsActivePort') }; +} + +test('reads a ready Chromium debugging port', async t => { + const { directory, portFile } = await createProfile(t); + await writeFile(portFile, '43210\n/devtools/browser/test\n'); + + assert.equal(await waitForDebuggingPort(directory), 43210); +}); + +test('waits for Chromium to create its debugging port file', async t => { + const { directory, portFile } = await createProfile(t); + const portPromise = waitForDebuggingPort(directory); + await setTimeout(20); + await writeFile(portFile, '43210\n/devtools/browser/test\n'); + + assert.equal(await portPromise, 43210); +}); + +test('waits for an incomplete Chromium debugging port file', async t => { + const { directory, portFile } = await createProfile(t); + await writeFile(portFile, '432'); + const portPromise = waitForDebuggingPort(directory); + await setTimeout(20); + await writeFile(portFile, '43210\n/devtools/browser/test\n'); + + assert.equal(await portPromise, 43210); +}); + +test('times out if Chromium never publishes a debugging port', async t => { + const { directory } = await createProfile(t); + + await assert.rejects(waitForDebuggingPort(directory, { timeoutMs: 20 }), { + message: /Timed out waiting for Chromium debugging port in .*DevToolsActivePort/ + }); +}); + +test('rejects invalid Chromium debugging ports', async t => { + const { directory, portFile } = await createProfile(t); + await writeFile(portFile, '65536\n/devtools/browser/test\n'); + + await assert.rejects(waitForDebuggingPort(directory, { timeoutMs: 20 }), /Timed out/); +}); + +test('preserves file errors unrelated to startup readiness', async t => { + const { portFile } = await createProfile(t); + await writeFile(portFile, 'not a directory'); + + await assert.rejects(waitForDebuggingPort(portFile), { code: 'ENOTDIR' }); +}); diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts index f8544491f6..2f78407039 100644 --- a/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media fullscreen button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(21.5); + expect(report.payload.javascript.kb).toBeLessThan(21.28); }); }); diff --git a/projects/media/src/fullscreen-button/fullscreen-button.ts b/projects/media/src/fullscreen-button/fullscreen-button.ts index 4ac7b46015..025bc71984 100644 --- a/projects/media/src/fullscreen-button/fullscreen-button.ts +++ b/projects/media/src/fullscreen-button/fullscreen-button.ts @@ -5,7 +5,8 @@ import { property } from 'lit/decorators/property.js'; import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { ButtonFormControlMixin, type ButtonType } from '@nvidia-elements/forms/mixins'; -import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; import { mediaCommands } from '../internal/media-command.js'; @@ -13,6 +14,11 @@ import type { MediaState } from '../internal/media-state.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './fullscreen-button.css?inline'; +const i18nDefaults = { + enterFullscreen: 'enter full screen', + exitFullscreen: 'exit full screen' +} satisfies I18nDefaults; + /** * @element nve-media-fullscreen-button * @description Requests controller full-screen changes without adding form submission state. @@ -50,7 +56,7 @@ export class MediaFullscreenButton extends ButtonFormControlMixin(LitElement) { @property({ type: Boolean, reflect: true }) pressed = false; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/loop-button/loop-button.ts b/projects/media/src/loop-button/loop-button.ts index 780cbdd628..70be1fb3cf 100644 --- a/projects/media/src/loop-button/loop-button.ts +++ b/projects/media/src/loop-button/loop-button.ts @@ -5,7 +5,8 @@ import { property } from 'lit/decorators/property.js'; import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; -import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; import { mediaCommands } from '../internal/media-command.js'; @@ -13,6 +14,11 @@ import type { MediaState } from '../internal/media-state.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './loop-button.css?inline'; +const i18nDefaults = { + enableLoop: 'enable looping', + disableLoop: 'disable looping' +} satisfies I18nDefaults; + /** * @element nve-media-loop-button * @description Requests continuous looping changes for the controlled media. @@ -48,7 +54,7 @@ export class MediaLoopButton extends ButtonFormControlMixin(LitElement) { @property({ type: Boolean, reflect: true, noAccessor: true }) declare pressed: boolean; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/mute-button/mute-button.test.lighthouse.ts b/projects/media/src/mute-button/mute-button.test.lighthouse.ts index 978d08e20e..b48d6155ce 100644 --- a/projects/media/src/mute-button/mute-button.test.lighthouse.ts +++ b/projects/media/src/mute-button/mute-button.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media mute button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(24.5); + expect(report.payload.javascript.kb).toBeLessThan(24.25); }); }); diff --git a/projects/media/src/mute-button/mute-button.ts b/projects/media/src/mute-button/mute-button.ts index a04552f4f3..1b6f6a2079 100644 --- a/projects/media/src/mute-button/mute-button.ts +++ b/projects/media/src/mute-button/mute-button.ts @@ -5,7 +5,8 @@ import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ButtonFormControlMixin, CheckboxFormControlMixin } from '@nvidia-elements/forms/mixins'; -import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; import { mediaCommands } from '../internal/media-command.js'; @@ -13,6 +14,11 @@ import type { MediaState } from '../internal/media-state.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './mute-button.css?inline'; +const i18nDefaults = { + muteMedia: 'mute media', + unmuteMedia: 'unmute media' +} satisfies I18nDefaults; + /** * @element nve-media-mute-button * @description Requests muted changes and submits a checkbox-style value for muted media. @@ -46,7 +52,7 @@ export class MediaMuteButton extends CheckboxFormControlMixin(ButtonFormControlM override command: string = mediaCommands.toggleMuted; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/pause-button/pause-button.test.lighthouse.ts b/projects/media/src/pause-button/pause-button.test.lighthouse.ts index c42d2a0c06..44174aacd3 100644 --- a/projects/media/src/pause-button/pause-button.test.lighthouse.ts +++ b/projects/media/src/pause-button/pause-button.test.lighthouse.ts @@ -19,6 +19,6 @@ describe('media pause button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(24.25); + expect(report.payload.javascript.kb).toBeLessThan(24); }); }); diff --git a/projects/media/src/pause-button/pause-button.ts b/projects/media/src/pause-button/pause-button.ts index 985f3c63ce..dc21da052c 100644 --- a/projects/media/src/pause-button/pause-button.ts +++ b/projects/media/src/pause-button/pause-button.ts @@ -5,7 +5,8 @@ import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ButtonFormControlMixin, CheckboxFormControlMixin } from '@nvidia-elements/forms/mixins'; -import { I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; import { mediaCommands } from '../internal/media-command.js'; @@ -13,6 +14,11 @@ import type { MediaState } from '../internal/media-state.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './pause-button.css?inline'; +const i18nDefaults = { + pauseMedia: 'pause media', + playMedia: 'play media' +} satisfies I18nDefaults; + /** * @element nve-media-pause-button * @description Requests playback changes and submits paused media as a checkbox-style value. @@ -46,7 +52,7 @@ export class MediaPauseButton extends CheckboxFormControlMixin(ButtonFormControl override command: string = mediaCommands.togglePlayback; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/pip-button/pip-button.ts b/projects/media/src/pip-button/pip-button.ts index dbdbbd23ed..af3b5d3d83 100644 --- a/projects/media/src/pip-button/pip-button.ts +++ b/projects/media/src/pip-button/pip-button.ts @@ -5,7 +5,8 @@ import { property } from 'lit/decorators/property.js'; import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins'; -import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; import { mediaCommands } from '../internal/media-command.js'; @@ -13,6 +14,11 @@ import type { MediaState } from '../internal/media-state.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './pip-button.css?inline'; +const i18nDefaults = { + enterPip: 'enter picture in picture', + exitPip: 'exit picture in picture' +} satisfies I18nDefaults; + /** * @element nve-media-pip-button * @description Requests picture-in-picture changes for the controlled video. @@ -58,7 +64,7 @@ export class MediaPipButton extends ButtonFormControlMixin(LitElement) { super.disabled = value; } - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts index fefc372e2a..6fe89a2210 100644 --- a/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media playback rate select lighthouse', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(15.5); + expect(report.payload.javascript.kb).toBeLessThan(15.27); }); }); diff --git a/projects/media/src/playback-rate-select/playback-rate-select.ts b/projects/media/src/playback-rate-select/playback-rate-select.ts index 8fda47e5f7..2ddf897390 100644 --- a/projects/media/src/playback-rate-select/playback-rate-select.ts +++ b/projects/media/src/playback-rate-select/playback-rate-select.ts @@ -5,7 +5,8 @@ import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { I18nController, LogService, useStyles } from '@nvidia-elements/core/internal'; +import { LogService, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { TypeCommandController } from '@nvidia-elements/forms/internal'; import { SelectFormControlMixin } from '@nvidia-elements/forms/mixins'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; @@ -14,6 +15,10 @@ import type { MediaState } from '../internal/media-state.js'; import styles from './playback-rate-select.css?inline'; const defaultRates = [0.5, 1.0, 1.5, 2.0]; +const i18nDefaults = { + playbackRate: 'playback rate', + playbackRateOption: '{rate}x' +} satisfies I18nDefaults; /** * @element nve-media-playback-rate-select @@ -52,7 +57,7 @@ export class MediaPlaybackRateSelect extends SelectFormControlMixin(LitElement) @property({ type: Array }) rates: Array = [...defaultRates]; // eslint-disable-line local/primitive-property -- Playback rate lists map directly to native select options. - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/seek-button/seek-button.test.lighthouse.ts b/projects/media/src/seek-button/seek-button.test.lighthouse.ts index 791aa0e5cb..d350b51799 100644 --- a/projects/media/src/seek-button/seek-button.test.lighthouse.ts +++ b/projects/media/src/seek-button/seek-button.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media seek button lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(21.1); + expect(report.payload.javascript.kb).toBeLessThan(20.94); }); }); diff --git a/projects/media/src/seek-button/seek-button.ts b/projects/media/src/seek-button/seek-button.ts index 3e71692efd..bb7ffda9be 100644 --- a/projects/media/src/seek-button/seek-button.ts +++ b/projects/media/src/seek-button/seek-button.ts @@ -5,12 +5,20 @@ import type { PropertyValues } from 'lit'; import { html, LitElement } from 'lit'; import { property } from 'lit/decorators/property.js'; import { ButtonFormControlMixin, type ButtonType } from '@nvidia-elements/forms/mixins'; -import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { attachInternals, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { Icon } from '@nvidia-elements/core/icon'; import { mediaCommands, type MediaCommand, type MediaSeekAction } from '../internal/media-command.js'; import mediaButtonStyles from '../internal/media-button.css?inline'; import styles from './seek-button.css?inline'; +const i18nDefaults = { + seekBackward: 'seek backward {value} seconds', + seekForward: 'seek forward {value} seconds', + seekToEnd: 'seek to end', + seekToStart: 'seek to start' +} satisfies I18nDefaults; + /** * @element nve-media-seek-button * @description Sends absolute or relative seek commands without adding form submission state. @@ -63,7 +71,7 @@ export class MediaSeekButton extends ButtonFormControlMixin(LitElement) { override type: ButtonType = 'button'; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/time-range/time-range.test.lighthouse.ts b/projects/media/src/time-range/time-range.test.lighthouse.ts index 869a3d90b3..b4dac93e26 100644 --- a/projects/media/src/time-range/time-range.test.lighthouse.ts +++ b/projects/media/src/time-range/time-range.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media time range lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(16.5); + expect(report.payload.javascript.kb).toBeLessThan(16.27); }); }); diff --git a/projects/media/src/time-range/time-range.ts b/projects/media/src/time-range/time-range.ts index 66bbd657b6..0ccabf92f9 100644 --- a/projects/media/src/time-range/time-range.ts +++ b/projects/media/src/time-range/time-range.ts @@ -6,7 +6,8 @@ import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators/property.js'; import { state } from 'lit/decorators/state.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { TypeCommandController } from '@nvidia-elements/forms/internal'; import { SliderFormControlMixin } from '@nvidia-elements/forms/mixins'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; @@ -15,6 +16,10 @@ import { isMediaTimeSpans, type MediaState, type MediaTimeSpan } from '../intern import mediaRangeStyles from '../internal/media-range.css?inline'; import styles from './time-range.css?inline'; +const i18nDefaults = { + currentTime: 'current time' +} satisfies I18nDefaults; + /** * @element nve-media-time-range * @description Scrubs finite media time and submits the current time as a numeric form value. @@ -60,7 +65,7 @@ export class MediaTimeRange extends SliderFormControlMixin(LitElement) { @property({ type: Array, attribute: 'buffered-ranges' }) bufferedRanges: readonly MediaTimeSpan[] = []; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/media/src/volume-range/volume-range.test.lighthouse.ts b/projects/media/src/volume-range/volume-range.test.lighthouse.ts index aa1fa543a0..2af8745b87 100644 --- a/projects/media/src/volume-range/volume-range.test.lighthouse.ts +++ b/projects/media/src/volume-range/volume-range.test.lighthouse.ts @@ -16,6 +16,6 @@ describe('media volume range lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.kb).toBeLessThan(15.5); + expect(report.payload.javascript.kb).toBeLessThan(15.27); }); }); diff --git a/projects/media/src/volume-range/volume-range.ts b/projects/media/src/volume-range/volume-range.ts index c201f49b1b..4e8abea35a 100644 --- a/projects/media/src/volume-range/volume-range.ts +++ b/projects/media/src/volume-range/volume-range.ts @@ -5,7 +5,8 @@ import type { PropertyValues } from 'lit'; import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators/property.js'; import { state } from 'lit/decorators/state.js'; -import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { TypeCommandController } from '@nvidia-elements/forms/internal'; import { SliderFormControlMixin } from '@nvidia-elements/forms/mixins'; import { MediaStateController } from '../internal/controllers/media-state.controller.js'; @@ -14,6 +15,10 @@ import type { MediaState } from '../internal/media-state.js'; import mediaRangeStyles from '../internal/media-range.css?inline'; import styles from './volume-range.css?inline'; +const i18nDefaults = { + volume: 'volume' +} satisfies I18nDefaults; + /** * @element nve-media-volume-range * @description Sets native media volume and submits the current volume as a numeric form value. @@ -54,7 +59,7 @@ export class MediaVolumeRange extends SliderFormControlMixin(LitElement) { @property({ attribute: false }) commandForElement: HTMLElement | null = null; - #i18nController: I18nController = new I18nController(this); + #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal string values for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n; diff --git a/projects/site/src/docs/foundations/i18n.md b/projects/site/src/docs/foundations/i18n.md index e7aa28aeda..4aaa420b8b 100644 --- a/projects/site/src/docs/foundations/i18n.md +++ b/projects/site/src/docs/foundations/i18n.md @@ -12,10 +12,10 @@ The i18n service provides a way to update internalized `aria-*` attributes for m ## Global -The I18nService service can both read and override the default component strings for all components globally. +The I18nService service can read registered component strings and override them globally. The registry starts empty. Each component registers its defaults when its first instance initializes. Defaults preserve any translations the application has already set. ```typescript -import { I18nService } from '@nvidia-elements/core'; +import { I18nService } from '@nvidia-elements/foundations/i18n'; // get values I18nService.i18n diff --git a/projects/viewport/src/zoom-range/zoom-range.test.axe.ts b/projects/viewport/src/zoom-range/zoom-range.test.axe.ts index 1f38bd3f79..5566b85b68 100644 --- a/projects/viewport/src/zoom-range/zoom-range.test.axe.ts +++ b/projects/viewport/src/zoom-range/zoom-range.test.axe.ts @@ -12,7 +12,7 @@ describe(ViewportZoomRange.metadata.tag, () => { it('should pass axe check', async () => { const fixture = await createFixture(html` - + `); diff --git a/projects/viewport/src/zoom-range/zoom-range.test.ts b/projects/viewport/src/zoom-range/zoom-range.test.ts index 7e2cad000d..b718ad16e0 100644 --- a/projects/viewport/src/zoom-range/zoom-range.test.ts +++ b/projects/viewport/src/zoom-range/zoom-range.test.ts @@ -40,10 +40,23 @@ describe(ViewportZoomRange.metadata.tag, () => { afterEach(() => { removeFixture(fixture); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('setup and targeting', () => { + it('provides a default slider label with an empty translation registry', async () => { + vi.stubGlobal('NVE_ELEMENTS', { + ...globalThis.NVE_ELEMENTS, + state: { ...globalThis.NVE_ELEMENTS.state, i18nRegistry: {} } + }); + const unlabelledRange = new ViewportZoomRange(); + fixture.append(unlabelledRange); + await elementIsStable(unlabelledRange); + + expect(unlabelledRange.shadowRoot?.querySelector('input')?.getAttribute('aria-label')).toBe('scale'); + }); + it('initializes its bounds and value from the viewport', () => { expect(customElements.get(ViewportZoomRange.metadata.tag)).toBe(ViewportZoomRange); expect(range.min).toBe(0.25); diff --git a/projects/viewport/src/zoom-range/zoom-range.ts b/projects/viewport/src/zoom-range/zoom-range.ts index 5e87046c29..d771c3bcdf 100644 --- a/projects/viewport/src/zoom-range/zoom-range.ts +++ b/projects/viewport/src/zoom-range/zoom-range.ts @@ -4,13 +4,18 @@ import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property } from 'lit/decorators/property.js'; import { state } from 'lit/decorators/state.js'; -import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { useStyles } from '@nvidia-elements/core/internal'; +import { I18nController, type I18nDefaults } from '@nvidia-elements/foundations/internal/i18n'; import { TypeCommandController } from '@nvidia-elements/forms/internal'; import { SliderFormControlMixin } from '@nvidia-elements/forms/mixins'; import { ViewportAccessoryMixin } from '../internal/mixins/viewport-accessory.js'; import type { ViewportTarget } from '../internal/viewport.types.js'; import styles from './zoom-range.css?inline'; +const i18nDefaults = { + scale: 'scale' +} satisfies I18nDefaults; + /** * @element nve-viewport-zoom-range * @description Controls viewport zoom with a range slider. @@ -48,7 +53,7 @@ export class ViewportZoomRange extends ViewportAccessoryMixin(SliderFormControlM return this.valueAsNumber; } - readonly #i18nController: I18nController = new I18nController(this); + readonly #i18nController: I18nController = new I18nController(this, i18nDefaults); /** Enables updating internal strings for internationalization. */ @property({ type: Object }) i18n = this.#i18nController.i18n;