feat(portal-shell): light / dark / auto theme switcher #86

Merged
julien merged 1 commits from feat/portal-shell/theme-toggle into main 2026-05-11 03:01:12 +02:00
20 changed files with 505 additions and 76 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+6
View File
@@ -21,6 +21,12 @@
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
background-color: #f9fafb; background-color: #f9fafb;
color: #111827;
:where(.dark) & {
background-color: #0b1220;
color: #e5e7eb;
}
} }
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully // Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
@@ -1,12 +1,14 @@
<header class="flex h-16 items-center border-b border-gray-200 bg-white"> <header
class="flex h-16 items-center border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
>
<div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()"> <div class="header__logo-zone" [class.header__logo-zone--collapsed]="collapsed()">
<a <a
routerLink="/" 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" 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 dark:text-brand-primary-300 dark:hover:text-brand-primary-200"
[class.justify-center]="collapsed()" [class.justify-center]="collapsed()"
> >
<img <img
src="logos/apf-portal.svg" src="logos/apf-small.png"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width="36" width="36"
@@ -23,14 +25,14 @@
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()"> <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> <label class="sr-only" for="global-search">Search the portal</label>
<div <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" 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 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
> >
<app-icon name="search" [size]="18" /> <app-icon name="search" [size]="18" />
<input <input
id="global-search" id="global-search"
type="search" type="search"
placeholder="Search…" placeholder="Search…"
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none" class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
/> />
</div> </div>
</form> </form>
@@ -38,25 +40,26 @@
<nav aria-label="Primary" class="ml-auto flex items-center gap-1"> <nav aria-label="Primary" class="ml-auto flex items-center gap-1">
<button <button
type="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" 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 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Notifications" aria-label="Notifications"
> >
<app-icon name="bell" /> <app-icon name="bell" />
<span <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" class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white dark:ring-gray-900"
></span> ></span>
</button> </button>
<button <button
type="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" 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 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Help" aria-label="Help"
> >
<app-icon name="circle-help" /> <app-icon name="circle-help" />
</button> </button>
<app-theme-switcher />
<button <button
type="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" 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 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
aria-label="Settings" aria-label="Settings"
> >
<app-icon name="settings" /> <app-icon name="settings" />
@@ -15,6 +15,10 @@
border-right: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
:where(.dark) & {
border-right-color: #1f2937;
}
&--collapsed { &--collapsed {
width: 4rem; width: 4rem;
padding: 0 0.5rem; padding: 0 0.5rem;
@@ -48,6 +48,12 @@ describe('Header', () => {
expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
}); });
it('embeds the theme switcher', async () => {
const fixture = TestBed.createComponent(Header);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
});
it('renders the logo zone, expanded with wordmark by default', async () => { it('renders the logo zone, expanded with wordmark by default', async () => {
const fixture = TestBed.createComponent(Header); const fixture = TestBed.createComponent(Header);
fixture.detectChanges(); fixture.detectChanges();
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } 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 { ThemeSwitcher } from '../theme-switcher/theme-switcher';
import { LayoutStateService } from '../../state/layout-state.service'; import { LayoutStateService } from '../../state/layout-state.service';
/** /**
@@ -23,7 +24,7 @@ import { LayoutStateService } from '../../state/layout-state.service';
*/ */
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
imports: [RouterLink, Icon], imports: [RouterLink, Icon, ThemeSwitcher],
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.scss', styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -4,6 +4,8 @@ import {
Bell, Bell,
Building2, Building2,
Calendar, Calendar,
Check,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
@@ -15,9 +17,12 @@ import {
type LucideIconData, type LucideIconData,
LucideAngularModule, LucideAngularModule,
Mail, Mail,
Monitor,
Moon,
Search, Search,
Settings, Settings,
Store, Store,
Sun,
UsersRound, UsersRound,
Wrench, Wrench,
} from 'lucide-angular'; } from 'lucide-angular';
@@ -48,6 +53,8 @@ const ICON_REGISTRY = {
bell: Bell, bell: Bell,
'building-2': Building2, 'building-2': Building2,
calendar: Calendar, calendar: Calendar,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft, 'chevron-left': ChevronLeft,
'chevron-right': ChevronRight, 'chevron-right': ChevronRight,
'circle-help': CircleHelp, 'circle-help': CircleHelp,
@@ -57,9 +64,12 @@ const ICON_REGISTRY = {
home: House, home: House,
'layout-dashboard': LayoutDashboard, 'layout-dashboard': LayoutDashboard,
mail: Mail, mail: Mail,
monitor: Monitor,
moon: Moon,
search: Search, search: Search,
settings: Settings, settings: Settings,
store: Store, store: Store,
sun: Sun,
'users-round': UsersRound, 'users-round': UsersRound,
wrench: Wrench, wrench: Wrench,
} satisfies Record<string, LucideIconData>; } satisfies Record<string, LucideIconData>;
@@ -1,5 +1,5 @@
<aside <aside
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white" class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
[class.sidebar--collapsed]="collapsed()" [class.sidebar--collapsed]="collapsed()"
[attr.aria-label]="'Sidebar navigation'" [attr.aria-label]="'Sidebar navigation'"
> >
@@ -7,7 +7,9 @@
@for (group of menu; track group.label) { @for (group of menu; track group.label) {
<div class="mb-4"> <div class="mb-4">
@if (!collapsed()) { @if (!collapsed()) {
<h2 class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-500"> <h2
class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{{ group.label }} {{ group.label }}
</h2> </h2>
} }
@@ -49,7 +51,7 @@
} }
</nav> </nav>
<div class="border-t border-gray-200 px-2 py-3"> <div class="border-t border-gray-200 px-2 py-3 dark:border-gray-800">
<nav aria-label="Accessibility" class="mb-3"> <nav aria-label="Accessibility" class="mb-3">
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
<li> <li>
@@ -86,8 +88,12 @@
</nav> </nav>
@if (!collapsed()) { @if (!collapsed()) {
<p class="mb-2 px-3 text-xs text-gray-500" aria-label="Current role: anonymous (signed-out)"> <p
Role: <span class="font-medium text-gray-700">Anonymous</span> class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
aria-label="Current role: anonymous (signed-out)"
>
Role:
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span>
</p> </p>
} }
@@ -44,14 +44,27 @@
background-color 0.12s ease-out, background-color 0.12s ease-out,
color 0.12s ease-out; color 0.12s ease-out;
:where(.dark) & {
color: #e5e7eb;
}
&:hover { &:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
color: var(--color-brand-primary-500); color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-brand-primary-500); outline: 2px solid var(--color-brand-primary-500);
outline-offset: 2px; outline-offset: 2px;
:where(.dark) & {
outline-color: var(--color-brand-primary-300);
}
} }
} }
@@ -63,6 +76,15 @@
background-color: var(--color-brand-primary-600); background-color: var(--color-brand-primary-600);
color: #fff; color: #fff;
} }
:where(.dark) & {
background-color: var(--color-brand-primary-400);
&:hover {
background-color: var(--color-brand-primary-300);
color: #0b1620;
}
}
} }
.sidebar__label { .sidebar__label {
@@ -0,0 +1,30 @@
<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 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
[cdkMenuTriggerFor]="themeMenu"
[attr.aria-label]="'Theme: ' + currentLabel() + ' (open menu)'"
>
<app-icon [name]="currentIcon()" />
</button>
<ng-template #themeMenu>
<div cdkMenu class="theme-switcher__menu" role="menu" aria-label="Theme">
@for (option of options; track option.mode) {
<button
type="button"
cdkMenuItem
class="theme-switcher__item"
[class.theme-switcher__item--active]="mode() === option.mode"
[attr.aria-checked]="mode() === option.mode"
role="menuitemradio"
(click)="select(option.mode)"
>
<app-icon [name]="option.icon" />
<span class="theme-switcher__label">{{ option.label }}</span>
@if (mode() === option.mode) {
<app-icon name="check" [size]="16" />
}
</button>
}
</div>
</ng-template>
@@ -0,0 +1,72 @@
// CDK Menu ships no opinions on visual styling — we colour, frame and
// align it here. The width pin keeps the three items aligned even
// when the labels grow under translation. dark-mode surface mirrors
// the rest of the shell (gray-900 background, gray-800 border).
.theme-switcher__menu {
min-width: 11rem;
margin-top: 0.25rem;
padding: 0.25rem;
background-color: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.05);
:where(.dark) & {
background-color: #111827;
border-color: #1f2937;
}
}
.theme-switcher__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
background: transparent;
border: 0;
color: #1f2937;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
transition:
background-color 0.12s ease-out,
color 0.12s ease-out;
:where(.dark) & {
color: #f3f4f6;
}
&:hover {
background-color: #f3f4f6;
color: var(--color-brand-primary-500);
:where(.dark) & {
background-color: #1f2937;
color: var(--color-brand-primary-300);
}
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary-500);
outline-offset: -2px;
}
}
.theme-switcher__label {
flex: 1 1 auto;
}
.theme-switcher__item--active {
font-weight: 600;
color: var(--color-brand-primary-500);
:where(.dark) & {
color: var(--color-brand-primary-300);
}
}
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { LayoutStateService } from '../../state/layout-state.service';
import { ThemeSwitcher } from './theme-switcher';
describe('ThemeSwitcher', () => {
beforeEach(async () => {
localStorage.clear();
document.documentElement.classList.remove('dark');
await TestBed.configureTestingModule({ imports: [ThemeSwitcher] }).compileComponents();
});
it('renders a trigger button announcing the current mode', async () => {
const fixture = TestBed.createComponent(ThemeSwitcher);
await fixture.whenStable();
const button = (fixture.nativeElement as HTMLElement).querySelector(
'button[aria-haspopup="menu"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.getAttribute('aria-label')).toContain('System');
});
it('updates the trigger label and effective theme when a mode is selected', async () => {
const layout = TestBed.inject(LayoutStateService);
const fixture = TestBed.createComponent(ThemeSwitcher);
fixture.detectChanges();
await fixture.whenStable();
layout.setThemeMode('dark');
fixture.detectChanges();
await fixture.whenStable();
const button = (fixture.nativeElement as HTMLElement).querySelector(
'button[aria-haspopup="menu"]',
) as HTMLButtonElement | null;
expect(button?.getAttribute('aria-label')).toContain('Dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
@@ -0,0 +1,55 @@
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
/**
* Light / dark / auto color-scheme switcher rendered in the header.
*
* The trigger is a single icon button whose glyph reflects the
* currently SELECTED mode (sun / moon / monitor — not the effective
* theme), so the user can tell which mode they're in even when `auto`
* happens to resolve to the same scheme as a manual pick. Clicking
* the button opens a CDK-driven menu (proper roving tabindex,
* Escape/click-outside dismissal) with the three options; the
* currently-selected one gets a check mark.
*
* State lives in `LayoutStateService` — this component is a thin view
* over `themeMode()` + `setThemeMode()`.
*/
interface ModeOption {
readonly mode: ThemeMode;
readonly label: string;
readonly icon: IconName;
}
const MODE_OPTIONS: readonly ModeOption[] = [
{ mode: 'light', label: 'Light', icon: 'sun' },
{ mode: 'dark', label: 'Dark', icon: 'moon' },
{ mode: 'auto', label: 'System', icon: 'monitor' },
];
@Component({
selector: 'app-theme-switcher',
imports: [CdkMenu, CdkMenuItem, CdkMenuTrigger, Icon],
templateUrl: './theme-switcher.html',
styleUrl: './theme-switcher.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ThemeSwitcher {
private readonly layout = inject(LayoutStateService);
protected readonly options = MODE_OPTIONS;
protected readonly mode = this.layout.themeMode;
protected readonly currentIcon = computed<IconName>(
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor',
);
protected readonly currentLabel = computed(
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System',
);
protected select(mode: ThemeMode): void {
this.layout.setThemeMode(mode);
}
}
@@ -1,18 +1,26 @@
<article class="mx-auto max-w-3xl px-6 py-12" [attr.lang]="copy().htmlLang"> <article class="mx-auto max-w-3xl px-6 py-12" [attr.lang]="copy().htmlLang">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900">{{ copy().pageTitle }}</h1> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
{{ copy().pageTitle }}
</h1>
<section class="mt-8" aria-labelledby="intro-heading"> <section class="mt-8" aria-labelledby="intro-heading">
<h2 id="intro-heading" class="text-lg font-medium text-gray-900">{{ copy().introTitle }}</h2> <h2 id="intro-heading" class="text-lg font-medium text-gray-900 dark:text-gray-100">
<p class="mt-3 text-base leading-relaxed text-gray-700">{{ copy().introBody }}</p> {{ copy().introTitle }}
</h2>
<p class="mt-3 text-base leading-relaxed text-gray-700 dark:text-gray-300">
{{ copy().introBody }}
</p>
</section> </section>
<section <section
class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4" class="mt-8 rounded-md border-l-4 border-amber-400 bg-amber-50 p-4 dark:border-amber-500 dark:bg-amber-950/40"
aria-labelledby="status-heading" aria-labelledby="status-heading"
> >
<h2 id="status-heading" class="text-base font-medium text-amber-900"> <h2 id="status-heading" class="text-base font-medium text-amber-900 dark:text-amber-200">
{{ copy().panelNoticeTitle }} {{ copy().panelNoticeTitle }}
</h2> </h2>
<p class="mt-2 text-sm leading-relaxed text-amber-900">{{ copy().panelNoticeBody }}</p> <p class="mt-2 text-sm leading-relaxed text-amber-900 dark:text-amber-200">
{{ copy().panelNoticeBody }}
</p>
</section> </section>
</article> </article>
+20 -14
View File
@@ -1,30 +1,36 @@
<section class="mx-auto max-w-3xl px-6 py-12"> <section class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900">Welcome to APF Portal</h1> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
<p class="mt-4 text-base leading-relaxed text-gray-700"> Welcome to APF Portal
</h1>
<p class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300">
This is the placeholder home page for the APF Portal. Real content lands once the comms team and This is the placeholder home page for the APF Portal. Real content lands once the comms team and
the accessibility review panel sign off on the copy. the accessibility review panel sign off on the copy.
</p> </p>
<section <section
aria-labelledby="system-status-heading" aria-labelledby="system-status-heading"
class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm" class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
> >
<h2 id="system-status-heading" class="text-base font-medium text-gray-900">System status</h2> <h2 id="system-status-heading" class="text-base font-medium text-gray-900 dark:text-gray-100">
System status
</h2>
@if (isLoading()) { @if (isLoading()) {
<p class="mt-2 text-sm text-gray-600" aria-live="polite">Checking the backend…</p> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
Checking the backend…
</p>
} @else if (health(); as h) { } @else if (health(); as h) {
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm"> <dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500">Service</dt> <dt class="text-gray-500 dark:text-gray-400">Service</dt>
<dd class="font-medium text-gray-900">{{ h.service }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd>
<dt class="text-gray-500">Version</dt> <dt class="text-gray-500 dark:text-gray-400">Version</dt>
<dd class="font-medium text-gray-900">{{ h.version }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd>
<dt class="text-gray-500">Uptime</dt> <dt class="text-gray-500 dark:text-gray-400">Uptime</dt>
<dd class="font-medium text-gray-900">{{ h.uptimeSeconds }} s</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.uptimeSeconds }} s</dd>
<dt class="text-gray-500">Status</dt> <dt class="text-gray-500 dark:text-gray-400">Status</dt>
<dd class="font-medium text-green-700">{{ h.status }}</dd> <dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd>
</dl> </dl>
} @else if (isError()) { } @else if (isError()) {
<p class="mt-2 text-sm text-amber-700" role="status"> <p class="mt-2 text-sm text-amber-700 dark:text-amber-300" role="status">
Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured. Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
</p> </p>
} }
@@ -4,9 +4,11 @@ import { LayoutStateService } from './layout-state.service';
describe('LayoutStateService', () => { describe('LayoutStateService', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
document.documentElement.classList.remove('dark');
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
describe('sidebar state', () => {
it('defaults to expanded when no preference is persisted', () => { it('defaults to expanded when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService); const service = TestBed.inject(LayoutStateService);
expect(service.sidebarCollapsed()).toBe(false); expect(service.sidebarCollapsed()).toBe(false);
@@ -40,4 +42,53 @@ describe('LayoutStateService', () => {
TestBed.tick(); TestBed.tick();
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
}); });
});
describe('theme state', () => {
it('defaults to auto when no preference is persisted', () => {
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('auto');
});
it('initialises from the persisted theme mode', () => {
localStorage.setItem('portal-shell:theme-mode', 'dark');
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('dark');
});
it('ignores an invalid persisted value and falls back to auto', () => {
localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string);
const service = TestBed.inject(LayoutStateService);
expect(service.themeMode()).toBe('auto');
});
it('setThemeMode persists the choice and toggles the .dark class on <html>', () => {
const service = TestBed.inject(LayoutStateService);
const html = document.documentElement;
service.setThemeMode('dark');
TestBed.tick();
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark');
expect(html.classList.contains('dark')).toBe(true);
expect(service.effectiveTheme()).toBe('dark');
service.setThemeMode('light');
TestBed.tick();
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('light');
expect(html.classList.contains('dark')).toBe(false);
expect(service.effectiveTheme()).toBe('light');
});
it('resolves auto via the system preference (jsdom defaults to light)', () => {
const service = TestBed.inject(LayoutStateService);
service.setThemeMode('auto');
TestBed.tick();
expect(service.themeMode()).toBe('auto');
// jsdom's matchMedia reports no `prefers-color-scheme: dark` match,
// so 'auto' resolves to light. We assert the resolution path —
// not the literal value of the system preference.
expect(service.effectiveTheme()).toBe('light');
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
});
}); });
@@ -1,38 +1,84 @@
import { Injectable, effect, signal } from '@angular/core'; import { DOCUMENT } from '@angular/common';
import { Injectable, computed, effect, inject, signal } from '@angular/core';
/** /**
* Shell-level layout state shared by the header and the sidebar. * Shell-level layout state shared by the header and the sidebar.
* *
* Currently a single piece of state: whether the sidebar is collapsed * v1 surfaces two settings:
* to its icon-only rail. The collapsed state is persisted in * - `sidebarCollapsed`: whether the sidebar is collapsed to its
* `localStorage` so it survives reloads without a backend round-trip. * icon-only rail. The header reads it to size its logo zone in
* lock-step with the sidebar.
* - `themeMode`: the user's color-scheme preference among
* `light` / `dark` / `auto`. `effectiveTheme` resolves it to a
* concrete `'light' | 'dark'` by consulting the system preference
* when in `auto` mode, and reacts to OS-level theme changes.
* A side-effect toggles the `.dark` class on <html> so every
* `dark:` Tailwind utility flips at once.
* *
* Lives outside of `<app-sidebar>` so that `<app-header>` can also * Both pieces of state persist in `localStorage` so the user's
* read it — that's what lets the header's logo zone size itself to * preferences survive a reload without a backend round-trip.
* match the sidebar width, and stay perfectly aligned with the rail *
* below it. As more cross-cutting shell state lands (density, theme, * Lives outside of the components that consume it so multiple
* panel pinning, …), it joins here rather than spreading across * surfaces stay synchronised without prop-drilling, and so the
* sibling components. * service is the natural home for future cross-cutting shell state
* (density, RTL, panel pinning, ...).
*/ */
const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
const THEME_STORAGE_KEY = 'portal-shell:theme-mode';
const DARK_CLASS = 'dark';
export type ThemeMode = 'light' | 'dark' | 'auto';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LayoutStateService { export class LayoutStateService {
private readonly _sidebarCollapsed = signal(readPersistedCollapsed()); private readonly document = inject(DOCUMENT);
private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed());
private readonly _themeMode = signal<ThemeMode>(readPersistedThemeMode());
private readonly _systemPrefersDark = signal(systemPrefersDark(this.document));
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly(); readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly();
readonly themeMode = this._themeMode.asReadonly();
readonly effectiveTheme = computed<'light' | 'dark'>(() => {
const mode = this._themeMode();
if (mode === 'auto') {
return this._systemPrefersDark() ? 'dark' : 'light';
}
return mode;
});
constructor() { constructor() {
effect(() => { effect(() => {
const value = this._sidebarCollapsed(); const value = this._sidebarCollapsed();
try { try {
localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0');
} catch { } catch {
// localStorage can throw in private-mode contexts; the UI // localStorage can throw in private-mode contexts; the UI
// still works, the preference just doesn't persist. // still works, the preference just doesn't persist.
} }
}); });
effect(() => {
const mode = this._themeMode();
try {
localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// See above.
}
});
effect(() => {
const theme = this.effectiveTheme();
const root = this.document.documentElement;
if (theme === 'dark') {
root.classList.add(DARK_CLASS);
} else {
root.classList.remove(DARK_CLASS);
}
});
this.watchSystemPreference();
} }
toggleSidebar(): void { toggleSidebar(): void {
@@ -42,12 +88,47 @@ export class LayoutStateService {
setSidebarCollapsed(value: boolean): void { setSidebarCollapsed(value: boolean): void {
this._sidebarCollapsed.set(value); this._sidebarCollapsed.set(value);
} }
setThemeMode(mode: ThemeMode): void {
this._themeMode.set(mode);
}
private watchSystemPreference(): void {
const mql = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
if (!mql) {
return;
}
const listener = (e: MediaQueryListEvent) => this._systemPrefersDark.set(e.matches);
// `addEventListener` is the modern API; older Safari only exposes
// the deprecated `addListener` — guarded for robustness.
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', listener);
} else {
mql.addListener(listener);
}
}
} }
function readPersistedCollapsed(): boolean { function readPersistedSidebarCollapsed(): boolean {
try { try {
return localStorage.getItem(STORAGE_KEY) === '1'; return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1';
} catch { } catch {
return false; return false;
} }
} }
function readPersistedThemeMode(): ThemeMode {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY);
if (raw === 'light' || raw === 'dark' || raw === 'auto') {
return raw;
}
} catch {
// fall through
}
return 'auto';
}
function systemPrefersDark(doc: Document): boolean {
return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
+10
View File
@@ -9,6 +9,16 @@
@import 'tailwindcss'; @import 'tailwindcss';
/*
* Class-based dark mode. Tailwind v4 default is media-query-driven
* (`prefers-color-scheme`); we override it so a `.dark` class on
* <html> (set by `LayoutStateService` from the user's explicit
* preference, or computed from the system preference in `auto` mode)
* is the single switch. `&:where(.dark, .dark *)` keeps specificity
* neutral so utility ordering doesn't shift.
*/
@custom-variant dark (&:where(.dark, .dark *));
/* /*
* Brand palette — APF charte graphique. * Brand palette — APF charte graphique.
* *
+1
View File
@@ -111,6 +111,7 @@
"webpack-cli": "^5.1.4" "webpack-cli": "^5.1.4"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "~21.2.10",
"@angular/common": "~21.2.0", "@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0", "@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0", "@angular/core": "~21.2.0",
+20
View File
@@ -17,6 +17,9 @@ importers:
.: .:
dependencies: dependencies:
'@angular/cdk':
specifier: ~21.2.10
version: 21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)
'@angular/common': '@angular/common':
specifier: ~21.2.0 specifier: ~21.2.0
version: 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2) version: 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)
@@ -575,6 +578,14 @@ packages:
vitest: vitest:
optional: true optional: true
'@angular/cdk@21.2.10':
resolution: {integrity: sha512-yfCzUFNfeSMNnCkc0P5Pozqz1EViDe9KLPQyHVY/hApsNgBWvckpA+ZEWgKNfAf72f8bUJvZoHejaSMGYrpvuw==}
peerDependencies:
'@angular/common': ^21.0.0 || ^22.0.0
'@angular/core': ^21.0.0 || ^22.0.0
'@angular/platform-browser': ^21.0.0 || ^22.0.0
rxjs: ^6.5.3 || ^7.4.0
'@angular/cli@21.2.10': '@angular/cli@21.2.10':
resolution: {integrity: sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==} resolution: {integrity: sha512-ezf9LM0GgexG2l8ae/uN4fUyxGqeFEH9iu30mUMU5dwow76aK+b4Abuf5eSuR0F8zTLEA3ZUEYywI+gajbAUuA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
@@ -10383,6 +10394,15 @@ snapshots:
- tsx - tsx
- yaml - yaml
'@angular/cdk@21.2.10(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(@angular/platform-browser@21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)))(rxjs@7.8.2)':
dependencies:
'@angular/common': 21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2)
'@angular/core': 21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2)
'@angular/platform-browser': 21.2.12(@angular/common@21.2.12(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.12(@angular/compiler@21.2.12)(rxjs@7.8.2)(zone.js@0.16.2))
parse5: 8.0.1
rxjs: 7.8.2
tslib: 2.8.1
'@angular/cli@21.2.10(@types/node@24.12.3)(chokidar@5.0.0)': '@angular/cli@21.2.10(@types/node@24.12.3)(chokidar@5.0.0)':
dependencies: dependencies:
'@angular-devkit/architect': 0.2102.10(chokidar@5.0.0) '@angular-devkit/architect': 0.2102.10(chokidar@5.0.0)