From f7222b86a816522e871ccfb432502339d914b8cf Mon Sep 17 00:00:00 2001 From: George Richmond Date: Fri, 12 Jun 2026 09:55:45 +0100 Subject: [PATCH 1/3] Tweak modal overlay slide up on iOS --- dotcom-rendering/scripts/jest/setup.ts | 17 +++++++ .../src/components/ModalOverlay.test.tsx | 8 ++-- .../src/components/ModalOverlay.tsx | 45 ++++++++++--------- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/dotcom-rendering/scripts/jest/setup.ts b/dotcom-rendering/scripts/jest/setup.ts index 452ba1227a2..1113806bfb0 100644 --- a/dotcom-rendering/scripts/jest/setup.ts +++ b/dotcom-rendering/scripts/jest/setup.ts @@ -109,5 +109,22 @@ if (!isServer) { global.TextEncoder = TextEncoder as unknown as typeof global.TextEncoder; global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder; +if (!isServer) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + }); +} + // Mocks the version number used by CDK, we don't want our tests to fail every time we update our cdk dependency. jest.mock('@guardian/cdk/lib/constants/tracking-tag'); diff --git a/dotcom-rendering/src/components/ModalOverlay.test.tsx b/dotcom-rendering/src/components/ModalOverlay.test.tsx index cac148c2595..11f0703c744 100644 --- a/dotcom-rendering/src/components/ModalOverlay.test.tsx +++ b/dotcom-rendering/src/components/ModalOverlay.test.tsx @@ -48,12 +48,12 @@ describe('ModalOverlay', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('calls onClose when the overlay backdrop receives a mousedown', () => { + it('calls onClose when the overlay backdrop receives a pointerdown', () => { const onClose = jest.fn(); renderOverlay(onClose); const overlay = screen.getByRole('dialog').parentElement!; - fireEvent.mouseDown(overlay); + fireEvent.pointerDown(overlay); act(() => { jest.runAllTimers(); @@ -62,11 +62,11 @@ describe('ModalOverlay', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('does not call onClose when the dialog itself receives a mousedown', () => { + it('does not call onClose when the dialog itself receives a pointerdown', () => { const onClose = jest.fn(); renderOverlay(onClose); - fireEvent.mouseDown(screen.getByRole('dialog')); + fireEvent.pointerDown(screen.getByRole('dialog')); act(() => { jest.runAllTimers(); diff --git a/dotcom-rendering/src/components/ModalOverlay.tsx b/dotcom-rendering/src/components/ModalOverlay.tsx index b6248e89acd..061ab577908 100644 --- a/dotcom-rendering/src/components/ModalOverlay.tsx +++ b/dotcom-rendering/src/components/ModalOverlay.tsx @@ -14,6 +14,10 @@ import { getZIndex } from '../lib/getZIndex'; const OPEN_ANIMATION_DURATION_MS = 300; const CLOSE_ANIMATION_DURATION_MS = 225; +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const ModalRequestCloseContext = createContext<(() => void) | null>(null); export const useModalRequestClose = (): (() => void) => { @@ -130,19 +134,16 @@ export const ModalOverlay = ({ const overlayRef = useRef(null); const dialogRef = useRef(null); const closeTimeoutRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); + + const [isVisible, setIsVisible] = useState(() => prefersReducedMotion()); const requestClose = useCallback(() => { if (closeTimeoutRef.current !== null) { return; } - const prefersReducedMotion = - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? - false; - - if (prefersReducedMotion) { - closeTimeoutRef.current = 0; + if (prefersReducedMotion()) { + closeTimeoutRef.current = -1; onClose(); return; } @@ -156,21 +157,19 @@ export const ModalOverlay = ({ // Trigger open animation on mount useEffect(() => { - const prefersReducedMotion = - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? - false; - - if (prefersReducedMotion) { - setIsVisible(true); + if (prefersReducedMotion()) { return; } - - const animationFrameId = window.requestAnimationFrame(() => { - setIsVisible(true); + let innerAnimationFrameId: number = 0; + const outerAnimationFrameId = window.requestAnimationFrame(() => { + innerAnimationFrameId = window.requestAnimationFrame(() => { + setIsVisible(true); + }); }); return () => { - window.cancelAnimationFrame(animationFrameId); + window.cancelAnimationFrame(outerAnimationFrameId); + window.cancelAnimationFrame(innerAnimationFrameId); }; }, []); @@ -282,18 +281,21 @@ export const ModalOverlay = ({ return; } - const handleOverlayMouseDown = (event: MouseEvent) => { + const handleOverlayPointerDown = (event: PointerEvent) => { if (event.target === overlayElement) { requestClose(); } }; - overlayElement.addEventListener('mousedown', handleOverlayMouseDown); + overlayElement.addEventListener( + 'pointerdown', + handleOverlayPointerDown, + ); return () => { overlayElement.removeEventListener( - 'mousedown', - handleOverlayMouseDown, + 'pointerdown', + handleOverlayPointerDown, ); }; }, [requestClose]); @@ -305,7 +307,6 @@ export const ModalOverlay = ({ return createPortal(
Date: Fri, 12 Jun 2026 12:25:03 +0100 Subject: [PATCH 2/3] Tweak modal overlay slide up on iOS - prevent overlay passing through taps --- dotcom-rendering/src/components/ModalOverlay.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dotcom-rendering/src/components/ModalOverlay.tsx b/dotcom-rendering/src/components/ModalOverlay.tsx index 061ab577908..2af8912f2b9 100644 --- a/dotcom-rendering/src/components/ModalOverlay.tsx +++ b/dotcom-rendering/src/components/ModalOverlay.tsx @@ -160,16 +160,13 @@ export const ModalOverlay = ({ if (prefersReducedMotion()) { return; } - let innerAnimationFrameId: number = 0; - const outerAnimationFrameId = window.requestAnimationFrame(() => { - innerAnimationFrameId = window.requestAnimationFrame(() => { - setIsVisible(true); - }); + + const animationFrameId = window.requestAnimationFrame(() => { + setIsVisible(true); }); return () => { - window.cancelAnimationFrame(outerAnimationFrameId); - window.cancelAnimationFrame(innerAnimationFrameId); + window.cancelAnimationFrame(animationFrameId); }; }, []); @@ -204,7 +201,9 @@ export const ModalOverlay = ({ ? document.activeElement : null; - dialogElement.focus(); + // preventScroll stops iOS Safari from jerking the viewport to bring + // the off-screen element into view before the slide-up animation runs. + dialogElement.focus({ preventScroll: true }); return () => { if ( @@ -283,6 +282,7 @@ export const ModalOverlay = ({ const handleOverlayPointerDown = (event: PointerEvent) => { if (event.target === overlayElement) { + event.preventDefault(); requestClose(); } }; From 64c927cf49b721e6661d2319a5d97e58624545e7 Mon Sep 17 00:00:00 2001 From: George Richmond Date: Fri, 12 Jun 2026 14:19:27 +0100 Subject: [PATCH 3/3] remove closeTimeoutRef as not required --- dotcom-rendering/src/components/ModalOverlay.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/dotcom-rendering/src/components/ModalOverlay.tsx b/dotcom-rendering/src/components/ModalOverlay.tsx index fb797e97e25..f72d5b68efb 100644 --- a/dotcom-rendering/src/components/ModalOverlay.tsx +++ b/dotcom-rendering/src/components/ModalOverlay.tsx @@ -143,9 +143,6 @@ export const ModalOverlay = ({ } if (prefersReducedMotion()) { - // Use -1 as a sentinel to block re-entrant calls. 0 is a valid - // setTimeout ID and must not be used here. - closeTimeoutRef.current = -1; onClose(); return; }