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
5 changes: 5 additions & 0 deletions .changeset/fix-mobile-drawer-offscreen-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed the mobile nav drawer sliding permanently out of frame, and made the panel slide continue the speed of your swipe instead of always taking the same time.
10 changes: 10 additions & 0 deletions src/app/components/page/MobileNavDrawer.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { globalStyle, style } from '@vanilla-extract/css';

export const NoScrollbar = style({
msOverflowStyle: 'none',
scrollbarWidth: 'none',
});

globalStyle(`${NoScrollbar}::-webkit-scrollbar`, {
display: 'none',
});
51 changes: 51 additions & 0 deletions src/app/components/page/MobileNavDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="persistent-room-host" />,
}));

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(
<MemoryRouter initialEntries={['/home']}>
<MobileNavDrawer nav={<div>nav</div>}>
<div>content</div>
</MobileNavDrawer>
</MemoryRouter>
);

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');
});
});
159 changes: 102 additions & 57 deletions src/app/components/page/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { resolveSection } from '$pages/pathUtils';
import { isRoomAlias, isRoomId } from '$utils/matrix';
import { PersistentRoomHost } from './PersistentRoomHost';
import { MobileNavDrawerContext, type MobileSwipeTarget } from './MobileNavDrawerContext';
import { NoScrollbar } from './MobileNavDrawer.css';
import {
classifyMobileGesture,
getDrawerSettlePosition,
Expand All @@ -41,7 +42,17 @@ type MobileNavDrawerProps = {
children: ReactNode;
};

const DRAWER_TRANSITION_MS = 220;
const DRAWER_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
// Initial slope of DRAWER_EASE; matched against release speed so the slide picks up the
// finger's motion instead of starting from a standstill.
const DRAWER_EASE_INITIAL_SLOPE = 2.25;
const DRAWER_FULL_SLIDE_MS = 300;
const DRAWER_MIN_SLIDE_MS = 160;
const DRAWER_MAX_SLIDE_MS = 420;
const DRAWER_FLICK_SPEED = 0.05; // px/ms

const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);

type ActiveTouchGesture = {
startX: number;
Expand Down Expand Up @@ -100,53 +111,94 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
const gestureRef = useRef<ActiveTouchGesture>();
const messageTargetsRef = useRef(new WeakMap<HTMLElement, MobileSwipeTarget>());
const chatTargetsRef = useRef(new WeakMap<HTMLElement, MobileSwipeTarget>());
const settleAnimationRef = useRef<number>();
const settleTimeoutRef = useRef<number>();
const programmaticTargetRef = useRef<number>();

const setTrackPosition = useCallback((position: number) => {
positionRef.current = position;
if (trackRef.current) {
trackRef.current.style.transform = `translate3d(${position}px, 0, 0)`;
const el = trackRef.current;
if (!el) return;
el.style.transition = 'none';
el.style.transform = `translate3d(${position}px, 0, 0)`;
}, []);

// Reading the computed transform also flushes pending style, without which a settle
// starting right after a drag frame would jump instead of interpolating.
const readTrackPosition = useCallback((): number => {
const el = trackRef.current;
if (!el) return positionRef.current;
const { transform } = getComputedStyle(el);
if (!transform || transform === 'none') return positionRef.current;
try {
return new DOMMatrix(transform).m41;
} catch {
return positionRef.current;
}
}, []);

// While a transition is in flight positionRef holds its target, not the visible position.
const currentPosition = useCallback(
() => (settleTimeoutRef.current === undefined ? positionRef.current : readTrackPosition()),
[readTrackPosition]
);

const setTrackBoosted = useCallback((boosted: boolean) => {
const el = trackRef.current;
if (el) el.style.willChange = boosted ? 'transform' : 'auto';
}, []);

const cancelSettle = useCallback(() => {
if (settleTimeoutRef.current === undefined) return;
window.clearTimeout(settleTimeoutRef.current);
settleTimeoutRef.current = undefined;
setTrackPosition(readTrackPosition());
}, [readTrackPosition, setTrackPosition]);

const settleToPanel = useCallback(
(targetPosition: number, onComplete?: () => void) => {
if (!trackRef.current) return;
(targetPosition: number, velocityX: number, onComplete?: () => void) => {
const el = trackRef.current;
if (!el) return;

window.cancelAnimationFrame(settleAnimationRef.current ?? 0);
if (reduceMotion) {
setTrackPosition(targetPosition);
onComplete?.();
return;
}
window.clearTimeout(settleTimeoutRef.current);
settleTimeoutRef.current = undefined;

const startPosition = positionRef.current;
const startPosition = readTrackPosition();
const distance = targetPosition - startPosition;
if (Math.abs(distance) <= 1) {

if (reduceMotion || Math.abs(distance) <= 1) {
setTrackPosition(targetPosition);
setTrackBoosted(false);
onComplete?.();
return;
}

const startTime = window.performance.now();
const tick = (time: number) => {
const progress = Math.min(1, (time - startTime) / DRAWER_TRANSITION_MS);
const eased = 1 - (1 - progress) ** 3;
setTrackPosition(startPosition + distance * eased);

if (progress < 1) {
settleAnimationRef.current = window.requestAnimationFrame(tick);
} else {
setTrackPosition(targetPosition);
settleAnimationRef.current = undefined;
onComplete?.();
}
};

settleAnimationRef.current = window.requestAnimationFrame(tick);
// A flick against the committed direction must not shorten the glide.
const speed = Math.sign(velocityX) === Math.sign(distance) ? Math.abs(velocityX) : 0;
const duration =
speed > DRAWER_FLICK_SPEED
? clamp(
(DRAWER_EASE_INITIAL_SLOPE * Math.abs(distance)) / speed,
DRAWER_MIN_SLIDE_MS,
DRAWER_MAX_SLIDE_MS
)
: clamp(
(Math.abs(distance) / Math.max(widthRef.current, 1)) * DRAWER_FULL_SLIDE_MS,
DRAWER_MIN_SLIDE_MS,
DRAWER_FULL_SLIDE_MS
);

setTrackBoosted(true);
positionRef.current = targetPosition;
el.style.transition = `transform ${Math.round(duration)}ms ${DRAWER_EASE}`;
el.style.transform = `translate3d(${targetPosition}px, 0, 0)`;

settleTimeoutRef.current = window.setTimeout(() => {
settleTimeoutRef.current = undefined;
setTrackBoosted(false);
onComplete?.();
}, Math.round(duration));
},
[reduceMotion, setTrackPosition]
[reduceMotion, readTrackPosition, setTrackBoosted, setTrackPosition]
);

const registerMessageSwipe = useCallback((element: HTMLElement, target: MobileSwipeTarget) => {
Expand Down Expand Up @@ -193,7 +245,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra

// Start the exact same settle used after a swipe immediately, while the
// selected room renders concurrently in the panel being revealed.
settleToPanel(-width, () => {
settleToPanel(-width, 0, () => {
if (programmaticTargetRef.current === -width) {
programmaticTargetRef.current = undefined;
}
Expand Down Expand Up @@ -239,7 +291,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
if (programmaticTargetRef.current === targetPosition) return;

if (Math.abs(positionRef.current - targetPosition) > 5) {
settleToPanel(targetPosition, () => setPanelIntent(contentOpen ? 1 : 0));
settleToPanel(targetPosition, 0, () => setPanelIntent(contentOpen ? 1 : 0));
} else {
setPanelIntent(contentOpen ? 1 : 0);
}
Expand All @@ -263,7 +315,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
return;
}
}
settleToPanel(0);
settleToPanel(0, 0);
setPanelIntent(0);
return;
}
Expand Down Expand Up @@ -316,14 +368,16 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
cancelled,
});

settleToPanel(targetPosition, () => commitPanel(targetPosition === -width));
settleToPanel(targetPosition, gesture.velocityX, () =>
commitPanel(targetPosition === -width)
);
},
[commitPanel, settleToPanel, width]
);

useEffect(() => {
return () => {
window.cancelAnimationFrame(settleAnimationRef.current ?? 0);
window.clearTimeout(settleTimeoutRef.current);
const gesture = gestureRef.current;
gestureRef.current = undefined;
gesture?.message?.cancel();
Expand All @@ -339,7 +393,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
const drawer = (
<div
ref={viewportRef}
className="no-scrollbar"
data-testid="mobile-nav-drawer-viewport"
onTouchStartCapture={(event) => {
const viewport = viewportRef.current;
const touch = event.touches[0];
Expand All @@ -360,7 +414,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
gestureRef.current = {
startX: touch.clientX,
startY: touch.clientY,
startPosition: positionRef.current,
startPosition: currentPosition(),
lastX: touch.clientX,
lastTime: event.timeStamp,
velocityX: 0,
Expand Down Expand Up @@ -389,18 +443,18 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
gesture.mode = classifyMobileGesture({
distanceX,
distanceY,
startPosition: positionRef.current,
startPosition: currentPosition(),
width,
canOpenRoom,
hasMessage: gesture.message !== undefined,
hasChat: gesture.chat !== undefined,
});
if (gesture.mode === 'drawer' || gesture.mode === 'message' || gesture.mode === 'chat') {
const animationActive = settleAnimationRef.current !== undefined;
window.cancelAnimationFrame(settleAnimationRef.current ?? 0);
settleAnimationRef.current = undefined;
const wasSettling = settleTimeoutRef.current !== undefined;
cancelSettle();
programmaticTargetRef.current = undefined;
if (animationActive) gesture.startPosition = positionRef.current - distanceX;
if (wasSettling) gesture.startPosition = positionRef.current - distanceX;
if (gesture.mode === 'drawer') setTrackBoosted(true);
}
}

Expand All @@ -419,20 +473,12 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
flexGrow: 1,
height: '100%',
width: '100%',
overflow: 'hidden',
overscrollBehaviorX: 'none',
// `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',
touchAction: 'pan-y',
}}
>
<style>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
<div
ref={trackRef}
style={{
Expand All @@ -441,12 +487,11 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
height: '100%',
flexShrink: 0,
transform: 'translate3d(0, 0, 0)',
willChange: 'transform',
}}
>
<div
ref={navPanelRef}
className="no-scrollbar"
className={NoScrollbar}
style={{
width: '50%',
flexBasis: '50%',
Expand Down Expand Up @@ -479,7 +524,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
</div>
<div
ref={contentPanelRef}
className="no-scrollbar"
className={NoScrollbar}
style={{
width: '50%',
flexBasis: '50%',
Expand Down
Loading
Loading