feat(portal-shell): app-shell layout with collapsable sidebar (#83)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m37s
CI / check (push) Successful in 2m25s
CI / a11y (push) Successful in 57s
CI / perf (push) Successful in 3m5s

## Summary

- Replace the flat header+main+footer layout with a real app-shell: fixed header on top, collapsable sidebar + scrollable main below. Sidebar state (collapsed / expanded) persists across reloads via `localStorage`.
- Introduce the APF brand palette as Tailwind v4 `@theme` tokens (primary teal `#12546c`, accent orange `#f7a919`) so every utility (`bg-brand-primary-500`, `text-brand-accent-400`, `ring-brand-primary-200`, …) is available from now on.
- Add an `<app-icon>` façade backed by `lucide-angular` for v1. Logical kebab-case names already match the icomoon-sprite convention, so the future migration is a single-file change in `icon.ts` and consumers stay untouched.
- Migrate the accessibility-statement links from the (now-deleted) footer to the bottom of the sidebar.

## Decisions worth flagging

- **Static menu, permission-shaped data.** Items point to `#` placeholders in v1, except *Dashboard* which is `routerLink="/"` so the active-state styling is visible on the home page. The `MenuItem` shape already carries an optional `requiredPermissions: string[]` so the permission-aware filter (PR 2, alongside ADR-0009 auth) plugs in without restructuring.
- **`<app-icon>` over direct lucide imports.** Consumers write `<app-icon name="bell">` rather than importing the lucide pascal-case symbol. When the icomoon sprite lands, only the registry in `icon.ts` changes — templates do not.
- **Sidebar persistence via `localStorage`, not backend.** Zero round-trip, survives reloads, falls back gracefully when storage is blocked (private mode). Eventually mirrored server-side if the user-preferences feature lands.
- **Footer removed entirely.** With the sidebar carrying the FR + EN accessibility-statement links and the role badge, the bottom rail no longer earned its vertical real estate. The version badge moved out for now; it will return as part of a debug/help menu when there's a real release to surface.

## Accessibility (ADR-0016)

- Skip-link preserved (WCAG 2.4.1 *Bypass Blocks*) and restyled in the brand palette.
- Sidebar exposes named landmarks (`<nav aria-label="Sections">`, `<nav aria-label="Accessibility">`) and the collapse button uses `aria-expanded` + a descriptive `aria-label`.
- Active links carry `ariaCurrentWhenActive="page"`.
- All interactive controls (header action buttons, sidebar links/toggle) meet the 44×44 px minimum hit-target.
- Sidebar width transition is skipped under `prefers-reduced-motion: reduce`.
- Lucide SVGs are marked `aria-hidden` (decorative); accessible names live on the parent control.

## Perf (ADR-0017)

- Production build: **100 kB gzip** initial transfer (budget: 300 kB). Lucide imports are tree-shaken — only the ~18 icons actually used ship.

## What this PR explicitly does NOT do

- Wire icon-set migration to icomoon (kept as a deferred swap behind `<app-icon>`).
- Filter the menu by permission (deferred to PR 2 once the auth flow lands).
- Replace the avatar placeholder with a real user menu (waits on ADR-0009).
- Implement the search input behavior (placeholder only; needs a search backend).

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (19 / 19 specs).
- [x] Production build under bundle budgets (100 kB gzip initial).
- [ ] Manual: load `/`, confirm Dashboard appears active in the sidebar, collapse → reload → still collapsed, focus the address bar then Tab → skip-link visible, keyboard-traverse the sidebar.
- [ ] Manual: `prefers-reduced-motion: reduce` → no width animation when toggling.
- [ ] Manual: zoom to 200 % → no horizontal scroll, header search hides at narrow widths (`md:` breakpoint).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-05-11 00:36:41 +02:00
parent a463199728
commit 3371fbd613
21 changed files with 683 additions and 105 deletions
@@ -0,0 +1,109 @@
<aside
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white"
[class.sidebar--collapsed]="collapsed()"
[attr.aria-label]="'Sidebar navigation'"
>
<nav aria-label="Sections" class="flex-1 overflow-y-auto px-2 py-4">
@for (group of menu; track group.label) {
<div class="mb-4">
@if (!collapsed()) {
<h2 class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-500">
{{ group.label }}
</h2>
}
<ul class="flex flex-col gap-1">
@for (item of group.items; track item.label) {
<li>
@if (item.routerLink) {
<a
[routerLink]="item.routerLink"
routerLinkActive="sidebar__link--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
class="sidebar__link"
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
<app-icon [name]="item.icon" />
@if (!collapsed()) {
<span class="sidebar__label">{{ item.label }}</span>
}
</a>
} @else {
<a
[attr.href]="item.href"
class="sidebar__link"
[attr.aria-label]="collapsed() ? item.label : null"
[title]="collapsed() ? item.label : ''"
>
<app-icon [name]="item.icon" />
@if (!collapsed()) {
<span class="sidebar__label">{{ item.label }}</span>
}
</a>
}
</li>
}
</ul>
</div>
}
</nav>
<div class="border-t border-gray-200 px-2 py-3">
<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()) {
<p class="mb-2 px-3 text-xs text-gray-500" aria-label="Current role: anonymous (signed-out)">
Role: <span class="font-medium text-gray-700">Anonymous</span>
</p>
}
<button
type="button"
(click)="toggle()"
class="sidebar__toggle"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
collapsed() ? 'Expand sidebar' : 'Collapse sidebar'
"
>
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
@if (!collapsed()) {
<span class="sidebar__label">Collapse</span>
}
</button>
</div>
</aside>
@@ -0,0 +1,81 @@
// Sidebar widths are tokens-of-tokens: 16rem expanded gives enough
// room for two-word labels in both English and French; 4rem collapsed
// matches the 44px+ touch-target minimum from ADR-0016 with breathing
// room on each side. The transition is short and animation-friendly —
// users with `prefers-reduced-motion: reduce` get the no-animation
// branch below.
.sidebar {
width: 16rem;
transition: width 0.18s ease-out;
&--collapsed {
width: 4rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sidebar {
transition: none;
}
}
// Link + toggle share the same row geometry so transitions between
// expanded/collapsed don't reflow neighbouring items. Tailwind utility
// classes would also work; an SCSS rule keeps the template terse and
// the hover/focus/active variants colocated.
.sidebar__link,
.sidebar__toggle {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: #374151;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
transition:
background-color 0.12s ease-out,
color 0.12s ease-out;
&:hover {
background-color: #f3f4f6;
color: var(--color-brand-primary-500);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px;
}
}
.sidebar__link--active {
background-color: var(--color-brand-primary-500);
color: #fff;
&:hover {
background-color: var(--color-brand-primary-600);
color: #fff;
}
}
.sidebar__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar--collapsed {
.sidebar__link,
.sidebar__toggle {
justify-content: center;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Sidebar } from './sidebar';
describe('Sidebar', () => {
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Sidebar],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders the three v1 menu groups', async () => {
const fixture = TestBed.createComponent(Sidebar);
await fixture.whenStable();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('General');
expect(text).toContain('Establishments');
expect(text).toContain('Communication');
});
it('renders the FR + EN accessibility links', async () => {
const fixture = TestBed.createComponent(Sidebar);
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();
});
it('starts expanded and collapses on toggle, persisting to localStorage', async () => {
const fixture = TestBed.createComponent(Sidebar);
fixture.detectChanges();
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const aside = root.querySelector('aside');
expect(aside?.classList.contains('sidebar--collapsed')).toBe(false);
const toggle = root.querySelector('button.sidebar__toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
await fixture.whenStable();
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
});
it('restores the collapsed state from localStorage on init', async () => {
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
const fixture = TestBed.createComponent(Sidebar);
fixture.detectChanges();
await fixture.whenStable();
const aside = (fixture.nativeElement as HTMLElement).querySelector('aside');
expect(aside?.classList.contains('sidebar--collapsed')).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from '../icon/icon';
/**
* Primary side navigation.
*
* Two visual modes:
* - expanded (default): icon + label + group titles. ~256 px wide.
* - collapsed: icon-only. ~64 px wide. Group titles hide; labels
* remain in the accessible name of each link via `aria-label`.
*
* The collapsed state is owned here and persisted in `localStorage`
* so a user's preference survives reloads (zero backend roundtrip).
*
* v1 menu is static and points to `#` placeholders — the live routes
* land alongside the auth flow (ADR-0009). The shape is already
* permission-shaped (`requiredPermissions?: string[]`) so PR 2 can
* plug in a filter without restructuring the data. The Dashboard
* entry uses `routerLink="/"` so the active-state styling is visible
* on the home page; the rest are anchors until real routes exist.
*/
export interface MenuItem {
readonly label: string;
readonly icon: IconName;
/** v1: anchor placeholder. v2: Angular route path. */
readonly href?: string;
readonly routerLink?: string;
/** Reserved for PR 2 (permission-aware filtering). */
readonly requiredPermissions?: readonly string[];
}
export interface MenuGroup {
readonly label: string;
readonly items: readonly MenuItem[];
}
const MENU: readonly MenuGroup[] = [
{
label: 'General',
items: [
{ label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' },
{ label: 'Calendar', icon: 'calendar', href: '#' },
],
},
{
label: 'Establishments',
items: [
{ label: 'Directory', icon: 'building-2', href: '#' },
{ label: 'Activities', icon: 'folder', href: '#' },
],
},
{
label: 'Communication',
items: [
{ label: 'Messages', icon: 'mail', href: '#' },
{ label: 'Documents', icon: 'file-text', href: '#' },
],
},
];
const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
@Component({
selector: 'app-sidebar',
imports: [RouterLink, RouterLinkActive, Icon],
templateUrl: './sidebar.html',
styleUrl: './sidebar.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Sidebar {
protected readonly menu = MENU;
protected readonly collapsed = signal(readPersistedCollapsed());
constructor() {
effect(() => {
const value = this.collapsed();
try {
localStorage.setItem(STORAGE_KEY, value ? '1' : '0');
} catch {
// localStorage can throw in private-mode contexts; the
// sidebar still works, the preference just doesn't persist.
}
});
}
protected toggle(): void {
this.collapsed.update((v) => !v);
}
}
function readPersistedCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}