From 7f0ae05dbd5d15677e0ec3c7713f500bf9200084 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 25 Jul 2026 10:14:59 +0200 Subject: [PATCH] fix(mobile): clip the nav drawer viewport so it cannot scroll out of frame --- .../fix-mobile-drawer-offscreen-scroll.md | 5 ++ .../components/page/MobileNavDrawer.test.tsx | 51 +++++++++++++++++++ src/app/components/page/MobileNavDrawer.tsx | 5 +- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-mobile-drawer-offscreen-scroll.md create mode 100644 src/app/components/page/MobileNavDrawer.test.tsx diff --git a/.changeset/fix-mobile-drawer-offscreen-scroll.md b/.changeset/fix-mobile-drawer-offscreen-scroll.md new file mode 100644 index 0000000000..da2c6d4128 --- /dev/null +++ b/.changeset/fix-mobile-drawer-offscreen-scroll.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed the mobile nav drawer sliding permanently out of frame after the keyboard or a focus change scrolled the panel track. diff --git a/src/app/components/page/MobileNavDrawer.test.tsx b/src/app/components/page/MobileNavDrawer.test.tsx new file mode 100644 index 0000000000..1f4c45167a --- /dev/null +++ b/src/app/components/page/MobileNavDrawer.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { MobileNavDrawer } from './MobileNavDrawer'; + +vi.mock('$state/hooks/settings', () => ({ + useSetting: () => [true, vi.fn<() => void>()], +})); + +vi.mock('./PersistentRoomHost', () => ({ + PersistentRoomHost: () =>
, +})); + +beforeAll(() => { + window.matchMedia = + window.matchMedia ?? + ((query: string) => + ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + }) as unknown as MediaQueryList); +}); + +const renderDrawer = () => + render( + + nav
}> +
content
+ + + ); + +describe('MobileNavDrawer', () => { + // The panels sit side by side in a track twice the viewport wide, moved by transform. + // `hidden` leaves a scrollport that focus or scrollIntoView scrolls a full panel width, + // stacking on top of the transform and stranding the active panel off frame. + it('clips the viewport instead of hiding overflow, so it can never be scrolled', () => { + renderDrawer(); + + const viewport = screen.getByTestId('mobile-nav-drawer-viewport'); + + expect(viewport.style.overflow).toBe('clip'); + expect(viewport.style.overflow).not.toBe('hidden'); + }); +}); diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 3cd0b84a30..ee61d3b6cf 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -340,6 +340,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
{ const viewport = viewportRef.current; const touch = event.touches[0]; @@ -419,7 +420,9 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra flexGrow: 1, height: '100%', width: '100%', - overflow: 'hidden', + // `clip`, not `hidden`: hidden keeps a scrollport, so a focus or scrollIntoView on + // the revealed panel scrolls it a full panel width out of frame, permanently. + overflow: 'clip', overscrollBehaviorX: 'none', touchAction: 'pan-y', }}