feat(portal-shell): locale switcher in the footer (#95)
CI / scan (push) Successful in 2m4s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m15s
CI / a11y (push) Successful in 1m47s
CI / perf (push) Successful in 3m40s

## Summary

Add a locale switcher (FR / EN) to the footer's right cluster, next to the accessibility link. Closes the user-facing i18n loop — the FR bundle has existed since the sweep PR but had no in-app entry point until now.

The switcher reads the active locale from `<html lang>` (set per locale by the build), shows the native name + a globe + chevron-down chip, and on selection rewrites the URL prefix (`/en/...` ↔ `/fr/...`) and hard-refreshes so the right bundle boots — per ADR-0019.

## Architecture

Same pattern as the theme switcher:

- **`@angular/cdk/menu`** for the trigger + roving-focus menu + escape / click-outside dismissal.
- **`ViewEncapsulation.None`** because the menu opens in an overlay portal outside the component host — BEM-style class names (`.locale-switcher__*`) keep the global emissions contained.
- Each menu item carries `[attr.lang]="locale.code"` so screen readers pronounce the native names correctly.

## Decisions worth flagging

- **Locale display names ("Français", "English") are NOT i18n-marked.** Universal switcher convention: each language is always shown in its own language. Translating them would defeat the purpose for someone trying to switch *away* from the active locale they can't read.
- **No backend, no cookie, no smart `/` redirect — yet.** The URL prefix is the source of truth in v1: the next visit lands on the same locale because the URL says so. The `__Host-portal_locale` cookie + the BFF route at `/api/preferences/locale` + the smart `/` redirect described in ADR-0019 wait for the auth flow to bring the BFF online.
- **Dev-mode limitation, accepted.** Under `nx serve`, the dev server has no locale prefix in the URL — clicking the trigger lands on a non-existent path. The switcher works against the production build (`nx run portal-shell:serve-static` or any real deploy). This matches ADR-0019: dev = source locale, locale switching is a built-bundle concern.
- **Touch target.** Visible height stays at ~28 px to fit the 40 px footer; vertical padding extends the tap area to **44 px**, meeting the ADR-0016 AAA minimum without inflating the footer.

## Translation choices

Two new i18n keys:

| Key | Source (EN) | Target (FR) |
|---|---|---|
| `@@locale.trigger.aria` | `Language: <name> (change language)` | `Langue : <name> (changer de langue)` |
| `@@locale.menu.aria` | `Language` | `Langue` |

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **40 / 40 specs** (+5: four new for `LocaleSwitcher`, one for the footer embedding).
- [x] Production build emits both locales; spot-checked the FR bundle for "Langue", "changer de langue", and the absence of "Language:" leakage.
- [ ] Manual: build prod + serve-static → on `/en/`, click switcher → lands on `/fr/`; widget shows "Français"; reload stays in FR.
- [ ] Manual: keyboard the trigger → ENTER opens, arrows navigate, ENTER selects, ESC closes; focus returns to trigger on close.
- [ ] Manual: screen reader announces both languages with the right pronunciation (`<button lang="fr">Français</button>` is announced with the FR voice).
- [ ] Manual: query/hash preserved across switch (`/en/accessibility?foo=bar` → `/fr/accessibility?foo=bar`).

## What this PR explicitly does NOT do

- BFF route `/api/preferences/locale` + `__Host-portal_locale` cookie.
- Smart `/` redirect (cookie → Accept-Language → fr) — that's reverse-proxy / BFF work.
- CI gate on missing translations.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #95
This commit was merged in pull request #95.
This commit is contained in:
2026-05-11 20:48:27 +02:00
parent 8f84cc6389
commit 192cc483b6
9 changed files with 340 additions and 15 deletions
@@ -5,12 +5,15 @@
>
<p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p>
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<a
routerLink="/accessibility"
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"
i18n="@@footer.accessibilityLink"
>Accessibility statement</a
>
</nav>
<div class="flex items-center gap-4">
<app-locale-switcher />
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<a
routerLink="/accessibility"
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"
i18n="@@footer.accessibilityLink"
>Accessibility statement</a
>
</nav>
</div>
</footer>
@@ -36,4 +36,10 @@ describe('Footer', () => {
// redirects at the route level rather than being a separate link.
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
});
it('embeds the locale switcher', async () => {
const fixture = TestBed.createComponent(Footer);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-locale-switcher')).not.toBeNull();
});
});
@@ -1,22 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { LocaleSwitcher } from '../locale-switcher/locale-switcher';
/**
* Thin full-width footer pinned to the bottom of the shell.
*
* v1 ships:
* - APF France handicap copyright with the current year.
* - Accessibility statement links in both languages (RGAA-mandated;
* ADR-0016). Both stay visible until the language selector lands,
* after which we drop the one matching the current locale.
* - Locale switcher (FR / EN) — per ADR-0019, hard-refreshes to
* the matching `/fr/...` or `/en/...` prefix.
* - Accessibility statement link — single i18n-marked link
* pointing to the canonical `/accessibility` path.
*
* The footer is reserved for cross-cutting page furniture — once we
* have them, the language toggle and a dev-only version badge land
* here. Real product navigation stays in the sidebar.
* The footer is reserved for cross-cutting page furniture. A
* dev-only version badge lands here once `SERVICE_VERSION` is wired
* to a build-time injection.
*/
@Component({
selector: 'app-footer',
imports: [RouterLink],
imports: [RouterLink, LocaleSwitcher],
templateUrl: './footer.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -11,6 +11,7 @@ import {
CircleHelp,
FileText,
Folder,
Globe,
Grid2x2,
House,
LayoutDashboard,
@@ -60,6 +61,7 @@ const ICON_REGISTRY = {
'circle-help': CircleHelp,
'file-text': FileText,
folder: Folder,
globe: Globe,
'grid-2x2': Grid2x2,
home: House,
'layout-dashboard': LayoutDashboard,
@@ -0,0 +1,38 @@
<button
type="button"
class="locale-switcher__trigger"
[cdkMenuTriggerFor]="localeMenu"
[attr.aria-label]="triggerAriaLabel"
>
<app-icon name="globe" [size]="14" />
<span>{{ currentDisplayName }}</span>
<app-icon name="chevron-down" [size]="12" />
</button>
<ng-template #localeMenu>
<div
cdkMenu
class="locale-switcher__menu"
role="menu"
i18n-aria-label="@@locale.menu.aria"
aria-label="Language"
>
@for (locale of locales; track locale.code) {
<button
type="button"
cdkMenuItem
class="locale-switcher__item"
[class.locale-switcher__item--active]="locale.code === currentCode"
[attr.aria-checked]="locale.code === currentCode"
[attr.lang]="locale.code"
role="menuitemradio"
(click)="select(locale.code)"
>
<span class="locale-switcher__label">{{ locale.displayName }}</span>
@if (locale.code === currentCode) {
<app-icon name="check" [size]="16" />
}
</button>
}
</div>
</ng-template>
@@ -0,0 +1,107 @@
// Trigger sits inside the thin footer. Visual height stays small
// (~28px) but the tap target meets the AAA 44×44 minimum from
// ADR-0016 by extending vertical padding to the full footer height
// — the surrounding `<nav>` clips at footer bounds.
.locale-switcher__trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
min-height: 2.75rem;
margin: -0.5rem 0;
padding: 0 0.5rem;
border-radius: 0.25rem;
background: transparent;
border: 0;
color: inherit;
font-size: inherit;
font-weight: 500;
cursor: pointer;
transition:
background-color 0.12s ease-out,
color 0.12s ease-out;
&:hover {
background-color: #f3f4f6;
color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px;
}
}
// Menu mirrors the theme-switcher styling so the two header/footer
// dropdowns read as one family.
.locale-switcher__menu {
min-width: 10rem;
margin-bottom: 0.25rem;
padding: 0.25rem;
background-color: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.05);
:where(.dark) & {
background-color: #111827;
border-color: #1f2937;
}
}
.locale-switcher__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
background: transparent;
border: 0;
color: #1f2937;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
transition:
background-color 0.12s ease-out,
color 0.12s ease-out;
:where(.dark) & {
color: #f3f4f6;
}
&:hover {
background-color: #f3f4f6;
color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500);
outline-offset: -2px;
}
}
.locale-switcher__label {
flex: 1 1 auto;
}
.locale-switcher__item--active {
font-weight: 600;
color: var(--color-brand-primary-500);
:where(.dark) & {
color: var(--color-brand-primary-300);
}
}
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LocaleSwitcher } from './locale-switcher';
describe('LocaleSwitcher', () => {
beforeEach(async () => {
document.documentElement.lang = 'en';
await TestBed.configureTestingModule({ imports: [LocaleSwitcher] }).compileComponents();
});
afterEach(() => {
document.documentElement.lang = '';
});
it('renders a trigger labelled with the active locale display name', async () => {
const fixture = TestBed.createComponent(LocaleSwitcher);
await fixture.whenStable();
const trigger = (fixture.nativeElement as HTMLElement).querySelector(
'button[aria-haspopup="menu"]',
) as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
expect(trigger?.textContent).toContain('English');
expect(trigger?.getAttribute('aria-label')).toContain('English');
});
it('picks up the active locale from <html lang>', async () => {
document.documentElement.lang = 'fr';
const fixture = TestBed.createComponent(LocaleSwitcher);
await fixture.whenStable();
const trigger = (fixture.nativeElement as HTMLElement).querySelector(
'button[aria-haspopup="menu"]',
) as HTMLButtonElement | null;
expect(trigger?.textContent).toContain('Français');
});
it('builds the target URL by swapping the locale prefix and hard-navigates', () => {
const fixture = TestBed.createComponent(LocaleSwitcher);
const component = fixture.componentInstance as unknown as {
select(code: 'fr' | 'en'): void;
};
const original = window.location;
const assign = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { pathname: '/en/accessibility', search: '?from=footer', hash: '', assign },
});
component.select('fr');
expect(assign).toHaveBeenCalledWith('/fr/accessibility?from=footer');
Object.defineProperty(window, 'location', { configurable: true, value: original });
});
it('does nothing when the selected locale matches the current one', () => {
const fixture = TestBed.createComponent(LocaleSwitcher);
const component = fixture.componentInstance as unknown as {
select(code: 'fr' | 'en'): void;
};
const original = window.location;
const assign = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { pathname: '/en/', search: '', hash: '', assign },
});
component.select('en');
expect(assign).not.toHaveBeenCalled();
Object.defineProperty(window, 'location', { configurable: true, value: original });
});
});
@@ -0,0 +1,90 @@
import { DOCUMENT } from '@angular/common';
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
import { Icon } from '../icon/icon';
/**
* Locale switcher rendered in the footer.
*
* The trigger is a small chip showing the active locale's native
* name (e.g. "Français" or "English"). Clicking it opens a CDK menu
* with one entry per supported locale; the currently-active one
* carries a check mark. Selecting a locale rewrites the current URL
* to the matching per-locale prefix (`/fr/...`, `/en/...`) and
* triggers a hard refresh so the right bundle boots.
*
* Locale names are NOT i18n-marked — each language's name is always
* shown in its own language ("Français" / "English"), which is the
* universal pattern for locale switchers.
*
* v1 is SPA-only: the URL prefix carries the locale, and the next
* visit naturally lands on the same locale's bundle. The
* `__Host-portal_locale` cookie + smart redirect at `/` envisaged in
* ADR-0019 land once a small BFF route is in place; until then the
* `/` URL falls back to `Accept-Language` then `fr`.
*/
type LocaleCode = 'fr' | 'en';
interface Locale {
readonly code: LocaleCode;
readonly displayName: string;
}
const LOCALES: readonly Locale[] = [
{ code: 'fr', displayName: 'Français' },
{ code: 'en', displayName: 'English' },
];
const LOCALE_PREFIX_RE = /^\/(en|fr)(?=\/|$)/;
@Component({
selector: 'app-locale-switcher',
imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, Icon],
templateUrl: './locale-switcher.html',
styleUrl: './locale-switcher.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
// Same reasoning as ThemeSwitcher: the CDK menu opens in an
// overlay portal outside this component's host, so encapsulated
// styles would not reach it. The BEM-style class names below are
// specific enough to stay contained.
encapsulation: ViewEncapsulation.None,
})
export class LocaleSwitcher {
private readonly document = inject(DOCUMENT);
protected readonly locales = LOCALES;
protected readonly currentCode: LocaleCode;
protected readonly currentDisplayName: string;
protected readonly triggerAriaLabel: string;
constructor() {
this.currentCode = this.detectCurrentLocale();
this.currentDisplayName =
LOCALES.find((l) => l.code === this.currentCode)?.displayName ?? 'English';
this.triggerAriaLabel = $localize`:@@locale.trigger.aria:Language: ${this.currentDisplayName}:locale: (change language)`;
}
protected select(code: LocaleCode): void {
if (code === this.currentCode) {
return;
}
const target = this.buildTargetUrl(code);
this.document.defaultView?.location.assign(target);
}
private detectCurrentLocale(): LocaleCode {
const lang = this.document.documentElement.lang;
return lang === 'fr' ? 'fr' : 'en';
}
private buildTargetUrl(targetCode: LocaleCode): string {
const win = this.document.defaultView;
const pathname = win?.location.pathname ?? '/';
const search = win?.location.search ?? '';
const hash = win?.location.hash ?? '';
const stripped = pathname.replace(LOCALE_PREFIX_RE, '') || '/';
const normalised = stripped.startsWith('/') ? stripped : `/${stripped}`;
return `/${targetCode}${normalised}${search}${hash}`;
}
}