feat(portal-shell): light / dark / auto theme switcher (#86)
## Summary
- Add a header dropdown letting users pick **light**, **dark**, or **auto** (follow the OS) color schemes. Choice persists in `localStorage`, applies on next reload, and reacts live to OS theme changes when in `auto`.
- Built on `@angular/cdk/menu` for accessible roving focus, escape-to-close, and proper `menuitemradio` semantics on the three options.
- Apply `dark:` variants across the shell (header, sidebar, main bg) and the existing two pages. No semantic-token refactor yet — that belongs in a future ADR (`--color-surface-1`, `--color-text-1`, …).
## Architecture
- [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) grows a `themeMode: signal<'light'|'dark'|'auto'>` alongside the existing `sidebarCollapsed`, plus an `effectiveTheme` computed that resolves `auto` against the system preference. A side-effect toggles the `.dark` class on `<html>`, so every `dark:` Tailwind utility flips at once.
- Tailwind v4 dark mode is rewired to **class-based** via `@custom-variant dark (&:where(.dark, .dark *));` in `styles.css`. This overrides the v4 default (`prefers-color-scheme` only) so the user's explicit override wins over the OS preference.
- The switcher trigger reflects the **selected** mode (sun / moon / monitor), not the effective theme — so users can tell which mode they're in even when `auto` happens to resolve to the same scheme as a manual pick.
## Side-edits in the same PR (already validated in the chat)
- **Logo asset.** Replace `apf-small.svg` (94 kB — a base64-PNG wrapped in SVG markup, not actually vector) with `apf-small.png` (144×144, 7.6 kB after `sharp --kernel lanczos3 --compressionLevel 9`). Header swaps to the PNG. The wide vector `apf-portal.svg` stays around for future surfaces that want the horizontal lockup.
- **Revert FR strings** that crept into the header template — project rule (CLAUDE.md) is English-only for source artefacts; FR localisation will happen properly via `@angular/localize` (separate ADR).
## Decisions worth flagging
- **Dropdown over segmented control.** The CDK Menu pays its weight: accessible by default (proper `aria-haspopup`, focus management, ESC handling, click-outside dismissal), reusable for future header menus (user, language, notifications), and one tidy primitive rather than three competing buttons.
- **`auto` is the default,** not `light`. Most users have an OS-level preference already; respecting it is the least surprising baseline.
- **`<html>.dark` class lives at the root,** not on `<app-root>`. That's the Tailwind convention and it means CDK overlay popups (the menu itself, future dialogs) inherit the right theme without extra wiring.
- **Bundle delta +21 kB gzip** (100 → 121 kB initial). All of it is `@angular/cdk/{menu,overlay,a11y}` and the dark CSS rules. We stay well under the 300 kB budget. The CDK is already on the architecture menu (ADR-0016 — *UI stack: Angular CDK + TailwindCSS*) so this is on-strategy spend.
- **No semantic tokens yet.** The dark variants use raw Tailwind gray ramps (`dark:bg-gray-900`, etc.) instead of a `--color-surface-1` / `--color-text-1` token layer. That keeps the change tractable for now; promotion to semantic tokens deserves its own ADR with the design team in the loop.
## Accessibility (ADR-0016)
- Menu trigger has `aria-haspopup="menu"`, `aria-label` announcing the current mode + "(open menu)".
- Menu uses `role="menu"`, items use `role="menuitemradio"` with `aria-checked` — assistive tech announces the selection state correctly.
- All interactive controls keep the 44×44 px touch target.
- `prefers-reduced-motion: reduce` already covered by the sidebar transitions; theme switcher has no animations of its own.
- Contrast: dark surfaces are gray-900 + gray-800 border / gray-100 text — passes WCAG AA. Brand primary shifted to the `300` step in dark mode so the active states keep contrast against gray-900.
## What this PR explicitly does NOT do
- Tokenise the palette into semantic surface / text / border roles (next iteration, ADR-led).
- Localise UI strings (separate `@angular/localize` ADR + PR).
- Animate the theme transition (FOIT-style flicker on toggle is acceptable in v1; we can soften later with a `color-scheme` CSS transition if it bothers users).
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (**33 / 33 specs**, +8 for the theme work).
- [x] Production build: **121 kB gzip initial** (was 100 kB). Under the 300 kB budget.
- [ ] Manual: toggle each of the three modes → header / sidebar / main / cards switch surface colors instantly; trigger glyph updates.
- [ ] Manual: pick `auto`, change OS theme → UI follows live (Chrome DevTools → Rendering → "Emulate CSS media feature prefers-color-scheme").
- [ ] Manual: reload after each pick → the chosen mode is restored.
- [ ] Manual: keyboard the trigger → ENTER opens menu, arrow keys navigate, ENTER selects, ESC closes; focus returns to the trigger on close.
- [ ] Manual: Lighthouse accessibility on `/` in dark mode — score unchanged from light mode.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
@@ -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()">
|
||||
<a
|
||||
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()"
|
||||
>
|
||||
<img
|
||||
src="logos/apf-portal.svg"
|
||||
src="logos/apf-small.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="36"
|
||||
@@ -23,14 +25,14 @@
|
||||
<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"
|
||||
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" />
|
||||
<input
|
||||
id="global-search"
|
||||
type="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>
|
||||
</form>
|
||||
@@ -38,25 +40,26 @@
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
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>
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
<app-icon name="circle-help" />
|
||||
</button>
|
||||
<app-theme-switcher />
|
||||
<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"
|
||||
>
|
||||
<app-icon name="settings" />
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
border-right: 1px solid #e5e7eb;
|
||||
transition: width 0.18s ease-out;
|
||||
|
||||
:where(.dark) & {
|
||||
border-right-color: #1f2937;
|
||||
}
|
||||
|
||||
&--collapsed {
|
||||
width: 4rem;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
@@ -48,6 +48,12 @@ describe('Header', () => {
|
||||
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 () => {
|
||||
const fixture = TestBed.createComponent(Header);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon } from '../icon/icon';
|
||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,7 @@ import { LayoutStateService } from '../../state/layout-state.service';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
imports: [RouterLink, Icon],
|
||||
imports: [RouterLink, Icon, ThemeSwitcher],
|
||||
templateUrl: './header.html',
|
||||
styleUrl: './header.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Bell,
|
||||
Building2,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
@@ -15,9 +17,12 @@ import {
|
||||
type LucideIconData,
|
||||
LucideAngularModule,
|
||||
Mail,
|
||||
Monitor,
|
||||
Moon,
|
||||
Search,
|
||||
Settings,
|
||||
Store,
|
||||
Sun,
|
||||
UsersRound,
|
||||
Wrench,
|
||||
} from 'lucide-angular';
|
||||
@@ -48,6 +53,8 @@ const ICON_REGISTRY = {
|
||||
bell: Bell,
|
||||
'building-2': Building2,
|
||||
calendar: Calendar,
|
||||
check: Check,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-right': ChevronRight,
|
||||
'circle-help': CircleHelp,
|
||||
@@ -57,9 +64,12 @@ const ICON_REGISTRY = {
|
||||
home: House,
|
||||
'layout-dashboard': LayoutDashboard,
|
||||
mail: Mail,
|
||||
monitor: Monitor,
|
||||
moon: Moon,
|
||||
search: Search,
|
||||
settings: Settings,
|
||||
store: Store,
|
||||
sun: Sun,
|
||||
'users-round': UsersRound,
|
||||
wrench: Wrench,
|
||||
} satisfies Record<string, LucideIconData>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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()"
|
||||
[attr.aria-label]="'Sidebar navigation'"
|
||||
>
|
||||
@@ -7,7 +7,9 @@
|
||||
@for (group of menu; track group.label) {
|
||||
<div class="mb-4">
|
||||
@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 }}
|
||||
</h2>
|
||||
}
|
||||
@@ -49,7 +51,7 @@
|
||||
}
|
||||
</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">
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li>
|
||||
@@ -86,8 +88,12 @@
|
||||
</nav>
|
||||
|
||||
@if (!collapsed()) {
|
||||
<p class="mb-2 px-3 text-xs text-gray-500" aria-label="Current role: anonymous (signed-out)">
|
||||
Role: <span class="font-medium text-gray-700">Anonymous</span>
|
||||
<p
|
||||
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>
|
||||
}
|
||||
|
||||
|
||||
@@ -44,14 +44,27 @@
|
||||
background-color 0.12s ease-out,
|
||||
color 0.12s ease-out;
|
||||
|
||||
:where(.dark) & {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
&: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;
|
||||
|
||||
:where(.dark) & {
|
||||
outline-color: var(--color-brand-primary-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +76,15 @@
|
||||
background-color: var(--color-brand-primary-600);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:where(.dark) & {
|
||||
background-color: var(--color-brand-primary-400);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-primary-300);
|
||||
color: #0b1620;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user