integration: collapse the mobile nav after hydration and keep fragment targets clear of the sticky header

SSR still ships the expanded menu, but phones collapse it after render, stop sticking the header below md, and use the header-offset token so case anchors no longer sit underneath the bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 19:32:13 +02:00
parent 271df12334
commit 1d2965a7ff
7 changed files with 304 additions and 19 deletions

47
e2e/fragments.e2e.ts Normal file
View File

@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
import { APP_LOCALES } from '../src/app/core/i18n/locale';
import { pagePath } from './helpers';
test.describe('fragment scrolling', () => {
test('case headings clear the sticky header at desktop width', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'sticky header applies from md up');
await page.setViewportSize({ width: 1440, height: 900 });
for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header');
const heading = page.locator('#myspa h2');
await expect(heading).toBeVisible();
await page.evaluate(async () => {
await new Promise<void>((resolve) => {
let last = window.scrollY;
let stableFrames = 0;
const tick = () => {
if (window.scrollY === last) {
stableFrames += 1;
if (stableFrames >= 8) {
resolve();
return;
}
} else {
stableFrames = 0;
last = window.scrollY;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
});
const headerBox = await header.boundingBox();
const headingBox = await heading.boundingBox();
expect(headerBox, path).toBeTruthy();
expect(headingBox, path).toBeTruthy();
expect(headingBox!.y, path).toBeGreaterThanOrEqual(headerBox!.y + headerBox!.height - 1);
}
});
});

View File

@@ -10,16 +10,48 @@ test.describe('keyboard and palette', () => {
await expect(page.locator('#main-content')).toBeFocused(); await expect(page.locator('#main-content')).toBeFocused();
}); });
test('mobile nav toggle keeps aria-expanded in sync', async ({ page }) => { test('mobile nav toggle keeps aria-expanded in sync', async ({ page, request }) => {
const ssr = await request.get('/');
expect(ssr.ok()).toBe(true);
const html = await ssr.text();
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('id="primary-nav"');
expect(html).toContain('class="site-nav"');
expect(html).toMatch(/<div[^>]*class="site"/);
expect(html).not.toMatch(/<div[^>]*class="[^"]*\bsite\b[^"]*\bnav-collapsed\b/);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/'); await page.goto('/');
const toggle = page.locator('.nav-toggle'); const toggle = page.locator('.nav-toggle');
await expect(toggle).toBeVisible(); await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click(); await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true'); await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('collapsed header stays under 25% of the viewport and is not sticky below md', async ({
page,
}) => {
const viewport = { width: 390, height: 844 };
await page.setViewportSize(viewport);
await page.goto('/');
const header = page.locator('.site-header');
const box = await header.boundingBox();
expect(box).toBeTruthy();
expect(box!.height).toBeLessThan(viewport.height * 0.25);
await expect
.poll(async () => header.evaluate((element) => getComputedStyle(element).position))
.not.toBe('sticky');
await page.locator('.nav-toggle').click();
await expect(page.locator('.nav-toggle')).toHaveAttribute('aria-expanded', 'true');
expect(await header.evaluate((element) => getComputedStyle(element).position)).not.toBe(
'sticky',
);
}); });
test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({ test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({

View File

@@ -1,7 +1,15 @@
import { expect, test } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content'; import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { expectHead, jsonLdGraph, pagePath } from './helpers'; import { expectHead, jsonLdGraph, pagePath } from './helpers';
async function ensurePrimaryNavOpen(page: Page): Promise<void> {
const toggle = page.locator('.nav-toggle');
if ((await toggle.isVisible()) && (await toggle.getAttribute('aria-expanded')) === 'false') {
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
}
test.describe('server-rendered metadata', () => { test.describe('server-rendered metadata', () => {
test('German and English Home include the full head contract', async ({ request, page }) => { test('German and English Home include the full head contract', async ({ request, page }) => {
await expectHead(request, 'home', 'de'); await expectHead(request, 'home', 'de');
@@ -40,6 +48,7 @@ test.describe('server-rendered metadata', () => {
test('client navigation does not duplicate head tags', async ({ page }) => { test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await ensurePrimaryNavOpen(page);
await page.locator('a.contact-cta').first().waitFor(); await page.locator('a.contact-cta').first().waitFor();
const assertUnique = async (canonical: string, description: string) => { const assertUnique = async (canonical: string, description: string) => {
@@ -57,6 +66,7 @@ test.describe('server-rendered metadata', () => {
); );
}; };
await ensurePrimaryNavOpen(page);
await page await page
.locator(`a[href="${pagePath('projects', 'de')}"]`) .locator(`a[href="${pagePath('projects', 'de')}"]`)
.first() .first()
@@ -67,6 +77,7 @@ test.describe('server-rendered metadata', () => {
SITE_CONTENT_DATA.de.pages.projects.description, SITE_CONTENT_DATA.de.pages.projects.description,
); );
await ensurePrimaryNavOpen(page);
await page await page
.locator(`a[href="${pagePath('servicesAi', 'de')}"]`) .locator(`a[href="${pagePath('servicesAi', 'de')}"]`)
.first() .first()

View File

@@ -14,7 +14,7 @@
} }
.site-header { .site-header {
position: sticky; position: relative;
top: 0; top: 0;
z-index: 10; z-index: 10;
padding-block: var(--space-3); padding-block: var(--space-3);
@@ -157,6 +157,10 @@
} }
@include bp.respond-to(md) { @include bp.respond-to(md) {
.site-header {
position: sticky;
}
.nav-toggle { .nav-toggle {
display: none; display: none;
} }

View File

@@ -1,28 +1,67 @@
import { ApplicationRef } from '@angular/core'; import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { provideRouter, TitleStrategy } from '@angular/router'; import { provideRouter, Router, TitleStrategy } from '@angular/router';
import { App } from './app'; import { App, WIDE_NAV_QUERY } from './app';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token'; import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content'; import { SITE_CONTENT_DATA } from './core/content/site-content';
import { SeoTitleStrategy } from './core/seo/seo-title.strategy'; import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
function mockViewport(wide: boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: wide && query === WIDE_NAV_QUERY,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
async function configureApp(
extraProviders: { provide: unknown; useValue: unknown }[] = [],
): Promise<void> {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
...extraProviders,
],
}).compileComponents();
}
async function flush(fixture: ComponentFixture<App>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function toggle(root: HTMLElement): HTMLButtonElement {
return root.querySelector('.nav-toggle') as HTMLButtonElement;
}
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(true);
await TestBed.configureTestingModule({ await configureApp();
imports: [App],
providers: [
provideRouter(routes),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
],
}).compileComponents();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
}); });
it('should create the shell', async () => { it('should create the shell', async () => {
@@ -72,4 +111,60 @@ describe('App', () => {
expect(site?.contains(dialog)).toBe(false); expect(site?.contains(dialog)).toBe(false);
expect(site?.hasAttribute('inert')).toBe(true); expect(site?.hasAttribute('inert')).toBe(true);
}); });
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
const shell = fixture.componentInstance as unknown as { navOpen: () => boolean };
expect(shell.navOpen()).toBe(true);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-controls')).toBe('primary-nav');
expect(button.getAttribute('aria-label')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
true,
);
});
it('does not collapse the nav on the server even when the viewport helper would be narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
const fixture = TestBed.createComponent(App);
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('true');
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
false,
);
});
it('closes the nav on navigation when the viewport is narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-expanded')).toBe('true');
await TestBed.inject(Router).navigateByUrl('/projekte');
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('false');
});
}); });

View File

@@ -1,15 +1,30 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { DOCUMENT, ViewportScroller } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { DotBackground } from './components/dot-background/dot-background'; import { DotBackground } from './components/dot-background/dot-background';
import { SHELL_COPY } from './core/content/shell-copy'; import { SHELL_COPY } from './core/content/shell-copy';
import { SITE_CONFIG } from './core/content/site-config'; 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 { isBrowserPlatform, viewportMatches } from './core/platform/browser';
import { CommandPalette } from './shared/command-palette/command-palette'; import { CommandPalette } from './shared/command-palette/command-palette';
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger'; import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
import { CommandPaletteService } from './shared/command-palette/command-palette.service'; import { CommandPaletteService } from './shared/command-palette/command-palette.service';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -28,6 +43,12 @@ export class App {
protected readonly navigation = inject(NavigationService); protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService); protected readonly localeService = inject(LocaleService);
protected readonly palette = inject(CommandPaletteService); protected readonly palette = inject(CommandPaletteService);
private readonly router = inject(Router);
private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT);
private readonly viewportScroller = inject(ViewportScroller);
private readonly isBrowser = isBrowserPlatform();
private readonly wideNav = viewportMatches(WIDE_NAV_QUERY);
protected readonly siteConfig = SITE_CONFIG; protected readonly siteConfig = SITE_CONFIG;
protected readonly navOpen = signal(true); protected readonly navOpen = signal(true);
@@ -38,7 +59,61 @@ export class App {
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen, this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
); );
constructor() {
afterNextRender(
() => {
this.collapseNavIfNarrow();
this.bindHeaderScrollOffset();
},
{ injector: this.injector },
);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.collapseNavIfNarrow());
}
protected toggleNav(): void { protected toggleNav(): void {
this.navOpen.update((open) => !open); this.navOpen.update((open) => !open);
} }
private collapseNavIfNarrow(): void {
if (!this.isBrowser || this.wideNav) {
return;
}
this.navOpen.set(false);
}
private bindHeaderScrollOffset(): void {
if (!this.isBrowser) {
return;
}
this.viewportScroller.setOffset(() => [0, this.headerOffsetPx()]);
}
private headerOffsetPx(): number {
const view = this.document.defaultView;
if (!view) {
return 0;
}
const styles = view.getComputedStyle(this.document.documentElement);
const raw = styles.getPropertyValue('--header-offset').trim();
const numeric = Number.parseFloat(raw);
if (!Number.isFinite(numeric)) {
return 0;
}
if (raw.endsWith('rem')) {
const rootSize = Number.parseFloat(styles.fontSize);
return numeric * (Number.isFinite(rootSize) ? rootSize : 16);
}
return numeric;
}
} }

View File

@@ -1,3 +1,5 @@
@use 'breakpoints' as bp;
/* Design tokens — the only place raw brand hex values are defined. */ /* Design tokens — the only place raw brand hex values are defined. */
:root { :root {
/* Color — surface ramp built on #0a0a0f */ /* Color — surface ramp built on #0a0a0f */
@@ -79,6 +81,16 @@
--focus-ring-color: var(--color-accent); --focus-ring-color: var(--color-accent);
--focus-ring-width: 2px; --focus-ring-width: 2px;
--focus-ring-offset: 3px; --focus-ring-offset: 3px;
/* Scroll offset: no sticky header below md, so phones need no extra inset. */
--header-offset: 0rem;
}
@include bp.respond-to(md) {
:root {
/* Matches the sticky header once the primary nav and service list sit in one row. */
--header-offset: 22rem;
}
} }
*, *,
@@ -87,11 +99,20 @@
box-sizing: border-box; box-sizing: border-box;
} }
html,
body {
scroll-padding-top: var(--header-offset);
}
html { html {
color-scheme: dark; color-scheme: dark;
scroll-behavior: smooth; scroll-behavior: smooth;
} }
:where(article, section, h1, h2, h3)[id] {
scroll-margin-top: var(--header-offset);
}
body { body {
margin: 0; margin: 0;
background: var(--color-surface); background: var(--color-surface);