feat(portal-shell): locale switcher in the footer (#95)
## 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:
@@ -5,6 +5,8 @@
|
||||
>
|
||||
<p i18n="@@footer.copyright">© {{ year }} APF France handicap</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<app-locale-switcher />
|
||||
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
||||
<a
|
||||
routerLink="/accessibility"
|
||||
@@ -13,4 +15,5 @@
|
||||
>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}`;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,16 @@
|
||||
<target>Portail APF</target>
|
||||
</trans-unit>
|
||||
|
||||
<!-- locale switcher -->
|
||||
<trans-unit id="locale.menu.aria" datatype="html">
|
||||
<source>Language</source>
|
||||
<target>Langue</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="locale.trigger.aria" datatype="html">
|
||||
<source>Language: <x id="locale" equiv-text="this.currentDisplayName"/> (change language)</source>
|
||||
<target>Langue : <x id="locale" equiv-text="this.currentDisplayName"/> (changer de langue)</target>
|
||||
</trans-unit>
|
||||
|
||||
<!-- page.accessibility -->
|
||||
<trans-unit id="page.accessibility.intro.body" datatype="html">
|
||||
<source> The APF Portal is committed to making its digital services accessible, in line with WCAG 2.2 level AA and the French RGAA 4.1 reference, with targeted AAA criteria on the items most impactful to the association's user base (see ADR-0016). </source>
|
||||
|
||||
Reference in New Issue
Block a user