diff --git a/packages/x-components/src/components/modals/__tests__/base-modal.spec.ts b/packages/x-components/src/components/modals/__tests__/base-modal.spec.ts index fa1a29ad95..9a81745c50 100644 --- a/packages/x-components/src/components/modals/__tests__/base-modal.spec.ts +++ b/packages/x-components/src/components/modals/__tests__/base-modal.spec.ts @@ -24,6 +24,7 @@ window.ResizeObserver = MockResizeObserver as any * @param options.defaultSlot - defaultSlot option. * @param options.open - open option. * @param options.focusOnOpen - focusOnOpen option. + * @param options.scrollLock - scrollLock option. * @param options.contentClass - contentClass option. * @param options.overlayClass - overlayClass option. * @param options.referenceSelector - referenceSelector option. @@ -35,6 +36,7 @@ function mountBaseModal(options: MountBaseModalOptions = {}) { defaultSlot = 'Modal', open = false, focusOnOpen = true, + scrollLock = true, contentClass = '', overlayClass = '', referenceSelector = undefined, @@ -47,6 +49,7 @@ function mountBaseModal(options: MountBaseModalOptions = {}) { `, components: { BaseModal }, - props: ['open', 'focusOnOpen', 'contentClass', 'overlayClass', 'referenceSelector'], + props: [ + 'open', + 'focusOnOpen', + 'scrollLock', + 'contentClass', + 'overlayClass', + 'referenceSelector', + ], }, { - propsData: { open, focusOnOpen, contentClass, overlayClass, referenceSelector }, + propsData: { open, focusOnOpen, scrollLock, contentClass, overlayClass, referenceSelector }, global: { provide: ariaProvide(ariaLabels) }, slots: { default: defaultSlot }, }, @@ -68,7 +78,9 @@ function mountBaseModal(options: MountBaseModalOptions = {}) { return { wrapper: baseModalWrapper, + unmount: () => wrapper.unmount(), getModalContent: () => baseModalWrapper.find(getDataTestSelector('modal-content')), + setScrollLock: async (scrollLock: boolean) => wrapper.setProps({ scrollLock }), setOpen: async (open: boolean) => wrapper.setProps({ open }), setReferenceSelector: async (referenceSelector: string) => wrapper.setProps({ referenceSelector }), @@ -208,6 +220,71 @@ describe('testing Base Modal component', () => { expect(focusedElementBeforeOpen).toEqual(document.activeElement) }) + describe('scroll lock', () => { + beforeEach(() => { + document.body.style.overflow = '' + document.documentElement.style.overflow = '' + }) + + it('disables the page scroll while open by default and restores it on close', async () => { + const { setOpen } = mountBaseModal() + + await setOpen(true) + expect(document.body.style.overflow).toBe('hidden') + expect(document.documentElement.style.overflow).toBe('hidden') + + await setOpen(false) + expect(document.body.style.overflow).toBe('') + expect(document.documentElement.style.overflow).toBe('') + }) + + it("doesn't touch the page scroll if the scrollLock prop is false", async () => { + document.body.style.overflow = 'auto' + const { setOpen } = mountBaseModal({ scrollLock: false }) + + await setOpen(true) + expect(document.body.style.overflow).toBe('auto') + expect(document.documentElement.style.overflow).toBe('') + + await setOpen(false) + expect(document.body.style.overflow).toBe('auto') + }) + + it('releases and restores the lock when the scrollLock prop is toggled while open', async () => { + const { setOpen, setScrollLock } = mountBaseModal() + await setOpen(true) + + await setScrollLock(false) + expect(document.body.style.overflow).toBe('') + expect(document.documentElement.style.overflow).toBe('') + + await setScrollLock(true) + expect(document.body.style.overflow).toBe('hidden') + + await setOpen(false) + expect(document.body.style.overflow).toBe('') + }) + + it("doesn't restore the overflow it didn't set when it closes", async () => { + const { setOpen } = mountBaseModal({ scrollLock: false }) + await setOpen(true) + document.body.style.overflow = 'scroll' + + await setOpen(false) + + expect(document.body.style.overflow).toBe('scroll') + }) + + it('restores the page scroll when unmounted while open', () => { + const { unmount } = mountBaseModal({ open: true }) + expect(document.body.style.overflow).toBe('hidden') + + unmount() + + expect(document.body.style.overflow).toBe('') + }) + }) + it('allows adding classes to the modal content', () => { const { getModalContent } = mountBaseModal({ contentClass: 'test-class', @@ -248,6 +325,8 @@ describe('testing Base Modal component', () => { }) interface MountBaseModalOptions { + /** Whether the modal locks the page scroll while open. */ + scrollLock?: boolean /** The default slot content. */ defaultSlot?: string /** Whether the modal is open. */ diff --git a/packages/x-components/src/components/modals/base-modal.vue b/packages/x-components/src/components/modals/base-modal.vue index 879211bf3b..bf8ee2ebbd 100644 --- a/packages/x-components/src/components/modals/base-modal.vue +++ b/packages/x-components/src/components/modals/base-modal.vue @@ -79,6 +79,15 @@ export default defineComponent({ type: AnimationProp, default: () => Fade, }, + /** + * Determines if the scroll of the body and the window is disabled while the modal is open. + * Set it to `false` for modals that must not lock the page scroll. It can be toggled while the + * modal is open. + */ + scrollLock: { + type: Boolean, + default: true, + }, /** Class inherited by content element. */ contentClass: String, /** Class inherited by overlay element. */ @@ -102,8 +111,15 @@ export default defineComponent({ /** The reference element to use to find the modal's position. */ let referenceElement: HTMLElement | undefined + /** Whether this modal is currently holding the scroll lock, so it only restores what it set. */ + let isScrollDisabled = false + /** Disables the scroll of both the body and the window. */ function disableScroll() { + if (!props.scrollLock || isScrollDisabled) { + return + } + isScrollDisabled = true previousBodyOverflow.value = document.body.style.overflow previousHTMLOverflow.value = document.documentElement.style.overflow document.body.style.overflow = document.documentElement.style.overflow = 'hidden' @@ -111,6 +127,10 @@ export default defineComponent({ /** Restores the scroll of both the body and the window. */ function enableScroll() { + if (!isScrollDisabled) { + return + } + isScrollDisabled = false document.body.style.overflow = previousBodyOverflow.value document.documentElement.style.overflow = previousHTMLOverflow.value } @@ -210,6 +230,14 @@ export default defineComponent({ onMounted(() => { watch(() => props.open, syncBody) + watch( + () => props.scrollLock, + shouldLockScroll => { + if (props.open) { + shouldLockScroll ? disableScroll() : enableScroll() + } + }, + ) if (props.open) { syncBody(true) } @@ -381,6 +409,18 @@ const fadeAndSlide = FadeAndSlide ``` +#### Not locking the page scroll + +By default (`scrollLock` is `true`) the modal sets `overflow: hidden` on the `body` and `html` +elements while it is open, and restores them when it closes. Set the `scrollLock` prop to `false` +to keep the page scrollable. The prop can be toggled while the modal is open. + +```vue + +

The page behind can still be scrolled.

+
+``` + ## Vue Events A list of events that the component will emit: