Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ describe('Modal', () => {
expect(screen.getByText('Modal content')).toBeInTheDocument();
});

it('contains scroll inside the dialog so it cannot chain to the page (#552)', () => {
render(<Modal {...defaultProps} />);
const dialog = screen.getByRole('dialog');
expect(dialog.className).toContain('overflow-y-auto');
expect(dialog.className).toContain('overscroll-contain');
expect(dialog.className).toContain('max-h-[calc(100dvh-2rem)]');
expect(dialog.parentElement?.className).toContain('overscroll-contain');
});

it('has aria-modal attribute', () => {
render(<Modal {...defaultProps} />);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
Expand Down
25 changes: 24 additions & 1 deletion src/__tests__/useModalOverflow.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useModalOverflow, resetModalOverflow } from '../hooks/useModalOverflow';
import { useModalOverflow, resetModalOverflow, SCROLL_LOCK_CLASS } from '../hooks/useModalOverflow';
import { renderHook } from './renderHook';

describe('useModalOverflow', () => {
Expand Down Expand Up @@ -47,4 +47,27 @@ describe('useModalOverflow', () => {

expect(document.body.style.overflow).toBe('');
});

it('marks <html> with the scroll-lock class only while a lock is held (#552)', () => {
const first = renderHook(() => useModalOverflow());
const second = renderHook(() => useModalOverflow());
const html = document.documentElement;

first.current.lock();
second.current.lock();
expect(html.classList.contains(SCROLL_LOCK_CLASS)).toBe(true);

first.current.unlock();
expect(html.classList.contains(SCROLL_LOCK_CLASS)).toBe(true);

second.current.unlock();
expect(html.classList.contains(SCROLL_LOCK_CLASS)).toBe(false);
});

it('resetModalOverflow also clears the scroll-lock class', () => {
const hook = renderHook(() => useModalOverflow());
hook.current.lock();
resetModalOverflow();
expect(document.documentElement.classList.contains(SCROLL_LOCK_CLASS)).toBe(false);
});
});
7 changes: 7 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,10 @@ input[type=number] { -moz-appearance: textfield; }
transform: rotate(0deg) scale(1);
}
}

/* Set by useModalOverflow while a modal is open: stop iOS Safari from
chaining scroll / rubber-banding the page behind the modal (#552). */
html.modal-scroll-locked,
html.modal-scroll-locked body {
overscroll-behavior: none;
}
6 changes: 4 additions & 2 deletions src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }:

return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ease-out ${
className={`fixed inset-0 z-50 flex items-center justify-center overscroll-contain p-4 transition-all duration-300 ease-out ${
visible
? 'bg-black/50 dark:bg-black/70 backdrop-blur-sm opacity-100'
: 'bg-black/0 dark:bg-black/0 backdrop-blur-0 opacity-0'
Expand All @@ -110,7 +110,9 @@ export function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }:
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
tabIndex={-1}
className={`relative w-full ${maxWidth} rounded-2xl border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6 shadow-2xl transition-all duration-300 ease-out ${
// Own scroll container, capped to the viewport, with overscroll contained
// so reaching its top/bottom never chains to the page on iOS (#552).
className={`relative max-h-[calc(100dvh-2rem)] w-full ${maxWidth} overflow-y-auto overscroll-contain rounded-2xl border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6 shadow-2xl transition-all duration-300 ease-out ${
visible
? 'opacity-100 scale-100 translate-y-0'
: 'opacity-0 scale-95 translate-y-2'
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/useModalOverflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { useCallback, useRef } from 'react';
const activeLocks = new Set<symbol>();
let originalOverflow = '';

// Class on <html> while any modal is open. globals.css turns off
// overscroll/scroll chaining for html + body under it, since iOS Safari
// still rubber-bands and chains scroll to the page even with the body's
// overflow hidden (#552).
export const SCROLL_LOCK_CLASS = 'modal-scroll-locked';

export function useModalOverflow() {
const tokenRef = useRef<symbol | null>(null);

Expand All @@ -22,6 +28,7 @@ export function useModalOverflow() {
if (activeLocks.size === 0) {
originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.documentElement.classList.add(SCROLL_LOCK_CLASS);
}
activeLocks.add(token);
}, []);
Expand All @@ -34,6 +41,7 @@ export function useModalOverflow() {
activeLocks.delete(token);
if (activeLocks.size === 0) {
document.body.style.overflow = originalOverflow;
document.documentElement.classList.remove(SCROLL_LOCK_CLASS);
}
}, []);

Expand All @@ -50,4 +58,5 @@ export function useModalOverflow() {
export function resetModalOverflow() {
activeLocks.clear();
document.body.style.overflow = originalOverflow;
document.documentElement.classList.remove(SCROLL_LOCK_CLASS);
}