diff --git a/e2e/fragments.e2e.ts b/e2e/fragments.e2e.ts new file mode 100644 index 0000000..fafd8ed --- /dev/null +++ b/e2e/fragments.e2e.ts @@ -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((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); + } + }); +}); diff --git a/e2e/keyboard.e2e.ts b/e2e/keyboard.e2e.ts index af36b0b..b3fc896 100644 --- a/e2e/keyboard.e2e.ts +++ b/e2e/keyboard.e2e.ts @@ -10,16 +10,48 @@ test.describe('keyboard and palette', () => { 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(/]*class="site"/); + expect(html).not.toMatch(/]*class="[^"]*\bsite\b[^"]*\bnav-collapsed\b/); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); const toggle = page.locator('.nav-toggle'); await expect(toggle).toBeVisible(); - await expect(toggle).toHaveAttribute('aria-expanded', 'true'); - await toggle.click(); await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await toggle.click(); 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 ({ diff --git a/e2e/seo.e2e.ts b/e2e/seo.e2e.ts index a39bdde..ad21f7c 100644 --- a/e2e/seo.e2e.ts +++ b/e2e/seo.e2e.ts @@ -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 { expectHead, jsonLdGraph, pagePath } from './helpers'; +async function ensurePrimaryNavOpen(page: Page): Promise { + 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('German and English Home include the full head contract', async ({ request, page }) => { await expectHead(request, 'home', 'de'); @@ -40,6 +48,7 @@ test.describe('server-rendered metadata', () => { test('client navigation does not duplicate head tags', async ({ page }) => { await page.goto('/'); + await ensurePrimaryNavOpen(page); await page.locator('a.contact-cta').first().waitFor(); const assertUnique = async (canonical: string, description: string) => { @@ -57,6 +66,7 @@ test.describe('server-rendered metadata', () => { ); }; + await ensurePrimaryNavOpen(page); await page .locator(`a[href="${pagePath('projects', 'de')}"]`) .first() @@ -67,6 +77,7 @@ test.describe('server-rendered metadata', () => { SITE_CONTENT_DATA.de.pages.projects.description, ); + await ensurePrimaryNavOpen(page); await page .locator(`a[href="${pagePath('servicesAi', 'de')}"]`) .first() diff --git a/src/app/app.scss b/src/app/app.scss index defb5d7..09c5067 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -14,7 +14,7 @@ } .site-header { - position: sticky; + position: relative; top: 0; z-index: 10; padding-block: var(--space-3); @@ -157,6 +157,10 @@ } @include bp.respond-to(md) { + .site-header { + position: sticky; + } + .nav-toggle { display: none; } diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index bc2450b..ccebb65 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,28 +1,67 @@ -import { ApplicationRef } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { provideRouter, TitleStrategy } from '@angular/router'; -import { App } from './app'; +import { ApplicationRef, PLATFORM_ID } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { provideRouter, Router, TitleStrategy } from '@angular/router'; +import { App, WIDE_NAV_QUERY } from './app'; import { routes } from './app.routes'; import { SITE_CONTENT } from './core/content/content.token'; import { SITE_CONTENT_DATA } from './core/content/site-content'; 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 { + 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): Promise { + 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', () => { beforeEach(async () => { vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + mockViewport(true); - await TestBed.configureTestingModule({ - imports: [App], - providers: [ - provideRouter(routes), - { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }, - { provide: TitleStrategy, useClass: SeoTitleStrategy }, - ], - }).compileComponents(); + await configureApp(); }); afterEach(() => { vi.restoreAllMocks(); + Reflect.deleteProperty(window, 'matchMedia'); }); it('should create the shell', async () => { @@ -72,4 +111,60 @@ describe('App', () => { expect(site?.contains(dialog)).toBe(false); 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'); + }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 9ecc773..f2c039b 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,15 +1,30 @@ -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { DOCUMENT, ViewportScroller } from '@angular/common'; +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 { SHELL_COPY } from './core/content/shell-copy'; import { SITE_CONFIG } from './core/content/site-config'; import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale'; import { LocaleService } from './core/i18n/locale.service'; import { NavigationService } from './core/navigation/navigation.service'; +import { isBrowserPlatform, viewportMatches } from './core/platform/browser'; import { CommandPalette } from './shared/command-palette/command-palette'; import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger'; 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({ selector: 'app-root', changeDetection: ChangeDetectionStrategy.OnPush, @@ -28,6 +43,12 @@ export class App { protected readonly navigation = inject(NavigationService); protected readonly localeService = inject(LocaleService); 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 navOpen = signal(true); @@ -38,7 +59,61 @@ export class App { 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 { 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; + } } diff --git a/src/styles.scss b/src/styles.scss index 93f2103..7875787 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,3 +1,5 @@ +@use 'breakpoints' as bp; + /* Design tokens — the only place raw brand hex values are defined. */ :root { /* Color — surface ramp built on #0a0a0f */ @@ -79,6 +81,16 @@ --focus-ring-color: var(--color-accent); --focus-ring-width: 2px; --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; } +html, +body { + scroll-padding-top: var(--header-offset); +} + html { color-scheme: dark; scroll-behavior: smooth; } +:where(article, section, h1, h2, h3)[id] { + scroll-margin-top: var(--header-offset); +} + body { margin: 0; background: var(--color-surface);