feat(portal-shell): locale switcher in the footer #95

Merged
julien merged 1 commits from feat/portal-shell/i18n-locale-switcher into main 2026-05-11 20:48:29 +02:00
Owner

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

  • 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).
  • 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.
## 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.
julien added 1 commit 2026-05-11 20:48:20 +02:00
feat(portal-shell): locale switcher in the footer
CI / scan (pull_request) Successful in 2m24s
CI / commits (pull_request) Successful in 2m35s
CI / check (pull_request) Successful in 3m6s
CI / a11y (pull_request) Successful in 1m24s
CI / perf (pull_request) Successful in 5m24s
0808c0be68
Add a CDK-menu locale switcher (FR / EN) to the footer's right
cluster, next to the accessibility link. Trigger reads the active
locale from `<html lang>` (set per locale by the build) and shows
the native name plus a globe + chevron. Selecting another locale
rewrites the URL prefix (`/en/...` ↔ `/fr/...`) and triggers a hard
refresh so the right bundle boots — per ADR-0019.

Architecture mirrors the existing 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 keep the
  global emissions contained.
- The trigger's `aria-label` is `$localize`-marked with a named
  placeholder so screen readers announce the current language.

Locale display names ("Français", "English") are deliberately NOT
i18n-marked. Universal switcher convention: each language is always
shown in its own language, both in the trigger label and the menu
items — translating them would defeat the purpose. The
`<button>` for each menu entry carries `[attr.lang]="locale.code"`
so screen readers pronounce the name correctly.

v1 is SPA-only:

- No backend cookie, no `/api/preferences/locale` BFF route, no
  smart `/` redirect — those land when the BFF is in place. Until
  then the locale prefix in the URL is the source of truth; the
  next visit naturally lands on the same locale's bundle.
- The switcher works against the production build (`/en/`, `/fr/`).
  Under `nx serve` the dev server has no locale prefix, so clicking
  the trigger lands on a non-existent path. Expected — dev mode
  serves the source locale by design (ADR-0019).

Touch target: the trigger keeps a 28px visible height to fit the
40px footer, but extends to 44px via vertical padding — meets the
ADR-0016 AAA touch-target minimum without inflating the footer.

Add Globe to the icon registry (single new entry).
julien merged commit 192cc483b6 into main 2026-05-11 20:48:29 +02:00
julien deleted branch feat/portal-shell/i18n-locale-switcher 2026-05-11 20:48:32 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#95