From 7ba939fb9401c9a2d807ee5aa7c15c97dd904140 Mon Sep 17 00:00:00 2001 From: Sean Perkins <13732623+sean-perkins@users.noreply.github.com> Date: Wed, 1 Nov 2023 14:58:53 -0400 Subject: [PATCH] fix(overlays): prevent scroll gestures when the overlay is presented (#28415) Issue number: Resolves #23942 --------- ## What is the current behavior? When an overlay is created (inserted in the DOM), but not presented, the scroll gesture is prevented. This behavior comes from the `connectedCallback` of `ion-backdrop`, where the gesture is prevented as soon as the backdrop is inserted in the DOM. This means in situations where a developer creates an overlay, but does not present it immediately, the user cannot scroll. This is not desired. ## What is the new behavior? - Scroll blocking behavior tied to the gesture has been removed from `ion-backdrop` and implemented into the overlays directly. - When an overlay is presented, scroll blocking is enabled on the `body` element (the user cannot scroll on the main content). - When the last presented overlay is dismissed, scroll blocking is disabled on the `body` element (the user can scroll on the main content). ## Does this introduce a breaking change? - [x] Yes - [ ] No `ion-backdrop` no longer prevents scrolling on the main content when the backdrop is either inserted into the DOM or removed from the DOM. Developers using Ionic overlays do not need to migrate their implementations. Developers with custom overlays using `ion-backdrop` internally can either use Ionic's gesture controller to disable scrolling when their overlay is presented/dismissed or can manually add the `backdrop-no-scroll` Ionic global class to the `body` element. ## Other information --------- --- core/src/components/backdrop/backdrop.tsx | 15 ---- core/src/components/menu/menu.tsx | 2 +- core/src/utils/gesture/gesture-controller.ts | 2 +- core/src/utils/overlays.ts | 9 +- .../overlays/overlays-scroll-blocking.spec.ts | 88 +++++++++++++++++++ 5 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 core/src/utils/test/overlays/overlays-scroll-blocking.spec.ts diff --git a/core/src/components/backdrop/backdrop.tsx b/core/src/components/backdrop/backdrop.tsx index 1e566a4bc8..7d85b485cc 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -1,6 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Event, Host, Listen, Prop, h } from '@stencil/core'; -import { GESTURE_CONTROLLER } from '@utils/gesture'; import { getIonMode } from '../../global/ionic-global'; @@ -13,10 +12,6 @@ import { getIonMode } from '../../global/ionic-global'; shadow: true, }) export class Backdrop implements ComponentInterface { - private blocker = GESTURE_CONTROLLER.createBlocker({ - disableScroll: true, - }); - /** * If `true`, the backdrop will be visible. */ @@ -37,16 +32,6 @@ export class Backdrop implements ComponentInterface { */ @Event() ionBackdropTap!: EventEmitter; - connectedCallback() { - if (this.stopPropagation) { - this.blocker.block(); - } - } - - disconnectedCallback() { - this.blocker.unblock(); - } - @Listen('click', { passive: false, capture: true }) protected onMouseDown(ev: TouchEvent) { this.emitTap(ev); diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 4925051837..45a03fad95 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -52,7 +52,7 @@ export class Menu implements ComponentInterface, MenuI { width!: number; _isOpen = false; - backdropEl?: HTMLElement; + backdropEl?: HTMLIonBackdropElement; menuInnerEl?: HTMLElement; contentEl?: HTMLElement; lastFocus?: HTMLElement; diff --git a/core/src/utils/gesture/gesture-controller.ts b/core/src/utils/gesture/gesture-controller.ts index 1faad19e5c..32f381008b 100644 --- a/core/src/utils/gesture/gesture-controller.ts +++ b/core/src/utils/gesture/gesture-controller.ts @@ -243,5 +243,5 @@ export interface BlockerConfig { disableScroll?: boolean; } -const BACKDROP_NO_SCROLL = 'backdrop-no-scroll'; +export const BACKDROP_NO_SCROLL = 'backdrop-no-scroll'; export const GESTURE_CONTROLLER = new GestureController(); diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index 07d8fc8c1d..3b452091d2 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -20,6 +20,7 @@ import type { } from '../interface'; import { CoreDelegate } from './framework-delegate'; +import { BACKDROP_NO_SCROLL } from './gesture/gesture-controller'; import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button'; import { addEventListener, componentOnReady, focusElement, getElementRoot, removeEventListener } from './helpers'; import { printIonWarning } from './logging'; @@ -471,6 +472,8 @@ export const present = async ( setRootAriaHidden(true); + document.body.classList.add(BACKDROP_NO_SCROLL); + overlay.presented = true; overlay.willPresent.emit(); overlay.willPresentShorthand?.emit(); @@ -549,12 +552,15 @@ export const dismiss = async ( return false; } + const lastOverlay = doc !== undefined && getPresentedOverlays(doc).length === 1; + /** * If this is the last visible overlay then * we want to re-add the root to the accessibility tree. */ - if (doc !== undefined && getPresentedOverlays(doc).length === 1) { + if (lastOverlay) { setRootAriaHidden(false); + document.body.classList.remove(BACKDROP_NO_SCROLL); } overlay.presented = false; @@ -574,6 +580,7 @@ export const dismiss = async ( if (role !== GESTURE) { await overlayAnimation(overlay, animationBuilder, overlay.el, opts); } + overlay.didDismiss.emit({ data, role }); overlay.didDismissShorthand?.emit({ data, role }); diff --git a/core/src/utils/test/overlays/overlays-scroll-blocking.spec.ts b/core/src/utils/test/overlays/overlays-scroll-blocking.spec.ts new file mode 100644 index 0000000000..6de327cd6d --- /dev/null +++ b/core/src/utils/test/overlays/overlays-scroll-blocking.spec.ts @@ -0,0 +1,88 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { Modal } from '../../../components/modal/modal'; + +describe('overlays: scroll blocking', () => { + it('should not block scroll when the overlay is created', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + `, + }); + + const body = page.doc.querySelector('body')!; + + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); + + it('should block scroll when the overlay is presented', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + `, + }); + + const modal = page.body.querySelector('ion-modal')!; + const body = page.doc.querySelector('body')!; + + await modal.present(); + + expect(body).toHaveClass('backdrop-no-scroll'); + + await modal.dismiss(); + + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); + + it('should not block scroll when the overlay is dismissed', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + `, + }); + + const modal = page.body.querySelector('ion-modal')!; + const body = page.doc.querySelector('body')!; + + await modal.present(); + + expect(body).toHaveClass('backdrop-no-scroll'); + + await modal.dismiss(); + + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); + + it('should not enable scroll until last overlay is dismissed', async () => { + const page = await newSpecPage({ + components: [Modal], + html: ` + + + `, + }); + + const modalOne = page.body.querySelector('#one')!; + const modalTwo = page.body.querySelector('#two')!; + const body = page.doc.querySelector('body')!; + + await modalOne.present(); + + expect(body).toHaveClass('backdrop-no-scroll'); + + await modalTwo.present(); + + expect(body).toHaveClass('backdrop-no-scroll'); + + await modalOne.dismiss(); + + expect(body).toHaveClass('backdrop-no-scroll'); + + await modalTwo.dismiss(); + + expect(body).not.toHaveClass('backdrop-no-scroll'); + }); +});