diff --git a/src/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor.ts b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor.ts new file mode 100644 index 00000000..fcf67676 --- /dev/null +++ b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/ScrollDomStateAccessor.ts @@ -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; +} diff --git a/src/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.ts b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.ts new file mode 100644 index 00000000..f29bdd2a --- /dev/null +++ b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/UseLockBodyBackgroundScroll.ts @@ -0,0 +1,294 @@ +import { Ref, onBeforeUnmount, watch } from 'vue'; +import { getWindowDomState } from './WindowScrollDomStateAccessor'; +import { ScrollDomStateAccessor } from './ScrollDomStateAccessor'; + +export function useLockBodyBackgroundScroll( + isActive: Ref, + 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( + propertyMutator: DomPropertyMutator, +): 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( + 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 { + 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; +} diff --git a/src/presentation/components/Shared/Modal/Hooks/ScrollLock/WindowScrollDomStateAccessor.ts b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/WindowScrollDomStateAccessor.ts new file mode 100644 index 00000000..956ea306 --- /dev/null +++ b/src/presentation/components/Shared/Modal/Hooks/ScrollLock/WindowScrollDomStateAccessor.ts @@ -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; } +} diff --git a/src/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll.ts b/src/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll.ts deleted file mode 100644 index 6fcd4fe9..00000000 --- a/src/presentation/components/Shared/Modal/Hooks/UseLockBodyBackgroundScroll.ts +++ /dev/null @@ -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) { - 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(); - }); -} diff --git a/src/presentation/components/Shared/Modal/ModalContainer.vue b/src/presentation/components/Shared/Modal/ModalContainer.vue index 2da65a40..eb6f434e 100644 --- a/src/presentation/components/Shared/Modal/ModalContainer.vue +++ b/src/presentation/components/Shared/Modal/ModalContainer.vue @@ -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'; diff --git a/src/presentation/components/Shared/Modal/ModalContent.vue b/src/presentation/components/Shared/Modal/ModalContent.vue index 4f157437..8b620777 100644 --- a/src/presentation/components/Shared/Modal/ModalContent.vue +++ b/src/presentation/components/Shared/Modal/ModalContent.vue @@ -3,7 +3,10 @@ name="modal-content-transition" @after-leave="onAfterTransitionLeave" > -