diff --git a/client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx b/client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx index 895e3d0203..a62375def7 100644 --- a/client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx +++ b/client/src/hooks/Nav/__tests__/useDrawerSwipe.spec.tsx @@ -494,7 +494,7 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => { kickDrawerAnimation(false, jest.fn()); expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)'); - expect(harness.pane.style.transform).toBe('translate3d(0, 0, 0)'); + expect(harness.pane.style.transform).toBe('none'); harness.rerender({ open: false, enabled: true }); jest.runAllTimers(); @@ -614,6 +614,33 @@ describe('useDrawerSwipe — kickDrawerAnimation (button toggles)', () => { harness.unmount(); }); + /** + * A programmatic close is a REVEAL: the pane repositions instantly under + * the opaque drawer's cover and only the drawer animates away. Animating + * the pane in from the right made every tap-to-navigate close visibly + * shift the chat leftward while the new conversation committed into the + * moving layer mid-slide. The gesture keeps the paired motion — a finger + * drags both — via `settle`, which this does not touch. + */ + it('reveals on a programmatic close: pane snaps under cover, only the drawer slides', () => { + jest.useFakeTimers(); + const harness = setup(true); + + kickDrawerAnimation(false, jest.fn()); + + expect(harness.drawer.style.transform).toBe('translate3d(-100%, 0, 0)'); + expect(harness.drawer.style.transition).not.toBe('none'); + expect(harness.pane.style.transform).toBe('none'); + expect(harness.pane.style.transition).toBe('none'); + + harness.rerender({ open: false, enabled: true }); + jest.runAllTimers(); + expect(harness.pane.style.transition).not.toBe('none'); + expect(harness.drawer.style.transform).toBe(''); + jest.useRealTimers(); + harness.unmount(); + }); + /** A superseded flip must never transiently commit the old direction — * its commit would re-register the animator and get the NEWER flip * dropped, stranding the drawer at the first intent. */ diff --git a/client/src/hooks/Nav/useDrawerSwipe.ts b/client/src/hooks/Nav/useDrawerSwipe.ts index 7ffb449780..f5c042297e 100644 --- a/client/src/hooks/Nav/useDrawerSwipe.ts +++ b/client/src/hooks/Nav/useDrawerSwipe.ts @@ -343,7 +343,9 @@ export default function useDrawerSwipe({ return; } drawer.style.willChange = 'transform'; - pane.style.willChange = 'transform'; + if (next) { + pane.style.willChange = 'transform'; + } /** Armed BEFORE the frame below (id: null — no deadline yet) so the * staged write can verify its target is still wanted, and so a * touchstart in between defers to the pending settle. A retarget or @@ -366,9 +368,22 @@ export default function useDrawerSwipe({ return; } drawer.style.transition = SIDEBAR_TRANSITION; - pane.style.transition = SIDEBAR_TRANSITION; drawer.style.transform = next ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)'; - pane.style.transform = next ? 'translateX(100%)' : 'translate3d(0, 0, 0)'; + if (next) { + pane.style.transition = SIDEBAR_TRANSITION; + pane.style.transform = 'translateX(100%)'; + } else { + /** A programmatic close is a REVEAL: the pane repositions + * instantly beneath the opaque drawer's cover and only the + * drawer slides away, uncovering content already in place. + * Animating the pane in from the right made every + * tap-to-navigate close visibly shift the chat leftward while + * the new conversation committed into the moving layer + * mid-slide. The gesture keeps the paired both-move motion in + * `settle` — there a finger drags both surfaces. */ + pane.style.transition = 'none'; + pane.style.transform = 'none'; + } void drawer.getBoundingClientRect(); armed.id = setTimeout(() => { settleRef.current = null;