feat(portal-shell): align header logo zone with the sidebar rail #85

Merged
julien merged 1 commits from feat/portal-shell/header-logo-aligned-to-sidebar into main 2026-05-11 01:14:43 +02:00
7 changed files with 243 additions and 92 deletions
@@ -1,65 +1,72 @@
<header class="flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 sm:px-6"> <header class="flex h-16 items-center border-b border-gray-200 bg-white">
<a <div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()">
routerLink="/" <a
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" routerLink="/"
> class="flex h-full 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 [class.justify-center]="collapsed()"
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"
> >
<app-icon name="search" [size]="18" /> <img
<input src="logos/apf-portal.svg"
id="global-search" alt=""
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" aria-hidden="true"
class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white" width="36"
></span> height="36"
</button> class="h-9 w-9 shrink-0"
<button />
type="button" @if (!collapsed()) {
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" <span class="text-base font-semibold sm:text-lg">APF Portal</span>
aria-label="Help" }
> </a>
<app-icon name="circle-help" /> </div>
</button>
<button <div class="flex flex-1 items-center gap-4 px-4 sm:px-6">
type="button" <form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
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" <label class="sr-only" for="global-search">Search the portal</label>
aria-label="Settings" <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"
<app-icon name="settings" /> >
</button> <app-icon name="search" [size]="18" />
<span <input
aria-label="User menu (coming soon)" id="global-search"
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" type="search"
> placeholder="Search…"
? class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none"
</span> />
</nav> </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>
</div>
</header> </header>
@@ -0,0 +1,29 @@
// Logo zone widths kept in lock-step with `sidebar.scss` (16rem
// expanded / 4rem collapsed). When the sidebar toggles, the rail
// below this zone reaches exactly the same boundary, so the brand
// stays flush with the navigation column. If those widths ever
// change, update both files together — extracting a shared SCSS
// variable would be the right move only if a third surface needs the
// same coupling.
.header__logo-zone {
display: flex;
align-items: center;
flex-shrink: 0;
width: 16rem;
height: 100%;
padding: 0 0.75rem;
border-right: 1px solid #e5e7eb;
transition: width 0.18s ease-out;
&--collapsed {
width: 4rem;
padding: 0 0.5rem;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.header__logo-zone {
transition: none;
}
}
@@ -1,9 +1,11 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { Header } from './header'; import { Header } from './header';
import { LayoutStateService } from '../../state/layout-state.service';
describe('Header', () => { describe('Header', () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Header], imports: [Header],
providers: [provideRouter([])], providers: [provideRouter([])],
@@ -45,4 +47,29 @@ describe('Header', () => {
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
}); });
it('renders the logo zone, expanded with wordmark by default', async () => {
const fixture = TestBed.createComponent(Header);
fixture.detectChanges();
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const zone = root.querySelector('.header__logo-zone');
expect(zone).not.toBeNull();
expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(false);
expect(root.textContent ?? '').toContain('APF Portal');
});
it('collapses the logo zone and hides the wordmark when the sidebar is collapsed', async () => {
const layout = TestBed.inject(LayoutStateService);
layout.setSidebarCollapsed(true);
const fixture = TestBed.createComponent(Header);
fixture.detectChanges();
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const zone = root.querySelector('.header__logo-zone');
expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(true);
const wordmark = root.querySelector('a[href="/"] span');
expect(wordmark).toBeNull();
});
}); });
@@ -1,12 +1,17 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { Icon } from '../icon/icon'; import { Icon } from '../icon/icon';
import { LayoutStateService } from '../../state/layout-state.service';
/** /**
* Top-of-page banner. * Top-of-page banner.
* *
* v1 layout (left → right): * v1 layout (left → right):
* - Brand: APF logo + portal name, linking to "/". * - Logo zone — sized to the sidebar rail so the brand sits flush
* above the navigation column. When the sidebar collapses, the
* logo zone shrinks too and the wordmark hides, leaving just the
* glyph centered above the icon-only rail. Driven by
* `LayoutStateService`.
* - Global search input (placeholder; wired to a real search service * - Global search input (placeholder; wired to a real search service
* once we have content to query). * once we have content to query).
* - Action cluster: notifications, help, settings, and an avatar * - Action cluster: notifications, help, settings, and an avatar
@@ -20,6 +25,11 @@ import { Icon } from '../icon/icon';
selector: 'app-header', selector: 'app-header',
imports: [RouterLink, Icon], imports: [RouterLink, Icon],
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class Header {} export class Header {
private readonly layout = inject(LayoutStateService);
protected readonly collapsed = this.layout.sidebarCollapsed;
}
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from '../icon/icon'; import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService } from '../../state/layout-state.service';
/** /**
* Primary side navigation. * Primary side navigation.
@@ -10,8 +11,9 @@ import { Icon, type IconName } from '../icon/icon';
* - collapsed: icon-only. ~64 px wide. Group titles hide; labels * - collapsed: icon-only. ~64 px wide. Group titles hide; labels
* remain in the accessible name of each link via `aria-label`. * remain in the accessible name of each link via `aria-label`.
* *
* The collapsed state is owned here and persisted in `localStorage` * The collapsed state is owned by `LayoutStateService` (shell-level
* so a user's preference survives reloads (zero backend roundtrip). * shared state) so the header's logo zone can size itself to match
* the sidebar rail.
* *
* v1 menu is static and points to `#` placeholders — the live routes * v1 menu is static and points to `#` placeholders — the live routes
* land alongside the auth flow (ADR-0009). The shape is already * land alongside the auth flow (ADR-0009). The shape is already
@@ -60,8 +62,6 @@ const MENU: readonly MenuGroup[] = [
}, },
]; ];
const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
imports: [RouterLink, RouterLinkActive, Icon], imports: [RouterLink, RouterLinkActive, Icon],
@@ -70,30 +70,12 @@ const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class Sidebar { export class Sidebar {
protected readonly menu = MENU; private readonly layout = inject(LayoutStateService);
protected readonly collapsed = signal(readPersistedCollapsed());
constructor() { protected readonly menu = MENU;
effect(() => { protected readonly collapsed = this.layout.sidebarCollapsed;
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 { protected toggle(): void {
this.collapsed.update((v) => !v); this.layout.toggleSidebar();
}
}
function readPersistedCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
} }
} }
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { LayoutStateService } from './layout-state.service';
describe('LayoutStateService', () => {
beforeEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
it('defaults to expanded when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(false);
});
it('initialises from the persisted preference', () => {
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(true);
});
it('toggleSidebar flips the value and persists it', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(false);
service.toggleSidebar();
expect(service.sidebarCollapsed()).toBe(true);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
service.toggleSidebar();
expect(service.sidebarCollapsed()).toBe(false);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0');
});
it('setSidebarCollapsed writes the explicit value', () => {
const service = TestBed.inject(LayoutStateService);
service.setSidebarCollapsed(true);
expect(service.sidebarCollapsed()).toBe(true);
TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
});
});
@@ -0,0 +1,53 @@
import { Injectable, effect, signal } from '@angular/core';
/**
* Shell-level layout state shared by the header and the sidebar.
*
* Currently a single piece of state: whether the sidebar is collapsed
* to its icon-only rail. The collapsed state is persisted in
* `localStorage` so it survives reloads without a backend round-trip.
*
* Lives outside of `<app-sidebar>` so that `<app-header>` can also
* read it — that's what lets the header's logo zone size itself to
* match the sidebar width, and stay perfectly aligned with the rail
* below it. As more cross-cutting shell state lands (density, theme,
* panel pinning, …), it joins here rather than spreading across
* sibling components.
*/
const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
@Injectable({ providedIn: 'root' })
export class LayoutStateService {
private readonly _sidebarCollapsed = signal(readPersistedCollapsed());
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly();
constructor() {
effect(() => {
const value = this._sidebarCollapsed();
try {
localStorage.setItem(STORAGE_KEY, value ? '1' : '0');
} catch {
// localStorage can throw in private-mode contexts; the UI
// still works, the preference just doesn't persist.
}
});
}
toggleSidebar(): void {
this._sidebarCollapsed.update((v) => !v);
}
setSidebarCollapsed(value: boolean): void {
this._sidebarCollapsed.set(value);
}
}
function readPersistedCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}