integration: compact the wide primary navigation and keep fragment targets visible
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,21 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
test.describe('fragment scrolling', () => {
|
const VIEWPORTS = [768, 820, 1024, 1280, 1440] as const;
|
||||||
test('case headings clear the sticky header at desktop width', async ({ page }, testInfo) => {
|
const VIEWPORT_HEIGHT = 900;
|
||||||
test.skip(testInfo.project.name === 'mobile', 'sticky header applies from md up');
|
const MAX_HEADER_HEIGHT = 200;
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
|
async function waitForScrollSettle(page: Page): Promise<void> {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
let last = window.scrollY;
|
let last = window.scrollY;
|
||||||
@@ -36,12 +27,53 @@ test.describe('fragment scrolling', () => {
|
|||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('fragment scrolling', () => {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const width of VIEWPORTS) {
|
||||||
|
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
await waitForScrollSettle(page);
|
||||||
|
|
||||||
const headerBox = await header.boundingBox();
|
const headerBox = await header.boundingBox();
|
||||||
const headingBox = await heading.boundingBox();
|
expect(headerBox, label).toBeTruthy();
|
||||||
expect(headerBox, path).toBeTruthy();
|
expect(
|
||||||
expect(headingBox, path).toBeTruthy();
|
headerBox!.height,
|
||||||
expect(headingBox!.y, path).toBeGreaterThanOrEqual(headerBox!.y + headerBox!.height - 1);
|
`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));
|
||||||
|
});
|
||||||
|
expect(headingOwnsCentre, `heading centre must not be covered at ${label}`).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
Reference in New Issue
Block a user