diff --git a/apps/transloco-playground/src/app/home/home.component.html b/apps/transloco-playground/src/app/home/home.component.html index 8ca6dba1..ef89c7fe 100644 --- a/apps/transloco-playground/src/app/home/home.component.html +++ b/apps/transloco-playground/src/app/home/home.component.html @@ -1,10 +1,13 @@ -
-

Welcome to the Transloco Playground

- 📖 Read the docs + 📖 Read the docs +
@@ -22,9 +25,13 @@

📚 Looking for More?

For detailed guides, advanced topics, and setup instructions, head over to the - official Transloco documentation. + + official Transloco documentation +

@@ -33,10 +40,12 @@

🤿 Dive into the implementation

You can find the source code for this app in the transloco repo. + target="_blank" + rel="noopener" + > + transloco repo +

@@ -50,7 +59,7 @@

Structural Directive

Regular: {{ t('home') }}
  • - With params: {{ t('alert', { value: dynamic }) }} + With params: {{ t('alert', { value: dynamic() }) }}
  • With translation reuse: {{ t('a.b.c') }} @@ -78,13 +87,16 @@

    Directive

    data-cy="d-with-params" > (click) With params: + >
  • With translation reuse:
  • - (click) Dynamic key: + (click) Dynamic key:
  • Static lang 'es': Pipe Regular: {{ 'home' | transloco }}
  • - With params: {{ 'alert' | transloco: { value: dynamic } }} + With params: {{ 'alert' | transloco: { value: dynamic() } }} +
  • +
  • + (click) Dynamic key: {{ key() | transloco }}
  • With translation reuse: {{ 'a.b.c' | transloco }} @@ -109,12 +124,33 @@

    Pipe

  • +
    +

    Signal

    + +

    Translation in {{ '@for' }}

    diff --git a/apps/transloco-playground/src/app/home/home.component.ts b/apps/transloco-playground/src/app/home/home.component.ts index 528c16c0..58a6b108 100644 --- a/apps/transloco-playground/src/app/home/home.component.ts +++ b/apps/transloco-playground/src/app/home/home.component.ts @@ -1,8 +1,10 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; -import { TranslocoModule } from '@jsverse/transloco'; - -import { environment } from '../../environments/environment'; +import { + TranslocoModule, + translateObjectSignal, + translateSignal, +} from '@jsverse/transloco'; @Component({ selector: 'app-home', @@ -13,16 +15,21 @@ import { environment } from '../../environments/environment'; imports: [TranslocoModule], }) export class HomeComponent { - dynamic = '🦄'; - key = 'home'; + dynamic = signal('🦄'); + key = signal('home'); + + transloco = translateSignal('home'); + translocoObject = translateObjectSignal('nested'); + translocoParams = translateSignal('alert', { value: this.dynamic }); + translocoKeys = translateSignal(this.key); - translateList = ['b', 'c']; + translateList = ['home', 'a.b.c', 'b', 'c']; changeKey() { - this.key = this.key === 'home' ? 'fromList' : 'home'; + this.key.update((key) => (key === 'home' ? 'fromList' : 'home')); } changeParam() { - this.dynamic = this.dynamic === '🦄' ? '🦄🦄🦄' : '🦄'; + this.dynamic.update((dynamic) => (dynamic === '🦄' ? '🦄🦄🦄' : '🦄')); } } diff --git a/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.html b/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.html index f45d1c80..5a47f715 100644 --- a/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.html +++ b/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.html @@ -1,8 +1,12 @@ - {{ t('inline.title') }} +

    Directive: {{ t('inline.title') }}

    +

    - {{ t('alert') }} + Directive Global Scope: + {{ t('alert', { value: 'global' }) }}

    -

    {{ 'inline.title' | transloco }}

    +

    Pipe: {{ 'inline.title' | transloco }}

    +

    Async: {{ title$ | async }}

    +

    Signal: {{ title() }}

    diff --git a/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.ts b/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.ts index 6279714e..1d692747 100644 --- a/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.ts +++ b/apps/transloco-playground/src/app/inline-loaders/inline-loaders.component.ts @@ -1,3 +1,4 @@ +import { AsyncPipe } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -5,6 +6,7 @@ import { TranslocoModule, TranslocoService, TRANSLOCO_SCOPE, + translateSignal, } from '@jsverse/transloco'; @Component({ @@ -12,15 +14,17 @@ import { templateUrl: './inline-loaders.component.html', styleUrls: ['./inline-loaders.component.scss'], standalone: true, - imports: [TranslocoModule], + imports: [TranslocoModule, AsyncPipe], }) export default class InlineLoadersComponent implements OnInit { private translocoService = inject(TranslocoService); private scope = inject(TRANSLOCO_SCOPE); - private title$ = this.translocoService + public title$ = this.translocoService .selectTranslate('title', {}, this.scope) .pipe(takeUntilDestroyed()); + public title = translateSignal('title'); + ngOnInit() { console.log(this.scope); this.title$.subscribe(console.log); diff --git a/libs/transloco/src/index.ts b/libs/transloco/src/index.ts index aa556d5c..3b8512f8 100644 --- a/libs/transloco/src/index.ts +++ b/libs/transloco/src/index.ts @@ -63,3 +63,4 @@ export { provideTranslocoLang, TranslocoOptions, } from './lib/transloco.providers'; +export { translateSignal, translateObjectSignal } from './lib/transloco.signal'; diff --git a/libs/transloco/src/lib/tests/signal.spec.ts b/libs/transloco/src/lib/tests/signal.spec.ts new file mode 100644 index 00000000..ff028d72 --- /dev/null +++ b/libs/transloco/src/lib/tests/signal.spec.ts @@ -0,0 +1,120 @@ +import { Component, signal } from '@angular/core'; +import { fakeAsync } from '@angular/core/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator'; + +import { TranslocoModule } from '../transloco.module'; +import { translateSignal, translateObjectSignal } from '../transloco.signal'; + +import { providersMock, runLoader } from './mocks'; + +@Component({ + imports: [TranslocoModule], + template: ` +
    {{ translatedText() }}
    +
    {{ translatedObject().title }}
    +
    {{ translatedDynamicKey() }}
    +
    {{ translatedDynamicParam() }}
    + `, +}) +class TestComponent { + translatedText = translateSignal('home'); + translatedObject = translateObjectSignal('nested'); + + dynamicKey = signal('home'); + dynamicParam = signal('Signal'); + + translatedDynamicKey = translateSignal(this.dynamicKey); + translatedDynamicParam = translateSignal('alert', { + value: this.dynamicParam, + }); + + translatedObjectDynamicKey = translateObjectSignal(this.dynamicKey); + translatedObjectDynamicParam = translateObjectSignal( + this.dynamicKey, + this.dynamicParam, + ); + + changeKey(key: string) { + this.dynamicKey.set(key); + } + + changeParam(param: any) { + this.dynamicParam.set(param); + } +} + +describe('translateSignal in component', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: TestComponent, + imports: [TranslocoModule], + providers: providersMock, + }); + + it('should translate a static key', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + expect(spectator.query('#text')).toHaveText('home english'); + })); + + it('should translate a dynamic key', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + spectator.component.changeKey('fromList'); + spectator.detectChanges(); + expect(spectator.query('#dynamicKey')).toHaveText('from list'); + })); + + it('should translate with params', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + spectator.component.changeParam('Signal Changed'); + spectator.detectChanges(); + expect(spectator.query('#dynamicParam')).toHaveText( + 'alert Signal Changed english', + ); + })); +}); + +describe('translateObjectSignal in component', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: TestComponent, + imports: [TranslocoModule], + providers: providersMock, + }); + + it('should translate a static key to an object', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + expect(spectator.query('#textObject')).toHaveText('Title english'); + })); + + it('should translate a dynamic key to an object', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + spectator.component.changeKey('key.is.like'); + spectator.detectChanges(); + expect(spectator.component.translatedObjectDynamicKey()).toEqual({ + path: 'key is like path', + }); + })); + + it('should translate with params to an object', fakeAsync(() => { + spectator = createComponent(); + runLoader(); + spectator.detectChanges(); + spectator.component.changeKey('a.b'); + spectator.component.changeParam({ c: { fromList: 'Signal Changed' } }); + spectator.detectChanges(); + console.log(spectator.component.translatedObjectDynamicParam()); + expect(spectator.component.translatedObjectDynamicParam()).toEqual({ + c: 'a.b.c Signal Changed english', + }); + })); +}); diff --git a/libs/transloco/src/lib/transloco.signal.ts b/libs/transloco/src/lib/transloco.signal.ts new file mode 100644 index 00000000..c285723b --- /dev/null +++ b/libs/transloco/src/lib/transloco.signal.ts @@ -0,0 +1,159 @@ +import { + assertInInjectionContext, + computed, + inject, + Injector, + isSignal, + runInInjectionContext, + Signal, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { switchMap } from 'rxjs'; + +import { TRANSLOCO_SCOPE } from './transloco-scope'; +import { TranslocoService } from './transloco.service'; +import { HashMap, Translation, TranslocoScope } from './types'; + +type ScopeType = string | TranslocoScope | TranslocoScope[]; +type SignalKey = Signal | Signal | Signal[]; +type TranslateSignalKey = string | string[] | SignalKey; +type TranslateSignalParams = + | HashMap + | HashMap> + | Signal; +type TranslateSignalRef = T extends unknown[] | Signal + ? Signal + : Signal; +type TranslateObjectSignalRef = T extends unknown[] | Signal + ? Signal + : Signal; + +/** + * Gets the translated value of a key as Signal + * + * @example + * text = translateSignal('hello'); + * textList = translateSignal(['green', 'blue']); + * textVar = translateSignal('hello', { variable: 'world' }); + * textSpanish = translateSignal('hello', { variable: 'world' }, 'es'); + * textTodosScope = translateSignal('hello', { variable: 'world' }, { scope: 'todos' }); + * + * @example + * dynamicKey = signal('hello'); + * dynamicParam = signal({ variable: 'world' }); + * text = translateSignal(this.dynamicKey, this.dynamicParam); + * + */ +export function translateSignal( + key: T, + params?: TranslateSignalParams, + lang?: ScopeType, + injector?: Injector, +): TranslateSignalRef { + if (!injector) { + assertInInjectionContext(translateSignal); + } + injector ??= inject(Injector); + const result = runInInjectionContext(injector, () => { + const service = inject(TranslocoService); + const scope = resolveScope(lang); + return toObservable(computerKeysAndParams(key, params)).pipe( + switchMap((dynamic) => + service.selectTranslate(dynamic.key, dynamic.params, scope), + ), + ); + }); + return toSignal(result, { initialValue: Array.isArray(key) ? [''] : '' }); +} + +/** + * Gets the translated object of a key as Signal + * + * @example + * object = translateObjectSignal('nested.object'); + * title = object().title; + * + * @example + * dynamicKey = signal('nested.object'); + * dynamicParam = signal({ variable: 'world' }); + * object = translateObjectSignal(this.dynamicKey, this.dynamicParam); + */ +export function translateObjectSignal( + key: T, + params?: TranslateSignalParams, + lang?: ScopeType, + injector?: Injector, +): TranslateObjectSignalRef { + if (!injector) { + assertInInjectionContext(translateObjectSignal); + } + injector ??= inject(Injector); + const result = runInInjectionContext(injector, () => { + const service = inject(TranslocoService); + const scope = resolveScope(lang); + return toObservable(computerKeysAndParams(key, params)).pipe( + switchMap((dynamic) => + service.selectTranslateObject( + dynamic.key, + dynamic.params, + scope as string, + ), + ), + ); + }); + return toSignal(result, { initialValue: Array.isArray(key) ? [] : {} }); +} + +function computerParams(params: HashMap> | Signal) { + if (isSignal(params)) { + return computed(() => params()); + } + return computed(() => { + return Object.entries(params).reduce((acc, [key, value]) => { + acc[key] = isSignal(value) ? value() : value; + return acc; + }, {} as HashMap); + }); +} + +function computerKeys( + keys: Signal | Signal | Signal[], +) { + if (Array.isArray(keys)) { + return computed(() => keys.map((key) => (isSignal(key) ? key() : key))); + } + return computed(() => keys()); +} + +function isSignalKey(key: TranslateSignalKey): key is SignalKey { + return Array.isArray(key) ? key.some(isSignal) : isSignal(key); +} + +function isSignalParams( + params?: HashMap, +): params is HashMap> | Signal { + return params + ? isSignal(params) || Object.values(params).some(isSignal) + : false; +} + +function computerKeysAndParams( + key: TranslateSignalKey, + params?: TranslateSignalParams, +) { + const computedKeys = isSignalKey(key) + ? computerKeys(key) + : computed(() => key); + const computedParams = isSignalParams(params) + ? computerParams(params) + : computed(() => params); + return computed(() => ({ key: computedKeys(), params: computedParams() })); +} + +function resolveScope(scope?: ScopeType) { + if (typeof scope === 'undefined' || scope === '') { + const translocoScope = inject(TRANSLOCO_SCOPE, { optional: true }); + return translocoScope ?? undefined; + } + return scope; +} diff --git a/tsconfig.json b/tsconfig.json index 540f38eb..b4686478 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "./tsconfig.base.json", "compileOnSave": false, "compilerOptions": { "resolveJsonModule": true,