feat(portal-shell): app-shell layout with collapsable sidebar
CI / commits (pull_request) Successful in 1m39s
CI / scan (pull_request) Successful in 1m51s
CI / check (pull_request) Successful in 2m10s
CI / a11y (pull_request) Successful in 1m9s
CI / perf (pull_request) Successful in 2m7s

Switch the SPA from header+main+footer to a fixed header above a
sidebar/main split. The sidebar carries grouped navigation, can be
collapsed to an icon-only rail (state persisted in localStorage), and
hosts the accessibility-statement links the footer used to own. Adds
brand palette tokens (primary teal #12546c, accent orange #f7a919) as
Tailwind v4 @theme entries, an <app-icon> façade over lucide-angular
so the future icomoon sprite migration is a single-file change, and
the APF logo asset under public/logos/.

Menu data is static for v1 — items point to '#' placeholders, with the
Dashboard entry routerLink-bound so the active-state styling is
visible on the home page. The MenuItem shape already carries an
optional requiredPermissions field so the permission-aware filter (to
land alongside the auth flow per ADR-0009) plugs in without
restructuring. WCAG-relevant choices preserved: skip-link, role/aria
landmarks, aria-current="page" on active links, aria-expanded on the
toggle, prefers-reduced-motion respected on width transitions, and a
44px minimum hit-target on every interactive element (ADR-0016).

Footer component removed — its links migrated to the sidebar bottom.
This commit is contained in:
Julien Gautier
2026-05-11 00:17:15 +02:00
parent a463199728
commit c144cdcbd5
21 changed files with 683 additions and 105 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+6 -4
View File
@@ -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>
+21 -5
View File
@@ -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;
}
}
+2 -2
View File
@@ -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();
});
});
+2 -2
View File
@@ -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;
}
}
+43
View File
@@ -8,3 +8,46 @@
*/
@import 'tailwindcss';
/*
* Brand palette — APF charte graphique.
*
* primary: #12546c (dark teal) — header bar, sidebar active items,
* primary buttons, headings on light bg
* accent: #f7a919 (warm orange) — CTAs, highlights, focus rings on
* branded surfaces
*
* The two scales below are hand-tuned (each step roughly halves the
* delta from the previous toward white / black). Tailwind v4 surfaces
* them automatically as utility classes — `bg-brand-primary-700`,
* `text-brand-accent-500`, `ring-brand-primary-500`, etc.
*
* Use the `500` step as the canonical brand value; the lighter / darker
* steps are for hover / focus / muted text without dropping out of the
* brand family.
*/
@theme {
--color-brand-primary-50: #eaf3f7;
--color-brand-primary-100: #cae0e8;
--color-brand-primary-200: #9ac4d2;
--color-brand-primary-300: #5fa2b6;
--color-brand-primary-400: #2f7f96;
--color-brand-primary-500: #12546c;
--color-brand-primary-600: #0f475c;
--color-brand-primary-700: #0c394a;
--color-brand-primary-800: #082a36;
--color-brand-primary-900: #051c24;
--color-brand-primary-950: #020e12;
--color-brand-accent-50: #fef5e0;
--color-brand-accent-100: #fce6b3;
--color-brand-accent-200: #fad17a;
--color-brand-accent-300: #f8bb47;
--color-brand-accent-400: #f7a919;
--color-brand-accent-500: #de9415;
--color-brand-accent-600: #b97a11;
--color-brand-accent-700: #93600d;
--color-brand-accent-800: #6e4709;
--color-brand-accent-900: #4a3006;
--color-brand-accent-950: #271903;
}
+1
View File
@@ -141,6 +141,7 @@
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"lucide-angular": "^1.0.0",
"nestjs-cls": "^6.2.0",
"nestjs-pino": "^4.6.1",
"nestjs-prisma": "^0.27.0",
+15
View File
@@ -107,6 +107,9 @@ importers:
class-validator:
specifier: ^0.15.1
version: 0.15.1
lucide-angular:
specifier: ^1.0.0
version: 1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))
nestjs-cls:
specifier: ^6.2.0
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -7360,6 +7363,12 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide-angular@1.0.0:
resolution: {integrity: sha512-YxCNEXHUz2IzAZIlxU4CkD55ljMjOlm3/am4eqadX/qkFszyGDzZwtbWOP1wj6vlbn/BNL4RhJeXbusLz96ajg==}
peerDependencies:
'@angular/common': 13.x - 21.x
'@angular/core': 13.x - 21.x
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
@@ -18408,6 +18417,12 @@ snapshots:
lru-cache@7.18.3: {}
lucide-angular@1.0.0(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)):
dependencies:
'@angular/common': 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)
'@angular/core': 21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)
tslib: 2.8.1
luxon@3.7.2: {}
magic-string@0.25.7: