diff --git a/packages/component-base/src/i18n-mixin.d.ts b/packages/component-base/src/i18n-mixin.d.ts new file mode 100644 index 00000000000..f4041955570 --- /dev/null +++ b/packages/component-base/src/i18n-mixin.d.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import type { Constructor } from '@open-wc/dedupe-mixin'; + +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +/** + * A mixin that allows to set partial I18N properties. + */ +export declare function I18nMixin, I>( + superclass: T, + defaultI18n: I, +): Constructor> & T; + +export declare class I18nMixinClass { + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + */ + i18n: DeepPartial; +} diff --git a/packages/component-base/src/i18n-mixin.js b/packages/component-base/src/i18n-mixin.js new file mode 100644 index 00000000000..f314f4ed624 --- /dev/null +++ b/packages/component-base/src/i18n-mixin.js @@ -0,0 +1,83 @@ +/** + * @license + * Copyright (c) 2025 - 2025 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +function deepMerge(target, ...sources) { + const isObject = (item) => item && typeof item === 'object' && !Array.isArray(item); + const merge = (target, source) => { + if (isObject(source) && isObject(target)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!target[key]) { + target[key] = {}; + } + + merge(target[key], source[key]); + } else if (source[key] !== undefined && source[key] !== null) { + target[key] = source[key]; + } + }); + } + }; + + sources.forEach((source) => { + merge(target, source); + }); + + return target; +} + +/** + * A mixin that allows to set partial I18N properties. + * + * @polymerMixin + */ +export const I18nMixin = (superClass, defaultI18n) => + class I18nMixinClass extends superClass { + static get properties() { + return { + /** @private */ + __effectiveI18n: { + type: Object, + sync: true, + }, + }; + } + + constructor() { + super(); + + this.i18n = deepMerge({}, defaultI18n); + } + + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + * + * Should be overridden by subclasses to provide a custom JSDoc with the + * default I18N properties. + * + * @returns {Object} + */ + get i18n() { + return this.__customI18n; + } + + /** + * The object used to localize this component. To change the default + * localization, replace this with an object that provides all properties, or + * just the individual properties you want to change. + * + * Should be overridden by subclasses to provide a custom JSDoc with the + * default I18N properties. + * + * @param {Object} value + */ + set i18n(value) { + this.__customI18n = value; + this.__effectiveI18n = deepMerge({}, defaultI18n, this.__customI18n); + } + }; diff --git a/packages/component-base/test/i18n-mixin.test.js b/packages/component-base/test/i18n-mixin.test.js new file mode 100644 index 00000000000..37c0fdf943e --- /dev/null +++ b/packages/component-base/test/i18n-mixin.test.js @@ -0,0 +1,83 @@ +import { expect } from '@vaadin/chai-plugins'; +import { nextRender } from '@vaadin/testing-helpers'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; +import { LitElement } from 'lit'; +import { I18nMixin } from '../src/i18n-mixin.js'; +import { PolylitMixin } from '../src/polylit-mixin.js'; + +const DEFAULT_I18N = { + foo: 'Foo', + bar: { + baz: 'Baz', + }, +}; + +class I18nMixinPolymerElement extends I18nMixin(PolymerElement, DEFAULT_I18N) { + static get is() { + return 'i18n-mixin-polymer-element'; + } +} + +customElements.define(I18nMixinPolymerElement.is, I18nMixinPolymerElement); + +class I18nMixinLitElement extends I18nMixin(PolylitMixin(LitElement), DEFAULT_I18N) { + static get is() { + return 'i18n-mixin-lit-element'; + } +} + +customElements.define(I18nMixinLitElement.is, I18nMixinLitElement); + +const runTests = (baseClass) => { + let element; + + beforeEach(async () => { + element = document.createElement(baseClass.is); + document.body.appendChild(element); + await nextRender(); + }); + + it('should initialize with copy of defaults', () => { + expect(element.i18n).to.deep.equal(DEFAULT_I18N); + expect(element.i18n).to.not.equal(DEFAULT_I18N); + + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + expect(element.__effectiveI18n).to.not.equal(DEFAULT_I18N); + }); + + it('should return same reference that was set previously', () => { + const customI18n = { foo: 'Custom Foo' }; + element.i18n = customI18n; + expect(element.i18n).to.equal(customI18n); + }); + + it('should deep merge custom i18n with default i18n', () => { + element.i18n = {}; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { foo: 'Custom Foo' }; + expect(element.__effectiveI18n).to.deep.equal({ foo: 'Custom Foo', bar: { baz: 'Baz' } }); + + element.i18n = { bar: { baz: 'Custom Baz' } }; + expect(element.__effectiveI18n).to.deep.equal({ foo: 'Foo', bar: { baz: 'Custom Baz' } }); + }); + + it('should ignore null and undefined values in custom i18n', () => { + element.i18n = { foo: null }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { bar: undefined }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + + element.i18n = { bar: { baz: null } }; + expect(element.__effectiveI18n).to.deep.equal(DEFAULT_I18N); + }); +}; + +describe('I18nMixin + Polymer', () => { + runTests(I18nMixinPolymerElement); +}); + +describe('I18nMixin + Lit', () => { + runTests(I18nMixinLitElement); +}); diff --git a/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts new file mode 100644 index 00000000000..a121f3b4519 --- /dev/null +++ b/packages/component-base/test/typings/data-provider-controller/i18n-mixin.types.ts @@ -0,0 +1,20 @@ +import { I18nMixin } from '../../../src/i18n-mixin'; + +const assertType = (actual: TExpected) => actual; + +const DEFAULT_I18N = { + foo: 'foo', + bar: { + baz: 'baz', + qux: 'qux', + }, +}; + +class TestElement extends I18nMixin(HTMLElement, DEFAULT_I18N) {} + +const element = new TestElement(); + +// Verify i18n property accepts deep partials +assertType<{ foo?: string }>(element.i18n); +assertType<{ bar?: object }>(element.i18n); +assertType<{ bar?: { baz?: string } }>(element.i18n);