Fix Chromium scrollbar-induced layout shifts
This commit addresses an issue in Chromium on Linux and Windows where the appearance of a vertical scrollbar causes unexpected horizontal layout shifts. This behavior typically occurs when the window is resized, a card is opened or a script is selected, resulting in content being pushed to the left. The solution implemented involves using `scrollbar-gutter: stable` to ensure space is always allocated for the scrollbar, thus preventing any shift in the page layout. This fix primarily affects Chromium-based browsers on Linux and Windows. It has no impact on Firefox on any platform, or any browser on macOS (including Chromium). Because these render the scrollbar as an overlay, and do not suffer from this issue. Steps to reproduce the issue using Chromium browser on Linux/Windows: 1. Open the app with a height large enough where a vertical scrollbar is not visible. 2. Resize the window to a height that triggers a vertical scrollbar. 3. Notice the layout shift as the body content moves to the right. Changes: - Add a CSS mixin to handle scrollbar gutter allocation with a fallback. - Add support for modal dialog background lock to handle `scrollbar-gutter: stable;` in calculations to avoid layout shift when a modal is open. - Add E2E test to avoid regression. - Update DevToolkit to accommodate new scrollbar spacing.
This commit is contained in:
@@ -13,11 +13,16 @@
|
|||||||
@use "_code-styling" as *;
|
@use "_code-styling" as *;
|
||||||
@use "_margin-padding" as *;
|
@use "_margin-padding" as *;
|
||||||
@use "_link-styling" as *;
|
@use "_link-styling" as *;
|
||||||
|
@use "_prevent-scrollbar-layout-shift" as *;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@include prevent-scrollbar-layout-shift;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
@include base-font-style;
|
@include base-font-style;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// This mixin prevents layout shifts caused by the appearance of a vertical scrollbar
|
||||||
|
// in Chromium-based browsers on Linux and Windows.
|
||||||
|
// It creates a reserved space for the scrollbar, ensuring content remains stable and does
|
||||||
|
// not shift horizontally when the scrollbar appears.
|
||||||
|
@mixin prevent-scrollbar-layout-shift {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
|
||||||
|
@supports not (scrollbar-gutter: stable) { // https://caniuse.com/mdn-css_properties_scrollbar-gutter
|
||||||
|
// Safari workaround: Shift content to accommodate non-overlay scrollbar.
|
||||||
|
// An issue: On small screens, the appearance of the scrollbar can shift content, due to limited space for
|
||||||
|
// both content and scrollbar.
|
||||||
|
$full-width-including-scrollbar: 100vw;
|
||||||
|
$full-width-excluding-scrollbar: 100%;
|
||||||
|
$scrollbar-width: calc($full-width-including-scrollbar - $full-width-excluding-scrollbar);
|
||||||
|
padding-inline-start: $scrollbar-width; // Allows both right-to-left (RTL) and left-to-right (LTR) text direction support
|
||||||
|
}
|
||||||
|
|
||||||
|
// More details: https://web.archive.org/web/20240509122237/https://stackoverflow.com/questions/1417934/how-to-prevent-scrollbar-from-repositioning-web-page
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { defineComponent, ref } from 'vue';
|
|||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||||
import { dumpNames } from './DumpNames';
|
import { dumpNames } from './DumpNames';
|
||||||
|
import { useScrollbarGutterWidth } from './UseScrollbarGutterWidth';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
@@ -39,6 +40,7 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const { log } = injectKey((keys) => keys.useLogger);
|
const { log } = injectKey((keys) => keys.useLogger);
|
||||||
const isOpen = ref(true);
|
const isOpen = ref(true);
|
||||||
|
const scrollbarGutterWidth = useScrollbarGutterWidth();
|
||||||
|
|
||||||
const devActions: readonly DevAction[] = [
|
const devActions: readonly DevAction[] = [
|
||||||
{
|
{
|
||||||
@@ -58,6 +60,7 @@ export default defineComponent({
|
|||||||
devActions,
|
devActions,
|
||||||
isOpen,
|
isOpen,
|
||||||
close,
|
close,
|
||||||
|
scrollbarGutterWidth,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -71,10 +74,14 @@ interface DevAction {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
$viewport-edge-offset: $spacing-absolute-large; // close to Chromium gutter width (15px)
|
||||||
|
|
||||||
.dev-toolkit-container {
|
.dev-toolkit-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
|
||||||
right: 0;
|
top: $viewport-edge-offset;
|
||||||
|
right: max(v-bind(scrollbarGutterWidth), $viewport-edge-offset);
|
||||||
|
|
||||||
background-color: rgba($color-on-surface, 0.5);
|
background-color: rgba($color-on-surface, 0.5);
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
padding: $spacing-absolute-medium;
|
padding: $spacing-absolute-medium;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
computed, readonly, ref, watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { throttle } from '@/application/Common/Timing/Throttle';
|
||||||
|
import { useAutoUnsubscribedEventListener } from '../Shared/Hooks/UseAutoUnsubscribedEventListener';
|
||||||
|
|
||||||
|
const RESIZE_EVENT_THROTTLE_MS = 200;
|
||||||
|
|
||||||
|
export function useScrollbarGutterWidth() {
|
||||||
|
const scrollbarWidthInPx = ref(getScrollbarGutterWidth());
|
||||||
|
|
||||||
|
const { startListening } = useAutoUnsubscribedEventListener();
|
||||||
|
startListening(window, 'resize', throttle(() => {
|
||||||
|
scrollbarWidthInPx.value = getScrollbarGutterWidth();
|
||||||
|
}, RESIZE_EVENT_THROTTLE_MS));
|
||||||
|
|
||||||
|
const bodyWidth = useBodyWidth();
|
||||||
|
watch(() => bodyWidth.value, () => {
|
||||||
|
scrollbarWidthInPx.value = getScrollbarGutterWidth();
|
||||||
|
}, { immediate: false });
|
||||||
|
|
||||||
|
const scrollbarWidthStyle = computed(() => `${scrollbarWidthInPx.value}px`);
|
||||||
|
return readonly(scrollbarWidthStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollbarGutterWidth(): number {
|
||||||
|
return document.documentElement.clientWidth - document.documentElement.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBodyWidth() {
|
||||||
|
const width = ref(document.body.offsetWidth);
|
||||||
|
const observer = new ResizeObserver((entries) => throttle(() => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
width.value = entry.borderBoxSize[0].inlineSize;
|
||||||
|
}
|
||||||
|
}, RESIZE_EVENT_THROTTLE_MS));
|
||||||
|
observer.observe(document.body, { box: 'border-box' });
|
||||||
|
return readonly(width);
|
||||||
|
}
|
||||||
@@ -16,4 +16,6 @@ export interface ScrollDomStateAccessor {
|
|||||||
readonly htmlScrollHeight: number;
|
readonly htmlScrollHeight: number;
|
||||||
readonly htmlClientWidth: number;
|
readonly htmlClientWidth: number;
|
||||||
readonly htmlClientHeight: number;
|
readonly htmlClientHeight: number;
|
||||||
|
readonly htmlOffsetWidth: number;
|
||||||
|
readonly htmlOffsetHeight: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ const BodyStyleLeft: DomPropertyMutator<{
|
|||||||
storeInitialState: (dom) => ({
|
storeInitialState: (dom) => ({
|
||||||
htmlScrollLeft: dom.htmlScrollLeft,
|
htmlScrollLeft: dom.htmlScrollLeft,
|
||||||
bodyStyleLeft: dom.bodyStyleLeft,
|
bodyStyleLeft: dom.bodyStyleLeft,
|
||||||
bodyMarginLeft: dom.bodyComputedMarginLeft,
|
|
||||||
}),
|
}),
|
||||||
onBlock: (initialState, dom) => {
|
onBlock: (initialState, dom) => {
|
||||||
if (initialState.htmlScrollLeft === 0) {
|
if (initialState.htmlScrollLeft === 0) {
|
||||||
@@ -206,13 +205,21 @@ const BodyPositionFixed: DomPropertyMutator<{
|
|||||||
|
|
||||||
const BodyWidth100Percent: DomPropertyMutator<{
|
const BodyWidth100Percent: DomPropertyMutator<{
|
||||||
readonly bodyStyleWidth: string;
|
readonly bodyStyleWidth: string;
|
||||||
|
readonly htmlOffsetWidth: number;
|
||||||
|
readonly htmlClientWidth: number;
|
||||||
}> = {
|
}> = {
|
||||||
storeInitialState: (dom) => ({
|
storeInitialState: (dom) => ({
|
||||||
bodyStyleWidth: dom.bodyStyleWidth,
|
bodyStyleWidth: dom.bodyStyleWidth,
|
||||||
|
htmlOffsetWidth: dom.htmlOffsetWidth,
|
||||||
|
htmlClientWidth: dom.htmlClientWidth,
|
||||||
}),
|
}),
|
||||||
onBlock: (_, dom) => {
|
onBlock: (initialState, dom) => {
|
||||||
dom.bodyStyleWidth = calculateBodyViewportStyleWithMargins(
|
dom.bodyStyleWidth = calculateAdjustedStyle(
|
||||||
[dom.bodyComputedMarginLeft, dom.bodyComputedMarginRight],
|
[
|
||||||
|
dom.bodyComputedMarginLeft,
|
||||||
|
dom.bodyComputedMarginRight,
|
||||||
|
calculateScrollbarGutterStyle(initialState.htmlClientWidth, initialState.htmlOffsetWidth),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return ScrollRevertAction.RestoreRequired;
|
return ScrollRevertAction.RestoreRequired;
|
||||||
},
|
},
|
||||||
@@ -223,13 +230,21 @@ const BodyWidth100Percent: DomPropertyMutator<{
|
|||||||
|
|
||||||
const BodyHeight100Percent: DomPropertyMutator<{
|
const BodyHeight100Percent: DomPropertyMutator<{
|
||||||
readonly bodyStyleHeight: string;
|
readonly bodyStyleHeight: string;
|
||||||
|
readonly htmlOffsetHeight: number;
|
||||||
|
readonly htmlClientHeight: number;
|
||||||
}> = {
|
}> = {
|
||||||
storeInitialState: (dom) => ({
|
storeInitialState: (dom) => ({
|
||||||
bodyStyleHeight: dom.bodyStyleHeight,
|
bodyStyleHeight: dom.bodyStyleHeight,
|
||||||
|
htmlOffsetHeight: dom.htmlOffsetHeight,
|
||||||
|
htmlClientHeight: dom.htmlClientHeight,
|
||||||
}),
|
}),
|
||||||
onBlock: (_, dom) => {
|
onBlock: (initialState, dom) => {
|
||||||
dom.bodyStyleHeight = calculateBodyViewportStyleWithMargins(
|
dom.bodyStyleHeight = calculateAdjustedStyle(
|
||||||
[dom.bodyComputedMarginTop, dom.bodyComputedMarginBottom],
|
[
|
||||||
|
dom.bodyComputedMarginTop,
|
||||||
|
dom.bodyComputedMarginBottom,
|
||||||
|
calculateScrollbarGutterStyle(initialState.htmlClientHeight, initialState.htmlOffsetHeight),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
return ScrollRevertAction.RestoreRequired;
|
return ScrollRevertAction.RestoreRequired;
|
||||||
},
|
},
|
||||||
@@ -280,11 +295,20 @@ interface DomPropertyMutator<TInitialStateValue> {
|
|||||||
restoreStateOnUnblock(storedState: TInitialStateValue, dom: ScrollDomStateAccessor): void;
|
restoreStateOnUnblock(storedState: TInitialStateValue, dom: ScrollDomStateAccessor): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateBodyViewportStyleWithMargins(
|
/** Calculates allocated scrollbar gutter, adjusting for `scrollbar-gutter: stable` */
|
||||||
margins: readonly string[],
|
function calculateScrollbarGutterStyle(
|
||||||
|
clientSize: number,
|
||||||
|
offsetSize: number,
|
||||||
|
): string {
|
||||||
|
const scrollbarGutterSize = clientSize - offsetSize;
|
||||||
|
return scrollbarGutterSize !== 0 ? `${scrollbarGutterSize}px` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAdjustedStyle(
|
||||||
|
spaceOffsets: readonly string[],
|
||||||
): string {
|
): string {
|
||||||
let value = '100%';
|
let value = '100%';
|
||||||
const calculatedMargin = margins
|
const calculatedMargin = spaceOffsets
|
||||||
.filter((marginText) => marginText.length > 0)
|
.filter((marginText) => marginText.length > 0)
|
||||||
.join(' + '); // without setting margins, it leads to layout shift if body has margin
|
.join(' + '); // without setting margins, it leads to layout shift if body has margin
|
||||||
if (calculatedMargin) {
|
if (calculatedMargin) {
|
||||||
|
|||||||
@@ -61,4 +61,8 @@ class WindowScrollDomState implements ScrollDomStateAccessor {
|
|||||||
get htmlClientWidth(): number { return HtmlElement.clientWidth; }
|
get htmlClientWidth(): number { return HtmlElement.clientWidth; }
|
||||||
|
|
||||||
get htmlClientHeight(): number { return HtmlElement.clientHeight; }
|
get htmlClientHeight(): number { return HtmlElement.clientHeight; }
|
||||||
|
|
||||||
|
get htmlOffsetWidth(): number { return HtmlElement.offsetWidth; }
|
||||||
|
|
||||||
|
get htmlOffsetHeight(): number { return HtmlElement.offsetHeight; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
|
import { ViewportTestScenarios, LargeScreen } from './support/scenarios/viewport-test-scenarios';
|
||||||
import { openCard } from './support/interactions/card';
|
import { openCard } from './support/interactions/card';
|
||||||
import { selectAllScripts, unselectAllScripts } from './support/interactions/script-selection';
|
import { selectAllScripts, unselectAllScripts } from './support/interactions/script-selection';
|
||||||
import { assertLayoutStability } from './support/assert/layout-stability';
|
import { assertLayoutStability } from './support/assert/layout-stability';
|
||||||
@@ -62,4 +62,18 @@ describe('Layout stability', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression test for bug on Chromium where horizontal scrollbar visibility causes layout shifts.
|
||||||
|
it('Scrollbar visibility', () => {
|
||||||
|
// arrange
|
||||||
|
cy.viewport(LargeScreen.width, LargeScreen.height);
|
||||||
|
cy.visit('/');
|
||||||
|
openCard({
|
||||||
|
cardIndex: 0,
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
assertLayoutStability('.app__wrapper', () => {
|
||||||
|
cy.viewport(LargeScreen.width, 100); // Set small height to trigger horizontal scrollbar.
|
||||||
|
}, { excludeHeight: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
|
||||||
export function assertLayoutStability(selector: string, action: ()=> void): void {
|
interface LayoutStabilityTestOptions {
|
||||||
|
excludeWidth: boolean;
|
||||||
|
excludeHeight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLayoutStability(
|
||||||
|
selector: string,
|
||||||
|
action: ()=> void,
|
||||||
|
options: Partial<LayoutStabilityTestOptions> | undefined = undefined,
|
||||||
|
): void {
|
||||||
// arrange
|
// arrange
|
||||||
|
if (options?.excludeWidth === true && options?.excludeHeight === true) {
|
||||||
|
throw new Error('Invalid test configuration: both width and height exclusions specified.');
|
||||||
|
}
|
||||||
let initialMetrics: ViewportMetrics | undefined;
|
let initialMetrics: ViewportMetrics | undefined;
|
||||||
captureViewportMetrics(selector, (metrics) => {
|
captureViewportMetrics(selector, (metrics) => {
|
||||||
initialMetrics = metrics;
|
initialMetrics = metrics;
|
||||||
@@ -11,10 +23,22 @@ export function assertLayoutStability(selector: string, action: ()=> void): void
|
|||||||
// assert
|
// assert
|
||||||
captureViewportMetrics(selector, (metrics) => {
|
captureViewportMetrics(selector, (metrics) => {
|
||||||
const finalMetrics = metrics;
|
const finalMetrics = metrics;
|
||||||
expect(initialMetrics).to.deep.equal(finalMetrics, formatAssertionMessage([
|
const assertionContext = [
|
||||||
`Expected (initial metrics before action): ${JSON.stringify(initialMetrics)}`,
|
`Expected (initial metrics before action): ${JSON.stringify(initialMetrics)}`,
|
||||||
`Actual (final metrics after action): ${JSON.stringify(finalMetrics)}`,
|
`Actual (final metrics after action): ${JSON.stringify(finalMetrics)}`,
|
||||||
]));
|
];
|
||||||
|
if (options?.excludeWidth !== true) {
|
||||||
|
expect(initialMetrics?.x).to.equal(finalMetrics.x, formatAssertionMessage([
|
||||||
|
'Width instability detected',
|
||||||
|
...assertionContext,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if (options?.excludeHeight !== true) {
|
||||||
|
expect(initialMetrics?.x).to.equal(finalMetrics.x, formatAssertionMessage([
|
||||||
|
'Height instability detected',
|
||||||
|
...assertionContext,
|
||||||
|
]));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
const SmallScreen: ViewportScenario = {
|
||||||
|
name: 'iPhone SE', width: 375, height: 667,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MediumScreen: ViewportScenario = {
|
||||||
|
name: '13-inch Laptop', width: 1280, height: 800,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LargeScreen: ViewportScenario = {
|
||||||
|
name: '4K Ultra HD Desktop', width: 3840, height: 2160,
|
||||||
|
};
|
||||||
|
|
||||||
export const ViewportTestScenarios: readonly ViewportScenario[] = [
|
export const ViewportTestScenarios: readonly ViewportScenario[] = [
|
||||||
{ name: 'iPhone SE', width: 375, height: 667 },
|
SmallScreen,
|
||||||
{ name: '13-inch Laptop', width: 1280, height: 800 },
|
MediumScreen,
|
||||||
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
|
LargeScreen,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface ViewportScenario {
|
interface ViewportScenario {
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { PropertyKeys } from '@/TypeHelpers';
|
||||||
|
import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor';
|
||||||
|
|
||||||
|
export const DomStateChangeTestScenarios: readonly DomStateChangeTestScenario[] = [
|
||||||
|
...createScenariosForProperty('bodyStyleOverflowX', [
|
||||||
|
{
|
||||||
|
description: 'visible horizontal scrollbar',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientWidth = 5;
|
||||||
|
dom.htmlScrollWidth = 10;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'scroll',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'invisible horizontal scrollbar',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientWidth = 10;
|
||||||
|
dom.htmlScrollWidth = 5;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleOverflowX,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...createScenariosForProperty('bodyStyleOverflowY', [
|
||||||
|
{
|
||||||
|
description: 'visible vertical scrollbar',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlScrollHeight = 10;
|
||||||
|
dom.htmlClientHeight = 5;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'scroll',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
...createScenariosForProperty('bodyStyleLeft', [
|
||||||
|
{
|
||||||
|
description: 'adjusts for scrolled position',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlScrollLeft = 22;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => '-22px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'unaffected by no horizontal scroll',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlScrollLeft = 0;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleLeft,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...createScenariosForProperty('bodyStyleTop', [
|
||||||
|
{
|
||||||
|
description: 'adjusts for scrolled position',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlScrollTop = 12;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => '-12px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'unaffected by no vertical scroll',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlScrollTop = 0;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: (initialDom) => initialDom.bodyStyleTop,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
propertyName: 'bodyStylePosition',
|
||||||
|
getExpectedValueOnBlock: () => 'fixed',
|
||||||
|
},
|
||||||
|
...createScenariosForProperty('bodyStyleWidth', [
|
||||||
|
{
|
||||||
|
description: 'no margin or scrollbar gutter',
|
||||||
|
getExpectedValueOnBlock: () => '100%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on left',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginLeft = '3px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (3px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on right',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginRight = '4px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (4px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on left and right',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginLeft = '5px';
|
||||||
|
dom.bodyComputedMarginRight = '5px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'scrollbar gutter',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientWidth = 10;
|
||||||
|
dom.htmlOffsetWidth = 4;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (6px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'scrollbar gutter and margins',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientWidth = 10;
|
||||||
|
dom.htmlOffsetWidth = 4;
|
||||||
|
dom.bodyComputedMarginLeft = '5px';
|
||||||
|
dom.bodyComputedMarginRight = '5px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px + 6px))',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...createScenariosForProperty('bodyStyleHeight', [
|
||||||
|
|
||||||
|
{
|
||||||
|
description: 'no margin or scrollbar gutter',
|
||||||
|
getExpectedValueOnBlock: () => '100%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on top',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginTop = '3px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (3px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on bottom',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginBottom = '4px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (4px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'margin on top and bottom',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.bodyComputedMarginTop = '5px';
|
||||||
|
dom.bodyComputedMarginBottom = '5px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'scrollbar gutter',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientHeight = 10;
|
||||||
|
dom.htmlOffsetHeight = 4;
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (6px))',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'scrollbar gutter and margins',
|
||||||
|
prepare: (dom) => {
|
||||||
|
dom.htmlClientHeight = 10;
|
||||||
|
dom.htmlOffsetHeight = 4;
|
||||||
|
dom.bodyComputedMarginTop = '5px';
|
||||||
|
dom.bodyComputedMarginBottom = '5px';
|
||||||
|
},
|
||||||
|
getExpectedValueOnBlock: () => 'calc(100% - (5px + 5px + 6px))',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function createScenariosForProperty(
|
||||||
|
propertyName: PropertyKeys<ScrollDomStateAccessor>,
|
||||||
|
scenarios: readonly Omit<DomStateChangeTestScenario, 'propertyName'>[],
|
||||||
|
): DomStateChangeTestScenario[] {
|
||||||
|
return scenarios.map((scenario): DomStateChangeTestScenario => ({
|
||||||
|
propertyName,
|
||||||
|
...scenario,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomPropertyType = string | number;
|
||||||
|
|
||||||
|
type Writable<T> = {
|
||||||
|
-readonly [P in keyof T]: T[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DomStateChangeTestScenario {
|
||||||
|
readonly propertyName: PropertyKeys<ScrollDomStateAccessor>;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly prepare?: (dom: Writable<ScrollDomStateAccessor>) => void;
|
||||||
|
getExpectedValueOnBlock(
|
||||||
|
initialDom: Readonly<ScrollDomStateAccessor>,
|
||||||
|
actualDom: Readonly<ScrollDomStateAccessor>,
|
||||||
|
): DomPropertyType;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
|
|||||||
import { ref, nextTick, defineComponent } from 'vue';
|
import { ref, nextTick, defineComponent } from 'vue';
|
||||||
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll';
|
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll';
|
||||||
import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor';
|
import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor';
|
||||||
import type { PropertyKeys } from '@/TypeHelpers';
|
import { DomStateChangeTestScenarios } from './DomStateChangeTestScenarios';
|
||||||
|
|
||||||
describe('useLockBodyBackgroundScroll', () => {
|
describe('useLockBodyBackgroundScroll', () => {
|
||||||
describe('initialization', () => {
|
describe('initialization', () => {
|
||||||
@@ -87,7 +87,7 @@ function createComponent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itEachScrollBlockEffect(act: (dom: ScrollDomStateAccessor) => Promise<void>) {
|
function itEachScrollBlockEffect(act: (dom: ScrollDomStateAccessor) => Promise<void>) {
|
||||||
testScenarios.forEach((m) => {
|
DomStateChangeTestScenarios.forEach((m) => {
|
||||||
const description = m.description ? ` (${m.description})` : '';
|
const description = m.description ? ` (${m.description})` : '';
|
||||||
it(`handles '${m.propertyName}'${description}`, async () => {
|
it(`handles '${m.propertyName}'${description}`, async () => {
|
||||||
// arrange
|
// arrange
|
||||||
@@ -106,161 +106,6 @@ function itEachScrollBlockEffect(act: (dom: ScrollDomStateAccessor) => Promise<v
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function createMockDomStateAccessor(): ScrollDomStateAccessor {
|
||||||
return {
|
return {
|
||||||
bodyStyleOverflowX: '',
|
bodyStyleOverflowX: '',
|
||||||
@@ -280,9 +125,7 @@ function createMockDomStateAccessor(): ScrollDomStateAccessor {
|
|||||||
htmlScrollHeight: 0,
|
htmlScrollHeight: 0,
|
||||||
htmlClientWidth: 0,
|
htmlClientWidth: 0,
|
||||||
htmlClientHeight: 0,
|
htmlClientHeight: 0,
|
||||||
|
htmlOffsetHeight: 0,
|
||||||
|
htmlOffsetWidth: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type Writable<T> = {
|
|
||||||
-readonly [P in keyof T]: T[P];
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user