From 192cc483b6d1a25e00c83896b5cb06386ca080ab Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 20:48:27 +0200 Subject: [PATCH] feat(portal-shell): locale switcher in the footer (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` (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: (change language)` | `Langue : (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 (`` 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 Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/95 --- .../src/app/components/footer/footer.html | 19 ++-- .../src/app/components/footer/footer.spec.ts | 6 + .../src/app/components/footer/footer.ts | 16 +-- .../src/app/components/icon/icon.ts | 2 + .../locale-switcher/locale-switcher.html | 38 +++++++ .../locale-switcher/locale-switcher.scss | 107 ++++++++++++++++++ .../locale-switcher/locale-switcher.spec.ts | 67 +++++++++++ .../locale-switcher/locale-switcher.ts | 90 +++++++++++++++ apps/portal-shell/src/locale/messages.fr.xlf | 10 ++ 9 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html create mode 100644 apps/portal-shell/src/app/components/locale-switcher/locale-switcher.scss create mode 100644 apps/portal-shell/src/app/components/locale-switcher/locale-switcher.spec.ts create mode 100644 apps/portal-shell/src/app/components/locale-switcher/locale-switcher.ts diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html index 22674c2..233d27e 100644 --- a/apps/portal-shell/src/app/components/footer/footer.html +++ b/apps/portal-shell/src/app/components/footer/footer.html @@ -5,12 +5,15 @@ >

© {{ year }} APF France handicap

- + diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts index 6f13e09..768d06c 100644 --- a/apps/portal-shell/src/app/components/footer/footer.spec.ts +++ b/apps/portal-shell/src/app/components/footer/footer.spec.ts @@ -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(); + }); }); diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts index 5c06715..ea2d6c6 100644 --- a/apps/portal-shell/src/app/components/footer/footer.ts +++ b/apps/portal-shell/src/app/components/footer/footer.ts @@ -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, }) diff --git a/apps/portal-shell/src/app/components/icon/icon.ts b/apps/portal-shell/src/app/components/icon/icon.ts index 79b58f9..c0cc980 100644 --- a/apps/portal-shell/src/app/components/icon/icon.ts +++ b/apps/portal-shell/src/app/components/icon/icon.ts @@ -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, diff --git a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html new file mode 100644 index 0000000..ca49c7d --- /dev/null +++ b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.html @@ -0,0 +1,38 @@ + + + + + diff --git a/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.scss b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.scss new file mode 100644 index 0000000..23cf88c --- /dev/null +++ b/apps/portal-shell/src/app/components/locale-switcher/locale-switcher.scss @@ -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 `