5795e3c19a
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.
54 lines
1.6 KiB
TypeScript
54 lines
1.6 KiB
TypeScript
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;
|
|
}
|
|
}
|