Fix layout jumps/shifts and overflow on modals
This commit improves the user interface in modal display. - Prevent layout shifts caused by background scrollbars when modals are active. - Fix unintended overflow of modals on small screens, preventing parts of the modal from being cut off on the right side. - Refactor DOM manipulation, enhancing modularity, reusability, extensibility, and separation of concerns. - Centralize viewport test scenarios for different sizes in a single definition for E2E tests.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { getHeaderBrandTitle } from './support/interactions/header';
|
||||
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
|
||||
|
||||
interface Stoppable {
|
||||
stop(): void;
|
||||
@@ -7,21 +8,12 @@ interface Stoppable {
|
||||
|
||||
describe('card list layout stability', () => {
|
||||
describe('during initial page load', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly name: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}> = [
|
||||
{ name: 'iPhone SE', width: 375, height: 667 },
|
||||
{ name: '13-inch Laptop', width: 1280, height: 800 },
|
||||
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
|
||||
];
|
||||
const testCleanup = new Array<Stoppable>();
|
||||
afterEach(() => {
|
||||
testCleanup.forEach((c) => c.stop());
|
||||
testCleanup.length = 0;
|
||||
});
|
||||
testScenarios.forEach(({ name, width, height }) => {
|
||||
ViewportTestScenarios.forEach(({ name, width, height }) => {
|
||||
it(`ensures layout stability on ${name}`, () => {
|
||||
// arrange
|
||||
const dimensions = new DimensionsStorage();
|
||||
|
||||
86
tests/e2e/modal-layout-shifts.cy.ts
Normal file
86
tests/e2e/modal-layout-shifts.cy.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
|
||||
|
||||
describe('Modal interaction and layout stability', () => {
|
||||
ViewportTestScenarios.forEach(({ // some shifts are observed only on extra small or large screens
|
||||
name, width, height,
|
||||
}) => {
|
||||
it(name, () => {
|
||||
cy.viewport(width, height);
|
||||
cy.visit('/');
|
||||
|
||||
let metricsBeforeModal: ViewportMetrics | undefined;
|
||||
|
||||
captureViewportMetrics((metrics) => {
|
||||
metricsBeforeModal = metrics;
|
||||
});
|
||||
|
||||
cy
|
||||
.contains('a', 'Privacy')
|
||||
.click();
|
||||
|
||||
cy
|
||||
.get('.modal-content')
|
||||
.should('be.visible');
|
||||
|
||||
captureViewportMetrics((metrics) => {
|
||||
const metricsAfterModal = metrics;
|
||||
expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, [
|
||||
`Expected (initial metrics before modal): ${JSON.stringify(metricsBeforeModal)}`,
|
||||
`Actual (metrics after modal is opened): ${JSON.stringify(metricsAfterModal)}`,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface ViewportMetrics {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/*
|
||||
Excluding height and width from the metrics to ensure test accuracy.
|
||||
Height and width measurements can lead to false negatives due to layout shifts caused by
|
||||
delayed loading of fonts and icons.
|
||||
*/
|
||||
}
|
||||
|
||||
function captureViewportMetrics(callback: (metrics: ViewportMetrics) => void): void {
|
||||
cy.window().then((win) => {
|
||||
cy.get('body')
|
||||
.then((body) => {
|
||||
const position = getElementViewportMetrics(body[0], win);
|
||||
cy.log(`Captured metrics: ${JSON.stringify(position)}`);
|
||||
callback(position);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getElementViewportMetrics(element: HTMLElement, win: Window): ViewportMetrics {
|
||||
const elementXRelativeToViewport = getElementXRelativeToViewport(element, win);
|
||||
const elementYRelativeToViewport = getElementYRelativeToViewport(element, win);
|
||||
return {
|
||||
x: elementXRelativeToViewport,
|
||||
y: elementYRelativeToViewport,
|
||||
};
|
||||
}
|
||||
|
||||
function getElementYRelativeToViewport(element: HTMLElement, win: Window): number {
|
||||
const relativeTop = element.getBoundingClientRect().top;
|
||||
const { position, top } = win.getComputedStyle(element);
|
||||
const topValue = position === 'static' ? 0 : parseInt(top, 10);
|
||||
if (Number.isNaN(topValue)) {
|
||||
throw new Error(`Could not calculate Y position value from 'top': ${top}`);
|
||||
}
|
||||
const viewportRelativeY = relativeTop - topValue + win.scrollY;
|
||||
return viewportRelativeY;
|
||||
}
|
||||
|
||||
function getElementXRelativeToViewport(element: HTMLElement, win: Window): number {
|
||||
const relativeLeft = element.getBoundingClientRect().left;
|
||||
const { position, left } = win.getComputedStyle(element);
|
||||
const leftValue = position === 'static' ? 0 : parseInt(left, 10);
|
||||
if (Number.isNaN(leftValue)) {
|
||||
throw new Error(`Could not calculate X position value from 'left': ${left}`);
|
||||
}
|
||||
const viewportRelativeX = relativeLeft - leftValue + win.scrollX;
|
||||
return viewportRelativeX;
|
||||
}
|
||||
11
tests/e2e/support/scenarios/viewport-test-scenarios.ts
Normal file
11
tests/e2e/support/scenarios/viewport-test-scenarios.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const ViewportTestScenarios: readonly ViewportScenario[] = [
|
||||
{ name: 'iPhone SE', width: 375, height: 667 },
|
||||
{ name: '13-inch Laptop', width: 1280, height: 800 },
|
||||
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
|
||||
] as const;
|
||||
|
||||
interface ViewportScenario {
|
||||
readonly name: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
Reference in New Issue
Block a user