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,12 +5,15 @@
|
||||
>
|
||||
<p i18n="@@footer.copyright">© {{ 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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user