feat(portal-shell): light / dark / auto theme switcher #86
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -41,3 +43,52 @@ describe('LayoutStateService', () => {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPersistedCollapsed(): boolean {
|
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 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+20
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user