feat(portal-shell): thin full-width footer with copyright + a11y links #88
@@ -6,3 +6,4 @@
|
|||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<app-footer />
|
||||||
|
|||||||
@@ -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,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>© {{ 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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user