Dans la banque canadienne, le bilinguisme n'est pas une demande de fonctionnalité. C'est une exigence légale. Pour une banque qui sert le Québec, l'obligation vient de la Charte de la langue française du Québec, que la loi 96 a explicitement étendue en 2022 aux entreprises de compétence fédérale exerçant leurs activités dans la province. Au fédéral, le projet de loi C-13 a édicté en 2023 la Loi sur l'usage du français dans les entreprises privées de compétence fédérale, mais cette loi n'est pas encore en vigueur. La Loi sur les langues officielles, elle, lie les institutions fédérales, pas les banques, alors il n'y a pas de mandat général du Commissaire sur chaque écran destiné au client. J'ai livré des fonctionnalités bilingues (EN/FR) avec ngx-translate sur une application Angular chez BMO. Après avoir construit dedans, c'est l'approche que je choisirais à nouveau plutôt que le $localize intégré d'Angular. Le code de cet article est une configuration générique pour une base de code Angular actuelle, pas celle de la banque.
Pourquoi ngx-translate plutôt que l'i18n intégré d'Angular
Le $localize d'Angular nécessite un build séparé par locale. Cela signifie deux artefacts de déploiement, un pipeline CI plus complexe, et un rechargement complet de page pour changer de langue. ngx-translate charge les fichiers de traduction à l'exécution via HTTP et change de langue sans rechargement. Pour un portail bancaire où les utilisateurs s'attendent à basculer la langue en cours de session, ce compromis est clair. Le coût à l'exécution du chargement d'un fichier JSON est faible à cette taille.
Configuration
npm install @ngx-translate/core @ngx-translate/http-loader// app.config.ts (ngx-translate v17 provider API, unchanged in v18)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideTranslateService({
fallbackLang: 'en',
loader: provideTranslateHttpLoader({
prefix: './assets/i18n/',
suffix: '.json'
})
})
]
};Structure des fichiers de traduction
N'aplatissez rien. Imbriquez par domaine fonctionnel. Un fichier plat avec 300 clés est un cauchemar de maintenance quand deux personnes l'éditent simultanément.
// assets/i18n/en.json
{
"common": {
"save": "Save",
"cancel": "Cancel",
"loading": "Loading...",
"error": "An error occurred. Please try again."
},
"accounts": {
"title": "My Accounts",
"balance": "Available Balance",
"noTransactions": "No transactions found."
},
"lending": {
"applyNow": "Apply Now",
"creditLimit": "Credit Limit",
"pendingReview": "Your application is under review."
}
}// assets/i18n/fr.json
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"loading": "Chargement...",
"error": "Une erreur s'est produite. Veuillez réessayer."
},
"accounts": {
"title": "Mes comptes",
"balance": "Solde disponible",
"noTransactions": "Aucune transaction trouvée."
},
"lending": {
"applyNow": "Faire une demande",
"creditLimit": "Limite de crédit",
"pendingReview": "Votre demande est en cours d'examen."
}
}LanguageService avec état Signal
Je persiste la préférence dans localStorage ici (pour les utilisateurs connectés, un réglage dans le profil côté serveur les suit d'un appareil à l'autre) et je mets à jour l'attribut html[lang] à chaque changement. Les lecteurs d'écran utilisent cet attribut pour sélectionner le bon profil vocal. Omettre cette étape signifie que votre contenu français est lu avec une voix anglaise.
import { DOCUMENT, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
export type Language = 'en' | 'fr';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private translate = inject(TranslateService);
private document = inject(DOCUMENT);
currentLanguage = signal<Language>(
(localStorage.getItem('lang') as Language) ?? 'en'
);
constructor() {
this.translate.addLangs(['en', 'fr']);
this.setLanguage(this.currentLanguage());
}
setLanguage(lang: Language): void {
this.translate.use(lang);
this.currentLanguage.set(lang);
localStorage.setItem('lang', lang);
this.document.documentElement.lang = lang;
}
}Utiliser TranslatePipe dans les templates
@Component({
standalone: true,
// TranslatePipe is standalone. TranslateModule still works on v17 but was removed in v18,
// so import the pipe and the sample survives the upgrade.
imports: [TranslatePipe, CurrencyPipe],
template: `
<h1>{{ 'accounts.title' | translate }}</h1>
<p>{{ 'accounts.balance' | translate }}: {{ balance | currency:'CAD' }}</p>
<!-- interpolation -->
<p>{{ 'greeting' | translate:{ name: userName } }}</p>
`
})
export class AccountsComponent {
balance = 4250.00;
userName = 'Marie';
}Traduire les aria-labels dans le code du composant
Le pipe ne fonctionne que dans les templates. Pour les valeurs aria-label liées depuis TypeScript, injectez TranslateService. N'enveloppez pas instant() dans un getter que vous liez ensuite dans le template : un getter est un appel de méthode, et il se réexécute à chaque cycle de détection de changement. Résolvez la chaîne une seule fois, ou liez un signal alimenté par stream(), qui réémet quand l'utilisateur change de langue.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
@Component({
standalone: true,
template: `<button [attr.aria-label]="closeLabel()">X</button>`
})
export class ModalComponent {
private translate = inject(TranslateService);
// stream() emits the current translation and re-emits on every language change,
// so the label stays correct without a getter running on every change detection pass.
readonly closeLabel = toSignal(this.translate.stream('common.closeModal'), {
initialValue: ''
});
}Pluralisation
La pluralisation basique fonctionne avec le format de message ICU, mais cela nécessite d'installer ngx-translate-messageformat-compiler. Pour la plupart des cas en anglais et français, un pattern plus simple utilisant des clés séparées est plus facile à maintenir et plus facile à utiliser pour les traducteurs.
// en.json, separate keys (no extra package)
{
"transactions": {
"none": "No transactions",
"one": "1 transaction",
"many": "{{count}} transactions"
}
}
// ICU alternative (needs ngx-translate-messageformat-compiler)
{
"transactionCount": "{count, plural, =0 {No transactions} =1 {1 transaction} other {# transactions}}"
}Le problème de layout dont personne ne vous prévient
Les chaînes françaises sont systématiquement 20-30 % plus longues que leurs équivalents anglais. Cela signifie que chaque bouton à largeur fixe, chaque label tronqué, chaque cellule de tableau coupée par ellipse aura un mauvais rendu en français. La façon classique de l'apprendre, c'est une phase de QA tardive en français qui révèle d'un coup des cassures de layout sur plusieurs pages. La solution est de tester avec du contenu français dès le premier sprint, pas le dernier.
- Utilisez min-width plutôt que des largeurs fixes sur les boutons
- Utilisez text-overflow: ellipsis avec un tooltip pour le contenu tronqué, pas des coupes dures
- Lancez votre Storybook ou vos tests de composants avec des données de fixture françaises par défaut
- Ne codez pas en dur les longueurs de ligne ou les hypothèses de retour à la ligne en CSS
- Pour les breakpoints mobiles, le contenu français nécessite souvent un breakpoint supplémentaire par rapport à l'anglais
Préchargement des traductions pour un rendu initial plus rapide
Par défaut, ngx-translate récupère les fichiers de traduction après le bootstrap de l'application. Les utilisateurs sur des connexions lentes voient brièvement les clés brutes. Bloquez le premier rendu sur cette requête avec provideAppInitializer. APP_INITIALIZER est déprécié depuis Angular 19 et la forme fonction demande de toute façon moins de cérémonie : pas de factory, pas de tableau deps, inject() directement à l'intérieur.
// app.config.ts
import { inject, provideAppInitializer } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
// Goes in the same providers array as provideTranslateService from the setup block.
// It runs in an injection context, so inject() works directly.
provideAppInitializer(() => {
const translate = inject(TranslateService);
const lang = (localStorage.getItem('lang') as 'en' | 'fr') ?? 'en';
translate.addLangs(['en', 'fr']);
// The fallback is already configured as fallbackLang in provideTranslateService. To change it
// at runtime, call setFallbackLang, which replaced setDefaultLang in v17.
return firstValueFrom(translate.use(lang));
});Le bilinguisme n'est pas un sprint à la fin. Concevez vos clés de traduction, votre système de layout et vos contrats de données avec les deux langues dès la première semaine.
Références
- ngx-translate. https://github.com/ngx-translate/core
- Angular provideAppInitializer. https://angular.dev/api/core/provideAppInitializer
- Angular locale ID. https://angular.dev/guide/i18n-common-locale-id
- Angular DatePipe. https://angular.dev/api/common/DatePipe