feat(portal-shell): align header logo zone with the sidebar rail
Promote the sidebar's collapsed state to a shell-level `LayoutStateService` (signal + localStorage persistence) so the header can read it. Split the header into two zones: a logo zone whose width matches the sidebar (16rem expanded / 4rem collapsed) and the existing search + actions cluster on the right. The logo glyph stays visible at both widths; the "APF Portal" wordmark hides when the rail is collapsed. The logo-zone right border and the sidebar right border share the same x coordinate, so they read as one continuous column separator running the full height of the shell. The width transition matches the sidebar's (0.18s ease-out) and is skipped under `prefers-reduced-motion: reduce`. Side-effect: cleans up `<app-sidebar>` — its own signal + effect + storage glue is gone; it now just delegates reads and the toggle click to the service. Same UX, single source of truth, ready to absorb future shared shell state (density, theme, ...) without prop-drilling.
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user