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:
undergroundwires
2024-05-09 18:35:02 +02:00
parent dd71536316
commit bc4879cfe9
12 changed files with 377 additions and 180 deletions

View File

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

View File

@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { ref, nextTick, defineComponent } from 'vue';
import { useLockBodyBackgroundScroll } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll';
import type { ScrollDomStateAccessor } from '@/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor';
import type { PropertyKeys } from '@/TypeHelpers';
import { DomStateChangeTestScenarios } from './DomStateChangeTestScenarios';
describe('useLockBodyBackgroundScroll', () => {
describe('initialization', () => {
@@ -87,7 +87,7 @@ function createComponent(
}
function itEachScrollBlockEffect(act: (dom: ScrollDomStateAccessor) => Promise<void>) {
testScenarios.forEach((m) => {
DomStateChangeTestScenarios.forEach((m) => {
const description = m.description ? ` (${m.description})` : '';
it(`handles '${m.propertyName}'${description}`, async () => {
// 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 {
return {
bodyStyleOverflowX: '',
@@ -280,9 +125,7 @@ function createMockDomStateAccessor(): ScrollDomStateAccessor {
htmlScrollHeight: 0,
htmlClientWidth: 0,
htmlClientHeight: 0,
htmlOffsetHeight: 0,
htmlOffsetWidth: 0,
};
}
type Writable<T> = {
-readonly [P in keyof T]: T[P];
};