From c939a6fb17598568fdb9ac73b4adc700ea62346b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 16 Aug 2026 19:45:35 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=B1=20feat:=20Swipe=20the=20Mobile=20D?= =?UTF-8?q?rawer=20Open=20and=20Closed=20(#14902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * πŸ“± 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 --- .../UnifiedSidebar/UnifiedSidebar.tsx | 7 +- .../components/UnifiedSidebar/constants.ts | 6 + .../Nav/__tests__/useDrawerSwipe.spec.tsx | 489 ++++++++++++++++++ client/src/hooks/Nav/useDrawerSwipe.ts | 404 +++++++++++++++ client/src/routes/Root.tsx | 38 +- client/src/style.css | 12 + 6 files changed, 952 insertions(+), 4 deletions(-) create mode 100644 client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx create mode 100644 client/src/hooks/Nav/useDrawerSwipe.ts diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx index a264ce3602..fcee6e34c1 100644 --- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx +++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx @@ -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 (
+ ({ 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(); + }); +}); diff --git a/client/src/hooks/Nav/useDrawerSwipe.ts b/client/src/hooks/Nav/useDrawerSwipe.ts new file mode 100644 index 0000000000..6e59a9006a --- /dev/null +++ b/client/src/hooks/Nav/useDrawerSwipe.ts @@ -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; + 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(null); + const settleRef = useRef<{ + id: ReturnType; + 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); + } + }, + [], + ); +} diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index a3bfaa9892..7253f61f84 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -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(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() {