feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
## Summary
Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.
**43 trans-units** marked, grouped by feature:
| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |
## Tooling
- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).
## Translation choices worth flagging
- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.
## Verification
- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.
## Out of scope (each its own follow-up PR)
- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
<footer
|
||||
i18n-aria-label="@@footer.aria.label"
|
||||
aria-label="Page footer"
|
||||
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
|
||||
>
|
||||
<p>© {{ year }} APF France handicap</p>
|
||||
<p i18n="@@footer.copyright">© {{ year }} APF France handicap</p>
|
||||
|
||||
<nav aria-label="Legal">
|
||||
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
||||
<ul class="flex items-center gap-4">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/accessibility"
|
||||
lang="en"
|
||||
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
||||
>Accessibility</a
|
||||
>
|
||||
@@ -17,6 +19,7 @@
|
||||
<li>
|
||||
<a
|
||||
routerLink="/accessibilite"
|
||||
lang="fr"
|
||||
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
||||
>Accessibilité</a
|
||||
>
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
class="h-9 w-9 shrink-0"
|
||||
/>
|
||||
@if (!collapsed()) {
|
||||
<span class="text-base font-semibold sm:text-lg">APF Portal</span>
|
||||
<span class="text-base font-semibold sm:text-lg" i18n="@@header.wordmark">APF Portal</span>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center gap-4 px-4 sm:px-6">
|
||||
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
|
||||
<label class="sr-only" for="global-search">Search the portal</label>
|
||||
<label class="sr-only" for="global-search" i18n="@@header.search.label"
|
||||
>Search the portal</label
|
||||
>
|
||||
<div
|
||||
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
|
||||
>
|
||||
@@ -31,6 +33,7 @@
|
||||
<input
|
||||
id="global-search"
|
||||
type="search"
|
||||
i18n-placeholder="@@header.search.placeholder"
|
||||
placeholder="Search…"
|
||||
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
@@ -41,6 +44,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||
i18n-aria-label="@@header.action.notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<app-icon name="bell" />
|
||||
@@ -52,6 +56,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||
i18n-aria-label="@@header.action.help"
|
||||
aria-label="Help"
|
||||
>
|
||||
<app-icon name="circle-help" />
|
||||
@@ -60,11 +65,13 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||
i18n-aria-label="@@header.action.settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<app-icon name="settings" />
|
||||
</button>
|
||||
<span
|
||||
i18n-aria-label="@@header.action.userMenu"
|
||||
aria-label="User menu (coming soon)"
|
||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<aside
|
||||
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
[class.sidebar--collapsed]="collapsed()"
|
||||
[attr.aria-label]="'Sidebar navigation'"
|
||||
i18n-aria-label="@@sidebar.aria.label"
|
||||
aria-label="Sidebar navigation"
|
||||
>
|
||||
<nav aria-label="Sections" class="flex-1 overflow-y-auto px-2 py-4">
|
||||
<nav
|
||||
i18n-aria-label="@@sidebar.aria.sections"
|
||||
aria-label="Sections"
|
||||
class="flex-1 overflow-y-auto px-2 py-4"
|
||||
>
|
||||
@for (group of menu; track group.label) {
|
||||
<div class="mb-4">
|
||||
@if (!collapsed()) {
|
||||
@@ -55,10 +60,13 @@
|
||||
@if (!collapsed()) {
|
||||
<p
|
||||
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
||||
i18n-aria-label="@@sidebar.role.aria"
|
||||
aria-label="Current role: anonymous (signed-out)"
|
||||
>
|
||||
Role:
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span>
|
||||
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
|
||||
>Anonymous</span
|
||||
>
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -67,13 +75,11 @@
|
||||
(click)="toggle()"
|
||||
class="sidebar__toggle"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
[attr.aria-label]="
|
||||
collapsed() ? 'Expand sidebar' : 'Collapse sidebar'
|
||||
"
|
||||
[attr.aria-label]="toggleAriaLabel()"
|
||||
>
|
||||
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
||||
@if (!collapsed()) {
|
||||
<span class="sidebar__label">Collapse</span>
|
||||
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { Icon, type IconName } from '../icon/icon';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
@@ -21,6 +21,11 @@ import { LayoutStateService } from '../../state/layout-state.service';
|
||||
* plug in a filter without restructuring the data. The Dashboard
|
||||
* entry uses `routerLink="/"` so the active-state styling is visible
|
||||
* on the home page; the rest are anchors until real routes exist.
|
||||
*
|
||||
* String localisation: the menu data uses `$localize` tagged template
|
||||
* literals (per ADR-0019). The literals evaluate at module load — the
|
||||
* `@angular/localize/init` polyfill is loaded before the bundle so
|
||||
* the active-locale value is resolved by the time MENU is built.
|
||||
*/
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -40,24 +45,28 @@ export interface MenuGroup {
|
||||
|
||||
const MENU: readonly MenuGroup[] = [
|
||||
{
|
||||
label: 'General',
|
||||
label: $localize`:@@sidebar.group.general:General`,
|
||||
items: [
|
||||
{ label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' },
|
||||
{ label: 'Calendar', icon: 'calendar', href: '#' },
|
||||
{
|
||||
label: $localize`:@@sidebar.item.dashboard:Dashboard`,
|
||||
icon: 'layout-dashboard',
|
||||
routerLink: '/',
|
||||
},
|
||||
{ label: $localize`:@@sidebar.item.calendar:Calendar`, icon: 'calendar', href: '#' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Establishments',
|
||||
label: $localize`:@@sidebar.group.establishments:Establishments`,
|
||||
items: [
|
||||
{ label: 'Directory', icon: 'building-2', href: '#' },
|
||||
{ label: 'Activities', icon: 'folder', href: '#' },
|
||||
{ label: $localize`:@@sidebar.item.directory:Directory`, icon: 'building-2', href: '#' },
|
||||
{ label: $localize`:@@sidebar.item.activities:Activities`, icon: 'folder', href: '#' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Communication',
|
||||
label: $localize`:@@sidebar.group.communication:Communication`,
|
||||
items: [
|
||||
{ label: 'Messages', icon: 'mail', href: '#' },
|
||||
{ label: 'Documents', icon: 'file-text', href: '#' },
|
||||
{ label: $localize`:@@sidebar.item.messages:Messages`, icon: 'mail', href: '#' },
|
||||
{ label: $localize`:@@sidebar.item.documents:Documents`, icon: 'file-text', href: '#' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -75,6 +84,12 @@ export class Sidebar {
|
||||
protected readonly menu = MENU;
|
||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||
|
||||
protected readonly toggleAriaLabel = computed(() =>
|
||||
this.collapsed()
|
||||
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||
: $localize`:@@sidebar.toggle.aria.collapse:Collapse sidebar`,
|
||||
);
|
||||
|
||||
protected toggle(): void {
|
||||
this.layout.toggleSidebar();
|
||||
}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
type="button"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||
[cdkMenuTriggerFor]="themeMenu"
|
||||
[attr.aria-label]="'Theme: ' + currentLabel() + ' (open menu)'"
|
||||
[attr.aria-label]="triggerAriaLabel()"
|
||||
>
|
||||
<app-icon [name]="currentIcon()" />
|
||||
</button>
|
||||
|
||||
<ng-template #themeMenu>
|
||||
<div cdkMenu class="theme-switcher__menu" role="menu" aria-label="Theme">
|
||||
<div
|
||||
cdkMenu
|
||||
class="theme-switcher__menu"
|
||||
role="menu"
|
||||
i18n-aria-label="@@theme.menu.aria"
|
||||
aria-label="Theme"
|
||||
>
|
||||
@for (option of options; track option.mode) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -31,9 +31,9 @@ interface ModeOption {
|
||||
}
|
||||
|
||||
const MODE_OPTIONS: readonly ModeOption[] = [
|
||||
{ mode: 'light', label: 'Light', icon: 'sun' },
|
||||
{ mode: 'dark', label: 'Dark', icon: 'moon' },
|
||||
{ mode: 'auto', label: 'System', icon: 'monitor' },
|
||||
{ mode: 'light', label: $localize`:@@theme.mode.light:Light`, icon: 'sun' },
|
||||
{ mode: 'dark', label: $localize`:@@theme.mode.dark:Dark`, icon: 'moon' },
|
||||
{ mode: 'auto', label: $localize`:@@theme.mode.system:System`, icon: 'monitor' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
@@ -60,7 +60,12 @@ export class ThemeSwitcher {
|
||||
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor',
|
||||
);
|
||||
protected readonly currentLabel = computed(
|
||||
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System',
|
||||
() =>
|
||||
MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ??
|
||||
$localize`:@@theme.mode.system:System`,
|
||||
);
|
||||
protected readonly triggerAriaLabel = computed(
|
||||
() => $localize`:@@theme.trigger.aria:Theme: ${this.currentLabel()}:mode: (open menu)`,
|
||||
);
|
||||
|
||||
protected select(mode: ThemeMode): void {
|
||||
|
||||
Reference in New Issue
Block a user