Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ async function profilesOnlyLoader() {

// Unified base layout for public/protected wrappers
function BaseLayout({ children, mode }: { children: React.ReactNode, mode: 'public' | 'app' }) {
const baseClasses = 'relative flex flex-col min-h-screen overflow-x-hidden bg-[var(--shadcn-ui-app-background)]';
// overflow-x-clip, not -hidden: `hidden` computes overflow-y:auto and turns the
// wrapper into a scroll container nested inside the viewport scroller.
const baseClasses = 'relative flex flex-col min-h-screen overflow-x-clip bg-[var(--shadcn-ui-app-background)]';
if (mode === 'public') {
return (
<div data-testid="public-layout" className={baseClasses + ' w-full'} style={{ width: '100vw', maxWidth: '100vw' }}>
Expand Down
89 changes: 89 additions & 0 deletions app/src/__tests__/e2e/layout/faq-expand-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test';
import { registerMocks } from '../../mocks/registerMocks';

// Expanding an FAQ answer while scrolled must keep the viewport where it is.
// Mobile projects only: the report is a phone-viewport regression.

const MOBILE_PROJECTS = ['chromium-mobile-dark', 'iphone15pro-dark'];
const LAST_QUESTION = 'Do you support 2FA?';
// Height transition is 300ms; give layout time to settle before measuring.
const SETTLE_MS = 600;

async function openFaqScrolledToLastQuestion(page: Page) {
await registerMocks(page);
await page.goto('/faq');
await page.waitForLoadState('networkidle');
const lastQuestion = page.getByRole('button', { name: LAST_QUESTION });
await lastQuestion.scrollIntoViewIfNeeded();
await page.waitForTimeout(SETTLE_MS);
const scrollYBefore = await page.evaluate(() => window.scrollY);
expect(scrollYBefore).toBeGreaterThan(500);
return { lastQuestion, scrollYBefore };
}

test.describe('FAQ expand keeps scroll position', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(async ({}, testInfo) => {
test.skip(!MOBILE_PROJECTS.includes(testInfo.project.name), 'mobile projects only');
});

test('expanding the last answer does not move the viewport', async ({ page }) => {
const { lastQuestion, scrollYBefore } = await openFaqScrolledToLastQuestion(page);
await lastQuestion.click();
await page.waitForTimeout(SETTLE_MS);
const scrollYAfter = await page.evaluate(() => window.scrollY);
expect(Math.abs(scrollYAfter - scrollYBefore)).toBeLessThan(8);
await expect(page.getByText('Two-Factor Authentication adds an additional layer')).toBeInViewport();
});

test('expand all from a scrolled position does not jump to the top', async ({ page }) => {
const { scrollYBefore } = await openFaqScrolledToLastQuestion(page);
// The control sits at the top of the page; drive it without scrolling there.
await page.getByRole('button', { name: 'Expand All' }).dispatchEvent('click');
await page.waitForTimeout(SETTLE_MS);
const scrollYAfter = await page.evaluate(() => window.scrollY);
expect(scrollYAfter).toBeGreaterThan(0);
// Content above grew, so the offset may rise; it must never fall back to the top.
expect(scrollYAfter).toBeGreaterThanOrEqual(scrollYBefore - 8);
});

test('no scrollable space below the footer links', async ({ page }) => {
await registerMocks(page);
await page.goto('/faq');
await page.waitForLoadState('networkidle');
// Collapsed answers must not push the document past the footer: absolutely
// positioned descendants need the clipped wrapper as their containing block.
const { scrollHeight, footerBottom } = await page.evaluate(() => {
const link = document.querySelector('a[title="Go to Terms of Service page"]') as HTMLElement;
const footer = link.closest('div')!.parentElement as HTMLElement;
return {
scrollHeight: document.documentElement.scrollHeight,
footerBottom: footer.getBoundingClientRect().bottom + window.scrollY,
};
});
// 32px is the page wrapper's bottom padding.
expect(scrollHeight - footerBottom).toBeLessThanOrEqual(40);
});

test('the document is the only vertical scroller', async ({ page }) => {
await registerMocks(page);
await page.goto('/faq');
await page.waitForLoadState('networkidle');
// A wrapper that clips or scrolls vertically AND holds more content than it
// shows is a nested scroller; those are what confuse mobile engines.
const nested = await page.evaluate(() => {
const found: string[] = [];
for (const el of document.querySelectorAll<HTMLElement>('body *')) {
const overflowY = getComputedStyle(el).overflowY;
if (overflowY === 'visible' || overflowY === 'clip') continue;
// Collapsed answers (height 0) and sr-only spans clip on purpose.
if (el.clientHeight <= 1) continue;
if (el.scrollHeight > el.clientHeight + 1) {
found.push(`${el.tagName.toLowerCase()}.${el.className.toString().slice(0, 60)} ${el.clientHeight}/${el.scrollHeight}`);
}
}
return found;
});
expect(nested).toEqual([]);
});
});
4 changes: 2 additions & 2 deletions app/src/pages/legal/FAQ.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function FAQItem({ question, answer, globalToggleSignal, globalToggleState }: FA
<div
ref={contentRef}
style={{ height }}
className="overflow-hidden transition-all duration-300 ease-in-out"
className="relative overflow-hidden transition-all duration-300 ease-in-out"
>
<div className="p-4 pt-0 text-[var(--shadcn-ui-app-foreground)] leading-relaxed">
{typeof answer === 'string' ? (
Expand Down Expand Up @@ -837,7 +837,7 @@ export default function FAQ(): JSX.Element {
);

return (
<div className="relative min-h-screen w-full overflow-x-hidden bg-[var(--public-page-background)]">
<div className="relative min-h-screen w-full overflow-x-clip bg-[var(--public-page-background)]">
<div className="relative z-10 py-8">
<div className="w-full max-w-4xl mx-auto p-8">
{hasHistory && (
Expand Down
Loading