From ae86c19d6bfef819f7064f439e61840e4b225971 Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 27 Oct 2026 14:16:59 +1100 Subject: [PATCH 1/3] [drawer] Place a swipe-opened popup under the pointer on its first frame --- .../swipe-area/DrawerSwipeArea.test.tsx | 75 +++++++++++++++++++ .../src/drawer/swipe-area/DrawerSwipeArea.tsx | 10 ++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx index de815d9fc88..a770ac312cc 100644 --- a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx +++ b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx @@ -1463,6 +1463,81 @@ describe('', () => { }, ); + it.skipIf(isJSDOM)( + 'places a swipe-opened popup under the pointer in the commit that mounts it', + async () => { + await render( + + + + + + Close + + + + , + ); + + const swipeArea = screen.getByTestId('swipe-area'); + + // The second open matters: the first one also re-renders the swipe area once the popup's + // height is first measured, which hides a missed commit. + for (let openCount = 0; openCount < 2; openCount += 1) { + expect(screen.queryByTestId('popup')).toBe(null); + + firePointer.down(swipeArea, { + button: 0, + buttons: 1, + pointerId: 1, + clientX: 10, + clientY: 120, + pointerType: 'mouse', + timeStamp: 1, + }); + // A single move opens the drawer; no later move re-applies the movement. + firePointer.move(swipeArea, { + pointerId: 1, + clientX: 10, + clientY: 90, + buttons: 1, + pointerType: 'mouse', + timeStamp: 17, + }); + // eslint-disable-next-line no-await-in-loop + await flushMicrotasks(); + + const popup = screen.getByTestId('popup'); + expect(popup).toHaveAttribute('data-swiping'); + expect(popup.style.getPropertyValue('--drawer-swipe-movement-y')).toBe('170px'); + + firePointer.move(swipeArea, { + pointerId: 1, + clientX: 10, + clientY: -100, + buttons: 1, + pointerType: 'mouse', + timeStamp: 33, + }); + firePointer.up(swipeArea, { + pointerId: 1, + clientX: 10, + clientY: -100, + pointerType: 'mouse', + timeStamp: 49, + }); + // eslint-disable-next-line no-await-in-loop + await act(async () => { + screen.getByRole('button', { name: 'Close' }).click(); + }); + // eslint-disable-next-line no-await-in-loop + await waitFor(() => { + expect(screen.queryByTestId('popup')).toBe(null); + }); + } + }, + ); + it('opens on a quick flick whose only move is already released (buttons: 0)', async () => { // On a fast flick — especially on a low-refresh-rate display — the pointer can be lifted before // the first `pointermove` is sampled, so the single move arrives with `buttons: 0` and no diff --git a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx index 52e044a38ae..44abfcf11a3 100644 --- a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx +++ b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.tsx @@ -120,6 +120,9 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea( }, [registerTrigger, swipeAreaId, store]); const open = store.useState('open'); + // The popup mounts a commit after `open` flips; re-render then so a swipe-opened popup is + // placed under the pointer before it paints. + store.useState('popupElement'); const resetDragDelta = useStableCallback(() => { dragDeltaRef.current.x = 0; @@ -401,10 +404,11 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea( const resetSwipe = swipe.reset; // The commit that opens the drawer re-renders the popup, resetting `--swipe-movement-*` to `0px` - // (the viewport isn't swiping). Re-assert after the DOM mutation but before paint. No deps: must - // run on every commit the swipe area participates in. + // (the viewport isn't swiping), and a popup the swipe just mounted has no movement yet. Apply it + // after the DOM mutation but before paint. No deps: must run on every commit the swipe area + // participates in. useIsoLayoutEffect(() => { - if (swipeActive && appliedSwipeStylesRef.current) { + if (swipeActive && (appliedSwipeStylesRef.current || openedBySwipeRef.current)) { applySwipeMovement(); } }); From 0d488c7d7fa7ff6acb5da4f88e49f0ed0a3bc01b Mon Sep 17 00:00:00 2001 From: atomiks Date: Tue, 27 Oct 2026 14:21:53 +1100 Subject: [PATCH 2/3] [drawer] Skip the starting style for a swipe-opened popup --- .../react/src/dialog/store/DialogStore.ts | 1 + .../react/src/drawer/popup/DrawerPopup.tsx | 9 +++- .../swipe-area/DrawerSwipeArea.test.tsx | 44 +++++++++++++------ 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/react/src/dialog/store/DialogStore.ts b/packages/react/src/dialog/store/DialogStore.ts index 030078e90f3..7207280a233 100644 --- a/packages/react/src/dialog/store/DialogStore.ts +++ b/packages/react/src/dialog/store/DialogStore.ts @@ -44,6 +44,7 @@ const selectors = { nestedOpenDrawerCount: (state: State) => state.nestedOpenDrawerCount, disablePointerDismissal: (state: State) => state.disablePointerDismissal, openMethod: (state: State) => state.openMethod, + openChangeReason: (state: State) => state.openChangeReason, descriptionElementId: (state: State) => state.descriptionElementId, titleElementId: (state: State) => state.titleElementId, viewportElement: (state: State) => state.viewportElement, diff --git a/packages/react/src/drawer/popup/DrawerPopup.tsx b/packages/react/src/drawer/popup/DrawerPopup.tsx index 7b3836a8ac5..0dfbadfec41 100644 --- a/packages/react/src/drawer/popup/DrawerPopup.tsx +++ b/packages/react/src/drawer/popup/DrawerPopup.tsx @@ -19,6 +19,7 @@ import * as DrawerPopupDataAttributes from './DrawerPopupDataAttributes'; import { useDialogPortalContext } from '../../dialog/portal/DialogPortalContext'; import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import { COMPOSITE_KEYS } from '../../internals/composite/composite'; +import { REASONS } from '../../internals/reasons'; import { useDrawerRootContext } from '../root/DrawerRootContext'; import type { DrawerSwipeDirection } from '../root/DrawerRootContext'; import { getSnapPointSwipeMovement, useDrawerSnapPoints } from '../root/useDrawerSnapPoints'; @@ -141,6 +142,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( const nested = store.useState('nested'); const nestedOpenDrawerCount = store.useState('nestedOpenDrawerCount'); const transitionStatus = store.useState('transitionStatus'); + const openChangeReason = store.useState('openChangeReason'); const open = store.useState('open'); const openMethod = store.useState('openMethod'); const titleElementId = store.useState('titleElementId'); @@ -309,7 +311,12 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup( const state: DrawerPopupState = { open, nested, - transitionStatus, + // A swipe-opened popup follows the pointer from its first frame, so it has no starting style + // to transition from; one would override the swipe movement until the next frame. + transitionStatus: + transitionStatus === 'starting' && openChangeReason === REASONS.swipe + ? undefined + : transitionStatus, expanded: activeSnapPoint === 1, nestedDrawerOpen, nestedDrawerSwiping: nestedSwiping, diff --git a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx index a770ac312cc..9bebac2e81e 100644 --- a/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx +++ b/packages/react/src/drawer/swipe-area/DrawerSwipeArea.test.tsx @@ -1466,17 +1466,34 @@ describe('', () => { it.skipIf(isJSDOM)( 'places a swipe-opened popup under the pointer in the commit that mounts it', async () => { + // Matches the documented transition styles, where the starting style's transform would + // override the swipe movement. + const style = ` + .swipe-open-popup { + height: 200px; + transform: translateY(var(--drawer-swipe-movement-y)); + } + + .swipe-open-popup[data-starting-style] { + transform: translateY(100%); + } + `; + await render( - - - - - - Close - - - - , +
+ {/* eslint-disable-next-line react/no-danger */} +