From c144cdcbd5081353247c0fbeebcabed1b3fa73da Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 00:17:15 +0200 Subject: [PATCH] feat(portal-shell): app-shell layout with collapsable sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- apps/portal-shell/public/favicon.ico | Bin 15086 -> 9274 bytes apps/portal-shell/public/logos/apf-portal.svg | 12 ++ apps/portal-shell/src/app/app.html | 10 +- apps/portal-shell/src/app/app.scss | 26 ++++- apps/portal-shell/src/app/app.spec.ts | 4 +- apps/portal-shell/src/app/app.ts | 4 +- .../src/app/components/footer/footer.html | 25 ---- .../src/app/components/footer/footer.spec.ts | 27 ----- .../src/app/components/footer/footer.ts | 23 ---- .../src/app/components/header/header.html | 72 ++++++++++-- .../src/app/components/header/header.spec.ts | 23 +++- .../src/app/components/header/header.ts | 20 ++-- .../src/app/components/icon/icon.spec.ts | 49 ++++++++ .../src/app/components/icon/icon.ts | 88 ++++++++++++++ .../src/app/components/sidebar/sidebar.html | 109 ++++++++++++++++++ .../src/app/components/sidebar/sidebar.scss | 81 +++++++++++++ .../app/components/sidebar/sidebar.spec.ts | 57 +++++++++ .../src/app/components/sidebar/sidebar.ts | 99 ++++++++++++++++ apps/portal-shell/src/styles.css | 43 +++++++ package.json | 1 + pnpm-lock.yaml | 15 +++ 21 files changed, 683 insertions(+), 105 deletions(-) create mode 100644 apps/portal-shell/public/logos/apf-portal.svg delete mode 100644 apps/portal-shell/src/app/components/footer/footer.html delete mode 100644 apps/portal-shell/src/app/components/footer/footer.spec.ts delete mode 100644 apps/portal-shell/src/app/components/footer/footer.ts create mode 100644 apps/portal-shell/src/app/components/icon/icon.spec.ts create mode 100644 apps/portal-shell/src/app/components/icon/icon.ts create mode 100644 apps/portal-shell/src/app/components/sidebar/sidebar.html create mode 100644 apps/portal-shell/src/app/components/sidebar/sidebar.scss create mode 100644 apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts create mode 100644 apps/portal-shell/src/app/components/sidebar/sidebar.ts diff --git a/apps/portal-shell/public/favicon.ico b/apps/portal-shell/public/favicon.ico index 317ebcb2336e0833a22dddf0ab287849f26fda57..696fec8f66bfda95853d2284469248d1ab48fffe 100644 GIT binary patch literal 9274 zcmeI1Yitx%6vt1kSbSiEs3FnR+TB)SBPFeV02->GAJjj`7{6frg0c8O(IQg1t)`LI_z1=ai(Q`7>*@%9E6MLCx|)cVL2!v4fXH?E>8m30EjL76DBw>xOc%8W zXnizDuXP0JB=oP)6OeDBA$qSpL{GGa>Ba(56vY)>cY^V22Y z@|&&swlH0T+<)o$!QJaLezcjEz;{sdPvhDhPW=n9L(RKa%PY0zOynNA#7)e$4O9ug zvs#?b-T3Egi}UximQ&dGDaAwq1|RS$I_Q* z`GoyMzVW3#>9Hak>B@X#IrJ+*zA-&#G)yZU`pz)Jxqm~C4D%IXQ25-|At1{@H~0?J0kp?<8wF2LMF zIyYYLt3E!p!Z*Z@`-)7l^SCDG_7#ZYw&_u<=gWA1o)iJ|tC;^zvzeCbRRa?hSo3&Q zoGbFkUyw`X7%$x1QQK&;ozd7b-H*B^XS*{%t9>VV6}V4V$GNQ_hyt9ehWRcqJX`zO zFSnpqZ^VAyj(gY-`4ln$`2_NodJlrMeAx&u@elJ3_)LPkuYlCE;tKw1upEGV>mTC1 zkSO-SI^PI4)C_T*FV1V_nrSlu{Tui<>B;3h=;OJ54W4>%?Nav;XNJ}#jqwm5pTwQ;^e&)L>YYq6I;fE<%&hCb)T*Ii+{%IxP(fLYG& zDV&j|USmZ)sRG+Zvxj&=>I=pxlUcaW;dxf;HExUN(J);M_BCK^2kRc_e?DIbsgLR` zQ%~#8Al(f9q^vvYNcLvO*U=Lyc??CSs@sDy@ zG5NB^2KPB($NGPn&CZK+;67%q`|Doh%f$8AS*v;Ah(b%$1s}2kbqKXCQNcbZsCX z_+!%B;CsRAbC`Q}8vOOxN9*J}I6(f_YLREx^?$J3E5Ipq`e`CgQj^jNDpz`(%9QSJ zEHyM6x`%tzxQ7ob-OJ-PoiN9VZi(B=hVC(SziAJx=Es}*l}=no$}a&QgQrC46d8Hb zRHp1WcA23QThu(8Je)A>IB9S*mp4fqPG4TGbn4nNrIU@NGjw9RF;1k7`2-vKtg$X^ f>4Z79bh6vXpK3BWjCEuF&>SbbRUS6|X6nBI*Zt%B literal 15086 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 + + + + + + + + + diff --git a/apps/portal-shell/src/app/app.html b/apps/portal-shell/src/app/app.html index 9467553..bd16644 100644 --- a/apps/portal-shell/src/app/app.html +++ b/apps/portal-shell/src/app/app.html @@ -1,6 +1,8 @@ -
- -
- +
+ +
+ +
+
diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss index cee7709..d8810e0 100644 --- a/apps/portal-shell/src/app/app.scss +++ b/apps/portal-shell/src/app/app.scss @@ -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; } } diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts index b5e71e6..1e3cd49 100644 --- a/apps/portal-shell/src/app/app.spec.ts +++ b/apps/portal-shell/src/app/app.spec.ts @@ -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(); }); }); diff --git a/apps/portal-shell/src/app/app.ts b/apps/portal-shell/src/app/app.ts index 5617ede..5f82fdd 100644 --- a/apps/portal-shell/src/app/app.ts +++ b/apps/portal-shell/src/app/app.ts @@ -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, diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html deleted file mode 100644 index f8ea508..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.html +++ /dev/null @@ -1,25 +0,0 @@ - diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts deleted file mode 100644 index abffa02..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.spec.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts deleted file mode 100644 index 91db109..0000000 --- a/apps/portal-shell/src/app/components/footer/footer.ts +++ /dev/null @@ -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'; -} diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index 100ec4b..e8a886f 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -1,11 +1,65 @@ -
-
- + + + APF Portal + + +
+ + +
diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts index b49c058..1054dd3 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -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(); + }); }); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index 7588b85..cfcadae 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -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, }) diff --git a/apps/portal-shell/src/app/components/icon/icon.spec.ts b/apps/portal-shell/src/app/components/icon/icon.spec.ts new file mode 100644 index 0000000..fa8f1de --- /dev/null +++ b/apps/portal-shell/src/app/components/icon/icon.spec.ts @@ -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: ``, +}) +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 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 ', 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'); + }); +}); diff --git a/apps/portal-shell/src/app/components/icon/icon.ts b/apps/portal-shell/src/app/components/icon/icon.ts new file mode 100644 index 0000000..afabafc --- /dev/null +++ b/apps/portal-shell/src/app/components/icon/icon.ts @@ -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 ``, + * 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 `` 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; + +export type IconName = keyof typeof ICON_REGISTRY; + +@Component({ + selector: 'app-icon', + imports: [LucideAngularModule], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Icon { + readonly name = input.required(); + readonly size = input(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(1.5); + + protected readonly icon = computed(() => ICON_REGISTRY[this.name()]); +} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.html b/apps/portal-shell/src/app/components/sidebar/sidebar.html new file mode 100644 index 0000000..cef3013 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.html @@ -0,0 +1,109 @@ +
+ @if (!collapsed()) { +

+ {{ group.label }} +

+ } + +
+ } + + +
+ + + @if (!collapsed()) { +

+ Role: Anonymous +

+ } + + +
+ diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.scss b/apps/portal-shell/src/app/components/sidebar/sidebar.scss new file mode 100644 index 0000000..4f8dece --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.scss @@ -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; + } +} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts new file mode 100644 index 0000000..11bc051 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.spec.ts @@ -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); + }); +}); diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.ts new file mode 100644 index 0000000..46f3c93 --- /dev/null +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.ts @@ -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; + } +} diff --git a/apps/portal-shell/src/styles.css b/apps/portal-shell/src/styles.css index c0fbd2c..2b1d81a 100644 --- a/apps/portal-shell/src/styles.css +++ b/apps/portal-shell/src/styles.css @@ -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; +} diff --git a/package.json b/package.json index d707ae0..fb70ef3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82518b7..7830029 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: