+ 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 @@
+
@@ -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'),
+ },
};
}