diff --git a/e2e/fragments.e2e.ts b/e2e/fragments.e2e.ts index fafd8ed..aedb98c 100644 --- a/e2e/fragments.e2e.ts +++ b/e2e/fragments.e2e.ts @@ -1,47 +1,79 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { APP_LOCALES } from '../src/app/core/i18n/locale'; import { pagePath } from './helpers'; +const VIEWPORTS = [768, 820, 1024, 1280, 1440] as const; +const VIEWPORT_HEIGHT = 900; +const MAX_HEADER_HEIGHT = 200; + +async function waitForScrollSettle(page: Page): Promise { + 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); + }); + }); +} + 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'); + test('case headings stay fully visible under the compact header', async ({ page }, testInfo) => { + test.skip( + testInfo.project.name === 'mobile', + 'desktop widths are measured here to avoid a duplicate run', + ); - await page.setViewportSize({ width: 1440, height: 900 }); + for (const width of VIEWPORTS) { + await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); - for (const locale of APP_LOCALES) { - const path = `${pagePath('projects', locale)}#myspa`; - await page.goto(path, { waitUntil: 'networkidle' }); + for (const locale of APP_LOCALES) { + const path = `${pagePath('projects', locale)}#myspa`; + const label = `${width}px ${locale}`; + await page.goto(path, { waitUntil: 'networkidle' }); - const header = page.locator('.site-header'); - const heading = page.locator('#myspa h2'); - await expect(heading).toBeVisible(); + 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); + await waitForScrollSettle(page); + + const headerBox = await header.boundingBox(); + expect(headerBox, label).toBeTruthy(); + expect( + headerBox!.height, + `header height at ${label} must be at most ${MAX_HEADER_HEIGHT}px`, + ).toBeLessThanOrEqual(MAX_HEADER_HEIGHT); + expect( + headerBox!.height, + `header height at ${label} must be at most 25% of the viewport`, + ).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.25); + + await expect(header).not.toHaveCSS('position', 'sticky'); + await expect(heading).toBeInViewport({ ratio: 1 }); + + const headingOwnsCentre = await heading.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const node = document.elementFromPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + return node !== null && (element === node || element.contains(node)); }); - }); - - 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); + expect(headingOwnsCentre, `heading centre must not be covered at ${label}`).toBe(true); + } } }); }); diff --git a/e2e/keyboard.e2e.ts b/e2e/keyboard.e2e.ts index b3fc896..3570c90 100644 --- a/e2e/keyboard.e2e.ts +++ b/e2e/keyboard.e2e.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { pagePath } from './helpers'; test.describe('keyboard and palette', () => { test('skip link is first and moves focus to main', async ({ page }) => { @@ -52,6 +53,40 @@ test.describe('keyboard and palette', () => { expect(await header.evaluate((element) => getComputedStyle(element).position)).not.toBe( 'sticky', ); + + for (const width of [768, 820, 1024, 1280, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto('/'); + expect( + await header.evaluate((element) => getComputedStyle(element).position), + `header must not be sticky at ${width}px`, + ).not.toBe('sticky'); + } + }); + + test('compact services submenu stays keyboard accessible at 1024px', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name === 'mobile', 'compact submenu applies from md up'); + + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto('/'); + + const servicesParent = page.locator( + `.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, + ); + const firstChild = page.locator( + `.primary-nav > li > .submenu a[href="${pagePath('servicesSoftware', 'de')}"]`, + ); + + await servicesParent.focus(); + await expect(firstChild).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(firstChild).toBeFocused(); + + const headerBox = await page.locator('.site-header').boundingBox(); + expect(headerBox, 'header while submenu is open').toBeTruthy(); + expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200); }); 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 ad21f7c..77b0773 100644 --- a/e2e/seo.e2e.ts +++ b/e2e/seo.e2e.ts @@ -78,10 +78,17 @@ test.describe('server-rendered metadata', () => { ); await ensurePrimaryNavOpen(page); - await page - .locator(`a[href="${pagePath('servicesAi', 'de')}"]`) - .first() - .click(); + const servicesParent = page.locator( + `.primary-nav > li > a[href="${pagePath('services', 'de')}"]`, + ); + const servicesAiLink = page + .locator(`.primary-nav a[href="${pagePath('servicesAi', 'de')}"]`) + .first(); + if (!(await servicesAiLink.isVisible())) { + await servicesParent.hover(); + await expect(servicesAiLink).toBeVisible(); + } + await servicesAiLink.click(); await page.waitForURL('**/leistungen/ai-integration'); await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( diff --git a/src/app/app.html b/src/app/app.html index ce3b096..53afa24 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -31,7 +31,7 @@ >{{ item.label }} @if (item.children; as children) { -