feat(portal-shell): app-shell layout with collapsable sidebar (#83)
## 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:
@@ -1,6 +1,8 @@
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<app-header />
|
||||
<main id="main-content" tabindex="-1" class="flex-1">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-footer />
|
||||
<div class="shell-body">
|
||||
<app-sidebar />
|
||||
<main id="main-content" tabindex="-1" class="shell-main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
// Page-level layout: header + main + footer in a flex column so the
|
||||
// footer sticks to the viewport bottom even when the main content is
|
||||
// short. Driven on the host so it covers the whole document.
|
||||
// App shell: fixed-height header on top, sidebar + main beside each
|
||||
// other below, both filling the remaining viewport height. The sidebar
|
||||
// owns its own scrolling (long menus don't push the header off-screen);
|
||||
// the main pane scrolls independently so opening a long page never
|
||||
// scrolls the sidebar out of view.
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
|
||||
@@ -17,7 +33,7 @@
|
||||
left: 0.75rem;
|
||||
z-index: 50;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1d4ed8;
|
||||
background: var(--color-brand-primary-500);
|
||||
color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
@@ -26,7 +42,7 @@
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
top: 0.5rem;
|
||||
outline: 2px solid #93c5fd;
|
||||
outline: 2px solid var(--color-brand-accent-300);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ describe('App', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('renders the layout shell — skip link, header, main, footer', async () => {
|
||||
it('renders the layout shell — skip link, header, sidebar, main', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('a.skip-link')).not.toBeNull();
|
||||
expect(root.querySelector('app-header')).not.toBeNull();
|
||||
expect(root.querySelector('app-sidebar')).not.toBeNull();
|
||||
expect(root.querySelector('main#main-content')).not.toBeNull();
|
||||
expect(root.querySelector('app-footer')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { Header } from './components/header/header';
|
||||
import { Footer } from './components/footer/footer';
|
||||
import { Sidebar } from './components/sidebar/sidebar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, Header, Footer],
|
||||
imports: [RouterOutlet, Header, Sidebar],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<footer class="mt-auto border-t border-gray-200 bg-gray-50 px-6 py-6">
|
||||
<div
|
||||
class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 text-sm text-gray-600 sm:flex-row"
|
||||
>
|
||||
<nav aria-label="Footer">
|
||||
<ul class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<li>
|
||||
<a
|
||||
routerLink="/accessibility"
|
||||
class="hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-700"
|
||||
>Accessibility</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
routerLink="/accessibilite"
|
||||
class="hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-700"
|
||||
>Accessibilité</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p class="text-xs text-gray-500">v{{ version }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,27 +0,0 @@
|
||||
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('renders both accessibility statement links (FR + EN)', 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();
|
||||
});
|
||||
|
||||
it('shows the version badge', async () => {
|
||||
const fixture = TestBed.createComponent(Footer);
|
||||
await fixture.whenStable();
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('vdev');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Bottom-of-page utility links. v1 ships:
|
||||
* - Accessibility statement link (RGAA-mandated, both locales) per
|
||||
* ADR-0016.
|
||||
* - Build/version badge for support triage.
|
||||
*
|
||||
* Same app-level placement as the header — promotion to
|
||||
* libs/shared/ui/ when a second consumer materialises.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './footer.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Footer {
|
||||
// Hard-coded for now; swapped to a build-time injection once the
|
||||
// env-config story (Angular `environment.ts`) lands.
|
||||
protected readonly version = 'dev';
|
||||
}
|
||||
@@ -1,11 +1,65 @@
|
||||
<header class="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between">
|
||||
<a
|
||||
routerLink="/"
|
||||
class="text-lg font-semibold text-gray-900 transition-colors hover:text-blue-700 focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-blue-700"
|
||||
<header class="flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 sm:px-6">
|
||||
<a
|
||||
routerLink="/"
|
||||
class="flex shrink-0 items-center gap-3 rounded-md text-brand-primary-500 transition-colors hover:text-brand-primary-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500"
|
||||
>
|
||||
<img
|
||||
src="logos/apf-portal.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="36"
|
||||
height="36"
|
||||
class="h-9 w-9"
|
||||
/>
|
||||
<span class="text-base font-semibold sm:text-lg">APF Portal</span>
|
||||
</a>
|
||||
|
||||
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
|
||||
<label class="sr-only" for="global-search">Search the portal</label>
|
||||
<div
|
||||
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200"
|
||||
>
|
||||
APF Portal
|
||||
</a>
|
||||
<nav aria-label="Primary"></nav>
|
||||
</div>
|
||||
<app-icon name="search" [size]="18" />
|
||||
<input
|
||||
id="global-search"
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav aria-label="Primary" class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<app-icon name="bell" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
||||
aria-label="Help"
|
||||
>
|
||||
<app-icon name="circle-help" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<app-icon name="settings" />
|
||||
</button>
|
||||
<span
|
||||
aria-label="User menu (coming soon)"
|
||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Header', () => {
|
||||
await fixture.whenStable();
|
||||
const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.textContent?.trim()).toBe('APF Portal');
|
||||
expect(link?.textContent?.trim()).toContain('APF Portal');
|
||||
});
|
||||
|
||||
it('exposes a primary navigation landmark', async () => {
|
||||
@@ -24,4 +24,25 @@ describe('Header', () => {
|
||||
const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]');
|
||||
expect(nav).not.toBeNull();
|
||||
});
|
||||
|
||||
it('exposes a search landmark with a labelled input', async () => {
|
||||
const fixture = TestBed.createComponent(Header);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('form[role="search"]')).not.toBeNull();
|
||||
const input = root.querySelector('input#global-search') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.type).toBe('search');
|
||||
const label = root.querySelector('label[for="global-search"]');
|
||||
expect(label).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the notifications / help / settings action buttons', async () => {
|
||||
const fixture = TestBed.createComponent(Header);
|
||||
await fixture.whenStable();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
|
||||
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
|
||||
expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon } from '../icon/icon';
|
||||
|
||||
/**
|
||||
* Top-of-page banner. v1 holds only the brand link to "/" and the
|
||||
* primary navigation landmark — the live navigation grows once we
|
||||
* have real authenticated pages (per ADR-0009).
|
||||
* Top-of-page banner.
|
||||
*
|
||||
* Lives at app level (not in libs/shared/ui/) on purpose: there is
|
||||
* one consumer (portal-shell). Promotion to the shared lib happens
|
||||
* when a second app needs it.
|
||||
* v1 layout (left → right):
|
||||
* - Brand: APF logo + portal name, linking to "/".
|
||||
* - Global search input (placeholder; wired to a real search service
|
||||
* once we have content to query).
|
||||
* - Action cluster: notifications, help, settings, and an avatar
|
||||
* placeholder. Real menus are added once the auth flow lands
|
||||
* (ADR-0009).
|
||||
*
|
||||
* Lives at app level (not in libs/shared/ui/) on purpose: one consumer
|
||||
* for now. Promotion when a second app needs it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
imports: [RouterLink],
|
||||
imports: [RouterLink, Icon],
|
||||
templateUrl: './header.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Icon } from './icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon-test-host',
|
||||
imports: [Icon],
|
||||
template: `<app-icon [name]="name" [size]="size" [strokeWidth]="strokeWidth" />`,
|
||||
})
|
||||
class IconTestHost {
|
||||
name: 'home' | 'bell' = 'home';
|
||||
size = 20;
|
||||
strokeWidth = 1.5;
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [IconTestHost] }).compileComponents();
|
||||
});
|
||||
|
||||
it('renders a lucide <svg> for the given name', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
await fixture.whenStable();
|
||||
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg?.classList.contains('lucide')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the lucide host as decorative (aria-hidden)', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
await fixture.whenStable();
|
||||
const host = fixture.nativeElement.querySelector(
|
||||
'app-icon lucide-angular',
|
||||
) as HTMLElement | null;
|
||||
expect(host?.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('propagates size and strokeWidth to the underlying <svg>', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
fixture.componentInstance.size = 32;
|
||||
fixture.componentInstance.strokeWidth = 2;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const svg = fixture.nativeElement.querySelector('app-icon svg') as SVGElement | null;
|
||||
expect(svg?.getAttribute('width')).toBe('32');
|
||||
expect(svg?.getAttribute('height')).toBe('32');
|
||||
expect(svg?.getAttribute('stroke-width')).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import {
|
||||
Accessibility,
|
||||
Bell,
|
||||
Building2,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
FileText,
|
||||
Folder,
|
||||
Grid2x2,
|
||||
House,
|
||||
LayoutDashboard,
|
||||
type LucideIconData,
|
||||
LucideAngularModule,
|
||||
Mail,
|
||||
Search,
|
||||
Settings,
|
||||
Store,
|
||||
UsersRound,
|
||||
Wrench,
|
||||
} from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Icon façade for the SPA.
|
||||
*
|
||||
* v1 is backed by `lucide-angular`. The migration to an in-house
|
||||
* icomoon-generated set (sprite SVG under
|
||||
* `apps/portal-shell/public/icons/`) is a single-file change to
|
||||
* THIS component — every consumer just writes `<app-icon name="…">`,
|
||||
* so swapping the implementation never touches them.
|
||||
*
|
||||
* Convention: `name` is the lowercase, kebab-cased "logical" name we
|
||||
* will keep across implementations (`home`, `bar-chart`, etc.). The
|
||||
* registry below maps each logical name to its lucide-angular icon
|
||||
* data. When the icomoon export lands, each name will instead map to
|
||||
* a `<symbol id="icon-…">` in the sprite — but the template-side
|
||||
* contract stays identical.
|
||||
*/
|
||||
|
||||
// Adding an icon to the registry: import the lucide pascal-case name
|
||||
// above (Tailwind eslint sorts the import block), then add a single
|
||||
// `'kebab-name': PascalImport,` line below. Both lists are alphabetical
|
||||
// so contributors don't have to think about placement.
|
||||
const ICON_REGISTRY = {
|
||||
accessibility: Accessibility,
|
||||
bell: Bell,
|
||||
'building-2': Building2,
|
||||
calendar: Calendar,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-right': ChevronRight,
|
||||
'circle-help': CircleHelp,
|
||||
'file-text': FileText,
|
||||
folder: Folder,
|
||||
'grid-2x2': Grid2x2,
|
||||
home: House,
|
||||
'layout-dashboard': LayoutDashboard,
|
||||
mail: Mail,
|
||||
search: Search,
|
||||
settings: Settings,
|
||||
store: Store,
|
||||
'users-round': UsersRound,
|
||||
wrench: Wrench,
|
||||
} satisfies Record<string, LucideIconData>;
|
||||
|
||||
export type IconName = keyof typeof ICON_REGISTRY;
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon',
|
||||
imports: [LucideAngularModule],
|
||||
template: `<lucide-angular
|
||||
[img]="icon()"
|
||||
[size]="size()"
|
||||
[strokeWidth]="strokeWidth()"
|
||||
aria-hidden="true"
|
||||
/>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Icon {
|
||||
readonly name = input.required<IconName>();
|
||||
readonly size = input<number>(20);
|
||||
/** Lucide draws with stroke-width 2 by default; 1.5 reads slightly
|
||||
* lighter and matches the visual weight of most icomoon sets. */
|
||||
readonly strokeWidth = input<number>(1.5);
|
||||
|
||||
protected readonly icon = computed(() => ICON_REGISTRY[this.name()]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user