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:
@@ -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