feat(portal-shell): align header logo zone with the sidebar rail (#85)
## Summary - Split the header into two zones: a **logo zone** whose width tracks the sidebar (16 rem expanded / 4 rem collapsed), and the existing search + actions cluster on the right. - The logo glyph stays at both widths; the "APF Portal" wordmark hides when the rail collapses, mirroring the sidebar's icon-only mode. - Promote the sidebar's `collapsed` state to a new [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) (Signals + `localStorage`) so header and sidebar share a single source of truth. ## Why a service rather than prop-drilling Two reasons: 1. The state is **shell-level**, not sidebar-internal — the header now reads it, and future surfaces (right rail, breadcrumbs, command palette) will too. Passing it down via inputs would force `<app-root>` to act as the owner and turn every intermediate component into a relay. 2. As we add other cross-cutting shell state (density, theme, panel pinning, RTL), they all belong in the same place. `LayoutStateService` is the natural collector and stays trivial as long as we don't over-broaden it. v1 ships with one signal — keeping it narrow. The service is `providedIn: 'root'` (singleton), Signals-only, and owns localStorage persistence — same UX as before, just relocated. ## Decisions worth flagging - **Widths stay duplicated between `header.scss` and `sidebar.scss`** (16 rem / 4 rem). Extracting a shared SCSS variable would be premature — only two callers, and the coupling is loud (cross-file comment in `header.scss`). If a third surface needs the same widths, we promote to a shared token. - **Border continuity.** The logo zone's right border and the sidebar's right border share the same x coordinate, so they read as one continuous vertical separator running the full shell height. Same `#e5e7eb` so the seam is invisible. - **Width transition** matches the sidebar's (`0.18s ease-out`) and is skipped under `prefers-reduced-motion: reduce`. - **Sidebar component lost its private state.** Its own signal + effect + storage glue is gone; it delegates reads to the service and the toggle click to `layout.toggleSidebar()`. Net `sidebar.ts` shrunk by ~15 lines. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (25 / 25 specs, +6 for the new service spec + header zone tests). - [x] Production build under bundle budgets (100 kB gzip initial — +0.2 kB vs main). - [ ] Manual: load `/`, confirm the logo zone's right edge aligns exactly with the sidebar's right edge at both widths. - [ ] Manual: toggle the sidebar → both columns animate in sync; wordmark hides; logo glyph stays centered in the 4 rem zone. - [ ] Manual: reload after toggling → both header and sidebar restore to the persisted state. - [ ] Manual: `prefers-reduced-motion: reduce` → no width animation in either zone. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
@@ -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 { Icon, type IconName } from '../icon/icon';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
|
||||
/**
|
||||
* Primary side navigation.
|
||||
@@ -10,8 +11,9 @@ import { Icon, type IconName } from '../icon/icon';
|
||||
* - 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).
|
||||
* The collapsed state is owned by `LayoutStateService` (shell-level
|
||||
* 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
|
||||
* 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({
|
||||
selector: 'app-sidebar',
|
||||
imports: [RouterLink, RouterLinkActive, Icon],
|
||||
@@ -70,30 +70,12 @@ const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Sidebar {
|
||||
protected readonly menu = MENU;
|
||||
protected readonly collapsed = signal(readPersistedCollapsed());
|
||||
private readonly layout = inject(LayoutStateService);
|
||||
|
||||
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 readonly menu = MENU;
|
||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||
|
||||
protected toggle(): void {
|
||||
this.collapsed.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
this.layout.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user