feat(portal-shell): collapse accessibility routes into one i18n-marked route (#94)
## 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:
@@ -1,9 +1,9 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
// Tab titles per route — marked with `$localize` so each locale's
|
||||
// bundle ships its own. The `/accessibility` and `/accessibilite`
|
||||
// pair shares a title key; the upcoming route fusion (per ADR-0019)
|
||||
// collapses them to a single localised route.
|
||||
// bundle ships its own. The accessibility statement now lives at the
|
||||
// single canonical path `/accessibility` (per ADR-0019, replacing the
|
||||
// previous `/accessibility` + `/accessibilite` pair).
|
||||
const homeTitle = $localize`:@@route.home.title: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),
|
||||
title: homeTitle,
|
||||
},
|
||||
// RGAA + ADR-0016: the accessibility statement must be reachable
|
||||
// from both locales' canonical paths. Same component, content
|
||||
// driven by the `lang` route data property.
|
||||
// RGAA + ADR-0016: the accessibility statement is reachable from
|
||||
// `/accessibility` in every locale. Content is i18n-marked so the
|
||||
// per-locale bundle ships the right copy automatically.
|
||||
{
|
||||
path: 'accessibility',
|
||||
loadComponent: () =>
|
||||
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
||||
data: { lang: 'en' },
|
||||
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',
|
||||
loadComponent: () =>
|
||||
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
||||
data: { lang: 'fr' },
|
||||
title: accessibilityTitle,
|
||||
redirectTo: 'accessibility',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,24 +6,11 @@
|
||||
<p i18n="@@footer.copyright">© {{ year }} APF France handicap</p>
|
||||
|
||||
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
||||
<ul class="flex items-center gap-4">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/accessibility"
|
||||
lang="en"
|
||||
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>
|
||||
<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>
|
||||
</footer>
|
||||
|
||||
@@ -25,11 +25,15 @@ describe('Footer', () => {
|
||||
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);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull();
|
||||
expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull();
|
||||
const link = root.querySelector('a[href="/accessibility"]') as HTMLAnchorElement | null;
|
||||
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">
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
{{ copy().pageTitle }}
|
||||
<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"
|
||||
i18n="@@page.accessibility.title"
|
||||
>
|
||||
Accessibility statement
|
||||
</h1>
|
||||
|
||||
<section class="mt-8" aria-labelledby="intro-heading">
|
||||
<h2 id="intro-heading" class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ copy().introTitle }}
|
||||
<h2
|
||||
id="intro-heading"
|
||||
class="text-lg font-medium text-gray-900 dark:text-gray-100"
|
||||
i18n="@@page.accessibility.intro.title"
|
||||
>
|
||||
About this statement
|
||||
</h2>
|
||||
<p class="mt-3 text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
{{ copy().introBody }}
|
||||
<p
|
||||
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>
|
||||
</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"
|
||||
aria-labelledby="status-heading"
|
||||
>
|
||||
<h2 id="status-heading" class="text-base font-medium text-amber-900 dark:text-amber-200">
|
||||
{{ copy().panelNoticeTitle }}
|
||||
<h2
|
||||
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>
|
||||
<p class="mt-2 text-sm leading-relaxed text-amber-900 dark:text-amber-200">
|
||||
{{ copy().panelNoticeBody }}
|
||||
<p
|
||||
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>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
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', () => {
|
||||
it('renders the French copy on /accessibilite (lang: fr)', async () => {
|
||||
await configureWithLang('fr');
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [AccessibilityStatement] }).compileComponents();
|
||||
});
|
||||
|
||||
it('renders the heading + intro + status sections', async () => {
|
||||
const fixture = TestBed.createComponent(AccessibilityStatement);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('h1')?.textContent).toContain('Déclaration d’accessibilité');
|
||||
expect(root.querySelector('article')?.getAttribute('lang')).toBe('fr');
|
||||
expect(root.querySelector('h1')?.textContent?.trim()).toBe('Accessibility statement');
|
||||
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 () => {
|
||||
await configureWithLang('en');
|
||||
it('exposes the panel-review status with an amber callout structure', async () => {
|
||||
const fixture = TestBed.createComponent(AccessibilityStatement);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('h1')?.textContent).toContain('Accessibility statement');
|
||||
expect(root.querySelector('article')?.getAttribute('lang')).toBe('en');
|
||||
});
|
||||
|
||||
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',
|
||||
const status = root.querySelector('section[aria-labelledby="status-heading"]');
|
||||
expect(status?.querySelector('#status-heading')?.textContent).toContain(
|
||||
'Status of this document',
|
||||
);
|
||||
expect(status?.textContent).toContain('APF France handicap user panel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } 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 d’accessibilité',
|
||||
introTitle: 'À propos de cette déclaration',
|
||||
introBody:
|
||||
'Le portail APF s’engage à 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 l’association (voir ADR-0016).',
|
||||
panelNoticeTitle: 'Statut du présent document',
|
||||
panelNoticeBody:
|
||||
'Cette déclaration est en attente de revue et de validation par le panel d’usagers ' +
|
||||
'd’APF France handicap. La version définitive intégrera la grille d’audit 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 association’s 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.',
|
||||
},
|
||||
};
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Accessibility statement, mounted at both `/accessibility` (en) and
|
||||
* `/accessibilite` (fr) per ADR-0016. Same component, content
|
||||
* selected from a route data property.
|
||||
* Accessibility statement, mounted at `/accessibility` per ADR-0019.
|
||||
*
|
||||
* 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
|
||||
* review" — content is required to exist (RGAA + ADR-0016) but the
|
||||
* 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({
|
||||
selector: 'app-accessibility-statement',
|
||||
templateUrl: './accessibility.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
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()]);
|
||||
}
|
||||
export class AccessibilityStatement {}
|
||||
|
||||
Reference in New Issue
Block a user