merge: integrate signature UX domain
This commit is contained in:
@@ -47,6 +47,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="site-actions cluster">
|
<div class="site-actions cluster">
|
||||||
|
<app-command-palette></app-command-palette>
|
||||||
<a
|
<a
|
||||||
class="language-switch"
|
class="language-switch"
|
||||||
[routerLink]="navigation.alternateLocaleLink()"
|
[routerLink]="navigation.alternateLocaleLink()"
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-actions app-command-palette {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.site-identity,
|
.site-identity,
|
||||||
.site-nav a,
|
.site-nav a,
|
||||||
.site-actions a,
|
.site-actions a,
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { SITE_CONFIG } from './core/content/site-config';
|
|||||||
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||||
import { LocaleService } from './core/i18n/locale.service';
|
import { LocaleService } from './core/i18n/locale.service';
|
||||||
import { NavigationService } from './core/navigation/navigation.service';
|
import { NavigationService } from './core/navigation/navigation.service';
|
||||||
|
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground],
|
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, CommandPalette],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,53 +1,281 @@
|
|||||||
|
import { ApplicationRef } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { DotBackground } from './dot-background';
|
import { DotBackground } from './dot-background';
|
||||||
|
|
||||||
describe('DotBackground', () => {
|
function mockContext(): CanvasRenderingContext2D {
|
||||||
let component: DotBackground;
|
|
||||||
let fixture: ComponentFixture<DotBackground>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [DotBackground],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DotBackground);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw when destroyed after browser initialization', async () => {
|
|
||||||
const gradient = { addColorStop: vi.fn() };
|
const gradient = { addColorStop: vi.fn() };
|
||||||
const context = {
|
|
||||||
|
return {
|
||||||
clearRect: vi.fn(),
|
clearRect: vi.fn(),
|
||||||
beginPath: vi.fn(),
|
beginPath: vi.fn(),
|
||||||
arc: vi.fn(),
|
arc: vi.fn(),
|
||||||
fill: vi.fn(),
|
fill: vi.fn(),
|
||||||
createRadialGradient: vi.fn(() => gradient),
|
createRadialGradient: vi.fn(() => gradient),
|
||||||
};
|
} as unknown as CanvasRenderingContext2D;
|
||||||
|
}
|
||||||
|
|
||||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
|
||||||
context as unknown as CanvasRenderingContext2D,
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
);
|
configurable: true,
|
||||||
const animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
|
writable: true,
|
||||||
|
value: (query: string): MediaQueryList =>
|
||||||
|
({
|
||||||
|
matches: matchesQuery(query),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}) as MediaQueryList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const initializedFixture = TestBed.createComponent(DotBackground);
|
describe('DotBackground', () => {
|
||||||
initializedFixture.detectChanges();
|
afterEach(() => {
|
||||||
await initializedFixture.whenStable();
|
|
||||||
|
|
||||||
expect(() => initializedFixture.destroy()).not.toThrow();
|
|
||||||
|
|
||||||
animationFrameSpy.mockRestore();
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(window, 'matchMedia');
|
||||||
|
Object.defineProperty(document, 'hidden', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DotBackground],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(DotBackground);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should create', async () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||||
|
|
||||||
|
const fixture = await createFixture();
|
||||||
|
expect(fixture.componentInstance).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when destroyed after browser initialization', async () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||||
|
|
||||||
|
const fixture = await createFixture();
|
||||||
|
expect(() => fixture.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||||
|
const scheduled: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
});
|
||||||
|
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||||
|
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DotBackground],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(DotBackground);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
|
||||||
|
const pending = scheduled.splice(0);
|
||||||
|
for (const callback of pending) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(scheduled).toEqual([]);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||||
|
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
|
||||||
|
expect(() => fixture.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels the scheduled frame and removes every listener on destroy', async () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||||
|
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
|
||||||
|
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||||
|
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||||
|
const windowRemove = vi.spyOn(window, 'removeEventListener');
|
||||||
|
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||||
|
const documentRemove = vi.spyOn(document, 'removeEventListener');
|
||||||
|
|
||||||
|
const fixture = await createFixture();
|
||||||
|
|
||||||
|
expect(raf).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const windowAdds = windowAdd.mock.calls.filter((call) =>
|
||||||
|
['resize', 'mousemove', 'click'].includes(String(call[0])),
|
||||||
|
);
|
||||||
|
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
|
||||||
|
|
||||||
|
expect(windowAdds.length).toBeGreaterThan(0);
|
||||||
|
expect(documentAdds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
fixture.destroy();
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (const [type, handler] of windowAdds) {
|
||||||
|
expect(windowRemove).toHaveBeenCalledWith(type, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, handler] of documentAdds) {
|
||||||
|
expect(documentRemove).toHaveBeenCalledWith(type, handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a single static frame under reduced motion and skips pointer listeners', async () => {
|
||||||
|
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||||
|
const context = mockContext();
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||||
|
const scheduled: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
});
|
||||||
|
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DotBackground],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(DotBackground);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
|
||||||
|
const pending = scheduled.splice(0);
|
||||||
|
for (const callback of pending) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(context.clearRect).toHaveBeenCalled();
|
||||||
|
expect(scheduled).toEqual([]);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers no pointer listeners for a coarse pointer', async () => {
|
||||||
|
mockMatchMedia((query) => query.includes('pointer: coarse'));
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||||
|
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||||
|
|
||||||
|
await createFixture();
|
||||||
|
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||||
|
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||||
|
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
|
||||||
|
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||||
|
|
||||||
|
await createFixture();
|
||||||
|
expect(raf).toHaveBeenCalled();
|
||||||
|
raf.mockClear();
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'hidden', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'hidden', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => false,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
|
||||||
|
expect(raf).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with a bounded initial dot count on a large desktop viewport', async () => {
|
||||||
|
const context = mockContext();
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||||
|
const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560);
|
||||||
|
const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440);
|
||||||
|
const scheduled: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const pending = scheduled.splice(0);
|
||||||
|
for (const callback of pending) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arcCalls = vi.mocked(context.arc).mock.calls.length;
|
||||||
|
expect(arcCalls).toBeGreaterThanOrEqual(6);
|
||||||
|
expect(arcCalls).toBeLessThanOrEqual(24);
|
||||||
|
fixture.destroy();
|
||||||
|
} finally {
|
||||||
|
widthStub.mockRestore();
|
||||||
|
heightStub.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redraws the static frame after resize under reduced motion without starting a loop', async () => {
|
||||||
|
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||||
|
const context = mockContext();
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||||
|
const scheduled: FrameRequestCallback[] = [];
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||||
|
scheduled.push(callback);
|
||||||
|
return scheduled.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DotBackground],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(DotBackground);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
|
||||||
|
const pending = scheduled.splice(0);
|
||||||
|
for (const callback of pending) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(context.clearRect).mockClear();
|
||||||
|
vi.mocked(context.arc).mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
|
const resizeFrames = scheduled.splice(0);
|
||||||
|
for (const callback of resizeFrames) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(context.clearRect).toHaveBeenCalled();
|
||||||
|
expect(context.arc).toHaveBeenCalled();
|
||||||
|
expect(scheduled).toEqual([]);
|
||||||
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
NgZone,
|
||||||
OnDestroy,
|
|
||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
|
import {
|
||||||
|
isBrowserPlatform,
|
||||||
|
prefersCoarsePointer,
|
||||||
|
prefersReducedMotion,
|
||||||
|
} from '../../core/platform/browser';
|
||||||
import { Dot } from '../../models/dot';
|
import { Dot } from '../../models/dot';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -15,21 +20,33 @@ import { Dot } from '../../models/dot';
|
|||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './dot-background.html',
|
templateUrl: './dot-background.html',
|
||||||
styleUrl: './dot-background.scss',
|
styleUrl: './dot-background.scss',
|
||||||
|
host: {
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class DotBackground implements OnDestroy {
|
export class DotBackground {
|
||||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
private readonly coarsePointer = prefersCoarsePointer();
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly isBrowser = isBrowserPlatform();
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
|
private readonly coarsePointer = prefersCoarsePointer();
|
||||||
|
private readonly reducedMotion = prefersReducedMotion();
|
||||||
|
|
||||||
private ctx: CanvasRenderingContext2D | undefined;
|
private ctx: CanvasRenderingContext2D | undefined;
|
||||||
private dots: Dot[] = [];
|
private dots: Dot[] = [];
|
||||||
private mouse = { x: -1000, y: -1000 };
|
private mouse = { x: -1000, y: -1000 };
|
||||||
private animationId = 0;
|
private animationId = 0;
|
||||||
private initialized = false;
|
private resizeFrameId = 0;
|
||||||
|
private loopActive = false;
|
||||||
|
private tornDown = false;
|
||||||
|
private readonly teardowns: Array<() => void> = [];
|
||||||
|
|
||||||
private readonly INIT_DOT_COUNT = 12;
|
private readonly INITIAL_DOT_COUNT_MIN = 6;
|
||||||
|
private readonly INITIAL_DOT_COUNT_MAX = 24;
|
||||||
|
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
|
||||||
|
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
|
||||||
private readonly MAX_DOT_COUNT = 100;
|
private readonly MAX_DOT_COUNT = 100;
|
||||||
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
||||||
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
||||||
@@ -39,27 +56,34 @@ export class DotBackground implements OnDestroy {
|
|||||||
private ballSpawnNextColor = 0;
|
private ballSpawnNextColor = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => this.teardown());
|
||||||
|
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
this.init();
|
this.init();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
private view(): Window | null {
|
||||||
if (!this.initialized) {
|
return this.document.defaultView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(): void {
|
||||||
|
if (this.tornDown || !this.isBrowser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelAnimationFrame(this.animationId);
|
const view = this.view();
|
||||||
|
|
||||||
if (this.isBrowser) {
|
if (!view) {
|
||||||
window.removeEventListener('resize', this.resize);
|
return;
|
||||||
window.removeEventListener('mousemove', this.onMouseMove);
|
}
|
||||||
window.removeEventListener('click', this.onMouseClick);
|
|
||||||
}
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
|
||||||
const canvas = this.canvasRef.nativeElement;
|
|
||||||
let ctx: CanvasRenderingContext2D | null = null;
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -76,21 +100,113 @@ export class DotBackground implements OnDestroy {
|
|||||||
this.resize();
|
this.resize();
|
||||||
this.initDots();
|
this.initDots();
|
||||||
|
|
||||||
window.addEventListener('resize', this.resize);
|
this.listen(view, 'resize', this.onResize);
|
||||||
window.addEventListener('mousemove', this.onMouseMove);
|
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
||||||
window.addEventListener('click', this.onMouseClick);
|
|
||||||
|
|
||||||
this.initialized = true;
|
if (!this.reducedMotion && !this.coarsePointer) {
|
||||||
this.ngZone.runOutsideAngular(() => this.animate());
|
this.listen(view, 'mousemove', this.onMouseMove);
|
||||||
|
this.listen(view, 'click', this.onMouseClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resize = () => {
|
if (this.reducedMotion) {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
this.drawFrame();
|
||||||
const width = window.innerWidth;
|
return;
|
||||||
const height = window.innerHeight;
|
}
|
||||||
|
|
||||||
const dx = Math.abs(width - canvas.width) / width;
|
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||||
const dy = Math.abs(height - canvas.height) / height;
|
}
|
||||||
|
|
||||||
|
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
||||||
|
target.addEventListener(type, handler);
|
||||||
|
this.teardowns.push(() => target.removeEventListener(type, handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestFrame(callback: FrameRequestCallback): number {
|
||||||
|
const view = this.view();
|
||||||
|
return view ? view.requestAnimationFrame(callback) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelFrame(id: number): void {
|
||||||
|
const view = this.view();
|
||||||
|
|
||||||
|
if (!view || id === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startLoop(): void {
|
||||||
|
if (this.loopActive || this.reducedMotion || this.tornDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loopActive = true;
|
||||||
|
this.scheduleAnimate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopLoop(): void {
|
||||||
|
this.loopActive = false;
|
||||||
|
this.cancelFrame(this.animationId);
|
||||||
|
this.animationId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleAnimate(): void {
|
||||||
|
this.animationId = this.requestFrame(this.animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown(): void {
|
||||||
|
if (this.tornDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tornDown = true;
|
||||||
|
this.stopLoop();
|
||||||
|
this.cancelFrame(this.resizeFrameId);
|
||||||
|
this.resizeFrameId = 0;
|
||||||
|
|
||||||
|
for (const dispose of this.teardowns) {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.teardowns.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onResize = (): void => {
|
||||||
|
if (this.resizeFrameId !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resizeFrameId = this.requestFrame(() => {
|
||||||
|
this.resizeFrameId = 0;
|
||||||
|
this.resize();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onVisibilityChange = (): void => {
|
||||||
|
if (this.document.hidden) {
|
||||||
|
this.stopLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.reducedMotion) {
|
||||||
|
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private resize = (): void => {
|
||||||
|
const view = this.view();
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!view || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = view.innerWidth;
|
||||||
|
const height = view.innerHeight;
|
||||||
|
|
||||||
|
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
|
||||||
|
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
|
||||||
|
|
||||||
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
@@ -100,21 +216,44 @@ export class DotBackground implements OnDestroy {
|
|||||||
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
|
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
|
||||||
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.reducedMotion && this.ctx) {
|
||||||
|
this.drawFrame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMouseMove = (e: MouseEvent) => {
|
private onMouseMove = (event: Event): void => {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
if (!(event instanceof MouseEvent)) {
|
||||||
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
|
return;
|
||||||
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
|
}
|
||||||
|
|
||||||
|
const view = this.view();
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!view || !canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
|
||||||
|
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onMouseClick = () => {
|
private onMouseClick = (): void => {
|
||||||
const dot = this.spawnDot();
|
const dot = this.spawnDot();
|
||||||
dot.x = this.mouse.x;
|
dot.x = this.mouse.x;
|
||||||
dot.y = this.mouse.y;
|
dot.y = this.mouse.y;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private targetDotCount(width: number, height: number): number {
|
||||||
|
const maxInitial = this.coarsePointer
|
||||||
|
? this.INITIAL_DOT_COUNT_MAX_COARSE
|
||||||
|
: this.INITIAL_DOT_COUNT_MAX;
|
||||||
|
const area = Math.max(0, width) * Math.max(0, height);
|
||||||
|
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
|
||||||
|
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
|
||||||
|
}
|
||||||
|
|
||||||
private spawnDot(): Dot {
|
private spawnDot(): Dot {
|
||||||
const dotId = this.ballSpawnId++;
|
const dotId = this.ballSpawnId++;
|
||||||
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||||
@@ -140,7 +279,7 @@ export class DotBackground implements OnDestroy {
|
|||||||
return dot;
|
return dot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private populateDot(dot: Dot) {
|
private populateDot(dot: Dot): void {
|
||||||
const { width, height } = this.canvasRef.nativeElement;
|
const { width, height } = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
dot.x = Math.random() * width;
|
dot.x = Math.random() * width;
|
||||||
@@ -151,17 +290,29 @@ export class DotBackground implements OnDestroy {
|
|||||||
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
private initDots() {
|
private initDots(): void {
|
||||||
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
const { width, height } = this.canvasRef.nativeElement;
|
||||||
|
const count = this.targetDotCount(width, height);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
this.spawnDot();
|
this.spawnDot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private animate = () => {
|
private animate = (): void => {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
this.animationId = 0;
|
||||||
|
this.drawFrame();
|
||||||
|
|
||||||
|
if (this.loopActive && !this.tornDown) {
|
||||||
|
this.scheduleAnimate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private drawFrame(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
|
|
||||||
if (!ctx) {
|
if (!canvas || !ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +364,5 @@ export class DotBackground implements OnDestroy {
|
|||||||
ctx.fillStyle = gradient;
|
ctx.fillStyle = gradient;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.animationId = requestAnimationFrame(this.animate);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/app/core/content/signature-copy.spec.ts
Normal file
60
src/app/core/content/signature-copy.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { APP_LOCALES } from '../i18n/locale';
|
||||||
|
import { COMMAND_IDS } from '../../shared/command-palette/commands';
|
||||||
|
import { SIGNATURE_COPY } from './signature-copy';
|
||||||
|
|
||||||
|
function leafPaths(value: unknown, prefix = ''): string[] {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return [prefix];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return Object.keys(value).flatMap((key) => {
|
||||||
|
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||||
|
return leafPaths((value as Record<string, unknown>)[key], next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [prefix];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStrings(value: unknown): string[] {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return Object.values(value).flatMap((entry) => collectStrings(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SIGNATURE_COPY', () => {
|
||||||
|
it('exposes the same key structure in both locales', () => {
|
||||||
|
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
|
||||||
|
|
||||||
|
for (const keys of rest) {
|
||||||
|
expect(keys).toEqual(first);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every string non-empty', () => {
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
|
||||||
|
expect(value.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes every CommandId in both locales', () => {
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||||
|
|
||||||
|
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||||
|
|
||||||
|
for (const id of COMMAND_IDS) {
|
||||||
|
expect(descriptions[id].trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
150
src/app/core/content/signature-copy.ts
Normal file
150
src/app/core/content/signature-copy.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { type AppLocale } from '../i18n/locale';
|
||||||
|
import { type CommandId } from '../../shared/command-palette/commands';
|
||||||
|
|
||||||
|
export interface SignatureCopy {
|
||||||
|
readonly palette: {
|
||||||
|
readonly triggerLabel: string;
|
||||||
|
readonly shortcutHint: string;
|
||||||
|
readonly dialogTitle: string;
|
||||||
|
readonly dialogDescription: string;
|
||||||
|
readonly inputLabel: string;
|
||||||
|
readonly inputPlaceholder: string;
|
||||||
|
readonly closeLabel: string;
|
||||||
|
readonly suggestionsLabel: string;
|
||||||
|
readonly outputLabel: string;
|
||||||
|
readonly emptySuggestions: string;
|
||||||
|
readonly unknownCommand: string;
|
||||||
|
readonly helpIntro: string;
|
||||||
|
readonly clearedMessage: string;
|
||||||
|
readonly cvOpened: string;
|
||||||
|
readonly navigating: string;
|
||||||
|
readonly commandDescriptions: Record<CommandId, string>;
|
||||||
|
readonly responses: {
|
||||||
|
readonly brew: string;
|
||||||
|
readonly ignite: string;
|
||||||
|
readonly rev: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
readonly systemsMap: {
|
||||||
|
readonly heading: string;
|
||||||
|
readonly intro: string;
|
||||||
|
readonly diagramDescription: string;
|
||||||
|
readonly listHeading: string;
|
||||||
|
readonly legendHeading: string;
|
||||||
|
readonly relationshipLabel: string;
|
||||||
|
readonly legend: {
|
||||||
|
readonly ai: string;
|
||||||
|
readonly cluster: string;
|
||||||
|
readonly hardware: string;
|
||||||
|
readonly software: string;
|
||||||
|
readonly project: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||||
|
de: {
|
||||||
|
palette: {
|
||||||
|
triggerLabel: 'Befehle öffnen',
|
||||||
|
shortcutHint: 'Strg+K',
|
||||||
|
dialogTitle: 'Befehle',
|
||||||
|
dialogDescription:
|
||||||
|
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||||
|
inputLabel: 'Befehl',
|
||||||
|
inputPlaceholder: 'Befehl eingeben',
|
||||||
|
closeLabel: 'Schließen',
|
||||||
|
suggestionsLabel: 'Vorschläge',
|
||||||
|
outputLabel: 'Ausgabe',
|
||||||
|
emptySuggestions: 'Keine passenden Befehle.',
|
||||||
|
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||||
|
helpIntro: 'Verfügbare Befehle:',
|
||||||
|
clearedMessage: 'Ausgabe geleert.',
|
||||||
|
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||||
|
navigating: 'Wechsel zu {target}.',
|
||||||
|
commandDescriptions: {
|
||||||
|
help: 'Listet die verfügbaren Befehle.',
|
||||||
|
projects: 'Öffnet die Projektübersicht.',
|
||||||
|
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
||||||
|
cv: 'Öffnet den Lebenslauf als PDF.',
|
||||||
|
contact: 'Öffnet die Kontaktseite.',
|
||||||
|
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||||
|
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
||||||
|
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||||
|
clear: 'Leert die Ausgabe.',
|
||||||
|
close: 'Schließt die Befehlsübersicht.',
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||||
|
ignite: 'Zündung frei. Die Systeme laufen warm.',
|
||||||
|
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
systemsMap: {
|
||||||
|
heading: 'Systemkarte',
|
||||||
|
intro:
|
||||||
|
'Wie die technischen Schichten zusammenhängen — von Hardware bis zu den Projektfeldern.',
|
||||||
|
diagramDescription: 'Diagramm der technischen Schichten und ihrer Verbindungen.',
|
||||||
|
listHeading: 'Knoten als Liste',
|
||||||
|
legendHeading: 'Legende',
|
||||||
|
relationshipLabel: 'Verbunden mit {targets}.',
|
||||||
|
legend: {
|
||||||
|
ai: 'KI-Integration',
|
||||||
|
cluster: 'Cluster',
|
||||||
|
hardware: 'Hardware und Netz',
|
||||||
|
software: 'Software',
|
||||||
|
project: 'Projektfeld',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
palette: {
|
||||||
|
triggerLabel: 'Open commands',
|
||||||
|
shortcutHint: 'Ctrl+K',
|
||||||
|
dialogTitle: 'Commands',
|
||||||
|
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||||
|
inputLabel: 'Command',
|
||||||
|
inputPlaceholder: 'Type a command',
|
||||||
|
closeLabel: 'Close',
|
||||||
|
suggestionsLabel: 'Suggestions',
|
||||||
|
outputLabel: 'Output',
|
||||||
|
emptySuggestions: 'No matching commands.',
|
||||||
|
unknownCommand: 'Unknown command: {command}',
|
||||||
|
helpIntro: 'Available commands:',
|
||||||
|
clearedMessage: 'Output cleared.',
|
||||||
|
cvOpened: 'Opened the CV in a new tab.',
|
||||||
|
navigating: 'Going to {target}.',
|
||||||
|
commandDescriptions: {
|
||||||
|
help: 'Lists the available commands.',
|
||||||
|
projects: 'Opens the projects overview.',
|
||||||
|
servicesAi: 'Opens the AI integration page.',
|
||||||
|
cv: 'Opens the CV as a PDF.',
|
||||||
|
contact: 'Opens the contact page.',
|
||||||
|
brew: 'A short playful acknowledgement.',
|
||||||
|
ignite: 'A short playful acknowledgement.',
|
||||||
|
rev: 'A short playful acknowledgement.',
|
||||||
|
clear: 'Clears the output.',
|
||||||
|
close: 'Closes the command palette.',
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||||
|
ignite: 'Ignition on. Systems are warming up.',
|
||||||
|
rev: 'Revs climbing, the content stays calm.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
systemsMap: {
|
||||||
|
heading: 'Systems map',
|
||||||
|
intro: 'How the technical layers connect — from hardware through to the project fields.',
|
||||||
|
diagramDescription: 'Diagram of the technical layers and their connections.',
|
||||||
|
listHeading: 'Nodes as a list',
|
||||||
|
legendHeading: 'Legend',
|
||||||
|
relationshipLabel: 'Connected to {targets}.',
|
||||||
|
legend: {
|
||||||
|
ai: 'AI integration',
|
||||||
|
cluster: 'Cluster',
|
||||||
|
hardware: 'Hardware and network',
|
||||||
|
software: 'Software',
|
||||||
|
project: 'Project field',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
86
src/app/shared/command-palette/command-palette.html
Normal file
86
src/app/shared/command-palette/command-palette.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<button
|
||||||
|
#trigger
|
||||||
|
type="button"
|
||||||
|
class="command-palette-trigger"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
[attr.aria-expanded]="open()"
|
||||||
|
[attr.aria-controls]="dialogId"
|
||||||
|
[attr.aria-label]="copy().triggerLabel"
|
||||||
|
(click)="onTriggerClick()"
|
||||||
|
>
|
||||||
|
<span>{{ copy().triggerLabel }}</span>
|
||||||
|
<kbd>{{ copy().shortcutHint }}</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (open()) {
|
||||||
|
<div class="command-palette-overlay">
|
||||||
|
<div class="command-palette-scrim" aria-hidden="true"></div>
|
||||||
|
<div
|
||||||
|
#dialog
|
||||||
|
class="command-palette-dialog glass-surface"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[id]="dialogId"
|
||||||
|
[attr.aria-labelledby]="titleId"
|
||||||
|
[attr.aria-describedby]="descriptionId"
|
||||||
|
(keydown)="onDialogKeydown($event, dialog)"
|
||||||
|
>
|
||||||
|
<div class="command-palette-header">
|
||||||
|
<div>
|
||||||
|
<h2 [id]="titleId" class="command-palette-title">{{ copy().dialogTitle }}</h2>
|
||||||
|
<p [id]="descriptionId" class="command-palette-description">
|
||||||
|
{{ copy().dialogDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="command-palette-close" (click)="closePalette()">
|
||||||
|
{{ copy().closeLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="command-palette-form" (submit)="onSubmit($event)">
|
||||||
|
<label [attr.for]="inputId">{{ copy().inputLabel }}</label>
|
||||||
|
<input
|
||||||
|
#commandInput
|
||||||
|
[id]="inputId"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
[attr.placeholder]="copy().inputPlaceholder"
|
||||||
|
[value]="query()"
|
||||||
|
(input)="onQueryInput($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="command-palette-kicker" [id]="suggestionsId">{{ copy().suggestionsLabel }}</p>
|
||||||
|
@if (suggestions().length === 0) {
|
||||||
|
<p class="command-palette-empty">{{ copy().emptySuggestions }}</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="command-palette-suggestions" [attr.aria-labelledby]="suggestionsId">
|
||||||
|
@for (command of suggestions(); track command.id) {
|
||||||
|
<li>
|
||||||
|
<button type="button" (click)="runDefinition(command)">
|
||||||
|
<span>{{ command.input }}</span>
|
||||||
|
<span>{{ copy().commandDescriptions[command.id] }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="command-palette-output"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
[attr.aria-label]="copy().outputLabel"
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
@for (line of output(); track line.id) {
|
||||||
|
<li>{{ line.text }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
168
src/app/shared/command-palette/command-palette.scss
Normal file
168
src/app/shared/command-palette/command-palette.scss
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-trigger kbd {
|
||||||
|
padding: 0.1rem var(--space-2);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-dialog {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
width: min(100%, 40rem);
|
||||||
|
max-height: min(36rem, 90vh);
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-5);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-title,
|
||||||
|
.command-palette-description,
|
||||||
|
.command-palette-kicker,
|
||||||
|
.command-palette-empty,
|
||||||
|
.command-palette-output ul,
|
||||||
|
.command-palette-suggestions {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-description,
|
||||||
|
.command-palette-empty,
|
||||||
|
.command-palette-output {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-close {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-form input {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-kicker {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-suggestions,
|
||||||
|
.command-palette-output ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-suggestions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-suggestions button {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-suggestions button span:first-child {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-suggestions button span:last-child,
|
||||||
|
.command-palette-output {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.command-palette-trigger:hover,
|
||||||
|
.command-palette-close:hover,
|
||||||
|
.command-palette-suggestions button:hover {
|
||||||
|
border-color: var(--surface-glass-border-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.command-palette-overlay,
|
||||||
|
.command-palette-dialog {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/app/shared/command-palette/command-palette.spec.ts
Normal file
277
src/app/shared/command-palette/command-palette.spec.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
|
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||||
|
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||||
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
|
import { CommandPalette } from './command-palette';
|
||||||
|
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||||
|
|
||||||
|
describe('CommandPalette', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFixture(
|
||||||
|
locale: AppLocale = 'de',
|
||||||
|
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||||
|
): Promise<ComponentFixture<CommandPalette>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CommandPalette],
|
||||||
|
providers: [provideRouter([]), ...extraProviders],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
TestBed.inject(LocaleService).setLocale(locale);
|
||||||
|
const fixture = TestBed.createComponent(CommandPalette);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
|
||||||
|
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
|
||||||
|
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||||
|
trigger(fixture).click();
|
||||||
|
await flush(fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitQuery(
|
||||||
|
fixture: ComponentFixture<CommandPalette>,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const input = fixture.nativeElement.querySelector('input');
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
input.value = value;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('form')
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputText(fixture: ComponentFixture<CommandPalette>): string {
|
||||||
|
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusable(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
|
).filter((element) => element.tabIndex >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'renders the trigger and not the dialog on the server (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||||
|
expect(trigger(fixture)).toBeTruthy();
|
||||||
|
expect(dialog(fixture)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const input = fixture.nativeElement.querySelector('input');
|
||||||
|
expect(dialog(fixture)).toBeTruthy();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||||
|
const openDialog = dialog(fixture);
|
||||||
|
if (openDialog) {
|
||||||
|
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...modifier,
|
||||||
|
});
|
||||||
|
const prevent = vi.spyOn(event, 'preventDefault');
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(prevent).toHaveBeenCalled();
|
||||||
|
expect(dialog(fixture)).toBeTruthy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const openDialog = dialog(fixture);
|
||||||
|
expect(openDialog).toBeTruthy();
|
||||||
|
|
||||||
|
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(dialog(fixture)).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger(fixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
|
||||||
|
closeButton.click();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(dialog(fixture)).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger(fixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const openDialog = dialog(fixture);
|
||||||
|
expect(openDialog).toBeTruthy();
|
||||||
|
|
||||||
|
const items = focusable(openDialog as HTMLElement);
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
|
||||||
|
last.focus();
|
||||||
|
openDialog?.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(first);
|
||||||
|
|
||||||
|
first.focus();
|
||||||
|
openDialog?.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(last);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const copy = SIGNATURE_COPY[locale].palette;
|
||||||
|
|
||||||
|
await submitQuery(fixture, 'help');
|
||||||
|
const helpText = outputText(fixture);
|
||||||
|
expect(helpText).toContain(copy.helpIntro);
|
||||||
|
for (const command of COMMANDS) {
|
||||||
|
expect(helpText).toContain(command.input);
|
||||||
|
expect(helpText).toContain(copy.commandDescriptions[command.id]);
|
||||||
|
}
|
||||||
|
expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true);
|
||||||
|
|
||||||
|
await submitQuery(fixture, 'brew');
|
||||||
|
expect(outputText(fixture)).toContain(copy.responses.brew);
|
||||||
|
await submitQuery(fixture, 'ignite');
|
||||||
|
expect(outputText(fixture)).toContain(copy.responses.ignite);
|
||||||
|
await submitQuery(fixture, 'rev');
|
||||||
|
expect(outputText(fixture)).toContain(copy.responses.rev);
|
||||||
|
|
||||||
|
await submitQuery(fixture, 'clear');
|
||||||
|
expect(outputText(fixture).trim()).toBe(copy.clearedMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'navigates projects, services ai and contact through NavigationService (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigation = TestBed.inject(NavigationService);
|
||||||
|
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, 'projects');
|
||||||
|
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, 'services ai');
|
||||||
|
expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi'));
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, 'contact');
|
||||||
|
expect(navigate).toHaveBeenCalledWith(navigation.link('contact'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||||
|
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, 'cv');
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'reports unknown input without navigating or opening a window (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||||
|
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||||
|
const raw = 'rm -rf /';
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, raw);
|
||||||
|
|
||||||
|
expect(outputText(fixture)).toContain(
|
||||||
|
SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw),
|
||||||
|
);
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('removes the document keydown listener on destroy', async () => {
|
||||||
|
const add = vi.spyOn(document, 'addEventListener');
|
||||||
|
const fixture = await createFixture();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
||||||
|
expect(added).toBeTruthy();
|
||||||
|
|
||||||
|
const remove = vi.spyOn(document, 'removeEventListener');
|
||||||
|
fixture.destroy();
|
||||||
|
|
||||||
|
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers no document listener on the server', async () => {
|
||||||
|
const add = vi.spyOn(document, 'addEventListener');
|
||||||
|
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||||
|
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
272
src/app/shared/command-palette/command-palette.ts
Normal file
272
src/app/shared/command-palette/command-palette.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
DestroyRef,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Injector,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
|
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||||
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
|
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||||
|
import {
|
||||||
|
COMMAND_IDS,
|
||||||
|
COMMANDS,
|
||||||
|
parseCommand,
|
||||||
|
suggestCommands,
|
||||||
|
type CommandDefinition,
|
||||||
|
} from './commands';
|
||||||
|
|
||||||
|
let commandPaletteInstanceId = 0;
|
||||||
|
let commandPaletteOutputId = 0;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-command-palette',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './command-palette.html',
|
||||||
|
styleUrl: './command-palette.scss',
|
||||||
|
})
|
||||||
|
export class CommandPalette {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly navigation = inject(NavigationService);
|
||||||
|
private readonly localeService = inject(LocaleService);
|
||||||
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
|
private readonly instanceId = commandPaletteInstanceId++;
|
||||||
|
|
||||||
|
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||||
|
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||||
|
|
||||||
|
protected readonly dialogId = `command-palette-dialog-${this.instanceId}`;
|
||||||
|
protected readonly titleId = `command-palette-title-${this.instanceId}`;
|
||||||
|
protected readonly descriptionId = `command-palette-description-${this.instanceId}`;
|
||||||
|
protected readonly inputId = `command-palette-input-${this.instanceId}`;
|
||||||
|
protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`;
|
||||||
|
|
||||||
|
protected readonly open = signal(false);
|
||||||
|
protected readonly query = signal('');
|
||||||
|
protected readonly output = signal<readonly { id: number; text: string }[]>([]);
|
||||||
|
|
||||||
|
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||||
|
protected readonly suggestions = computed(() =>
|
||||||
|
suggestCommands(this.query(), this.localeService.locale()),
|
||||||
|
);
|
||||||
|
|
||||||
|
private opener: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onTriggerClick(): void {
|
||||||
|
this.triggerRef()?.nativeElement.focus();
|
||||||
|
this.openPalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onQueryInput(event: Event): void {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLInputElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.query.set(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSubmit(event: Event): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.runRaw(this.query());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDialogKeydown(event: KeyboardEvent, dialog: HTMLElement): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closePalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'Tab') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusable = this.focusableElements(dialog);
|
||||||
|
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const active = this.document.activeElement;
|
||||||
|
|
||||||
|
if (event.shiftKey && active === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.shiftKey && active === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected runDefinition(definition: CommandDefinition): void {
|
||||||
|
const copy = this.copy();
|
||||||
|
|
||||||
|
switch (definition.action.kind) {
|
||||||
|
case 'navigate': {
|
||||||
|
void this.router.navigate(this.navigation.link(definition.action.routeId));
|
||||||
|
this.append(copy.navigating.replace('{target}', definition.input));
|
||||||
|
this.closePalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'openCv': {
|
||||||
|
if (this.isBrowser) {
|
||||||
|
this.document.defaultView?.open(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.append(copy.cvOpened);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'message': {
|
||||||
|
this.appendMessage(definition.action.messageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'clear': {
|
||||||
|
this.output.set([]);
|
||||||
|
this.append(copy.clearedMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
this.closePalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runRaw(raw: string): void {
|
||||||
|
const match = parseCommand(raw);
|
||||||
|
|
||||||
|
if (match.kind === 'empty') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.kind === 'unknown') {
|
||||||
|
this.append(this.copy().unknownCommand.replace('{command}', match.input));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runDefinition(match.definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendMessage(key: 'help' | 'brew' | 'ignite' | 'rev'): void {
|
||||||
|
const copy = this.copy();
|
||||||
|
|
||||||
|
if (key === 'help') {
|
||||||
|
const lines = [
|
||||||
|
copy.helpIntro,
|
||||||
|
...COMMAND_IDS.map((id) => {
|
||||||
|
const command = COMMANDS.find((entry) => entry.id === id);
|
||||||
|
const input = command?.input ?? id;
|
||||||
|
return `${input} — ${copy.commandDescriptions[id]}`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
this.append(lines.join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.append(copy.responses[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private append(text: string): void {
|
||||||
|
this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openPalette(): void {
|
||||||
|
if (this.open()) {
|
||||||
|
this.focusInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = this.document.activeElement;
|
||||||
|
this.opener = active instanceof HTMLElement ? active : null;
|
||||||
|
this.open.set(true);
|
||||||
|
this.focusInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected closePalette(): void {
|
||||||
|
if (!this.open()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.open.set(false);
|
||||||
|
this.restoreFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusInput(): void {
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
this.inputRef()?.nativeElement.focus();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreFocus(): void {
|
||||||
|
const opener = this.opener;
|
||||||
|
this.opener = null;
|
||||||
|
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||||
|
opener.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.triggerRef()?.nativeElement.focus();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusableElements(container: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
|
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.openPalette();
|
||||||
|
};
|
||||||
|
}
|
||||||
79
src/app/shared/command-palette/commands.spec.ts
Normal file
79
src/app/shared/command-palette/commands.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { APP_LOCALES } from '../../core/i18n/locale';
|
||||||
|
import {
|
||||||
|
COMMAND_IDS,
|
||||||
|
COMMANDS,
|
||||||
|
normalizeCommandInput,
|
||||||
|
parseCommand,
|
||||||
|
suggestCommands,
|
||||||
|
type CommandId,
|
||||||
|
} from './commands';
|
||||||
|
|
||||||
|
describe('command parsing', () => {
|
||||||
|
it('collapses whitespace and lowercases input', () => {
|
||||||
|
expect(normalizeCommandInput(' Services AI ')).toBe('services ai');
|
||||||
|
expect(normalizeCommandInput('HELP')).toBe('help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every CommandId from its canonical input and aliases in both locales', () => {
|
||||||
|
for (const command of COMMANDS) {
|
||||||
|
const canonical = parseCommand(command.input);
|
||||||
|
expect(canonical).toEqual({ kind: 'command', definition: command });
|
||||||
|
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
for (const alias of command.aliases[locale]) {
|
||||||
|
expect(parseCommand(alias)).toEqual({ kind: 'command', definition: command });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(COMMANDS.map((command) => command.id)).toEqual([...COMMAND_IDS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats hostile and prototype inputs as unknown without side effects', () => {
|
||||||
|
const prototypeNames = Object.getOwnPropertyNames(Object.prototype);
|
||||||
|
const hostile = [
|
||||||
|
'rm -rf /',
|
||||||
|
'eval(1+1)',
|
||||||
|
'new Function()',
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'__proto__',
|
||||||
|
'constructor',
|
||||||
|
'toString',
|
||||||
|
'hasOwnProperty',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const input of hostile) {
|
||||||
|
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
||||||
|
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
|
||||||
|
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
|
||||||
|
expect(Object.prototype).not.toHaveProperty('polluted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an equal result for the same input across repeated calls', () => {
|
||||||
|
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
expect(parseCommand(sample)).toEqual(parseCommand(sample));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
|
||||||
|
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
|
||||||
|
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
|
||||||
|
const expectedIds: CommandId[] = [...COMMAND_IDS];
|
||||||
|
|
||||||
|
expect(emptyDe).toEqual(expectedIds);
|
||||||
|
expect(emptyEn).toEqual(expectedIds);
|
||||||
|
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
|
||||||
|
'cv',
|
||||||
|
'contact',
|
||||||
|
'clear',
|
||||||
|
'close',
|
||||||
|
]);
|
||||||
|
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
|
||||||
|
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
|
||||||
|
});
|
||||||
|
});
|
||||||
167
src/app/shared/command-palette/commands.ts
Normal file
167
src/app/shared/command-palette/commands.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||||
|
import { type RouteId } from '../../core/routing/route-ids';
|
||||||
|
|
||||||
|
export type CommandId =
|
||||||
|
| 'help'
|
||||||
|
| 'projects'
|
||||||
|
| 'servicesAi'
|
||||||
|
| 'cv'
|
||||||
|
| 'contact'
|
||||||
|
| 'brew'
|
||||||
|
| 'ignite'
|
||||||
|
| 'rev'
|
||||||
|
| 'clear'
|
||||||
|
| 'close';
|
||||||
|
|
||||||
|
export const COMMAND_IDS: readonly CommandId[] = [
|
||||||
|
'help',
|
||||||
|
'projects',
|
||||||
|
'servicesAi',
|
||||||
|
'cv',
|
||||||
|
'contact',
|
||||||
|
'brew',
|
||||||
|
'ignite',
|
||||||
|
'rev',
|
||||||
|
'clear',
|
||||||
|
'close',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CommandAction =
|
||||||
|
| { readonly kind: 'navigate'; readonly routeId: RouteId }
|
||||||
|
| { readonly kind: 'openCv' }
|
||||||
|
| { readonly kind: 'message'; readonly messageKey: 'help' | 'brew' | 'ignite' | 'rev' }
|
||||||
|
| { readonly kind: 'clear' }
|
||||||
|
| { readonly kind: 'close' };
|
||||||
|
|
||||||
|
export interface CommandDefinition {
|
||||||
|
readonly id: CommandId;
|
||||||
|
readonly input: string;
|
||||||
|
readonly aliases: Record<AppLocale, readonly string[]>;
|
||||||
|
readonly action: CommandAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandMatch =
|
||||||
|
| { readonly kind: 'command'; readonly definition: CommandDefinition }
|
||||||
|
| { readonly kind: 'empty' }
|
||||||
|
| { readonly kind: 'unknown'; readonly input: string };
|
||||||
|
|
||||||
|
export const COMMANDS: readonly CommandDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'help',
|
||||||
|
input: 'help',
|
||||||
|
aliases: { de: ['hilfe'], en: [] },
|
||||||
|
action: { kind: 'message', messageKey: 'help' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
input: 'projects',
|
||||||
|
aliases: { de: ['projekte'], en: [] },
|
||||||
|
action: { kind: 'navigate', routeId: 'projects' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'servicesAi',
|
||||||
|
input: 'services ai',
|
||||||
|
aliases: { de: ['leistungen ai'], en: [] },
|
||||||
|
action: { kind: 'navigate', routeId: 'servicesAi' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cv',
|
||||||
|
input: 'cv',
|
||||||
|
aliases: { de: ['lebenslauf'], en: ['resume'] },
|
||||||
|
action: { kind: 'openCv' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
input: 'contact',
|
||||||
|
aliases: { de: ['kontakt'], en: [] },
|
||||||
|
action: { kind: 'navigate', routeId: 'contact' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brew',
|
||||||
|
input: 'brew',
|
||||||
|
aliases: { de: ['brauen'], en: [] },
|
||||||
|
action: { kind: 'message', messageKey: 'brew' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ignite',
|
||||||
|
input: 'ignite',
|
||||||
|
aliases: { de: ['zuenden', 'zünden'], en: [] },
|
||||||
|
action: { kind: 'message', messageKey: 'ignite' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rev',
|
||||||
|
input: 'rev',
|
||||||
|
aliases: { de: ['drehzahl'], en: [] },
|
||||||
|
action: { kind: 'message', messageKey: 'rev' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clear',
|
||||||
|
input: 'clear',
|
||||||
|
aliases: { de: ['leeren'], en: [] },
|
||||||
|
action: { kind: 'clear' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'close',
|
||||||
|
input: 'close',
|
||||||
|
aliases: { de: ['schliessen', 'schließen'], en: [] },
|
||||||
|
action: { kind: 'close' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMAND_LOOKUP = buildCommandLookup(COMMANDS);
|
||||||
|
|
||||||
|
function buildCommandLookup(
|
||||||
|
commands: readonly CommandDefinition[],
|
||||||
|
): ReadonlyMap<string, CommandDefinition> {
|
||||||
|
const lookup = new Map<string, CommandDefinition>();
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
lookup.set(normalizeCommandInput(command.input), command);
|
||||||
|
|
||||||
|
for (const locale of APP_LOCALES) {
|
||||||
|
for (const alias of command.aliases[locale]) {
|
||||||
|
lookup.set(normalizeCommandInput(alias), command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCommandInput(raw: string): string {
|
||||||
|
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCommand(raw: string): CommandMatch {
|
||||||
|
const normalized = normalizeCommandInput(raw);
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return { kind: 'empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = COMMAND_LOOKUP.get(normalized);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return { kind: 'unknown', input: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'command', definition };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestCommands(raw: string, locale: AppLocale): readonly CommandDefinition[] {
|
||||||
|
const normalized = normalizeCommandInput(raw);
|
||||||
|
|
||||||
|
return COMMANDS.filter((command) => {
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.input.startsWith(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.aliases[locale].some((alias) =>
|
||||||
|
normalizeCommandInput(alias).startsWith(normalized),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
src/app/shared/motion/_reveal.scss
Normal file
23
src/app/shared/motion/_reveal.scss
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@mixin reveal-target {
|
||||||
|
&.reveal-pending {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-base) var(--ease-standard),
|
||||||
|
transform var(--duration-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-revealed {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
&.reveal-pending,
|
||||||
|
&.is-revealed {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal file
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div class="metric-bar">
|
||||||
|
<div class="metric-bar-header">
|
||||||
|
<span [id]="labelId">{{ label() }}</span>
|
||||||
|
<span>{{ displayValue() }}</span>
|
||||||
|
</div>
|
||||||
|
@if (description(); as descriptionText) {
|
||||||
|
<p class="metric-bar-description">{{ descriptionText }}</p>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
class="metric-bar-track"
|
||||||
|
role="progressbar"
|
||||||
|
[attr.aria-valuenow]="clampedValue()"
|
||||||
|
aria-valuemin="0"
|
||||||
|
[attr.aria-valuemax]="max()"
|
||||||
|
[attr.aria-valuetext]="displayValue()"
|
||||||
|
[attr.aria-labelledby]="labelId"
|
||||||
|
>
|
||||||
|
<div class="metric-bar-fill" [style.width.%]="percent()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal file
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
@use 'app/shared/motion/reveal' as reveal;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
@include reveal.reveal-target;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-track {
|
||||||
|
height: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-accent);
|
||||||
|
transition: width var(--duration-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.metric-bar-fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal file
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MetricBar } from './metric-bar';
|
||||||
|
|
||||||
|
describe('MetricBar', () => {
|
||||||
|
async function createFixture(inputs: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
valueText?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<ComponentFixture<MetricBar>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [MetricBar],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(MetricBar);
|
||||||
|
fixture.componentRef.setInput('label', inputs.label);
|
||||||
|
fixture.componentRef.setInput('value', inputs.value);
|
||||||
|
|
||||||
|
if (inputs.max !== undefined) {
|
||||||
|
fixture.componentRef.setInput('max', inputs.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.valueText !== undefined) {
|
||||||
|
fixture.componentRef.setInput('valueText', inputs.valueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.description !== undefined) {
|
||||||
|
fixture.componentRef.setInput('description', inputs.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the label and value as visible text', async () => {
|
||||||
|
const fixture = await createFixture({
|
||||||
|
label: 'Coverage',
|
||||||
|
value: 40,
|
||||||
|
valueText: '40 of 80',
|
||||||
|
});
|
||||||
|
const text = fixture.nativeElement.textContent ?? '';
|
||||||
|
|
||||||
|
expect(text).toContain('Coverage');
|
||||||
|
expect(text).toContain('40 of 80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes progressbar semantics and an accessible name through aria-labelledby', async () => {
|
||||||
|
const fixture = await createFixture({
|
||||||
|
label: 'Latency',
|
||||||
|
value: 25,
|
||||||
|
max: 50,
|
||||||
|
valueText: '25 ms',
|
||||||
|
});
|
||||||
|
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||||
|
const labelId = bar.getAttribute('aria-labelledby');
|
||||||
|
const label = fixture.nativeElement.querySelector(`#${labelId}`);
|
||||||
|
|
||||||
|
expect(bar.getAttribute('aria-valuenow')).toBe('25');
|
||||||
|
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||||
|
expect(bar.getAttribute('aria-valuemax')).toBe('50');
|
||||||
|
expect(bar.getAttribute('aria-valuetext')).toBe('25 ms');
|
||||||
|
expect(label?.textContent?.trim()).toBe('Latency');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps values below 0 and above max', async () => {
|
||||||
|
const low = await createFixture({ label: 'Low', value: -12, max: 10 });
|
||||||
|
const lowBar = low.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||||
|
const lowFill = low.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||||
|
|
||||||
|
expect(lowBar.getAttribute('aria-valuenow')).toBe('0');
|
||||||
|
expect(lowFill.style.width).toBe('0%');
|
||||||
|
|
||||||
|
const high = await createFixture({ label: 'High', value: 140, max: 50 });
|
||||||
|
const highBar = high.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||||
|
const highFill = high.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||||
|
|
||||||
|
expect(highBar.getAttribute('aria-valuenow')).toBe('50');
|
||||||
|
expect(highFill.style.width).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not produce NaN when max is 0', async () => {
|
||||||
|
const fixture = await createFixture({ label: 'Empty', value: 8, max: 0 });
|
||||||
|
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||||
|
const fill = fixture.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||||
|
|
||||||
|
expect(bar.getAttribute('aria-valuenow')).toBe('0');
|
||||||
|
expect(fill.style.width).toBe('0%');
|
||||||
|
expect(fill.style.width).not.toContain('NaN');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Empty');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal file
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
let metricBarInstanceId = 0;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-metric-bar',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './metric-bar.html',
|
||||||
|
styleUrl: './metric-bar.scss',
|
||||||
|
})
|
||||||
|
export class MetricBar {
|
||||||
|
readonly label = input.required<string>();
|
||||||
|
readonly value = input.required<number>();
|
||||||
|
readonly max = input(100);
|
||||||
|
readonly valueText = input<string | undefined>(undefined);
|
||||||
|
readonly description = input<string | undefined>(undefined);
|
||||||
|
|
||||||
|
private readonly instanceId = metricBarInstanceId++;
|
||||||
|
protected readonly labelId = `metric-bar-label-${this.instanceId}`;
|
||||||
|
|
||||||
|
protected readonly clampedValue = computed(() => {
|
||||||
|
const max = this.max();
|
||||||
|
const value = this.value();
|
||||||
|
|
||||||
|
if (!Number.isFinite(max) || max <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(max, Math.max(0, value));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly percent = computed(() => {
|
||||||
|
const max = this.max();
|
||||||
|
|
||||||
|
if (!Number.isFinite(max) || max <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (this.clampedValue() / max) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly displayValue = computed(() => this.valueText() ?? String(this.clampedValue()));
|
||||||
|
}
|
||||||
181
src/app/shared/motion/reveal.directive.spec.ts
Normal file
181
src/app/shared/motion/reveal.directive.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { ApplicationRef, Component, PLATFORM_ID } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { RevealDirective } from './reveal.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RevealDirective],
|
||||||
|
template: `<div appReveal [appRevealThreshold]="0.2">Reveal host</div>`,
|
||||||
|
})
|
||||||
|
class RevealHost {}
|
||||||
|
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
static instances: MockIntersectionObserver[] = [];
|
||||||
|
|
||||||
|
readonly observe = vi.fn();
|
||||||
|
readonly unobserve = vi.fn();
|
||||||
|
readonly disconnect = vi.fn();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly callback: IntersectionObserverCallback,
|
||||||
|
readonly options?: IntersectionObserverInit,
|
||||||
|
) {
|
||||||
|
MockIntersectionObserver.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger(isIntersecting: boolean): void {
|
||||||
|
this.callback(
|
||||||
|
[{ isIntersecting } as IntersectionObserverEntry],
|
||||||
|
this as unknown as IntersectionObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: (query: string): MediaQueryList =>
|
||||||
|
({
|
||||||
|
matches: matchesQuery(query),
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}) as MediaQueryList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RevealDirective', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
MockIntersectionObserver.instances = [];
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Reflect.deleteProperty(window, 'matchMedia');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createHost(
|
||||||
|
providers: { provide: unknown; useValue: unknown }[] = [],
|
||||||
|
): Promise<ComponentFixture<RevealHost>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [RevealHost],
|
||||||
|
providers,
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(RevealHost);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
TestBed.inject(ApplicationRef).tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostElement(fixture: ComponentFixture<RevealHost>): HTMLElement {
|
||||||
|
return fixture.nativeElement.querySelector('[appReveal]');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reveals immediately on the server without constructing an observer', async () => {
|
||||||
|
const Observer = vi.fn();
|
||||||
|
vi.stubGlobal('IntersectionObserver', Observer);
|
||||||
|
|
||||||
|
const fixture = await createHost([{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||||
|
const element = hostElement(fixture);
|
||||||
|
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
expect(Observer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals immediately when IntersectionObserver is missing', async () => {
|
||||||
|
const original = window.IntersectionObserver;
|
||||||
|
Reflect.deleteProperty(window, 'IntersectionObserver');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fixture = await createHost();
|
||||||
|
const element = hostElement(fixture);
|
||||||
|
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
window.IntersectionObserver = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals immediately when reduced motion is requested', async () => {
|
||||||
|
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||||
|
const Observer = vi.fn();
|
||||||
|
vi.stubGlobal('IntersectionObserver', Observer);
|
||||||
|
|
||||||
|
const fixture = await createHost();
|
||||||
|
const element = hostElement(fixture);
|
||||||
|
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
expect(Observer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the element pending, then revealed, and disconnects on intersection', async () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
|
||||||
|
const fixture = await createHost();
|
||||||
|
const element = hostElement(fixture);
|
||||||
|
const observer = MockIntersectionObserver.instances[0];
|
||||||
|
|
||||||
|
expect(observer).toBeTruthy();
|
||||||
|
expect(observer.observe).toHaveBeenCalled();
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(false);
|
||||||
|
|
||||||
|
observer.trigger(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(true);
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(false);
|
||||||
|
|
||||||
|
observer.trigger(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals on a first intersecting callback without ever adding reveal-pending', async () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
|
||||||
|
const fixture = await createHost();
|
||||||
|
const element = hostElement(fixture);
|
||||||
|
const observer = MockIntersectionObserver.instances[0];
|
||||||
|
const addedTokens: string[] = [];
|
||||||
|
const add = element.classList.add.bind(element.classList);
|
||||||
|
vi.spyOn(element.classList, 'add').mockImplementation((...tokens: string[]) => {
|
||||||
|
addedTokens.push(...tokens);
|
||||||
|
add(...tokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(observer).toBeTruthy();
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
|
||||||
|
observer.trigger(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(addedTokens).not.toContain('reveal-pending');
|
||||||
|
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||||
|
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects when destroyed before intersection', async () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||||
|
|
||||||
|
const fixture = await createHost();
|
||||||
|
const observer = MockIntersectionObserver.instances[0];
|
||||||
|
expect(observer).toBeTruthy();
|
||||||
|
|
||||||
|
fixture.destroy();
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/app/shared/motion/reveal.directive.ts
Normal file
77
src/app/shared/motion/reveal.directive.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
DestroyRef,
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Injector,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isBrowserPlatform, prefersReducedMotion } from '../../core/platform/browser';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appReveal]',
|
||||||
|
})
|
||||||
|
export class RevealDirective {
|
||||||
|
readonly appRevealThreshold = input(0.12);
|
||||||
|
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
|
private readonly reducedMotion = prefersReducedMotion();
|
||||||
|
private observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!this.isBrowser || this.reducedMotion || !this.canObserve()) {
|
||||||
|
this.revealNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterNextRender(() => this.observe(), { injector: this.injector });
|
||||||
|
this.destroyRef.onDestroy(() => this.disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
private canObserve(): boolean {
|
||||||
|
const view = this.document.defaultView;
|
||||||
|
return !!view && typeof view.IntersectionObserver === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
private observe(): void {
|
||||||
|
const view = this.document.defaultView;
|
||||||
|
|
||||||
|
if (!view || typeof view.IntersectionObserver !== 'function') {
|
||||||
|
this.revealNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.observer = new view.IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
this.host.nativeElement.classList.remove('reveal-pending');
|
||||||
|
this.host.nativeElement.classList.add('is-revealed');
|
||||||
|
this.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.nativeElement.classList.add('reveal-pending');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '0px 0px -8% 0px',
|
||||||
|
threshold: this.appRevealThreshold(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.observer.observe(this.host.nativeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealNow(): void {
|
||||||
|
this.host.nativeElement.classList.add('is-revealed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnect(): void {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/shared/systems-map/systems-map.html
Normal file
87
src/app/shared/systems-map/systems-map.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<section class="systems-map" [attr.aria-labelledby]="headingId">
|
||||||
|
@if (headingLevel() === 3) {
|
||||||
|
<h3 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h3>
|
||||||
|
} @else {
|
||||||
|
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
|
||||||
|
}
|
||||||
|
<p class="systems-map-intro">{{ introText() }}</p>
|
||||||
|
|
||||||
|
<div class="systems-map-figure">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1000 560"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
role="group"
|
||||||
|
[attr.aria-labelledby]="svgTitleId"
|
||||||
|
[attr.aria-describedby]="svgDescId"
|
||||||
|
>
|
||||||
|
<title [id]="svgTitleId">{{ headingText() }}</title>
|
||||||
|
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
|
||||||
|
@for (edge of edges(); track edge.from + '-' + edge.to) {
|
||||||
|
<line
|
||||||
|
[attr.x1]="edge.x1"
|
||||||
|
[attr.y1]="edge.y1"
|
||||||
|
[attr.x2]="edge.x2"
|
||||||
|
[attr.y2]="edge.y2"
|
||||||
|
class="systems-map-edge"
|
||||||
|
[class.is-connected]="isConnectedEdge(edge)"
|
||||||
|
[class.is-dimmed]="isDimmedEdge(edge)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@for (node of nodes(); track node.id) {
|
||||||
|
<a
|
||||||
|
[attr.href]="node.href"
|
||||||
|
[attr.aria-label]="node.accessibleName"
|
||||||
|
class="systems-map-node"
|
||||||
|
[attr.data-kind]="node.kind"
|
||||||
|
[class.is-connected]="isConnectedNode(node.id)"
|
||||||
|
[class.is-dimmed]="isDimmedNode(node.id)"
|
||||||
|
(click)="onNodeActivate($event, node)"
|
||||||
|
(focusin)="onNodeEnter(node.id)"
|
||||||
|
(focusout)="onNodeLeave(node.id)"
|
||||||
|
(mouseenter)="onNodeEnter(node.id)"
|
||||||
|
(mouseleave)="onNodeLeave(node.id)"
|
||||||
|
>
|
||||||
|
@if (node.kind === 'ai' || node.kind === 'cluster') {
|
||||||
|
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
|
||||||
|
} @else {
|
||||||
|
<rect
|
||||||
|
[attr.x]="node.x - 70"
|
||||||
|
[attr.y]="node.y - 20"
|
||||||
|
width="140"
|
||||||
|
height="40"
|
||||||
|
rx="8"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<text [attr.x]="node.x" [attr.y]="node.y + 4" aria-hidden="true">{{ node.label }}</text>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="systems-map-list">
|
||||||
|
<p class="systems-map-kicker" [id]="listHeadingId">{{ copy().listHeading }}</p>
|
||||||
|
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
|
||||||
|
@for (node of nodes(); track node.id) {
|
||||||
|
<li>
|
||||||
|
<a [routerLink]="node.link" [attr.aria-label]="node.accessibleName">
|
||||||
|
<span class="systems-map-card-label">{{ node.label }}</span>
|
||||||
|
<span class="systems-map-card-summary">{{ node.summary }}</span>
|
||||||
|
<span class="systems-map-card-relation">{{ node.relationship }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="systems-map-kicker" [id]="legendHeadingId">{{ copy().legendHeading }}</p>
|
||||||
|
<ul class="systems-map-legend" [attr.aria-labelledby]="legendHeadingId">
|
||||||
|
@for (item of legendItems(); track item.kind) {
|
||||||
|
<li [attr.data-kind]="item.kind">{{ item.label }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="systems-map-readout" aria-hidden="true">{{ activeReadout() }}</p>
|
||||||
|
</section>
|
||||||
162
src/app/shared/systems-map/systems-map.model.ts
Normal file
162
src/app/shared/systems-map/systems-map.model.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
|
import { type RouteId } from '../../core/routing/route-ids';
|
||||||
|
|
||||||
|
export type SystemsMapNodeKind = 'ai' | 'cluster' | 'hardware' | 'software' | 'project';
|
||||||
|
|
||||||
|
export type SystemsMapNodeId =
|
||||||
|
| 'ai'
|
||||||
|
| 'clusters'
|
||||||
|
| 'hardware'
|
||||||
|
| 'software'
|
||||||
|
| 'stack'
|
||||||
|
| 'caseMigration'
|
||||||
|
| 'casePlatform'
|
||||||
|
| 'caseAutomation';
|
||||||
|
|
||||||
|
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
|
||||||
|
'ai',
|
||||||
|
'cluster',
|
||||||
|
'hardware',
|
||||||
|
'software',
|
||||||
|
'project',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SystemsMapNode {
|
||||||
|
readonly id: SystemsMapNodeId;
|
||||||
|
readonly kind: SystemsMapNodeKind;
|
||||||
|
readonly routeId: RouteId;
|
||||||
|
readonly label: Record<AppLocale, string>;
|
||||||
|
readonly summary: Record<AppLocale, string>;
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemsMapEdge {
|
||||||
|
readonly from: SystemsMapNodeId;
|
||||||
|
readonly to: SystemsMapNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||||
|
{
|
||||||
|
id: 'hardware',
|
||||||
|
kind: 'hardware',
|
||||||
|
routeId: 'servicesHardwareNetwork',
|
||||||
|
label: { de: 'Hardware', en: 'Hardware' },
|
||||||
|
summary: {
|
||||||
|
de: 'Geräte, Netz und physische Schicht',
|
||||||
|
en: 'Devices, network and the physical layer',
|
||||||
|
},
|
||||||
|
x: 140,
|
||||||
|
y: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clusters',
|
||||||
|
kind: 'cluster',
|
||||||
|
routeId: 'servicesClusters',
|
||||||
|
label: { de: 'Cluster', en: 'Clusters' },
|
||||||
|
summary: {
|
||||||
|
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
|
||||||
|
en: 'Orchestration and cluster operations',
|
||||||
|
},
|
||||||
|
x: 340,
|
||||||
|
y: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'software',
|
||||||
|
kind: 'software',
|
||||||
|
routeId: 'servicesSoftware',
|
||||||
|
label: { de: 'Software', en: 'Software' },
|
||||||
|
summary: {
|
||||||
|
de: 'Anwendungen und Schnittstellen',
|
||||||
|
en: 'Applications and interfaces',
|
||||||
|
},
|
||||||
|
x: 520,
|
||||||
|
y: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai',
|
||||||
|
kind: 'ai',
|
||||||
|
routeId: 'servicesAi',
|
||||||
|
label: { de: 'KI', en: 'AI' },
|
||||||
|
summary: {
|
||||||
|
de: 'Lokale Modelle und Integrationsarbeit',
|
||||||
|
en: 'Local models and integration work',
|
||||||
|
},
|
||||||
|
x: 720,
|
||||||
|
y: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stack',
|
||||||
|
kind: 'software',
|
||||||
|
routeId: 'stack',
|
||||||
|
label: { de: 'Stack', en: 'Stack' },
|
||||||
|
summary: {
|
||||||
|
de: 'Werkzeuge und Laufzeitumgebung',
|
||||||
|
en: 'Tools and runtime environment',
|
||||||
|
},
|
||||||
|
x: 340,
|
||||||
|
y: 460,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'caseMigration',
|
||||||
|
kind: 'project',
|
||||||
|
routeId: 'projects',
|
||||||
|
label: { de: 'Datenmigration', en: 'Data migration' },
|
||||||
|
summary: {
|
||||||
|
de: 'Datenbestände strukturiert überführen',
|
||||||
|
en: 'Moving data stores in a structured way',
|
||||||
|
},
|
||||||
|
x: 860,
|
||||||
|
y: 460,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'casePlatform',
|
||||||
|
kind: 'project',
|
||||||
|
routeId: 'projects',
|
||||||
|
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
|
||||||
|
summary: {
|
||||||
|
de: 'Plattformen betreiben und weiterentwickeln',
|
||||||
|
en: 'Operating and evolving platforms',
|
||||||
|
},
|
||||||
|
x: 860,
|
||||||
|
y: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'caseAutomation',
|
||||||
|
kind: 'project',
|
||||||
|
routeId: 'projects',
|
||||||
|
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
|
||||||
|
summary: {
|
||||||
|
de: 'Abläufe automatisieren und Modelle anbinden',
|
||||||
|
en: 'Automating workflows and connecting models',
|
||||||
|
},
|
||||||
|
x: 900,
|
||||||
|
y: 140,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
|
||||||
|
{ from: 'hardware', to: 'clusters' },
|
||||||
|
{ from: 'clusters', to: 'software' },
|
||||||
|
{ from: 'software', to: 'ai' },
|
||||||
|
{ from: 'ai', to: 'clusters' },
|
||||||
|
{ from: 'hardware', to: 'software' },
|
||||||
|
{ from: 'stack', to: 'software' },
|
||||||
|
{ from: 'ai', to: 'caseAutomation' },
|
||||||
|
{ from: 'software', to: 'casePlatform' },
|
||||||
|
{ from: 'software', to: 'caseMigration' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
|
||||||
|
const connected: SystemsMapNodeId[] = [];
|
||||||
|
|
||||||
|
for (const edge of SYSTEMS_MAP_EDGES) {
|
||||||
|
if (edge.from === id) {
|
||||||
|
connected.push(edge.to);
|
||||||
|
} else if (edge.to === id) {
|
||||||
|
connected.push(edge.from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
188
src/app/shared/systems-map/systems-map.scss
Normal file
188
src/app/shared/systems-map/systems-map.scss
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
@use 'breakpoints' as bp;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-heading,
|
||||||
|
.systems-map-intro,
|
||||||
|
.systems-map-kicker,
|
||||||
|
.systems-map-readout {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-heading {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-intro,
|
||||||
|
.systems-map-card-summary,
|
||||||
|
.systems-map-card-relation,
|
||||||
|
.systems-map-readout {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-kicker {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-figure {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-figure svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--color-surface-muted) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-surface-muted) 1px, transparent 1px);
|
||||||
|
background-size: 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-edge {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-accent-cool);
|
||||||
|
stroke-width: 1.25;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity var(--duration-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node circle,
|
||||||
|
.systems-map-node rect {
|
||||||
|
fill: var(--color-surface-overlay);
|
||||||
|
stroke: var(--color-accent);
|
||||||
|
stroke-width: 1.25;
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-base) var(--ease-standard),
|
||||||
|
stroke var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node[data-kind='project'] rect,
|
||||||
|
.systems-map-legend [data-kind='project'] {
|
||||||
|
stroke: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node[data-kind='ai'] circle,
|
||||||
|
.systems-map-legend [data-kind='ai'] {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node text {
|
||||||
|
fill: currentColor;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-anchor: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node.is-connected circle,
|
||||||
|
.systems-map-node.is-connected rect,
|
||||||
|
.systems-map-edge.is-connected {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node.is-dimmed,
|
||||||
|
.systems-map-edge.is-dimmed {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-cards,
|
||||||
|
.systems-map-legend {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-cards a {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--surface-glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node:focus-visible,
|
||||||
|
.systems-map-cards a:focus-visible {
|
||||||
|
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||||
|
outline-offset: var(--focus-ring-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-node:focus-visible circle,
|
||||||
|
.systems-map-node:focus-visible rect {
|
||||||
|
stroke: var(--focus-ring-color);
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-card-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-legend li {
|
||||||
|
padding-inline-start: var(--space-3);
|
||||||
|
border-inline-start: 2px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-readout {
|
||||||
|
min-height: var(--text-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.systems-map-node:hover circle,
|
||||||
|
.systems-map-node:hover rect {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-cards a:hover {
|
||||||
|
border-color: var(--surface-glass-border-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.systems-map-edge,
|
||||||
|
.systems-map-node circle,
|
||||||
|
.systems-map-node rect {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include bp.respond-to(lg) {
|
||||||
|
.systems-map-figure {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.systems-map-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/app/shared/systems-map/systems-map.spec.ts
Normal file
171
src/app/shared/systems-map/systems-map.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter, Router } from '@angular/router';
|
||||||
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
|
import { routePath } from '../../core/routing/route-paths';
|
||||||
|
import { SystemsMap } from './systems-map';
|
||||||
|
import { connectedNodeIds, SYSTEMS_MAP_NODES } from './systems-map.model';
|
||||||
|
|
||||||
|
describe('SystemsMap', () => {
|
||||||
|
async function createFixture(
|
||||||
|
providers: { provide: unknown; useValue: unknown }[] = [],
|
||||||
|
): Promise<ComponentFixture<SystemsMap>> {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SystemsMap],
|
||||||
|
providers: [provideRouter([]), ...providers],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SystemsMap);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrefsOf(root: ParentNode, selector: string): string[] {
|
||||||
|
return Array.from(root.querySelectorAll(selector)).map((element) => {
|
||||||
|
return element.getAttribute('href') ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedNodes(locale: AppLocale) {
|
||||||
|
const relationshipLabel = SIGNATURE_COPY[locale].systemsMap.relationshipLabel;
|
||||||
|
|
||||||
|
return SYSTEMS_MAP_NODES.map((node) => {
|
||||||
|
const connected = new Set(connectedNodeIds(node.id));
|
||||||
|
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
|
||||||
|
(entry) => entry.label[locale],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
label: node.label[locale],
|
||||||
|
summary: node.summary[locale],
|
||||||
|
href: routePath(node.routeId, locale),
|
||||||
|
connectedLabels,
|
||||||
|
relationship: relationshipLabel.replace('{targets}', connectedLabels.join(', ')),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('exposes the same ordered hrefs in the SVG and the card list from the routing contract', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const locale = TestBed.inject(LocaleService).locale();
|
||||||
|
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, locale));
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
||||||
|
expect(hrefsOf(compiled, '.systems-map-cards a')).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every node a non-empty accessible name with label, summary and neighbors', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
|
||||||
|
const svgAnchors = compiled.querySelectorAll('svg a');
|
||||||
|
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
|
||||||
|
|
||||||
|
expect(svgAnchors.length).toBe(nodes.length);
|
||||||
|
expect(listAnchors.length).toBe(nodes.length);
|
||||||
|
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const svgName = svgAnchors.item(index).getAttribute('aria-label') ?? '';
|
||||||
|
const listName = listAnchors.item(index).getAttribute('aria-label') ?? '';
|
||||||
|
|
||||||
|
expect(svgName.length).toBeGreaterThan(0);
|
||||||
|
expect(listName.length).toBeGreaterThan(0);
|
||||||
|
expect(svgName).toContain(node.label);
|
||||||
|
expect(svgName).toContain(node.summary);
|
||||||
|
expect(listName).toContain(node.label);
|
||||||
|
expect(listName).toContain(node.summary);
|
||||||
|
|
||||||
|
for (const label of node.connectedLabels) {
|
||||||
|
expect(svgName).toContain(label);
|
||||||
|
expect(listName).toContain(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every node keyboard reachable and navigates on a plain left click', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigation = TestBed.inject(NavigationService);
|
||||||
|
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
const anchors = compiled.querySelectorAll('svg a, .systems-map-cards a');
|
||||||
|
|
||||||
|
anchors.forEach((anchor) => {
|
||||||
|
expect(anchor.getAttribute('tabindex')).not.toBe('-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = SYSTEMS_MAP_NODES[0];
|
||||||
|
const svgAnchor = compiled.querySelector('svg a');
|
||||||
|
expect(svgAnchor).toBeTruthy();
|
||||||
|
svgAnchor?.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith(navigation.link(first.routeId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites every href when the locale switches to English', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const locale = TestBed.inject(LocaleService);
|
||||||
|
locale.setLocale('en');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const expected = SYSTEMS_MAP_NODES.map((node) => routePath(node.routeId, 'en'));
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(hrefsOf(compiled, 'svg a')).toEqual(expected);
|
||||||
|
expect(hrefsOf(compiled, '.systems-map-cards a')).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses heading and intro inputs and falls back to signature copy when they are empty', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const defaults = SIGNATURE_COPY.de.systemsMap;
|
||||||
|
|
||||||
|
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
|
||||||
|
defaults.heading,
|
||||||
|
);
|
||||||
|
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(defaults.intro);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('heading', 'Eigene Karte');
|
||||||
|
fixture.componentRef.setInput('intro', 'Eigene Einleitung');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
|
||||||
|
'Eigene Karte',
|
||||||
|
);
|
||||||
|
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(
|
||||||
|
'Eigene Einleitung',
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('heading', '');
|
||||||
|
fixture.componentRef.setInput('intro', ' ');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(compiled.querySelector('.systems-map-heading')?.textContent?.trim()).toBe(
|
||||||
|
defaults.heading,
|
||||||
|
);
|
||||||
|
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(defaults.intro);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders the SVG, card list, links and summaries on the server', async () => {
|
||||||
|
const fixture = await createFixture([{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const svgAnchors = compiled.querySelectorAll('svg a');
|
||||||
|
const listAnchors = compiled.querySelectorAll('.systems-map-cards a');
|
||||||
|
|
||||||
|
expect(svgAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
|
||||||
|
expect(listAnchors.length).toBe(SYSTEMS_MAP_NODES.length);
|
||||||
|
|
||||||
|
expectedNodes(TestBed.inject(LocaleService).locale()).forEach((node, index) => {
|
||||||
|
expect(svgAnchors.item(index).getAttribute('href')).toBe(node.href);
|
||||||
|
expect(listAnchors.item(index).textContent).toContain(node.summary);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
src/app/shared/systems-map/systems-map.ts
Normal file
214
src/app/shared/systems-map/systems-map.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||||
|
import { type AppLocale } from '../../core/i18n/locale';
|
||||||
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
|
import { routePath } from '../../core/routing/route-paths';
|
||||||
|
import {
|
||||||
|
connectedNodeIds,
|
||||||
|
SYSTEMS_MAP_EDGES,
|
||||||
|
SYSTEMS_MAP_NODE_KINDS,
|
||||||
|
SYSTEMS_MAP_NODES,
|
||||||
|
type SystemsMapNode,
|
||||||
|
type SystemsMapNodeId,
|
||||||
|
} from './systems-map.model';
|
||||||
|
|
||||||
|
let systemsMapInstanceId = 0;
|
||||||
|
|
||||||
|
export interface SystemsMapNodeView {
|
||||||
|
readonly id: SystemsMapNodeId;
|
||||||
|
readonly kind: SystemsMapNode['kind'];
|
||||||
|
readonly routeId: SystemsMapNode['routeId'];
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly label: string;
|
||||||
|
readonly summary: string;
|
||||||
|
readonly href: string;
|
||||||
|
readonly link: unknown[];
|
||||||
|
readonly relationship: string;
|
||||||
|
readonly accessibleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemsMapEdgeView {
|
||||||
|
readonly from: SystemsMapNodeId;
|
||||||
|
readonly to: SystemsMapNodeId;
|
||||||
|
readonly x1: number;
|
||||||
|
readonly y1: number;
|
||||||
|
readonly x2: number;
|
||||||
|
readonly y2: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-systems-map',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './systems-map.html',
|
||||||
|
styleUrl: './systems-map.scss',
|
||||||
|
})
|
||||||
|
export class SystemsMap {
|
||||||
|
readonly heading = input('');
|
||||||
|
readonly intro = input('');
|
||||||
|
readonly headingLevel = input<2 | 3>(2);
|
||||||
|
|
||||||
|
private readonly navigation = inject(NavigationService);
|
||||||
|
private readonly localeService = inject(LocaleService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly instanceId = systemsMapInstanceId++;
|
||||||
|
|
||||||
|
protected readonly headingId = `systems-map-heading-${this.instanceId}`;
|
||||||
|
protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`;
|
||||||
|
protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`;
|
||||||
|
protected readonly listHeadingId = `systems-map-list-${this.instanceId}`;
|
||||||
|
protected readonly legendHeadingId = `systems-map-legend-${this.instanceId}`;
|
||||||
|
|
||||||
|
protected readonly activeNodeId = signal<SystemsMapNodeId | null>(null);
|
||||||
|
|
||||||
|
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap);
|
||||||
|
|
||||||
|
protected readonly headingText = computed(() => {
|
||||||
|
const override = this.heading().trim();
|
||||||
|
return override.length > 0 ? override : this.copy().heading;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly introText = computed(() => {
|
||||||
|
const override = this.intro().trim();
|
||||||
|
return override.length > 0 ? override : this.copy().intro;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly nodes = computed(() => {
|
||||||
|
const locale = this.localeService.locale();
|
||||||
|
const copy = this.copy();
|
||||||
|
|
||||||
|
return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale, copy.relationshipLabel));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly edges = computed((): readonly SystemsMapEdgeView[] => {
|
||||||
|
const byId = new Map(SYSTEMS_MAP_NODES.map((node) => [node.id, node]));
|
||||||
|
|
||||||
|
return SYSTEMS_MAP_EDGES.map((edge) => {
|
||||||
|
const from = byId.get(edge.from);
|
||||||
|
const to = byId.get(edge.to);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: edge.from,
|
||||||
|
to: edge.to,
|
||||||
|
x1: from?.x ?? 0,
|
||||||
|
y1: from?.y ?? 0,
|
||||||
|
x2: to?.x ?? 0,
|
||||||
|
y2: to?.y ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly legendItems = computed(() => {
|
||||||
|
const legend = this.copy().legend;
|
||||||
|
|
||||||
|
return SYSTEMS_MAP_NODE_KINDS.map((kind) => ({
|
||||||
|
kind,
|
||||||
|
label: legend[kind],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly activeReadout = computed(() => {
|
||||||
|
const activeId = this.activeNodeId();
|
||||||
|
|
||||||
|
if (!activeId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.nodes().find((entry) => entry.id === activeId);
|
||||||
|
return node ? `${node.label}: ${node.relationship}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void {
|
||||||
|
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void this.router.navigate(this.navigation.link(node.routeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onNodeEnter(id: SystemsMapNodeId): void {
|
||||||
|
this.activeNodeId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onNodeLeave(id: SystemsMapNodeId): void {
|
||||||
|
if (this.activeNodeId() === id) {
|
||||||
|
this.activeNodeId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isConnectedNode(id: SystemsMapNodeId): boolean {
|
||||||
|
const active = this.activeNodeId();
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return active === id || this.neighborSet(active).has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isDimmedNode(id: SystemsMapNodeId): boolean {
|
||||||
|
const active = this.activeNodeId();
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return active !== id && !this.neighborSet(active).has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isConnectedEdge(edge: SystemsMapEdgeView): boolean {
|
||||||
|
const active = this.activeNodeId();
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edge.from === active || edge.to === active;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isDimmedEdge(edge: SystemsMapEdgeView): boolean {
|
||||||
|
const active = this.activeNodeId();
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edge.from !== active && edge.to !== active;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toNodeView(
|
||||||
|
node: SystemsMapNode,
|
||||||
|
locale: AppLocale,
|
||||||
|
relationshipLabel: string,
|
||||||
|
): SystemsMapNodeView {
|
||||||
|
const connected = new Set(connectedNodeIds(node.id));
|
||||||
|
const connectedLabels = SYSTEMS_MAP_NODES.filter((entry) => connected.has(entry.id)).map(
|
||||||
|
(entry) => entry.label[locale],
|
||||||
|
);
|
||||||
|
const relationship = relationshipLabel.replace('{targets}', connectedLabels.join(', '));
|
||||||
|
const label = node.label[locale];
|
||||||
|
const summary = node.summary[locale];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
kind: node.kind,
|
||||||
|
routeId: node.routeId,
|
||||||
|
x: node.x,
|
||||||
|
y: node.y,
|
||||||
|
label,
|
||||||
|
summary,
|
||||||
|
href: routePath(node.routeId, locale),
|
||||||
|
link: this.navigation.link(node.routeId),
|
||||||
|
relationship,
|
||||||
|
accessibleName: `${label}. ${summary} ${relationship}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private neighborSet(id: SystemsMapNodeId): ReadonlySet<SystemsMapNodeId> {
|
||||||
|
return new Set(connectedNodeIds(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user