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,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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user