diff --git a/apps/cookbook/src/app/app.routes.ts b/apps/cookbook/src/app/app.routes.ts index a446a6df3e..98d0d22f1b 100644 --- a/apps/cookbook/src/app/app.routes.ts +++ b/apps/cookbook/src/app/app.routes.ts @@ -5,6 +5,7 @@ import { ComponentOverviewComponent } from './component-overview/component-overv import { HomeComponent } from './home/home.component'; import { IntroComponent } from './intro/intro.component'; import { ExtensionsLandingPageComponent } from './extensions/extensions-landing-page.component'; +import { LocalizationComponent } from './localization/localization.component'; export const routes: Routes = [ { @@ -57,6 +58,13 @@ export const routes: Routes = [ resourceLink: 'Extensions', }, }, + { + path: 'localization', + component: LocalizationComponent, + data: { + resourceLink: 'Localization', + }, + }, ], }, { diff --git a/apps/cookbook/src/app/localization/locale-provider/da-locale-provider.component.ts b/apps/cookbook/src/app/localization/locale-provider/da-locale-provider.component.ts new file mode 100644 index 0000000000..3ce158551a --- /dev/null +++ b/apps/cookbook/src/app/localization/locale-provider/da-locale-provider.component.ts @@ -0,0 +1,10 @@ +import { Component, LOCALE_ID } from '@angular/core'; +import { TranslationService } from '@kirbydesign/designsystem/shared'; + +@Component({ + selector: 'cookbook-da-locale-provider', + template: '', + standalone: true, + providers: [{ provide: LOCALE_ID, useValue: 'da' }, TranslationService], +}) +export class DaLocaleProviderComponent {} diff --git a/apps/cookbook/src/app/localization/locale-provider/en-locale-provider.component.ts b/apps/cookbook/src/app/localization/locale-provider/en-locale-provider.component.ts new file mode 100644 index 0000000000..77a8ad00c1 --- /dev/null +++ b/apps/cookbook/src/app/localization/locale-provider/en-locale-provider.component.ts @@ -0,0 +1,10 @@ +import { Component, LOCALE_ID } from '@angular/core'; +import { TranslationService } from '@kirbydesign/designsystem/shared'; + +@Component({ + selector: 'cookbook-en-locale-provider', + template: '', + standalone: true, + providers: [{ provide: LOCALE_ID, useValue: 'en' }, TranslationService], +}) +export class EnLocaleProviderComponent {} diff --git a/apps/cookbook/src/app/localization/localization.component.html b/apps/cookbook/src/app/localization/localization.component.html new file mode 100644 index 0000000000..f8fa49d465 --- /dev/null +++ b/apps/cookbook/src/app/localization/localization.component.html @@ -0,0 +1,108 @@ +
+

Localization

+

+ Locale information is used by components such as + kirby-calendar + and + input type="date" + to automatically format values according to locale. Support has been added for three locales out + of the box: American English ( + en-US + ), British English ( + en-GB + ) and Danish ( + da + ). +

+ +

+ Locale is usually configured for the entire app in the + AppModule + . +

+ +

+ Specifying the locale will also ensure translation of + internal + component labels and texts that are not configurable. This allows assistive technology such as + screen readers to announce labels in the correct language. +

+ +

+ In this example visible information such as the month and weekday headers, and accessible names + for headers, calendar cells and interactive buttons have been automatically translated by + specifying the locale. +

+ + +
+
+ + + + + + + + + + + + +
+
+
+ +

Using the service directly

+

+ When developing custom components closely coupled to Kirby that need to use identical + translations, + TranslationService + can be used to fetch and display translated strings. +

+

+ This can be done by injecting the service into the component and get any key defined in the + + Translation + + interface. +

+ + +

Additional locales

+

+ If support for other locales or additional translations are needed please + + create an enhancement issue on Github + and consider contributing the code to make it happen. Please refer to the + + existing translation files + + and how + + translation registering + + is done in the + TranslationService + . +

+
diff --git a/apps/cookbook/src/app/localization/localization.component.scss b/apps/cookbook/src/app/localization/localization.component.scss new file mode 100644 index 0000000000..54ddc19891 --- /dev/null +++ b/apps/cookbook/src/app/localization/localization.component.scss @@ -0,0 +1,34 @@ +@use 'sass:map'; +@use '../showcase/showcase.shared'; +@use '@kirbydesign/core/src/scss/utils'; + +$avatar-offset: -1 * utils.size('s'); + +:host { + display: block; + container-type: inline-size; + + :has(> .locale-avatar) { + position: relative; + } +} + +.locale-examples { + display: flex; + justify-content: center; + gap: var(--kirby-spacing-xxxl); + + @container (width < #{map.get(utils.$breakpoints, small)}) { + flex-direction: column; + align-items: center; + } +} + +.locale-avatar { + position: absolute; + inset: $avatar-offset $avatar-offset auto auto; +} + +.code-only-example { + margin-block-end: utils.size('s'); +} diff --git a/apps/cookbook/src/app/localization/localization.component.ts b/apps/cookbook/src/app/localization/localization.component.ts new file mode 100644 index 0000000000..490235ac44 --- /dev/null +++ b/apps/cookbook/src/app/localization/localization.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; + +import { CardModule } from '@kirbydesign/designsystem/card'; +import { CalendarComponent } from '@kirbydesign/designsystem/calendar'; +import { TranslationService } from '@kirbydesign/designsystem/shared'; +import { AvatarComponent } from '@kirbydesign/designsystem/avatar'; +import { CodeViewerModule } from '../shared/code-viewer/code-viewer.module'; +import { ExamplesModule } from '../examples/examples.module'; +import { ShowcaseModule } from '../showcase/showcase.module'; +import { DaLocaleProviderComponent } from './locale-provider/da-locale-provider.component'; +import { EnLocaleProviderComponent } from './locale-provider/en-locale-provider.component'; + +@Component({ + selector: 'cookbook-localization', + templateUrl: './localization.component.html', + styleUrls: ['./localization.component.scss'], + standalone: true, + imports: [ + CodeViewerModule, + ShowcaseModule, + ExamplesModule, + DaLocaleProviderComponent, + EnLocaleProviderComponent, + CalendarComponent, + CardModule, + AvatarComponent, + ], +}) +export class LocalizationComponent { + localeConfigCodeSnippet = `import { registerLocaleData } from '@angular/common'; +import localeData from '@angular/common/locales/da'; +import { LOCALE_ID, NgModule } from '@angular/core'; + +registerLocaleData(localeData); + +@NgModule({ + ..., + providers: [ + { provide: LOCALE_ID, useValue: 'da' }, + ], +}) +export class AppModule {}`; + translationGetterCodeSnippet = `constructor(private translationService: TranslationService) {} + +get previousMonthLabel(): string { + return this.translationService.get('previousMonth'); +}`; + + constructor(public translations: TranslationService) {} + selectedDate: Date = new Date(2025, 0, 1); +} diff --git a/apps/cookbook/src/assets/images/dk.svg b/apps/cookbook/src/assets/images/dk.svg new file mode 100644 index 0000000000..563277f81d --- /dev/null +++ b/apps/cookbook/src/assets/images/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/cookbook/src/assets/images/gb.svg b/apps/cookbook/src/assets/images/gb.svg new file mode 100644 index 0000000000..dbac25eae4 --- /dev/null +++ b/apps/cookbook/src/assets/images/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/libs/designsystem/calendar/src/calendar.component.html b/libs/designsystem/calendar/src/calendar.component.html index 5c88496710..9870812dcc 100644 --- a/libs/designsystem/calendar/src/calendar.component.html +++ b/libs/designsystem/calendar/src/calendar.component.html @@ -4,7 +4,7 @@ kirby-button type="button" class="no-margin" - aria-label="Previous month" + [attr.aria-label]="translations.get('previousMonth')" [attr.aria-disabled]="_canNavigateBack ? null : true" [noDecoration]="true" (click)="_changeMonth(-1)" @@ -21,7 +21,7 @@ kirby-button type="button" class="no-margin" - aria-label="Next month" + [attr.aria-label]="translations.get('nextMonth')" [attr.aria-disabled]="_canNavigateForward ? null : true" [noDecoration]="true" (click)="_changeMonth(1)" @@ -34,6 +34,7 @@ [usePopover]="usePopover" [selectedIndex]="navigatedYear" [items]="navigableYears" + [attr.aria-label]="translations.get('selectYear')" popout="left" (change)="_changeYear($event)" > diff --git a/libs/designsystem/calendar/src/calendar.component.ts b/libs/designsystem/calendar/src/calendar.component.ts index 59e13125af..de25c70fad 100644 --- a/libs/designsystem/calendar/src/calendar.component.ts +++ b/libs/designsystem/calendar/src/calendar.component.ts @@ -40,6 +40,7 @@ import { DropdownModule } from '@kirbydesign/designsystem/dropdown'; import { UniqueIdGenerator } from '@kirbydesign/designsystem/helpers'; import { IconModule } from '@kirbydesign/designsystem/icon'; +import { TranslationService } from '@kirbydesign/designsystem/shared'; import { CalendarDay, CalendarDayMetadata } from './interfaces/calendar-day'; import { CalendarYearNavigatorConfig } from './interfaces/calendar-year-navigator-config'; @@ -223,7 +224,8 @@ export class CalendarComponent implements OnInit, OnChanges { constructor( @Inject(LOCALE_ID) locale: string, private elementRef: ElementRef, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + public translations: TranslationService ) { this.locale = this.mapLocale(locale); this.timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.html b/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.html index 2a44126ede..152738d491 100644 --- a/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.html +++ b/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.html @@ -20,6 +20,7 @@ attentionLevel="3" size="md" [noDecoration]="config.interactWithBackground" + [attr.aria-label]="translations.get('close')" (click)="close()" > diff --git a/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.ts b/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.ts index 99004e462f..8a30eb0057 100644 --- a/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.ts +++ b/libs/designsystem/modal/src/modal-wrapper/modal-wrapper.component.ts @@ -25,7 +25,7 @@ import { debounceTime, first, map, takeUntil } from 'rxjs/operators'; import { DesignTokenHelper } from '@kirbydesign/designsystem/helpers'; -import { ResizeObserverService } from '@kirbydesign/designsystem/shared'; +import { ResizeObserverService, TranslationService } from '@kirbydesign/designsystem/shared'; import { WindowRef } from '@kirbydesign/designsystem/types'; import { PlatformService } from '@kirbydesign/designsystem/helpers'; import { CommonModule } from '@angular/common'; @@ -174,7 +174,8 @@ export class ModalWrapperComponent private windowRef: WindowRef, private platform: PlatformService, private canDismissHelper: CanDismissHelper, - private environmentInjector: EnvironmentInjector + private environmentInjector: EnvironmentInjector, + public translations: TranslationService ) { this.setViewportHeight(); this.observeViewportResize(); diff --git a/libs/designsystem/page/src/page.component.html b/libs/designsystem/page/src/page.component.html index aa5771e482..7380e05435 100644 --- a/libs/designsystem/page/src/page.component.html +++ b/libs/designsystem/page/src/page.component.html @@ -9,6 +9,7 @@ [defaultHref]="defaultBackHref" icon="assets/kirby/icons/svg/arrow-back.svg" [style.visibility]="hideBackButton ? 'hidden' : null" + [attr.aria-label]="translations.get('back')" > @@ -68,7 +69,7 @@ class="page-header" [ngClass]="{ 'text-center': titleAlignment === 'center', - 'text-right': titleAlignment === 'right' + 'text-right': titleAlignment === 'right', }" >
diff --git a/libs/designsystem/page/src/page.component.ts b/libs/designsystem/page/src/page.component.ts index 3026a6568d..ff6778f261 100644 --- a/libs/designsystem/page/src/page.component.ts +++ b/libs/designsystem/page/src/page.component.ts @@ -60,7 +60,11 @@ import { ModalElementType, ModalNavigationService, } from '@kirbydesign/designsystem/modal'; -import { FitHeadingConfig, ResizeObserverService } from '@kirbydesign/designsystem/shared'; +import { + FitHeadingConfig, + ResizeObserverService, + TranslationService, +} from '@kirbydesign/designsystem/shared'; /** * Specify scroll event debounce time in ms and scrolled offset from top in pixels @@ -364,7 +368,8 @@ export class PageComponent private routerOutlet: IonRouterOutlet, @Optional() private navCtrl: NavController, - private ionicElementPartHelper: IonicElementPartHelper + private ionicElementPartHelper: IonicElementPartHelper, + public translations: TranslationService ) {} private contentReadyPromise: Promise; diff --git a/libs/designsystem/shared/src/public_api.ts b/libs/designsystem/shared/src/public_api.ts index ff43d995c5..aa17b5d63d 100644 --- a/libs/designsystem/shared/src/public_api.ts +++ b/libs/designsystem/shared/src/public_api.ts @@ -9,3 +9,5 @@ export * from './dynamic-component'; export * from './fit-heading/index'; export * from './controls/label-helpers'; + +export * from './translation/translation.service'; diff --git a/libs/designsystem/shared/src/translation/translation.interface.ts b/libs/designsystem/shared/src/translation/translation.interface.ts new file mode 100644 index 0000000000..aee11e5b41 --- /dev/null +++ b/libs/designsystem/shared/src/translation/translation.interface.ts @@ -0,0 +1,10 @@ +export interface Translation { + $code: string; + back: string; + close: string; + nextMonth: string; + nextSlide: string; + previousMonth: string; + previousSlide: string; + selectYear: string; +} diff --git a/libs/designsystem/shared/src/translation/translation.service.spec.ts b/libs/designsystem/shared/src/translation/translation.service.spec.ts new file mode 100644 index 0000000000..171c8c5686 --- /dev/null +++ b/libs/designsystem/shared/src/translation/translation.service.spec.ts @@ -0,0 +1,47 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; +import { LOCALE_ID } from '@angular/core'; +import { TranslationService } from './translation.service'; +import { en } from './translations/en'; +import { da } from './translations/da'; +import { Translation } from './translation.interface'; + +describe('TranslationService', () => { + let spectator: SpectatorService; + const createService = createServiceFactory({ + service: TranslationService, + providers: [{ provide: LOCALE_ID, useValue: 'en' }], + }); + + it('should be created', () => { + spectator = createService(); + expect(spectator.service).toBeTruthy(); + }); + + it('should set active translation based on locale', () => { + spectator = createService(); + expect(spectator.service.get('$code')).toBe(en.$code); + }); + + it('should fall back to default translation if locale is not found', () => { + spectator = createService({ + providers: [{ provide: LOCALE_ID, useValue: 'fr' }], + }); + expect(spectator.service.get('$code')).toBe(en.$code); + }); + + it('should return all translations correctly for da key', () => { + spectator = createService({ providers: [{ provide: LOCALE_ID, useValue: 'da' }] }); + + Object.keys(da).forEach((key: keyof Translation) => { + expect(spectator.service.get(key)).toBe(da[key]); + }); + }); + + it('should return all translations correctly for en key', () => { + spectator = createService({ providers: [{ provide: LOCALE_ID, useValue: 'en' }] }); + + Object.keys(en).forEach((key: keyof Translation) => { + expect(spectator.service.get(key)).toBe(en[key]); + }); + }); +}); diff --git a/libs/designsystem/shared/src/translation/translation.service.ts b/libs/designsystem/shared/src/translation/translation.service.ts new file mode 100644 index 0000000000..7efa64ef65 --- /dev/null +++ b/libs/designsystem/shared/src/translation/translation.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { Inject, LOCALE_ID } from '@angular/core'; +import { da } from './translations/da'; +import { en } from './translations/en'; +import { Translation } from './translation.interface'; + +@Injectable({ + providedIn: 'root', +}) +export class TranslationService { + private activeTranslation: Translation = en; + private translations: { [key: string]: Translation } = { da, en }; + + constructor(@Inject(LOCALE_ID) private localeId: string) { + this.setActiveTranslation(localeId); + } + + private setActiveTranslation(localeId: string): string { + const baseLocaleId = localeId.split('-')[0]; + const translation = this.translations[baseLocaleId]; + + if (!translation) { + console.warn( + `[Kirby] Internal component translations were not found for locale "${this.localeId}", falling back to ${this.get('$code')}` + ); + return; + } + + this.activeTranslation = translation; + } + + get(key: keyof Translation): string { + return this.activeTranslation[key]; + } +} diff --git a/libs/designsystem/shared/src/translation/translations/da.ts b/libs/designsystem/shared/src/translation/translations/da.ts new file mode 100644 index 0000000000..cb0cf03017 --- /dev/null +++ b/libs/designsystem/shared/src/translation/translations/da.ts @@ -0,0 +1,12 @@ +import { Translation } from '../translation.interface'; + +export const da: Translation = { + $code: 'da', + back: 'Tilbage', + close: 'Luk', + nextMonth: 'Næste måned', + nextSlide: 'Næste slide', + previousMonth: 'Forrige måned', + previousSlide: 'Forrige slide', + selectYear: 'Vælg år', +}; diff --git a/libs/designsystem/shared/src/translation/translations/en.ts b/libs/designsystem/shared/src/translation/translations/en.ts new file mode 100644 index 0000000000..23bc39e415 --- /dev/null +++ b/libs/designsystem/shared/src/translation/translations/en.ts @@ -0,0 +1,12 @@ +import { Translation } from '../translation.interface'; + +export const en: Translation = { + $code: 'en', + back: 'Back', + close: 'Close', + nextMonth: 'Next month', + nextSlide: 'Next slide', + previousMonth: 'Previous month', + previousSlide: 'Previous slide', + selectYear: 'Select year', +}; diff --git a/libs/designsystem/slide/src/slides.component.ts b/libs/designsystem/slide/src/slides.component.ts index 27c99dfdb4..5b3592456e 100644 --- a/libs/designsystem/slide/src/slides.component.ts +++ b/libs/designsystem/slide/src/slides.component.ts @@ -21,6 +21,7 @@ import { PlatformService, UniqueIdGenerator, } from '@kirbydesign/designsystem/helpers'; +import { TranslationService } from '@kirbydesign/designsystem/shared'; import { SlideDirective } from './slide.directive'; // Swiper is not an Angular library, @@ -45,7 +46,8 @@ type SwiperContainer = HTMLElement & { initialize: () => void; swiper: Swiper }; export class SlidesComponent implements OnInit, AfterViewInit, OnChanges { constructor( private platform: PlatformService, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private translations: TranslationService ) {} @ViewChild('swiperContainer') swiperContainer: ElementRef; @@ -126,6 +128,10 @@ export class SlidesComponent implements OnInit, AfterViewInit, OnChanges { }); }, }, + a11y: { + prevSlideMessage: this.translations.get('previousSlide'), + nextSlideMessage: this.translations.get('nextSlide'), + }, }; }