Na banca canadense, bilíngue não é um pedido de feature. É um requisito legal. Para um banco que atende o Quebec, a obrigação vem da Charter of the French Language do Quebec, que o Projeto de Lei 96 estendeu explicitamente em 2022 às empresas reguladas federalmente que operam na província. No nível federal, o Projeto de Lei C-13 promulgou em 2023 o Use of French in Federally Regulated Private Businesses Act, mas essa lei ainda não está em vigor. O Official Languages Act em si obriga instituições federais, não bancos, então não existe mandato geral do OCOL sobre toda tela voltada ao cliente. Eu entreguei features bilíngues (EN/FR) com ngx-translate em um app Angular no BMO. Depois de ter construído nele, é a abordagem que eu escolheria de novo em vez do $localize nativo do Angular. O código deste post é um setup genérico para um codebase Angular atual, não o do banco.
Por Que ngx-translate em Vez do i18n Nativo do Angular
O $localize do Angular requer um build separado por locale. Isso significa dois artefatos de deploy, um pipeline de CI mais complexo e um reload completo da página para trocar idiomas. O ngx-translate carrega arquivos de tradução em tempo de execução via HTTP e troca idiomas sem recarregar. Para um portal bancário onde os usuários esperam alternar o idioma no meio da sessão, esse trade-off é claro. O custo em tempo de execução de carregar um arquivo JSON é pequeno nesse tamanho.
Setup
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'
})
})
]
};Estrutura do Arquivo de Tradução
Não achate nada. Aninhe por domínio de feature. Um arquivo plano com 300 chaves é um pesadelo de manutenção quando duas pessoas estão editando simultaneamente.
// 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 com Estado via Signal
Eu persisto a preferência no localStorage aqui (para usuários logados, um ajuste no perfil no servidor acompanha a pessoa entre dispositivos) e atualizo o atributo html[lang] a cada troca. Leitores de tela usam esse atributo para selecionar o perfil de voz correto. Esquecer esse passo significa que seu conteúdo em francês será lido com uma voz em inglês.
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;
}
}Usando TranslatePipe nos 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';
}Traduzindo aria-labels no Código do Componente
O pipe só funciona em templates. Para valores de aria-label vinculados a partir do TypeScript, injete o TranslateService. Não envolva instant() em um getter e vincule esse getter no template: um getter é uma chamada de método, e ele roda de novo a cada ciclo de detecção de mudanças. Resolva a string uma vez só, ou vincule um signal alimentado por stream(), que reemite quando o usuário troca de idioma.
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: ''
});
}Pluralização
A pluralização básica funciona com formato de mensagem ICU, mas isso requer instalar o ngx-translate-messageformat-compiler. Para a maioria dos casos em inglês e francês, um padrão mais simples usando chaves separadas é mais fácil de manter e mais fácil para os tradutores trabalharem.
// 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}}"
}O Problema de Layout Sobre o Qual Ninguém Te Avisa
Strings em francês são consistentemente 20-30% mais longas que seus equivalentes em inglês. Isso significa que todo botão com largura fixa, todo label truncado, toda célula de tabela com ellipsis vai ficar errado em francês. A forma clássica de aprender isso é uma rodada de QA tardia em francês que revela quebras de layout em várias páginas de uma vez. A solução é testar com conteúdo em francês desde o primeiro sprint, não o último.
- Use min-width em vez de larguras fixas em botões
- Use text-overflow: ellipsis com tooltip para conteúdo truncado, não cortes bruscos
- Execute seu Storybook ou testes de componentes com dados de fixture em francês por padrão
- Não faça hardcode de comprimentos de linha ou suposições de quebra no CSS
- Para breakpoints mobile, conteúdo em francês frequentemente requer um breakpoint extra em relação ao inglês
Pré-carregando Traduções para Renderização Inicial Mais Rápida
Por padrão, o ngx-translate busca os arquivos de tradução depois que o app é inicializado. Usuários em conexões lentas veem brevemente as chaves cruas. Bloqueie a primeira renderização nessa busca com provideAppInitializer. O APP_INITIALIZER está depreciado desde o Angular 19 e a forma de função exige menos cerimônia de qualquer jeito: sem factory, sem array de deps, inject() direto lá dentro.
// 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));
});Bilíngue não é um sprint no final. Projete suas chaves de tradução, seu sistema de layout e seus contratos de dados com ambos os idiomas desde a primeira semana.
Referências
- 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