integration: compact the wide primary navigation and keep fragment targets visible
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<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('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<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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user