feat(portal-shell): thin full-width footer with copyright + a11y links (#88)
CI / check (push) Successful in 2m51s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m9s
CI / perf (push) Successful in 2m50s

## Summary

- Re-add a **40 px** (h-10) footer pinned to the bottom of the shell, spanning the full viewport width below header + sidebar + main.
- Hosts the **`© <year> APF France handicap`** copyright on the left and the FR + EN accessibility statement links on the right, separated by a thin divider.
- **Move the accessibility links out of the sidebar bottom.** Putting legal / compliance links in the footer matches universal web convention and keeps the sidebar focused on product navigation.

## Why footer rather than the help menu

The header's `circle-help` icon will eventually open a help menu (FAQ, keyboard shortcuts, contact support) — that's **product help**, not **legal / compliance**. Auditors (RGAA 4.1, EN 301 549) look for the accessibility statement in the footer; hiding it inside a help dropdown would hurt discoverability. The footer is the canonical home.

## Why both FR + EN stay visible

There is no language toggle yet — `@angular/localize` and its ADR are a separate chantier. Until then, exposing both languages prevents a francophone user from landing on the EN page (or vice versa) via a stale favourite. Once the locale switcher lands, the footer drops the link that doesn't match the active locale.

## Layout

```
:host  flex column, h-100vh
├── app-header           shrink-0, h-16
├── div.shell-body       flex-1, flex row
│   ├── app-sidebar      w-{64|16}, h-full (== shell-body)
│   └── main.shell-main  flex-1, overflow-y-auto
└── app-footer           shrink-0, h-10
```

The sidebar now sits *between* header and footer, so the collapse toggle stays flush above the footer at every viewport size. shell-main still owns its own vertical scroll so long content does not push the footer off-screen.

## What this PR reserves for later (placeholder)

- **Language toggle (FR / EN)** — lands once `@angular/localize` is in.
- **Dev-only version badge** — lands once `environment.ts` per ADR-0018 is wired up.

Both belong in the footer; the layout already has slots for them (center / right groupings).

## Accessibility

- `<footer aria-label="Page footer">` landmark.
- Inline text links inside `<nav aria-label="Legal">` with hover underline + visible focus ring (brand primary, 4 px offset). Inline-link exception applies to the 44×44 touch target (ADR-0016).
- Dark mode: white → `dark:bg-gray-900`, gray-500 text → `dark:text-gray-400`, brand-primary-500 hover → `dark:hover:text-brand-primary-300`.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**36 / 36 specs**, +3 for the footer).
- [x] Production build: **121 kB gzip initial** (unchanged from main).
- [ ] Manual: footer pinned at the bottom in every viewport size; sidebar height tracks shell-body so the collapse button sits just above it.
- [ ] Manual: both `/accessibility` and `/accessibilite` links navigate correctly and get `aria-current="page"` when active.
- [ ] Manual: dark mode → footer surface flips with the rest of the shell.
- [ ] Manual: keyboard — Tab into the footer reaches each link, focus ring is visible, Shift+Tab walks back out.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #88
This commit was merged in pull request #88.
This commit is contained in:
2026-05-11 11:07:36 +02:00
parent 3a0a9c700d
commit 8b986f3dc3
9 changed files with 95 additions and 41 deletions
+1
View File
@@ -6,3 +6,4 @@
<router-outlet /> <router-outlet />
</main> </main>
</div> </div>
<app-footer />
+2 -1
View File
@@ -10,7 +10,7 @@ describe('App', () => {
}).compileComponents(); }).compileComponents();
}); });
it('renders the layout shell — skip link, header, sidebar, main', async () => { it('renders the layout shell — skip link, header, sidebar, main, footer', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
@@ -18,5 +18,6 @@ describe('App', () => {
expect(root.querySelector('app-header')).not.toBeNull(); expect(root.querySelector('app-header')).not.toBeNull();
expect(root.querySelector('app-sidebar')).not.toBeNull(); expect(root.querySelector('app-sidebar')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull(); expect(root.querySelector('main#main-content')).not.toBeNull();
expect(root.querySelector('app-footer')).not.toBeNull();
}); });
}); });
+2 -1
View File
@@ -2,10 +2,11 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { Header } from './components/header/header'; import { Header } from './components/header/header';
import { Sidebar } from './components/sidebar/sidebar'; import { Sidebar } from './components/sidebar/sidebar';
import { Footer } from './components/footer/footer';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, Header, Sidebar], imports: [RouterOutlet, Header, Sidebar, Footer],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -0,0 +1,26 @@
<footer
aria-label="Page footer"
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
>
<p>&copy; {{ year }} APF France handicap</p>
<nav aria-label="Legal">
<ul class="flex items-center gap-4">
<li>
<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"
>Accessibility</a
>
</li>
<li aria-hidden="true" class="text-gray-300 dark:text-gray-700">|</li>
<li>
<a
routerLink="/accessibilite"
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>
</footer>
@@ -0,0 +1,35 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Footer } from './footer';
describe('Footer', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Footer],
providers: [provideRouter([])],
}).compileComponents();
});
it('exposes a footer landmark', async () => {
const fixture = TestBed.createComponent(Footer);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('footer[aria-label="Page footer"]')).not.toBeNull();
});
it('shows the APF copyright with the current year', async () => {
const fixture = TestBed.createComponent(Footer);
await fixture.whenStable();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('APF France handicap');
expect(text).toContain(String(new Date().getFullYear()));
});
it('renders both accessibility statement links (EN + FR)', 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();
});
});
@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
/**
* 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.
*
* 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.
*/
@Component({
selector: 'app-footer',
imports: [RouterLink],
templateUrl: './footer.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Footer {
protected readonly year = new Date().getFullYear();
}
@@ -1,5 +1,5 @@
<header <header
class="flex h-16 items-center border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900" class="flex h-16 shrink-0 items-center border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
> >
<div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()"> <div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()">
<a <a
@@ -52,41 +52,6 @@
</nav> </nav>
<div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800"> <div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800">
<nav aria-label="Accessibility" class="mb-3">
<ul class="flex flex-col gap-1">
<li>
<a
routerLink="/accessibility"
routerLinkActive="sidebar__link--active"
ariaCurrentWhenActive="page"
class="sidebar__link"
[attr.aria-label]="collapsed() ? 'Accessibility (English)' : null"
[title]="collapsed() ? 'Accessibility (English)' : ''"
>
<app-icon name="accessibility" />
@if (!collapsed()) {
<span class="sidebar__label">Accessibility</span>
}
</a>
</li>
<li>
<a
routerLink="/accessibilite"
routerLinkActive="sidebar__link--active"
ariaCurrentWhenActive="page"
class="sidebar__link"
[attr.aria-label]="collapsed() ? 'Accessibilité (Français)' : null"
[title]="collapsed() ? 'Accessibilité (Français)' : ''"
>
<app-icon name="accessibility" />
@if (!collapsed()) {
<span class="sidebar__label">Accessibilité</span>
}
</a>
</li>
</ul>
</nav>
@if (!collapsed()) { @if (!collapsed()) {
<p <p
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400" class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
@@ -20,12 +20,12 @@ describe('Sidebar', () => {
expect(text).toContain('Communication'); expect(text).toContain('Communication');
}); });
it('renders the FR + EN accessibility links', async () => { it('does not render the accessibility links (now lives in the footer)', async () => {
const fixture = TestBed.createComponent(Sidebar); const fixture = TestBed.createComponent(Sidebar);
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(); expect(root.querySelector('a[href="/accessibility"]')).toBeNull();
expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull(); expect(root.querySelector('a[href="/accessibilite"]')).toBeNull();
}); });
it('starts expanded and collapses on toggle, persisting to localStorage', async () => { it('starts expanded and collapses on toggle, persisting to localStorage', async () => {