mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📱 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:
parent
1b7e2a4e6a
commit
c939a6fb17
6 changed files with 952 additions and 4 deletions
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
489
client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx
Normal file
489
client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
404
client/src/hooks/Nav/useDrawerSwipe.ts
Normal file
404
client/src/hooks/Nav/useDrawerSwipe.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue