feat(portal-shell): collapse accessibility routes into one i18n-marked route (#94)
CI / check (push) Successful in 4m0s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m56s
CI / a11y (push) Successful in 2m6s
CI / perf (push) Successful in 3m50s

## Summary

Continue ADR-0019: replace the `/accessibility` + `/accessibilite` twin routes with a single canonical route whose content is i18n-marked in the template. The per-locale build (en/fr) already inlines the right copy — the route-data + `copy()` service indirection is no longer carrying its weight.

## What changes

- **`AccessibilityStatement`** loses its `ActivatedRoute` injection, the `Lang` discriminator, and the `COPY` lookup table. The component is now a plain shell over the template.
- **`accessibility.html`** carries the title + intro + status panel as `i18n="@@page.accessibility.*"` markers. Six new trans-units in [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) provide the French copy — verbatim from the old `COPY.fr` block, so the page reads the same in FR as before.
- **`app.routes.ts`** declares the single canonical route at `path: 'accessibility'` and keeps `/accessibilite` alive as a `redirectTo: 'accessibility'`. Drops `data: { lang: ... }` — no longer consumed.
- **`footer.html`** collapses the dual link into one i18n-marked link (`@@footer.accessibilityLink`). EN bundle reads "Accessibility statement"; FR bundle reads "Déclaration d'accessibilité".

## Decision worth flagging

The path stays in English across both locales for now: `/en/accessibility` and `/fr/accessibility`. Translating route *segments* (`/fr/declaration-d-accessibilite`) needs either a custom URL serializer or per-locale route trees — not worth the complexity at this scale. The page title and the link label already differ per locale via i18n, which is what's actually visible to users.

The historical `/accessibilite` path keeps working via the route-level redirect. Drops out of the codebase once analytics confirm no traffic reaches it.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green. **35 / 35 specs** (was 36; the obsolete `lang`-fallback test is removed).
- [x] Production build emits both locales. FR bundle contains `Statut`, `Déclaration`, the FR intro / panel bodies. No leftover English on swept strings.
- [x] `extract-i18n` clean (49 unique units now: +5 for `page.accessibility.*` + `footer.accessibilityLink`, −0; the old route-data `lang` markers were not i18n).
- [ ] Manual: serve-static then `/en/accessibility` and `/fr/accessibility` render their respective content; `/fr/accessibilite` 301-redirects to `/fr/accessibility`.
- [ ] Manual: footer shows one link, locale-aware ("Accessibility statement" / "Déclaration d'accessibilité").
- [ ] Manual: browser tab title flips between bundles ("Accessibility statement · APF Portal" / "Déclaration d'accessibilité · Portail APF").

## What this PR explicitly does NOT do

- Translate the URL path segment (next ADR-only refinement if needed).
- Add the locale switcher in the footer — that's the next PR on the i18n track.
- Wire a CI gate on missing translations.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #94
This commit was merged in pull request #94.
This commit is contained in:
2026-05-11 20:18:28 +02:00
parent 65fae7f963
commit 8f84cc6389
7 changed files with 105 additions and 134 deletions
+11 -11
View File
@@ -1,9 +1,9 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
// Tab titles per route — marked with `$localize` so each locale's // Tab titles per route — marked with `$localize` so each locale's
// bundle ships its own. The `/accessibility` and `/accessibilite` // bundle ships its own. The accessibility statement now lives at the
// pair shares a title key; the upcoming route fusion (per ADR-0019) // single canonical path `/accessibility` (per ADR-0019, replacing the
// collapses them to a single localised route. // previous `/accessibility` + `/accessibilite` pair).
const homeTitle = $localize`:@@route.home.title:APF Portal`; const homeTitle = $localize`:@@route.home.title:APF Portal`;
const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · APF Portal`; const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · APF Portal`;
@@ -14,21 +14,21 @@ export const appRoutes: Route[] = [
loadComponent: () => import('./pages/home/home').then((m) => m.Home), loadComponent: () => import('./pages/home/home').then((m) => m.Home),
title: homeTitle, title: homeTitle,
}, },
// RGAA + ADR-0016: the accessibility statement must be reachable // RGAA + ADR-0016: the accessibility statement is reachable from
// from both locales' canonical paths. Same component, content // `/accessibility` in every locale. Content is i18n-marked so the
// driven by the `lang` route data property. // per-locale bundle ships the right copy automatically.
{ {
path: 'accessibility', path: 'accessibility',
loadComponent: () => loadComponent: () =>
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
data: { lang: 'en' },
title: accessibilityTitle, title: accessibilityTitle,
}, },
// Backward-compat: the historical French path redirects to the
// canonical one. Bookmarks and existing inbound links keep working.
// Drops out of the codebase once analytics confirm no traffic.
{ {
path: 'accessibilite', path: 'accessibilite',
loadComponent: () => redirectTo: 'accessibility',
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), pathMatch: 'full',
data: { lang: 'fr' },
title: accessibilityTitle,
}, },
]; ];
@@ -6,24 +6,11 @@
<p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p> <p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p>
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal"> <nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<ul class="flex items-center gap-4"> <a
<li> routerLink="/accessibility"
<a 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"
routerLink="/accessibility" i18n="@@footer.accessibilityLink"
lang="en" >Accessibility statement</a
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" >
>Accessibility</a
>
</li>
<li aria-hidden="true" class="text-gray-300 dark:text-gray-700">|</li>
<li>
<a
routerLink="/accessibilite"
lang="fr"
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"
>Accessibilité</a
>
</li>
</ul>
</nav> </nav>
</footer> </footer>
@@ -25,11 +25,15 @@ describe('Footer', () => {
expect(text).toContain(String(new Date().getFullYear())); expect(text).toContain(String(new Date().getFullYear()));
}); });
it('renders both accessibility statement links (EN + FR)', async () => { it('renders the accessibility statement link (single, locale-aware via i18n)', async () => {
const fixture = TestBed.createComponent(Footer); const fixture = TestBed.createComponent(Footer);
await fixture.whenStable(); await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull(); const link = root.querySelector('a[href="/accessibility"]') as HTMLAnchorElement | null;
expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull(); expect(link).not.toBeNull();
expect(link?.textContent?.trim()).toBe('Accessibility statement');
// The historical `/accessibilite` link is gone — that path now
// redirects at the route level rather than being a separate link.
expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
}); });
}); });
@@ -1,14 +1,26 @@
<article class="mx-auto max-w-3xl px-6 py-12" [attr.lang]="copy().htmlLang"> <article class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"> <h1
{{ copy().pageTitle }} class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
i18n="@@page.accessibility.title"
>
Accessibility statement
</h1> </h1>
<section class="mt-8" aria-labelledby="intro-heading"> <section class="mt-8" aria-labelledby="intro-heading">
<h2 id="intro-heading" class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h2
{{ copy().introTitle }} id="intro-heading"
class="text-lg font-medium text-gray-900 dark:text-gray-100"
i18n="@@page.accessibility.intro.title"
>
About this statement
</h2> </h2>
<p class="mt-3 text-base leading-relaxed text-gray-700 dark:text-gray-300"> <p
{{ copy().introBody }} class="mt-3 text-base leading-relaxed text-gray-700 dark:text-gray-300"
i18n="@@page.accessibility.intro.body"
>
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).
</p> </p>
</section> </section>
@@ -16,11 +28,20 @@
class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4 dark:border-amber-500 dark:bg-amber-950/40" class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4 dark:border-amber-500 dark:bg-amber-950/40"
aria-labelledby="status-heading" aria-labelledby="status-heading"
> >
<h2 id="status-heading" class="text-base font-medium text-amber-900 dark:text-amber-200"> <h2
{{ copy().panelNoticeTitle }} id="status-heading"
class="text-base font-medium text-amber-900 dark:text-amber-200"
i18n="@@page.accessibility.panel.title"
>
Status of this document
</h2> </h2>
<p class="mt-2 text-sm leading-relaxed text-amber-900 dark:text-amber-200"> <p
{{ copy().panelNoticeBody }} class="mt-2 text-sm leading-relaxed text-amber-900 dark:text-amber-200"
i18n="@@page.accessibility.panel.body"
>
This statement is awaiting review and sign-off by the APF France handicap user panel. The
final version will integrate the RGAA audit grid, the list of any identified gaps, the
remediation plan, and the contact details for reporting accessibility issues.
</p> </p>
</section> </section>
</article> </article>
@@ -1,40 +1,28 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { AccessibilityStatement } from './accessibility'; import { AccessibilityStatement } from './accessibility';
function configureWithLang(lang: 'fr' | 'en' | undefined) {
return TestBed.configureTestingModule({
imports: [AccessibilityStatement],
providers: [{ provide: ActivatedRoute, useValue: { data: of({ lang }) } }],
}).compileComponents();
}
describe('AccessibilityStatement', () => { describe('AccessibilityStatement', () => {
it('renders the French copy on /accessibilite (lang: fr)', async () => { beforeEach(async () => {
await configureWithLang('fr'); await TestBed.configureTestingModule({ imports: [AccessibilityStatement] }).compileComponents();
});
it('renders the heading + intro + status sections', async () => {
const fixture = TestBed.createComponent(AccessibilityStatement); const fixture = TestBed.createComponent(AccessibilityStatement);
await fixture.whenStable(); await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('h1')?.textContent).toContain('Déclaration daccessibilité'); expect(root.querySelector('h1')?.textContent?.trim()).toBe('Accessibility statement');
expect(root.querySelector('article')?.getAttribute('lang')).toBe('fr'); expect(root.querySelector('section[aria-labelledby="intro-heading"]')).not.toBeNull();
expect(root.querySelector('section[aria-labelledby="status-heading"]')).not.toBeNull();
}); });
it('renders the English copy on /accessibility (lang: en)', async () => { it('exposes the panel-review status with an amber callout structure', async () => {
await configureWithLang('en');
const fixture = TestBed.createComponent(AccessibilityStatement); const fixture = TestBed.createComponent(AccessibilityStatement);
await fixture.whenStable(); await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('h1')?.textContent).toContain('Accessibility statement'); const status = root.querySelector('section[aria-labelledby="status-heading"]');
expect(root.querySelector('article')?.getAttribute('lang')).toBe('en'); expect(status?.querySelector('#status-heading')?.textContent).toContain(
}); 'Status of this document',
it('falls back to English when the route omits the lang', async () => {
await configureWithLang(undefined);
const fixture = TestBed.createComponent(AccessibilityStatement);
await fixture.whenStable();
expect((fixture.nativeElement as HTMLElement).querySelector('h1')?.textContent).toContain(
'Accessibility statement',
); );
expect(status?.textContent).toContain('APF France handicap user panel');
}); });
}); });
@@ -1,77 +1,22 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
type Lang = 'fr' | 'en';
interface AccessibilityCopy {
htmlLang: Lang;
pageTitle: string;
introTitle: string;
introBody: string;
panelNoticeTitle: string;
panelNoticeBody: string;
}
const COPY: Record<Lang, AccessibilityCopy> = {
fr: {
htmlLang: 'fr',
pageTitle: 'Déclaration daccessibilité',
introTitle: 'À propos de cette déclaration',
introBody:
'Le portail APF sengage à rendre ses services numériques accessibles, conformément ' +
'au RGAA 4.1 et à la norme WCAG 2.2 niveau AA, avec des critères AAA ciblés sur les ' +
'points à fort impact pour les usagers de lassociation (voir ADR-0016).',
panelNoticeTitle: 'Statut du présent document',
panelNoticeBody:
'Cette déclaration est en attente de revue et de validation par le panel dusagers ' +
'dAPF France handicap. La version définitive intégrera la grille daudit RGAA, le ' +
'recensement des écarts éventuels, le plan de mise en conformité, et les coordonnées ' +
'de signalement.',
},
en: {
htmlLang: 'en',
pageTitle: 'Accessibility statement',
introTitle: 'About this statement',
introBody:
'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 associations user base (see ADR-0016).',
panelNoticeTitle: 'Status of this document',
panelNoticeBody:
'This statement is awaiting review and sign-off by the APF France handicap user ' +
'panel. The final version will integrate the RGAA audit grid, the list of any ' +
'identified gaps, the remediation plan, and the contact details for reporting ' +
'accessibility issues.',
},
};
/** /**
* Accessibility statement, mounted at both `/accessibility` (en) and * Accessibility statement, mounted at `/accessibility` per ADR-0019.
* `/accessibilite` (fr) per ADR-0016. Same component, content *
* selected from a route data property. * Content is marked for i18n; the production build emits one bundle
* per locale with the right text inlined. The previous twin route
* `/accessibilite` survives as a redirect (in `app.routes.ts`) so
* existing bookmarks keep working.
* *
* v1 ships placeholder copy explicitly framed as "awaiting APF panel * v1 ships placeholder copy explicitly framed as "awaiting APF panel
* review" — content is required to exist (RGAA + ADR-0016) but the * review" — content is required to exist (RGAA + ADR-0016) but the
* substance of the statement (audit grid, gaps list, remediation * substance of the statement (audit grid, gaps list, remediation
* plan) is owned by the accessibility review and lands separately. * plan, reporting channel) is owned by the accessibility review and
* lands separately.
*/ */
@Component({ @Component({
selector: 'app-accessibility-statement', selector: 'app-accessibility-statement',
templateUrl: './accessibility.html', templateUrl: './accessibility.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AccessibilityStatement { export class AccessibilityStatement {}
private readonly route = inject(ActivatedRoute);
protected readonly lang = toSignal(
this.route.data.pipe(map((data): Lang => (data['lang'] as Lang | undefined) ?? 'en')),
// `as Lang` rather than `as const` so the toSignal overload
// picks `Signal<T>` instead of `Signal<T | U>` and downstream
// consumers see `Signal<Lang>` cleanly.
{ initialValue: 'en' as Lang },
);
protected readonly copy = computed<AccessibilityCopy>(() => COPY[this.lang()]);
}
@@ -26,6 +26,10 @@
<source>Legal</source> <source>Legal</source>
<target>Mentions légales</target> <target>Mentions légales</target>
</trans-unit> </trans-unit>
<trans-unit id="footer.accessibilityLink" datatype="html">
<source>Accessibility statement</source>
<target>Déclaration daccessibilité</target>
</trans-unit>
<trans-unit id="footer.copyright" datatype="html"> <trans-unit id="footer.copyright" datatype="html">
<source>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</source> <source>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</source>
<target>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</target> <target>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</target>
@@ -163,6 +167,28 @@
<target>Portail APF</target> <target>Portail APF</target>
</trans-unit> </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&apos;s user base (see ADR-0016). </source>
<target> Le portail APF sengage à rendre ses services numériques accessibles, conformément au RGAA 4.1 et à la norme WCAG 2.2 niveau AA, avec des critères AAA ciblés sur les points à fort impact pour les usagers de lassociation (voir ADR-0016). </target>
</trans-unit>
<trans-unit id="page.accessibility.intro.title" datatype="html">
<source> About this statement </source>
<target> À propos de cette déclaration </target>
</trans-unit>
<trans-unit id="page.accessibility.panel.body" datatype="html">
<source> This statement is awaiting review and sign-off by the APF France handicap user panel. The final version will integrate the RGAA audit grid, the list of any identified gaps, the remediation plan, and the contact details for reporting accessibility issues. </source>
<target> Cette déclaration est en attente de revue et de validation par le panel dusagers dAPF France handicap. La version définitive intégrera la grille daudit RGAA, le recensement des écarts éventuels, le plan de mise en conformité, et les coordonnées de signalement. </target>
</trans-unit>
<trans-unit id="page.accessibility.panel.title" datatype="html">
<source> Status of this document </source>
<target> Statut du présent document </target>
</trans-unit>
<trans-unit id="page.accessibility.title" datatype="html">
<source> Accessibility statement </source>
<target> Déclaration daccessibilité </target>
</trans-unit>
<!-- page.home --> <!-- page.home -->
<trans-unit id="page.home.intro" datatype="html"> <trans-unit id="page.home.intro" datatype="html">
<source> This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy. </source> <source> This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy. </source>