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:
undergroundwires
2023-11-19 23:51:25 +01:00
parent cb42f11b97
commit e299d40fa1
12 changed files with 772 additions and 165 deletions

View File

@@ -0,0 +1,19 @@
export interface ScrollDomStateAccessor {
bodyStyleOverflowX: string;
bodyStyleOverflowY: string;
htmlScrollLeft: number;
htmlScrollTop: number;
bodyStyleLeft: string;
bodyStyleTop: string;
bodyStylePosition: string;
bodyStyleWidth: string;
bodyStyleHeight: string;
readonly bodyComputedMarginLeft: string;
readonly bodyComputedMarginRight: string;
readonly bodyComputedMarginTop: string;
readonly bodyComputedMarginBottom: string;
readonly htmlScrollWidth: number;
readonly htmlScrollHeight: number;
readonly htmlClientWidth: number;
readonly htmlClientHeight: number;
}

View File

@@ -0,0 +1,294 @@
import { Ref, onBeforeUnmount, watch } from 'vue';
import { getWindowDomState } from './WindowScrollDomStateAccessor';
import { ScrollDomStateAccessor } from './ScrollDomStateAccessor';
export function useLockBodyBackgroundScroll(
isActive: Ref<boolean>,
dom: ScrollDomStateAccessor = getWindowDomState(),
) {
let isBlocked = false;
const applyScrollLock = () => {
ScrollLockMutators.forEach((mutator) => {
mutator.onBeforeBlock(dom);
});
ScrollLockMutators.forEach((mutator) => {
mutator.onBlock(dom);
});
isBlocked = true;
};
function revertScrollLock() {
if (!isBlocked) {
return;
}
ScrollLockMutators.forEach((mutator) => {
mutator.onUnblock(dom);
});
}
watch(isActive, (shouldBlock) => {
if (shouldBlock) {
applyScrollLock();
} else {
revertScrollLock();
}
}, { immediate: true });
onBeforeUnmount(() => {
revertScrollLock();
});
}
interface ScrollStateManipulator {
onBeforeBlock(dom: ScrollDomStateAccessor): void;
onBlock(dom: ScrollDomStateAccessor): void;
onUnblock(dom: ScrollDomStateAccessor): void;
}
function createScrollStateManipulator<TStoredState>(
propertyMutator: DomPropertyMutator<TStoredState>,
): ScrollStateManipulator {
let state: TStoredState | undefined;
let restoreAction: ScrollRevertAction | undefined;
return {
onBeforeBlock: (dom) => {
state = propertyMutator.storeInitialState(dom);
},
onBlock: (dom) => {
verifyStateInitialization(state);
restoreAction = propertyMutator.onBlock(state, dom);
},
onUnblock: (dom) => {
switch (restoreAction) {
case ScrollRevertAction.RestoreRequired:
verifyStateInitialization(state);
propertyMutator.restoreStateOnUnblock(state, dom);
break;
case ScrollRevertAction.SkipRestore:
return;
case undefined:
throw new Error('Undefined restore action');
default:
throw new Error(`Unknown action: ${ScrollRevertAction[restoreAction]}`);
}
},
};
}
function verifyStateInitialization<TState>(
value: TState | undefined,
): asserts value is TState {
if (value === null || value === undefined) {
throw new Error('Previous state not found. Ensure state initialization before mutation operations.');
}
}
const HtmlScrollLeft: DomPropertyMutator<{
readonly htmlScrollLeft: number;
}> = {
storeInitialState: (dom) => ({
htmlScrollLeft: dom.htmlScrollLeft,
}),
onBlock: () => ScrollRevertAction.RestoreRequired,
restoreStateOnUnblock: (initialState, dom) => {
dom.htmlScrollLeft = initialState.htmlScrollLeft;
},
};
const BodyStyleLeft: DomPropertyMutator<{
readonly htmlScrollLeft: number;
readonly bodyStyleLeft: string;
}> = {
storeInitialState: (dom) => ({
htmlScrollLeft: dom.htmlScrollLeft,
bodyStyleLeft: dom.bodyStyleLeft,
bodyMarginLeft: dom.bodyComputedMarginLeft,
}),
onBlock: (initialState, dom) => {
if (initialState.htmlScrollLeft === 0) {
return ScrollRevertAction.SkipRestore;
}
dom.bodyStyleLeft = `-${initialState.htmlScrollLeft}px`;
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleLeft = initialState.bodyStyleLeft;
},
};
const BodyStyleTop: DomPropertyMutator<{
readonly htmlScrollTop: number;
readonly bodyStyleTop: string;
}> = {
storeInitialState: (dom) => ({
bodyStyleTop: dom.bodyStyleTop,
htmlScrollTop: dom.htmlScrollTop,
}),
onBlock: (initialState, dom) => {
if (initialState.htmlScrollTop === 0) {
return ScrollRevertAction.SkipRestore;
}
dom.bodyStyleTop = `-${initialState.htmlScrollTop}px`;
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleTop = initialState.bodyStyleTop;
},
};
const BodyStyleOverflowX: DomPropertyMutator<{
readonly isHorizontalScrollbarVisible: boolean;
readonly bodyStyleOverflowX: string;
}> = {
storeInitialState: (dom) => ({
isHorizontalScrollbarVisible: dom.htmlScrollWidth > dom.htmlClientWidth,
bodyStyleOverflowX: dom.bodyStyleOverflowX,
}),
onBlock: (initialState, dom) => {
if (!initialState.isHorizontalScrollbarVisible) {
return ScrollRevertAction.SkipRestore;
}
dom.bodyStyleOverflowX = 'scroll';
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleOverflowX = initialState.bodyStyleOverflowX;
},
};
const BodyStyleOverflowY: DomPropertyMutator<{
readonly isVerticalScrollbarVisible: boolean;
readonly bodyStyleOverflowY: string;
}> = {
storeInitialState: (dom) => ({
isVerticalScrollbarVisible: dom.htmlScrollHeight > dom.htmlClientHeight,
bodyStyleOverflowY: dom.bodyStyleOverflowY,
}),
onBlock: (initialState, dom) => {
if (!initialState.isVerticalScrollbarVisible) {
return ScrollRevertAction.SkipRestore;
}
dom.bodyStyleOverflowY = 'scroll';
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleOverflowY = initialState.bodyStyleOverflowY;
},
};
const HtmlScrollTop: DomPropertyMutator<{
readonly htmlScrollTop: number;
}> = {
storeInitialState: (dom) => ({
htmlScrollTop: dom.htmlScrollTop,
}),
onBlock: () => ScrollRevertAction.RestoreRequired,
restoreStateOnUnblock: (initialState, dom) => {
dom.htmlScrollTop = initialState.htmlScrollTop;
},
};
const BodyPositionFixed: DomPropertyMutator<{
readonly bodyStylePosition: string;
}> = {
storeInitialState: (dom) => ({
bodyStylePosition: dom.bodyStylePosition,
}),
onBlock: (_, dom) => {
dom.bodyStylePosition = 'fixed';
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStylePosition = initialState.bodyStylePosition;
},
};
const BodyWidth100Percent: DomPropertyMutator<{
readonly bodyStyleWidth: string;
}> = {
storeInitialState: (dom) => ({
bodyStyleWidth: dom.bodyStyleWidth,
}),
onBlock: (_, dom) => {
dom.bodyStyleWidth = calculateBodyViewportStyleWithMargins(
[dom.bodyComputedMarginLeft, dom.bodyComputedMarginRight],
);
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleWidth = initialState.bodyStyleWidth;
},
};
const BodyHeight100Percent: DomPropertyMutator<{
readonly bodyStyleHeight: string;
}> = {
storeInitialState: (dom) => ({
bodyStyleHeight: dom.bodyStyleHeight,
}),
onBlock: (_, dom) => {
dom.bodyStyleHeight = calculateBodyViewportStyleWithMargins(
[dom.bodyComputedMarginTop, dom.bodyComputedMarginBottom],
);
return ScrollRevertAction.RestoreRequired;
},
restoreStateOnUnblock: (initialState, dom) => {
dom.bodyStyleHeight = initialState.bodyStyleHeight;
},
};
const ScrollLockMutators: readonly ScrollStateManipulator[] = [
createScrollStateManipulator(BodyPositionFixed), // Fix body position
/*
Using `position: 'fixed'` to lock background scroll.
This approach is chosen over:
1. `overflow: 'hidden'`: It hides the scrollbar, causing layout "jumps".
`scrollbar-gutter` can fix it but it lacks Safari support and introduces
complexity of positioning calculations on modal.
2. `overscrollBehavior`: Only stops scrolling at scroll limits, not suitable for all cases.
3. `touchAction: none`: Ineffective on non-touch (desktop) devices.
*/
...[ // Keep the scrollbar visible
createScrollStateManipulator(BodyStyleOverflowX), // Horizontal scrollbar
createScrollStateManipulator(BodyStyleOverflowY), // Vertical scrollbar
],
...[ // Fix scroll-to-top issue
// Horizontal
createScrollStateManipulator(HtmlScrollLeft), // Restore scroll position
createScrollStateManipulator(BodyStyleLeft), // Keep the body on scrolled position
// // Vertical
createScrollStateManipulator(HtmlScrollTop), // Restore scroll position
createScrollStateManipulator(BodyStyleTop), // Keep the body on scrolled position
],
...[ // Fix layout-shift on very large screens
// Using percentages instead of viewport allows content to grow if the content
// exceeds the viewport.
createScrollStateManipulator(BodyWidth100Percent),
createScrollStateManipulator(BodyHeight100Percent),
],
] as const;
enum ScrollRevertAction {
RestoreRequired,
SkipRestore,
}
interface DomPropertyMutator<TInitialStateValue> {
storeInitialState(dom: ScrollDomStateAccessor): TInitialStateValue;
onBlock(storedState: TInitialStateValue, dom: ScrollDomStateAccessor): ScrollRevertAction;
restoreStateOnUnblock(storedState: TInitialStateValue, dom: ScrollDomStateAccessor): void;
}
function calculateBodyViewportStyleWithMargins(
margins: readonly string[],
): string {
let value = '100%';
const calculatedMargin = margins
.filter((marginText) => marginText.length > 0)
.join(' + '); // without setting margins, it leads to layout shift if body has margin
if (calculatedMargin) {
value = `calc(${value} - (${calculatedMargin}))`;
}
return value;
}

View File

@@ -0,0 +1,64 @@
import { ScrollDomStateAccessor } from './ScrollDomStateAccessor';
const HtmlElement = document.documentElement;
const BodyElement = document.body;
export function getWindowDomState(): ScrollDomStateAccessor {
return new WindowScrollDomState();
}
class WindowScrollDomState implements ScrollDomStateAccessor {
get bodyStyleOverflowX(): string { return BodyElement.style.overflowX; }
set bodyStyleOverflowX(value: string) { BodyElement.style.overflowX = value; }
get bodyStyleOverflowY(): string { return BodyElement.style.overflowY; }
set bodyStyleOverflowY(value: string) { BodyElement.style.overflowY = value; }
get htmlScrollLeft(): number { return HtmlElement.scrollLeft; }
set htmlScrollLeft(value: number) { HtmlElement.scrollLeft = value; }
get htmlScrollTop(): number { return HtmlElement.scrollTop; }
set htmlScrollTop(value: number) { HtmlElement.scrollTop = value; }
get bodyStyleLeft(): string { return BodyElement.style.left; }
set bodyStyleLeft(value: string) { BodyElement.style.left = value; }
get bodyStyleTop(): string { return BodyElement.style.top; }
set bodyStyleTop(value: string) { BodyElement.style.top = value; }
get bodyStylePosition(): string { return BodyElement.style.position; }
set bodyStylePosition(value: string) { BodyElement.style.position = value; }
get bodyStyleWidth(): string { return BodyElement.style.width; }
set bodyStyleWidth(value: string) { BodyElement.style.width = value; }
get bodyStyleHeight(): string { return BodyElement.style.height; }
set bodyStyleHeight(value: string) { BodyElement.style.height = value; }
get bodyComputedMarginLeft(): string { return window.getComputedStyle(BodyElement).marginLeft; }
get bodyComputedMarginRight(): string { return window.getComputedStyle(BodyElement).marginRight; }
get bodyComputedMarginTop(): string { return window.getComputedStyle(BodyElement).marginTop; }
get bodyComputedMarginBottom(): string {
return window.getComputedStyle(BodyElement).marginBottom;
}
get htmlScrollWidth(): number { return HtmlElement.scrollWidth; }
get htmlScrollHeight(): number { return HtmlElement.scrollHeight; }
get htmlClientWidth(): number { return HtmlElement.clientWidth; }
get htmlClientHeight(): number { return HtmlElement.clientHeight; }
}

View File

@@ -1,37 +0,0 @@
import { Ref, watch, onBeforeUnmount } from 'vue';
/*
It blocks background scrolling.
Designed to be used by modals, overlays etc.
*/
export function useLockBodyBackgroundScroll(isActive: Ref<boolean>) {
const originalStyles = {
overflow: document.body.style.overflow,
width: document.body.style.width,
};
const block = () => {
originalStyles.overflow = document.body.style.overflow;
originalStyles.width = document.body.style.width;
document.body.style.overflow = 'hidden';
document.body.style.width = '100vw';
};
const unblock = () => {
document.body.style.overflow = originalStyles.overflow;
document.body.style.width = originalStyles.width;
};
watch(isActive, (shouldBlock) => {
if (shouldBlock) {
block();
} else {
unblock();
}
}, { immediate: true });
onBeforeUnmount(() => {
unblock();
});
}

View File

@@ -24,7 +24,7 @@ import {
} from 'vue';
import ModalOverlay from './ModalOverlay.vue';
import ModalContent from './ModalContent.vue';
import { useLockBodyBackgroundScroll } from './Hooks/UseLockBodyBackgroundScroll';
import { useLockBodyBackgroundScroll } from './Hooks/ScrollLock/UseLockBodyBackgroundScroll';
import { useCurrentFocusToggle } from './Hooks/UseCurrentFocusToggle';
import { useEscapeKeyListener } from './Hooks/UseEscapeKeyListener';
import { useAllTrueWatcher } from './Hooks/UseAllTrueWatcher';

View File

@@ -3,7 +3,10 @@
name="modal-content-transition"
@after-leave="onAfterTransitionLeave"
>
<div v-if="show" class="modal-content-wrapper">
<div
v-if="show"
class="modal-content-wrapper"
>
<div
ref="modalElement"
class="modal-content-content"
@@ -31,7 +34,7 @@ export default defineComponent({
'transitionedOut',
],
setup(_, { emit }) {
const modalElement = shallowRef<HTMLElement>();
const modalElement = shallowRef<HTMLElement | undefined>();
function onAfterTransitionLeave() {
emit('transitionedOut');
@@ -43,6 +46,7 @@ export default defineComponent({
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -55,7 +55,7 @@ $modal-overlay-color-background: $color-on-surface;
box-sizing: border-box;
left: 0;
top: 0;
width: 100%;
width: 100vw;
height: 100vh;
background: rgba($modal-overlay-color-background, 0.3);
opacity: 1;

View File

@@ -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();

View 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;
}

View 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;
}

View File

@@ -0,0 +1,288 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { ref, nextTick, defineComponent } from 'vue';
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll';
import { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor';
import { PropertyKeys } from '@/TypeHelpers';
describe('useLockBodyBackgroundScroll', () => {
describe('initialization', () => {
describe('activates scroll lock when initially active', () => {
itEachScrollBlockEffect(async (dom) => {
// arrange
const isInitiallyActive = true;
// act
createComponent(isInitiallyActive, dom);
await nextTick();
});
});
it('maintains initial styles when initially inactive', async () => {
// arrange
const isInitiallyActive = false;
// act
const { initialDomState, actualDomState } = createComponent(isInitiallyActive);
await nextTick();
// assert
expect(actualDomState).to.deep.equal(initialDomState);
});
});
describe('toggling scroll lock', () => {
describe('enforces scroll lock when activated', async () => {
itEachScrollBlockEffect(async (dom) => {
// arrange
const isInitiallyActive = false;
const { isActive } = createComponent(isInitiallyActive, dom);
// act
isActive.value = true;
await nextTick();
});
});
it('reverts to initial styles when deactivated', async () => {
// arrange
const isInitiallyActive = true;
const { isActive, initialDomState, actualDomState } = createComponent(isInitiallyActive);
// act
isActive.value = false;
await nextTick();
// assert
expect(actualDomState).to.deep.equal(initialDomState);
});
});
it('restores original styles on unmount', async () => {
// arrange
const isInitiallyActive = true;
const { component, initialDomState, actualDomState } = createComponent(isInitiallyActive);
// act
component.unmount();
await nextTick();
// assert
expect(actualDomState).to.deep.equal(initialDomState);
});
});
function createComponent(
initialIsActiveValue: boolean,
dom?: ScrollDomStateAccessor,
) {
const actualDomState = dom ?? createMockDomStateAccessor();
const initialDomState = { ...actualDomState };
const isActive = ref(initialIsActiveValue);
const component = shallowMount(defineComponent({
setup() {
useLockBodyBackgroundScroll(isActive, actualDomState);
},
template: '<div></div>',
}));
return {
component, isActive, initialDomState, actualDomState,
};
}
function itEachScrollBlockEffect(act: (dom: ScrollDomStateAccessor) => Promise<void>) {
testScenarios.forEach((m) => {
const description = m.description ? ` (${m.description})` : '';
it(`handles '${m.propertyName}'${description}`, async () => {
// arrange
const dom = createMockDomStateAccessor();
const initialDom = { ...dom };
if (m.prepare) {
m.prepare(dom);
}
// act
await act(dom);
// assert
const expectedValue = m.getExpectedValueOnBlock(initialDom, dom);
const actualValue = dom[m.propertyName];
expect(actualValue).to.equal(expectedValue);
});
});
}
type DomPropertyType = string | number;
interface DomStateChange {
readonly propertyName: PropertyKeys<ScrollDomStateAccessor>;
readonly description?: string;
readonly prepare?: (dom: Writable<ScrollDomStateAccessor>) => void;
getExpectedValueOnBlock(
initialDom: Readonly<ScrollDomStateAccessor>,
actualDom: Readonly<ScrollDomStateAccessor>,
): DomPropertyType;
}
const testScenarios: ReadonlyArray<DomStateChange> = [
{
propertyName: 'bodyStyleOverflowX',
description: 'visible horizontal scrollbar',
prepare: (dom) => {
dom.htmlClientWidth = 5;
dom.htmlScrollWidth = 10;
},
getExpectedValueOnBlock: () => 'scroll',
},
{
propertyName: 'bodyStyleOverflowX',
description: 'invisible horizontal scrollbar',
prepare: (dom) => {
dom.htmlClientWidth = 10;
dom.htmlScrollWidth = 5;
},
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleOverflowX,
},
{
propertyName: 'bodyStyleOverflowY',
description: 'visible vertical scrollbar',
prepare: (dom) => {
dom.htmlScrollHeight = 10;
dom.htmlClientHeight = 5;
},
getExpectedValueOnBlock: () => 'scroll',
},
{
propertyName: 'bodyStyleOverflowY',
description: 'invisible vertical scrollbar',
prepare: (dom) => {
dom.htmlScrollHeight = 5;
dom.htmlClientHeight = 10;
},
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleOverflowY,
},
{
propertyName: 'htmlScrollLeft',
getExpectedValueOnBlock: (initialDom) => initialDom.htmlScrollLeft,
},
{
propertyName: 'htmlScrollTop',
getExpectedValueOnBlock: (initialDom) => initialDom.htmlScrollTop,
},
{
propertyName: 'bodyStyleLeft',
description: 'adjusts for scrolled position',
prepare: (dom) => {
dom.htmlScrollLeft = 22;
},
getExpectedValueOnBlock: () => '-22px',
},
{
propertyName: 'bodyStyleLeft',
description: 'unaffected by no horizontal scroll',
prepare: (dom) => {
dom.htmlScrollLeft = 0;
},
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleLeft,
},
{
propertyName: 'bodyStyleTop',
description: 'adjusts for scrolled position',
prepare: (dom) => {
dom.htmlScrollTop = 12;
},
getExpectedValueOnBlock: () => '-12px',
},
{
propertyName: 'bodyStyleTop',
description: 'unaffected by no vertical scroll',
prepare: (dom) => {
dom.htmlScrollTop = 0;
},
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleTop,
},
{
propertyName: 'bodyStylePosition',
getExpectedValueOnBlock: () => 'fixed',
},
{
propertyName: 'bodyStyleWidth',
description: 'no margin',
getExpectedValueOnBlock: () => '100%',
},
{
propertyName: 'bodyStyleWidth',
description: 'margin on left',
prepare: (dom) => {
dom.bodyComputedMarginLeft = '3px';
},
getExpectedValueOnBlock: () => 'calc(100% - (3px))',
},
{
propertyName: 'bodyStyleWidth',
description: 'margin on right',
prepare: (dom) => {
dom.bodyComputedMarginRight = '4px';
},
getExpectedValueOnBlock: () => 'calc(100% - (4px))',
},
{
propertyName: 'bodyStyleWidth',
description: 'margin on left and right',
prepare: (dom) => {
dom.bodyComputedMarginLeft = '5px';
dom.bodyComputedMarginRight = '5px';
},
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px))',
},
{
propertyName: 'bodyStyleHeight',
description: 'no margin',
getExpectedValueOnBlock: () => '100%',
},
{
propertyName: 'bodyStyleHeight',
description: 'margin on top',
prepare: (dom) => {
dom.bodyComputedMarginTop = '3px';
},
getExpectedValueOnBlock: () => 'calc(100% - (3px))',
},
{
propertyName: 'bodyStyleHeight',
description: 'margin on bottom',
prepare: (dom) => {
dom.bodyComputedMarginBottom = '4px';
},
getExpectedValueOnBlock: () => 'calc(100% - (4px))',
},
{
propertyName: 'bodyStyleHeight',
description: 'margin on top and bottom',
prepare: (dom) => {
dom.bodyComputedMarginTop = '5px';
dom.bodyComputedMarginBottom = '5px';
},
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px))',
},
];
function createMockDomStateAccessor(): ScrollDomStateAccessor {
return {
bodyStyleOverflowX: '',
bodyStyleOverflowY: '',
htmlScrollLeft: 0,
htmlScrollTop: 0,
bodyStyleLeft: '',
bodyStyleTop: '',
bodyStylePosition: '',
bodyStyleWidth: '',
bodyStyleHeight: '',
bodyComputedMarginLeft: '',
bodyComputedMarginRight: '',
bodyComputedMarginTop: '',
bodyComputedMarginBottom: '',
htmlScrollWidth: 0,
htmlScrollHeight: 0,
htmlClientWidth: 0,
htmlClientHeight: 0,
};
}
type Writable<T> = {
-readonly [P in keyof T]: T[P];
};

View File

@@ -1,114 +0,0 @@
import {
describe, it, expect, afterEach,
} from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { ref, nextTick, defineComponent } from 'vue';
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll';
describe('useLockBodyBackgroundScroll', () => {
afterEach(() => {
document.body.style.overflow = '';
document.body.style.width = '';
});
describe('initialization', () => {
it('blocks scroll if initially active', async () => {
// arrange
createComponent(true);
// act
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('preserves initial styles if inactive', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
createComponent(false);
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
});
describe('toggling', () => {
it('blocks scroll when activated', async () => {
// arrange
const { isActive } = createComponent(false);
// act
isActive.value = true;
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('hidden');
expect(document.body.style.width).to.equal('100vw');
});
it('unblocks scroll when deactivated', async () => {
// arrange
const { isActive } = createComponent(true);
// act
isActive.value = false;
await nextTick();
// assert
expect(document.body.style.overflow).not.to.equal('hidden');
expect(document.body.style.width).not.to.equal('100vw');
});
});
describe('unmounting', () => {
it('restores original styles on unmount', async () => {
// arrange
const originalOverflow = 'scroll';
const originalWidth = '90vw';
document.body.style.overflow = originalOverflow;
document.body.style.width = originalWidth;
// act
const { component } = createComponent(true);
component.unmount();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal(originalOverflow);
expect(document.body.style.width).to.equal(originalWidth);
});
it('resets styles on unmount', async () => {
// arrange
const { component } = createComponent(true);
// act
component.unmount();
await nextTick();
// assert
expect(document.body.style.overflow).to.equal('');
expect(document.body.style.width).to.equal('');
});
});
});
function createComponent(initialIsActiveValue: boolean) {
const isActive = ref(initialIsActiveValue);
const component = shallowMount(defineComponent({
setup() {
useLockBodyBackgroundScroll(isActive);
},
template: '<div></div>',
}));
return { component, isActive };
}