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;