diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index 0895956667..f7b13d1ca3 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -40,6 +40,7 @@ import { createUseModal } from './createUseModal.js'; import { getFirstFocusableElement, hasNativeDialogSupport, + useScrollLock, } from './ModalService.js'; import { translations } from './translations/index.js'; @@ -124,6 +125,8 @@ export const Modal = forwardRef((props, ref) => { const hasNativeDialog = hasNativeDialogSupport(); + useScrollLock(open); + // set initial focus on the modal dialog content useEffect(() => { const dialogElement = dialogRef.current; @@ -142,19 +145,6 @@ export const Modal = forwardRef((props, ref) => { }; }, [open, initialFocusRef?.current]); - useEffect(() => { - function setScrollProperty() { - document.documentElement.style.setProperty( - '--scroll-y', - `${window.scrollY}px`, - ); - } - window.addEventListener('scroll', setScrollProperty); - return () => { - window.removeEventListener('scroll', setScrollProperty); - }; - }, []); - const handleDialogClose = useCallback(() => { const dialogElement = dialogRef.current; if (!dialogElement) { @@ -167,15 +157,6 @@ export const Modal = forwardRef((props, ref) => { ANIMATION_DURATION, ); } - // restore scroll to page - const { body } = document; - const scrollY = body.style.top; - body.style.position = ''; - body.style.top = ''; - body.style.left = ''; - body.style.right = ''; - window.scrollTo(0, Number.parseInt(scrollY || '0', 10) * -1); - // trigger closing animation dialogElement.classList.remove(classes.show); if (!hasNativeDialog) { @@ -270,14 +251,6 @@ export const Modal = forwardRef((props, ref) => { // trigger show animation dialogElement.classList.add(classes.show); - // disable scroll on page - const scrollY = - document.documentElement.style.getPropertyValue('--scroll-y'); - const { body } = document; - body.style.position = 'fixed'; - body.style.left = '0'; - body.style.right = '0'; - body.style.top = `-${scrollY}`; } } else if (dialogElement.open) { handleDialogClose(); diff --git a/packages/circuit-ui/components/Modal/ModalService.spec.tsx b/packages/circuit-ui/components/Modal/ModalService.spec.tsx index 495798f97f..45e612f001 100644 --- a/packages/circuit-ui/components/Modal/ModalService.spec.tsx +++ b/packages/circuit-ui/components/Modal/ModalService.spec.tsx @@ -13,9 +13,11 @@ * limitations under the License. */ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getKeyboardFocusableElements } from './ModalService.js'; +import { renderHook, act } from '../../util/test-utils.js'; + +import { getKeyboardFocusableElements, useScrollLock } from './ModalService.js'; describe('DialogService', () => { describe('getKeyboardFocusableElements', () => { @@ -85,4 +87,69 @@ describe('DialogService', () => { ); }); }); + describe('useScrollLock', () => { + Object.defineProperty(window, 'scrollTo', { + value: vi.fn(), + writable: true, + }); + + Object.defineProperty(window, 'scrollY', { value: 1, writable: true }); + + beforeEach(() => { + document.body.style.position = ''; + document.body.style.top = ''; + document.documentElement.style.setProperty('--scroll-y', ''); + }); + + it('locks the scroll when `isLocked` is true', () => { + document.documentElement.style.setProperty('--scroll-y', '100px'); + + const { rerender } = renderHook( + ({ isLocked }) => useScrollLock(isLocked), + { + initialProps: { isLocked: false }, + }, + ); + + rerender({ isLocked: true }); + + expect(document.body.style.position).toBe('fixed'); + expect(document.body.style.top).toBe('-100px'); + }); + + it('unlocks the scroll when `isLocked` is false', () => { + document.body.style.top = '-100px'; + + const { rerender } = renderHook( + ({ isLocked }) => useScrollLock(isLocked), + { + initialProps: { isLocked: true }, + }, + ); + + rerender({ isLocked: false }); + + expect(document.body.style.position).toBe(''); + expect(document.body.style.top).toBe(''); + expect(window.scrollTo).toHaveBeenCalledWith(0, 100); + }); + + it('updates `--scroll-y` on scroll', () => { + global.requestAnimationFrame = vi + .fn() + .mockImplementation((callback: () => void) => callback()); + + renderHook(() => useScrollLock(false)); + + act(() => { + window.scrollY = 200; + const scrollEvent = new Event('scroll'); + window.dispatchEvent(scrollEvent); + }); + + expect( + document.documentElement.style.getPropertyValue('--scroll-y'), + ).toBe('200px'); + }); + }); }); diff --git a/packages/circuit-ui/components/Modal/ModalService.ts b/packages/circuit-ui/components/Modal/ModalService.ts index ac47443d50..1fbffabbe3 100644 --- a/packages/circuit-ui/components/Modal/ModalService.ts +++ b/packages/circuit-ui/components/Modal/ModalService.ts @@ -13,6 +13,22 @@ * limitations under the License. */ +/** + * Copyright 2024, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect } from 'react'; + export function getKeyboardFocusableElements( element: HTMLElement, ): HTMLElement[] { @@ -40,3 +56,48 @@ export function getFirstFocusableElement( export const hasNativeDialogSupport = (): boolean => 'HTMLDialogElement' in window; + +export const useScrollLock = (isLocked: boolean): void => { + let busy = false; + + useEffect(() => { + function setScrollProperty() { + if (!busy) { + requestAnimationFrame(() => { + document.documentElement.style.setProperty( + '--scroll-y', + `${window.scrollY}px`, + ); + busy = false; + }); + busy = true; + } + } + window.addEventListener('scroll', setScrollProperty); + + return () => { + window.removeEventListener('scroll', setScrollProperty); + }; + }, [busy]); + + useEffect(() => { + if (isLocked) { + const scrollY = + document.documentElement.style.getPropertyValue('--scroll-y'); + const { body } = document; + body.style.position = 'fixed'; + body.style.left = '0'; + body.style.right = '0'; + body.style.top = `-${scrollY}`; + } else { + // restore scroll to page + const { body } = document; + const scrollY = body.style.top; + body.style.position = ''; + body.style.top = ''; + body.style.left = ''; + body.style.right = ''; + window.scrollTo(0, Number.parseInt(scrollY || '0', 10) * -1); + } + }, [isLocked]); +};