📱 feat: Swipe the Mobile Drawer Open and Closed (#14902)

* 📱 feat: Swipe the Mobile Drawer Open and Closed

* 📱 fix: Harden the Drawer Swipe Against Interrupts, RTL, and Cold Mounts

* 📱 fix: Track the Initiating Touch and Settle Only What the State Confirms

* 📱 fix: Resolve Interrupted Drags to the Current State and Scope Overscroll
This commit is contained in:
Danny Avila 2026-08-16 19:45:35 -04:00 committed by GitHub
parent 1b7e2a4e6a
commit c939a6fb17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 952 additions and 4 deletions

View file

@ -9,6 +9,7 @@ import {
EASING,
SIDEBAR_TRANSITION,
DRAWER_Z_INDEX,
MOBILE_DRAWER_ID,
} from './constants';
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
@ -148,8 +149,12 @@ function UnifiedSidebar() {
if (isSmallScreen) {
return (
<div
id={MOBILE_DRAWER_ID}
className={cn(
'fixed inset-y-0 left-0 flex w-full flex-col bg-surface-primary-alt',
/** The close swipe reads horizontal touches here (the drawer holds no
* horizontal scrollers), while pinch-zoom stays with the browser
* this full-viewport surface must not disable zooming entirely. */
'fixed inset-y-0 left-0 flex w-full touch-pan-y touch-pinch-zoom flex-col bg-surface-primary-alt',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{ transition: SIDEBAR_TRANSITION, zIndex: DRAWER_Z_INDEX }}

View file

@ -20,3 +20,9 @@ export const SIDEBAR_TRANSITION = `transform ${TRANSITION_MS}ms ${EASING}`;
* it their containing block, which clips them.
*/
export const DRAWER_Z_INDEX = 110;
/**
* Lets the swipe gesture (mounted in Root, which owns the chat pane) reach
* the drawer element without threading a ref across sibling trees.
*/
export const MOBILE_DRAWER_ID = 'mobile-drawer';

View file

@ -0,0 +1,489 @@
import { renderHook } from '@testing-library/react';
import useDrawerSwipe, { findHorizontalScrollBlocker } from '../useDrawerSwipe';
import { MOBILE_DRAWER_ID } from '~/components/UnifiedSidebar/constants';
const DRAWER_WIDTH = 375;
const createTouch = (x: number, y: number, identifier = 0) =>
({ clientX: x, clientY: y, identifier }) as Touch;
const touchEvent = (
type: string,
touches: Touch[],
timeStamp: number,
changedTouches?: Touch[],
): TouchEvent => {
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent;
Object.defineProperty(event, 'touches', { value: touches });
Object.defineProperty(event, 'changedTouches', {
value: changedTouches ?? [createTouch(0, 0, 0)],
});
Object.defineProperty(event, 'timeStamp', { value: timeStamp });
return event;
};
type Harness = {
pane: HTMLDivElement;
drawer: HTMLDivElement;
onOpenChange: jest.Mock;
swipe: (
surface: HTMLElement,
points: Array<{ x: number; y: number; t: number; target?: Element }>,
end?: boolean,
) => TouchEvent[];
rerender: (props: { open: boolean; enabled: boolean }) => void;
unmount: () => void;
};
const setup = (open: boolean, reducedMotion = false): Harness => {
const pane = document.createElement('div');
const drawer = document.createElement('div');
drawer.id = MOBILE_DRAWER_ID;
Object.defineProperty(drawer, 'clientWidth', { value: DRAWER_WIDTH, configurable: true });
document.body.append(pane, drawer);
jest.spyOn(window, 'matchMedia').mockReturnValue({
matches: reducedMotion,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
} as unknown as MediaQueryList);
jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 0;
});
const onOpenChange = jest.fn();
const paneRef = { current: pane };
const { unmount, rerender } = renderHook(
(props: { open: boolean; enabled: boolean }) =>
useDrawerSwipe({ paneRef, enabled: props.enabled, open: props.open, onOpenChange }),
{ initialProps: { open, enabled: true } },
);
const swipe: Harness['swipe'] = (surface, points, end = true) => {
const events: TouchEvent[] = [];
points.forEach(({ x, y, t, target }, index) => {
const event = touchEvent(index === 0 ? 'touchstart' : 'touchmove', [createTouch(x, y)], t);
(target ?? surface).dispatchEvent(event);
events.push(event);
});
if (end) {
const last = points[points.length - 1];
surface.dispatchEvent(touchEvent('touchend', [], last.t + 16));
}
return events;
};
return {
pane,
drawer,
onOpenChange,
swipe,
rerender,
unmount: () => {
unmount();
pane.remove();
drawer.remove();
},
};
};
afterEach(() => {
document.getElementById(MOBILE_DRAWER_ID)?.remove();
});
describe('useDrawerSwipe — opening from the chat pane', () => {
it('follows the finger and commits past the distance threshold', () => {
const harness = setup(false);
const events = harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 120, y: 104, t: 50 },
{ x: 220, y: 108, t: 100 },
],
false,
);
expect(events[1].defaultPrevented).toBe(true);
expect(harness.drawer.style.transform).toBe(`translate3d(${200 - DRAWER_WIDTH}px, 0, 0)`);
expect(harness.pane.style.transform).toBe('translate3d(200px, 0, 0)');
expect(harness.drawer.style.transition).toBe('none');
harness.pane.dispatchEvent(touchEvent('touchend', [], 120));
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
expect(harness.pane.style.transform).toBe('translateX(100%)');
harness.unmount();
});
it('reverts a slow swipe released under the threshold', () => {
const harness = setup(false);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 60, y: 100, t: 200 },
{ x: 80, y: 100, t: 400 },
]);
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
harness.unmount();
});
it('commits a fast flick that never reaches the distance threshold', () => {
const harness = setup(false);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 50, y: 100, t: 20 },
{ x: 80, y: 100, t: 40 },
]);
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
harness.unmount();
});
it('cedes the touch to vertical scrolling once the axis locks that way', () => {
const harness = setup(false);
const events = harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 24, y: 140, t: 50 },
{ x: 90, y: 180, t: 100 },
]);
expect(events[1].defaultPrevented).toBe(false);
expect(events[2].defaultPrevented).toBe(false);
expect(harness.drawer.style.transform).toBe('');
expect(harness.onOpenChange).not.toHaveBeenCalled();
harness.unmount();
});
it('defers to a horizontal scroller that can still pan the same way', () => {
const harness = setup(false);
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
Object.defineProperty(scroller, 'scrollWidth', { value: 600, configurable: true });
Object.defineProperty(scroller, 'clientWidth', { value: 300, configurable: true });
scroller.scrollLeft = 120;
harness.pane.append(scroller);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0, target: scroller },
{ x: 220, y: 100, t: 50, target: scroller },
{ x: 260, y: 100, t: 100, target: scroller },
]);
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('');
harness.unmount();
});
it('claims the drag when that scroller is already parked at its edge', () => {
const harness = setup(false);
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
Object.defineProperty(scroller, 'scrollWidth', { value: 600, configurable: true });
Object.defineProperty(scroller, 'clientWidth', { value: 300, configurable: true });
scroller.scrollLeft = 0;
harness.pane.append(scroller);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0, target: scroller },
{ x: 220, y: 100, t: 50, target: scroller },
]);
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
harness.unmount();
});
it('never claims a drag that starts in a text surface', () => {
const harness = setup(false);
const textarea = document.createElement('textarea');
harness.pane.append(textarea);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0, target: textarea },
{ x: 220, y: 100, t: 50, target: textarea },
]);
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('');
harness.unmount();
});
it('snaps without tracking under prefers-reduced-motion', () => {
const harness = setup(false, true);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 240, y: 100, t: 50 },
]);
expect(harness.drawer.style.transform).toBe('');
expect(harness.pane.style.transform).toBe('');
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
/** The snap must suppress the shared 300ms transition for the flip. */
expect(harness.drawer.style.transition).toBe('none');
expect(harness.pane.style.transition).toBe('none');
harness.unmount();
});
it('reverts cleanly when the browser cancels the touch', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
],
false,
);
harness.pane.dispatchEvent(touchEvent('touchcancel', [], 80));
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
harness.unmount();
});
});
describe('useDrawerSwipe — closing from the drawer', () => {
it('closes on a committed leftward swipe', () => {
const harness = setup(true);
harness.swipe(harness.drawer, [
{ x: 350, y: 100, t: 0 },
{ x: 250, y: 100, t: 50 },
{ x: 150, y: 100, t: 100 },
]);
expect(harness.onOpenChange).toHaveBeenCalledWith(false);
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)');
harness.unmount();
});
it('ignores a rightward swipe while already open', () => {
const harness = setup(true);
harness.swipe(harness.drawer, [
{ x: 50, y: 100, t: 0 },
{ x: 250, y: 100, t: 50 },
]);
expect(harness.onOpenChange).not.toHaveBeenCalled();
harness.unmount();
});
});
describe('useDrawerSwipe — interruptions and re-arming', () => {
it('cancels a stale settle when a button closes the drawer inside its window', () => {
jest.useFakeTimers();
const harness = setup(false);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
]);
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
expect(harness.pane.style.transform).toBe('translateX(100%)');
harness.rerender({ open: true, enabled: true });
harness.rerender({ open: false, enabled: true });
expect(harness.pane.style.transform).toBe('');
expect(harness.drawer.style.transform).toBe('');
jest.runAllTimers();
expect(harness.pane.style.transform).toBe('');
jest.useRealTimers();
harness.unmount();
});
it('keeps a settle that agrees with the state change it caused', () => {
jest.useFakeTimers();
const harness = setup(false);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
]);
harness.rerender({ open: true, enabled: true });
expect(harness.drawer.style.transform).toBe('translate3d(0, 0, 0)');
jest.runAllTimers();
expect(harness.pane.style.transform).toBe('translateX(100%)');
expect(harness.drawer.style.transform).toBe('');
jest.useRealTimers();
harness.unmount();
});
it('ignores a second finger lifting mid-drag and settles only on full release', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
],
false,
);
harness.pane.dispatchEvent(
touchEvent('touchend', [createTouch(220, 100, 0)], 80, [createTouch(80, 200, 1)]),
);
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.pane.style.transform).toBe('translate3d(200px, 0, 0)');
harness.pane.dispatchEvent(touchEvent('touchend', [], 100));
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
harness.unmount();
});
it('releases drag styles when an external toggle interrupts a held drag', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
],
false,
);
expect(harness.drawer.style.transition).toBe('none');
harness.rerender({ open: true, enabled: true });
expect(harness.drawer.style.transform).toBe('');
expect(harness.drawer.style.transition).not.toBe('none');
/** React committed the open pane transform before this cleanup and will
* not re-assert it the release must restore it, not clear it. */
expect(harness.pane.style.transform).toBe('translateX(100%)');
harness.unmount();
});
it('cancels a stale settle when crossing the breakpoint disables the hook', () => {
jest.useFakeTimers();
const harness = setup(false);
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
]);
harness.rerender({ open: true, enabled: true });
harness.rerender({ open: true, enabled: false });
expect(harness.pane.style.transform).toBe('');
jest.runAllTimers();
expect(harness.pane.style.transform).toBe('');
jest.useRealTimers();
harness.unmount();
});
it('reverts when the initiating finger lifts while a second remains', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
],
false,
);
harness.pane.dispatchEvent(
touchEvent('touchend', [createTouch(80, 200, 1)], 80, [createTouch(220, 100, 0)]),
);
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
harness.unmount();
});
it('expires flick momentum when the finger holds still before lifting', () => {
const harness = setup(false);
harness.swipe(
harness.pane,
[
{ x: 20, y: 100, t: 0 },
{ x: 50, y: 100, t: 20 },
{ x: 80, y: 100, t: 40 },
],
false,
);
harness.pane.dispatchEvent(touchEvent('touchend', [], 600));
expect(harness.onOpenChange).not.toHaveBeenCalled();
expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)');
harness.unmount();
});
it('arms itself when enabled flips true after the surfaces exist', () => {
const harness = setup(false);
harness.rerender({ open: false, enabled: false });
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 0 },
{ x: 220, y: 100, t: 50 },
]);
expect(harness.onOpenChange).not.toHaveBeenCalled();
harness.rerender({ open: false, enabled: true });
harness.swipe(harness.pane, [
{ x: 20, y: 100, t: 200 },
{ x: 220, y: 100, t: 250 },
]);
expect(harness.onOpenChange).toHaveBeenCalledWith(true);
harness.unmount();
});
});
describe('findHorizontalScrollBlocker', () => {
const scroller = (scrollLeft: number) => {
const el = document.createElement('div');
el.style.overflowX = 'auto';
Object.defineProperty(el, 'scrollWidth', { value: 600, configurable: true });
Object.defineProperty(el, 'clientWidth', { value: 300, configurable: true });
el.scrollLeft = scrollLeft;
return el;
};
it('is direction-aware at either edge', () => {
const boundary = document.createElement('div');
const atStart = scroller(0);
const child = document.createElement('span');
atStart.append(child);
boundary.append(atStart);
expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
expect(findHorizontalScrollBlocker(child, boundary, -1)).toBe(atStart);
const atEnd = scroller(300);
const endChild = document.createElement('span');
atEnd.append(endChild);
boundary.append(atEnd);
expect(findHorizontalScrollBlocker(endChild, boundary, 1)).toBe(atEnd);
expect(findHorizontalScrollBlocker(endChild, boundary, -1)).toBeNull();
});
it('normalizes RTL scroll offsets before deciding', () => {
const boundary = document.createElement('div');
const rtl = scroller(0);
rtl.style.direction = 'rtl';
const child = document.createElement('span');
rtl.append(child);
boundary.append(rtl);
expect(findHorizontalScrollBlocker(child, boundary, 1)).toBe(rtl);
expect(findHorizontalScrollBlocker(child, boundary, -1)).toBeNull();
rtl.scrollLeft = -300;
expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
expect(findHorizontalScrollBlocker(child, boundary, -1)).toBe(rtl);
});
it('stops searching at the boundary element', () => {
const outer = scroller(120);
const boundary = document.createElement('div');
const child = document.createElement('span');
boundary.append(child);
outer.append(boundary);
expect(findHorizontalScrollBlocker(child, boundary, 1)).toBeNull();
});
});

View file

@ -0,0 +1,404 @@
import { useEffect, useRef } from 'react';
import {
TRANSITION_MS,
MOBILE_DRAWER_ID,
SIDEBAR_TRANSITION,
} from '~/components/UnifiedSidebar/constants';
/** Horizontal travel before the gesture claims the touch (also the tap filter). */
const ACTIVATION_DISTANCE = 10;
/** |dx| must beat |dy| by this ratio to claim; otherwise vertical scroll wins. */
const AXIS_LOCK_RATIO = 1.5;
/** Fraction of the drawer width past which release commits the open/close. */
const COMMIT_DISTANCE_RATIO = 0.35;
/** px/ms — a flick past this commits regardless of distance… */
const COMMIT_VELOCITY = 0.3;
/** …but never from a twitch shorter than this. */
const FLICK_MIN_DISTANCE = 24;
/** A release this long after the last move is a hold, not a flick. */
const VELOCITY_HOLD_MS = 100;
/** Inline styles are cleared this long after TRANSITION_MS, then classes own the state. */
const SETTLE_BUFFER_MS = 80;
/** Surfaces where a horizontal drag means selection or caret work, never navigation. */
const TEXT_SURFACE_SELECTOR = 'textarea, input, select, [contenteditable="true"]';
/**
* Walks from `start` up to `boundary` looking for a horizontal scroller that
* can still consume a pan in `direction` (1 = finger moving right, which
* reveals content to the left, i.e. needs `scrollLeft > 0`). A rightward
* swipe inside a code block only defers to the block while it can actually
* scroll that way parked at its edge, the drawer may claim the drag.
*/
export function findHorizontalScrollBlocker(
start: Element | null,
boundary: Element,
direction: 1 | -1,
): Element | null {
let node: Element | null = start;
while (node != null) {
if (node instanceof HTMLElement && node.scrollWidth > node.clientWidth + 1) {
const style = window.getComputedStyle(node);
if (style.overflowX === 'auto' || style.overflowX === 'scroll') {
const maxScroll = node.scrollWidth - node.clientWidth;
/** RTL scrollers report `scrollLeft` in [-max, 0] with 0 at the right
* edge; normalize to visual distance-from-left-edge so the finger
* direction maps the same way in both document directions. */
const fromLeftEdge =
style.direction === 'rtl' ? node.scrollLeft + maxScroll : node.scrollLeft;
const remaining = direction === 1 ? fromLeftEdge : maxScroll - fromLeftEdge;
if (remaining > 1) {
return node;
}
}
}
if (node === boundary) {
return null;
}
node = node.parentElement;
}
return null;
}
type DrawerSwipeOptions = {
/** The chat pane that mirrors the drawer's motion (Root's Outlet wrapper). */
paneRef: React.RefObject<HTMLDivElement | null>;
enabled: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
};
type Sample = { t: number; x: number };
type Gesture = {
phase: 'tracking' | 'claimed' | 'dead';
opening: boolean;
/** Identifier of the initiating touch — a second finger never becomes it. */
touchId: number;
startX: number;
startY: number;
dx: number;
width: number;
reducedMotion: boolean;
drawer: HTMLElement;
pane: HTMLElement;
prevSample: Sample;
lastSample: Sample;
raf: number | null;
/** Set before scheduling so coalescing holds even if rAF fires synchronously. */
rafScheduled: boolean;
};
/** Returns every transient inline property to what React/classes render for
* `paneOpen`. The drawer's transform is class-driven so clearing suffices, but
* the pane's is a React style prop React will NOT re-assert while its value is
* unchanged an open pane must get its committed transform back explicitly. */
const releaseInlineStyles = (drawer: HTMLElement, pane: HTMLElement, paneOpen: boolean) => {
drawer.style.transform = '';
drawer.style.willChange = '';
drawer.style.transition = SIDEBAR_TRANSITION;
pane.style.transform = paneOpen ? 'translateX(100%)' : '';
pane.style.willChange = '';
pane.style.transition = SIDEBAR_TRANSITION;
};
const dragTransforms = (gesture: Gesture): { drawer: string; pane: string } => {
const progress = gesture.opening
? Math.min(Math.max(gesture.dx, 0), gesture.width)
: Math.min(Math.max(gesture.dx, -gesture.width), 0) + gesture.width;
return {
drawer: `translate3d(${progress - gesture.width}px, 0, 0)`,
pane: `translate3d(${progress}px, 0, 0)`,
};
};
/**
* Follow-the-finger swipe between the mobile drawer and the chat pane, which
* move as one object (see SIDEBAR_TRANSITION). Built on touch events with a
* selectively non-passive `touchmove` `touch-action` on the app shell would
* silently kill every horizontal scroller in the chat (code blocks, tables,
* carousels), and React's own onTouchMove registers passive, so
* `preventDefault` there is a no-op. During a drag both elements get direct
* rAF-coalesced transform writes and no React state changes; release animates
* to the nearest state and only then flips `useSidebarState`.
*
* Open listens on the pane (portaled overlays mount to `body`, outside it) and
* close on the drawer, which carries `touch-pan-y` since it has no horizontal
* scrollers of its own. On an iOS Safari tab the system back gesture owns the
* outer edge and is not preventable, which is exactly why activation is the
* whole pane rather than an edge zone; installed standalone, the edge is ours.
*/
export default function useDrawerSwipe({
paneRef,
enabled,
open,
onOpenChange,
}: DrawerSwipeOptions) {
const gestureRef = useRef<Gesture | null>(null);
const settleRef = useRef<{
id: ReturnType<typeof setTimeout>;
target: boolean;
drawer: HTMLElement;
pane: HTMLElement;
} | null>(null);
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
useEffect(() => {
/** A settle whose target the world has since contradicted drawer closed
* via a button inside its 380ms window, or the breakpoint crossed and
* disabled the hook must not fire: its stale write would strand the
* pane offscreen with React believing nothing changed. A settle that
* AGREES with `open` keeps running so its handoff finishes undisturbed. */
const pending = settleRef.current;
if (pending != null && (!enabled || pending.target !== open)) {
clearTimeout(pending.id);
settleRef.current = null;
releaseInlineStyles(pending.drawer, pending.pane, enabled && open);
}
/** A gesture surviving into a new effect run was interrupted its
* listeners are gone, so its drag styles must resolve to the CURRENT
* state (this closure knows it; the torn-down one did not). */
const abandoned = gestureRef.current;
if (abandoned != null) {
if (abandoned.phase === 'claimed' && !abandoned.reducedMotion) {
releaseInlineStyles(abandoned.drawer, abandoned.pane, enabled && open);
}
if (abandoned.raf != null) {
cancelAnimationFrame(abandoned.raf);
}
gestureRef.current = null;
}
if (!enabled) {
return;
}
const pane = paneRef.current;
const drawer = document.getElementById(MOBILE_DRAWER_ID);
if (pane == null || drawer == null) {
return;
}
const opening = !open;
const surface = opening ? pane : drawer;
const clearDrag = (gesture: Gesture) => {
if (gesture.raf != null) {
cancelAnimationFrame(gesture.raf);
}
gestureRef.current = null;
};
/** Animates both elements to `next`, flips state, then returns ownership
* of every inline property to what React/classes render for that state. */
const settle = (gesture: Gesture, next: boolean) => {
clearDrag(gesture);
if (gesture.reducedMotion) {
if (next !== open) {
/** Snap: suppress the shared 300ms transition for this state flip,
* then hand it back once the render has committed. */
gesture.drawer.style.transition = 'none';
gesture.pane.style.transition = 'none';
onOpenChangeRef.current(next);
const id = setTimeout(() => {
settleRef.current = null;
gesture.drawer.style.transition = SIDEBAR_TRANSITION;
gesture.pane.style.transition = SIDEBAR_TRANSITION;
}, SETTLE_BUFFER_MS);
settleRef.current = { id, target: next, drawer: gesture.drawer, pane: gesture.pane };
}
return;
}
const { drawer: drawerEl, pane: paneEl } = gesture;
drawerEl.style.transition = SIDEBAR_TRANSITION;
paneEl.style.transition = SIDEBAR_TRANSITION;
drawerEl.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)';
paneEl.style.transform = next ? 'translateX(100%)' : 'translate3d(0, 0, 0)';
if (next !== open) {
onOpenChangeRef.current(next);
}
const id = setTimeout(() => {
settleRef.current = null;
drawerEl.style.transform = '';
drawerEl.style.willChange = '';
drawerEl.style.transition = SIDEBAR_TRANSITION;
paneEl.style.transform = next ? 'translateX(100%)' : 'none';
paneEl.style.willChange = '';
paneEl.style.transition = SIDEBAR_TRANSITION;
}, TRANSITION_MS + SETTLE_BUFFER_MS);
settleRef.current = { id, target: next, drawer: drawerEl, pane: paneEl };
};
const onTouchStart = (event: TouchEvent) => {
if (gestureRef.current != null || settleRef.current != null || event.touches.length !== 1) {
return;
}
const target = event.target as Element | null;
if (target?.closest(TEXT_SURFACE_SELECTOR) != null) {
return;
}
const touch = event.touches[0];
const sample = { t: event.timeStamp, x: touch.clientX };
gestureRef.current = {
phase: 'tracking',
opening,
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
dx: 0,
width: drawer.clientWidth || window.innerWidth,
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
drawer,
pane,
prevSample: sample,
lastSample: sample,
raf: null,
rafScheduled: false,
};
};
const scheduleDragFrame = (gesture: Gesture) => {
if (gesture.reducedMotion || gesture.rafScheduled) {
return;
}
gesture.rafScheduled = true;
gesture.raf = requestAnimationFrame(() => {
gesture.rafScheduled = false;
gesture.raf = null;
const transforms = dragTransforms(gesture);
gesture.drawer.style.transform = transforms.drawer;
gesture.pane.style.transform = transforms.pane;
});
};
const onTouchMove = (event: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture == null || gesture.phase === 'dead') {
return;
}
const touch = Array.from(event.touches).find(
(candidate) => candidate.identifier === gesture.touchId,
);
if (event.touches.length !== 1 || touch == null) {
if (gesture.phase === 'claimed') {
settle(gesture, open);
} else {
clearDrag(gesture);
}
return;
}
const dx = touch.clientX - gesture.startX;
const dy = touch.clientY - gesture.startY;
if (gesture.phase === 'tracking') {
const horizontal = Math.abs(dx) >= ACTIVATION_DISTANCE;
const wins = Math.abs(dx) >= Math.abs(dy) * AXIS_LOCK_RATIO;
if (horizontal && wins) {
const direction = dx > 0 ? 1 : -1;
const wrongWay = gesture.opening ? direction !== 1 : direction !== -1;
const blocker = wrongWay
? null
: findHorizontalScrollBlocker(event.target as Element | null, surface, direction);
if (wrongWay || blocker != null) {
gesture.phase = 'dead';
return;
}
gesture.phase = 'claimed';
if (!gesture.reducedMotion) {
gesture.drawer.style.transition = 'none';
gesture.pane.style.transition = 'none';
gesture.drawer.style.willChange = 'transform';
gesture.pane.style.willChange = 'transform';
}
} else if (Math.abs(dy) >= ACTIVATION_DISTANCE) {
gesture.phase = 'dead';
return;
} else {
return;
}
}
event.preventDefault();
gesture.dx = dx;
gesture.prevSample = gesture.lastSample;
gesture.lastSample = { t: event.timeStamp, x: touch.clientX };
scheduleDragFrame(gesture);
};
const onTouchEnd = (event: TouchEvent) => {
const gesture = gestureRef.current;
if (gesture == null) {
return;
}
/** Only the initiating finger's lift ends the gesture; a second finger
* lifting is not a release. */
const initiatingEnded = Array.from(event.changedTouches).some(
(candidate) => candidate.identifier === gesture.touchId,
);
if (!initiatingEnded) {
return;
}
if (gesture.phase !== 'claimed') {
clearDrag(gesture);
return;
}
/** The initiating finger left while another remains ambiguous, so
* revert rather than let the survivor inherit half a gesture. */
if (event.touches.length > 0) {
settle(gesture, open);
return;
}
const progress = gesture.opening ? gesture.dx : -gesture.dx;
const elapsed = gesture.lastSample.t - gesture.prevSample.t;
/** A flick's momentum expires if the finger holds still before lifting
* velocity is only trusted when release follows the last move promptly. */
const held = event.timeStamp - gesture.lastSample.t > VELOCITY_HOLD_MS;
const velocity =
!held && elapsed > 0 ? (gesture.lastSample.x - gesture.prevSample.x) / elapsed : 0;
const towardTarget = gesture.opening ? velocity : -velocity;
const committed =
progress >= gesture.width * COMMIT_DISTANCE_RATIO ||
(towardTarget >= COMMIT_VELOCITY && progress >= FLICK_MIN_DISTANCE);
settle(gesture, committed ? gesture.opening : open);
};
const onTouchCancel = () => {
const gesture = gestureRef.current;
if (gesture == null) {
return;
}
if (gesture.phase === 'claimed') {
settle(gesture, open);
} else {
clearDrag(gesture);
}
};
surface.addEventListener('touchstart', onTouchStart, { passive: true });
surface.addEventListener('touchmove', onTouchMove, { passive: false });
surface.addEventListener('touchend', onTouchEnd, { passive: true });
surface.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
surface.removeEventListener('touchstart', onTouchStart);
surface.removeEventListener('touchmove', onTouchMove);
surface.removeEventListener('touchend', onTouchEnd);
surface.removeEventListener('touchcancel', onTouchCancel);
/** Interrupted gestures are resolved by the NEXT effect run, which
* knows the new state; here only the frame is cancelled so nothing
* writes after teardown. Unmount discards the DOM with its styles. */
const gesture = gestureRef.current;
if (gesture?.raf != null) {
cancelAnimationFrame(gesture.raf);
gesture.raf = null;
gesture.rafScheduled = false;
}
};
}, [enabled, open, paneRef]);
useEffect(
() => () => {
if (settleRef.current != null) {
clearTimeout(settleRef.current.id);
}
},
[],
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback, startTransition } from 'react';
import { Outlet } from 'react-router-dom';
import {
PromptGroupsProvider,
@ -18,9 +18,11 @@ import { UnifiedSidebar, SIDEBAR_TRANSITION } from '~/components/UnifiedSidebar'
import KeyboardShortcutsDialog from '~/components/Nav/KeyboardShortcutsDialog';
import KeyboardDeleteDialog from '~/components/Nav/KeyboardDeleteDialog';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import useKeyboardShortcuts from '~/hooks/useKeyboardShortcuts';
import useSidebarState from '~/hooks/Nav/useSidebarState';
import { TermsAndConditionsModal } from '~/components/ui';
import useDrawerSwipe from '~/hooks/Nav/useDrawerSwipe';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
@ -39,10 +41,39 @@ export default function Root() {
const [showTerms, setShowTerms] = useState(false);
const [bannerHeight, setBannerHeight] = useState(0);
/** Shared with the drawer so the two agree on the breakpoint-transition frame. */
const { isSmallScreen, expanded: sidebarExpanded } = useSidebarState();
const {
isSmallScreen,
expanded: sidebarExpanded,
setExpanded: setSidebarExpanded,
} = useSidebarState();
const paneRef = useRef<HTMLDivElement>(null);
const handleDrawerOpenChange = useCallback(
(next: boolean) => {
startTransition(() => {
setSidebarExpanded(next);
});
if (next) {
/** Same handoff as the OpenSidebar button: opening makes the pane
* inert, so keyboard/AT focus must land inside the drawer. */
setTimeout(() => {
document.getElementById(CLOSE_SIDEBAR_ID)?.focus();
}, 250);
}
},
[setSidebarExpanded],
);
const { isAuthenticated, logout } = useAuthContext();
useDrawerSwipe({
paneRef,
/** Auth gates the whole tree below (`return null`), so the swipe surfaces
* only exist once authenticated enabling earlier would attach to
* nothing and never re-run when they mount. */
enabled: isSmallScreen && isAuthenticated,
open: sidebarExpanded,
onOpenChange: handleDrawerOpenChange,
});
useHealthCheck(isAuthenticated);
const assistantsMap = useAssistantsMap({ isAuthenticated });
@ -86,6 +117,7 @@ export default function Root() {
<div className="relative z-0 flex h-full w-full overflow-hidden">
<UnifiedSidebar />
<div
ref={paneRef}
className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden"
style={{
/** Self-referential, so it needs no width literal and survives rotation. */

View file

@ -1349,6 +1349,18 @@ html {
height: 100%;
}
/* Scoped to the drawer-gesture breakpoint (useSidebarState's 768px): on small
screens the app never scrolls at the root, so chaining out of nested
scrollers has nothing legitimate to reach this kills Chrome Android's
pull-to-refresh and horizontal overscroll gliding into browser navigation.
Desktop keeps native trackpad back/forward and refresh gestures. */
@media (max-width: 768px) {
body,
html {
overscroll-behavior: none;
}
}
.dark body,
.dark html {
--tw-bg-opacity: 1;