integration: compact the wide primary navigation and keep fragment targets visible

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 19:55:22 +02:00
parent 575ba06bb9
commit b15dfd7801
6 changed files with 163 additions and 67 deletions

View File

@@ -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 { APP_LOCALES } from '../src/app/core/i18n/locale';
import { pagePath } from './helpers'; 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<void> {
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);
});
});
}
test.describe('fragment scrolling', () => { test.describe('fragment scrolling', () => {
test('case headings clear the sticky header at desktop width', async ({ page }, testInfo) => { test('case headings stay fully visible under the compact header', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'sticky header applies from md up'); 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) { for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`; const path = `${pagePath('projects', locale)}#myspa`;
await page.goto(path, { waitUntil: 'networkidle' }); const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header'); const header = page.locator('.site-header');
const heading = page.locator('#myspa h2'); const heading = page.locator('#myspa h2');
await expect(heading).toBeVisible(); await expect(heading).toBeVisible();
await page.evaluate(async () => { await waitForScrollSettle(page);
await new Promise<void>((resolve) => {
let last = window.scrollY; const headerBox = await header.boundingBox();
let stableFrames = 0; expect(headerBox, label).toBeTruthy();
const tick = () => { expect(
if (window.scrollY === last) { headerBox!.height,
stableFrames += 1; `header height at ${label} must be at most ${MAX_HEADER_HEIGHT}px`,
if (stableFrames >= 8) { ).toBeLessThanOrEqual(MAX_HEADER_HEIGHT);
resolve(); expect(
return; headerBox!.height,
} `header height at ${label} must be at most 25% of the viewport`,
} else { ).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.25);
stableFrames = 0;
last = window.scrollY; await expect(header).not.toHaveCSS('position', 'sticky');
} await expect(heading).toBeInViewport({ ratio: 1 });
requestAnimationFrame(tick);
}; const headingOwnsCentre = await heading.evaluate((element) => {
requestAnimationFrame(tick); 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));
}); });
}); expect(headingOwnsCentre, `heading centre must not be covered at ${label}`).toBe(true);
}
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

@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { pagePath } from './helpers';
test.describe('keyboard and palette', () => { test.describe('keyboard and palette', () => {
test('skip link is first and moves focus to main', async ({ page }) => { 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( expect(await header.evaluate((element) => getComputedStyle(element).position)).not.toBe(
'sticky', '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 ({ test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({

View File

@@ -78,10 +78,17 @@ test.describe('server-rendered metadata', () => {
); );
await ensurePrimaryNavOpen(page); await ensurePrimaryNavOpen(page);
await page const servicesParent = page.locator(
.locator(`a[href="${pagePath('servicesAi', 'de')}"]`) `.primary-nav > li > a[href="${pagePath('services', 'de')}"]`,
.first() );
.click(); 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 page.waitForURL('**/leistungen/ai-integration');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(

View File

@@ -31,7 +31,7 @@
>{{ item.label }}</a >{{ item.label }}</a
> >
@if (item.children; as children) { @if (item.children; as children) {
<ul> <ul class="submenu">
@for (child of children; track child.routeId) { @for (child of children; track child.routeId) {
<li> <li>
<a <a

View File

@@ -157,43 +157,74 @@
} }
@include bp.respond-to(md) { @include bp.respond-to(md) {
.site-header {
position: sticky;
}
.nav-toggle { .nav-toggle {
display: none; display: none;
} }
.site-header {
padding-block: var(--space-2);
}
.site-header-inner { .site-header-inner {
grid-template-columns: auto 1fr auto auto; grid-template-columns: 1fr auto auto;
align-items: center; align-items: center;
gap: var(--space-2);
padding-block: var(--space-2);
} }
.site-nav, .site-nav,
.nav-collapsed .site-nav {
display: flex;
grid-column: 1 / -1;
order: 5;
}
.site-actions, .site-actions,
.nav-collapsed .site-nav,
.nav-collapsed .site-actions { .nav-collapsed .site-actions {
display: flex; display: flex;
grid-column: auto; grid-column: auto;
}
.site-toolbar {
order: 3; order: 3;
} }
.site-actions { .site-toolbar {
order: 4; order: 2;
} }
.primary-nav { .primary-nav {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: flex-start; align-items: center;
gap: var(--space-4); gap: var(--space-3);
} }
.primary-nav ul { .primary-nav > li {
padding-inline-start: 0; position: relative;
}
.primary-nav > li > a {
white-space: nowrap;
}
.primary-nav > li > .submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: max-content;
z-index: 20;
padding: var(--space-2) var(--space-3);
gap: var(--space-1);
border-radius: var(--radius-md);
background: var(--surface-glass);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-soft);
}
.primary-nav > li:hover > .submenu,
.primary-nav > li:focus-within > .submenu {
display: flex;
flex-direction: column;
} }
} }

View File

@@ -1,5 +1,3 @@
@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 */
@@ -82,15 +80,8 @@
--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 is no longer sticky, so the inset is only breathing room. */
--header-offset: 0rem; --header-offset: var(--space-4);
}
@include bp.respond-to(md) {
:root {
/* Matches the sticky header once the primary nav and service list sit in one row. */
--header-offset: 22rem;
}
} }
*, *,