Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions packages/react/src/drawer/viewport/DrawerViewport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ describe('<Drawer.Viewport />', () => {
return point;
}

function setHeight(element: HTMLElement | null, value: number) {
if (element) {
Object.defineProperty(element, 'offsetHeight', { configurable: true, value });
}
}

function createNativeTouchMove(target: EventTarget, point: { clientX: number; clientY: number }) {
const touchMove = new Event('touchmove', { bubbles: true, cancelable: true });
Object.defineProperty(touchMove, 'touches', {
Expand Down Expand Up @@ -3135,6 +3141,252 @@ describe('<Drawer.Viewport />', () => {
expect(handleOpenChange).toHaveBeenCalledWith(false, expect.anything());
});

it('does not dismiss from a fast swipe that was never attributed to the snap point axis', async () => {
const handleOpenChange = vi.fn();
const handleSnapPointChange = vi.fn();

vi.useFakeTimers();
try {
vi.setSystemTime(new Date(1000));
await render(
<Drawer.Root
open
onOpenChange={handleOpenChange}
onSnapPointChange={handleSnapPointChange}
snapPoints={['100px', '200px']}
swipeDirection="down"
>
<Drawer.Portal>
<Drawer.Viewport data-testid="viewport" ref={(element) => setHeight(element, 400)}>
<Drawer.Popup data-testid="popup" ref={(element) => setHeight(element, 300)}>
Drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>,
);

const viewport = screen.getByTestId('viewport');
const popup = screen.getByTestId('popup');
const originalElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => popup;

try {
// A mostly horizontal flick: cumulative |deltaX| stays above |deltaY| on every
// move so no swipe direction is ever attributed, while the final samples carry
// fast downward velocity from the finger arcing down at lift.
fireEvent.pointerDown(viewport, {
button: 0,
buttons: 1,
pointerId: 1,
clientX: 100,
clientY: 10,
pointerType: 'mouse',
timeStamp: 1000,
});
vi.setSystemTime(new Date(1050));
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 120,
clientY: 12,
pointerType: 'mouse',
timeStamp: 1050,
});
vi.setSystemTime(new Date(1100));
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 200,
clientY: 20,
pointerType: 'mouse',
timeStamp: 1100,
});
vi.setSystemTime(new Date(1120));
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 240,
clientY: 55,
pointerType: 'mouse',
timeStamp: 1120,
});
vi.setSystemTime(new Date(1130));
fireEvent.pointerUp(viewport, {
pointerId: 1,
clientX: 240,
clientY: 55,
pointerType: 'mouse',
timeStamp: 1130,
});
await flushMicrotasks();
} finally {
document.elementFromPoint = originalElementFromPoint;
}

expect(handleOpenChange).not.toHaveBeenCalled();
// The release settles on the nearest snap point rather than dismissing.
expect(handleSnapPointChange).not.toHaveBeenCalledWith(null, expect.anything());
expect(handleSnapPointChange).toHaveBeenCalledWith('100px', expect.anything());
expect(popup).not.toHaveAttribute('data-ending-style');
expect(popup).not.toHaveAttribute('data-swipe-dismiss');
} finally {
vi.useRealTimers();
}
});

it('navigates snap points from a drag that was never attributed to the swipe axis', async () => {
const handleOpenChange = vi.fn();
const handleSnapPointChange = vi.fn();

await render(
<Drawer.Root
open
onOpenChange={handleOpenChange}
onSnapPointChange={handleSnapPointChange}
snapPoints={['100px', '200px']}
swipeDirection="down"
>
<Drawer.Portal>
<Drawer.Viewport data-testid="viewport" ref={(element) => setHeight(element, 400)}>
<Drawer.Popup data-testid="popup" ref={(element) => setHeight(element, 300)}>
Drawer
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>,
);

const viewport = screen.getByTestId('viewport');
const popup = screen.getByTestId('popup');
const originalElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => popup;

try {
// A sustained diagonal drag: cumulative |deltaX| stays above |deltaY| on every
// move so no direction is attributed, while the vertical component drags the
// sheet 150px upward toward the taller snap point.
fireEvent.pointerDown(viewport, {
button: 0,
buttons: 1,
pointerId: 1,
clientX: 300,
clientY: 300,
pointerType: 'mouse',
});
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 280,
clientY: 290,
pointerType: 'mouse',
});
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 200,
clientY: 220,
pointerType: 'mouse',
});
fireEvent.pointerMove(viewport, {
buttons: 1,
pointerId: 1,
clientX: 140,
clientY: 150,
pointerType: 'mouse',
});
fireEvent.pointerUp(viewport, {
pointerId: 1,
clientX: 140,
clientY: 150,
pointerType: 'mouse',
});
await flushMicrotasks();
} finally {
document.elementFromPoint = originalElementFromPoint;
}

expect(handleSnapPointChange).toHaveBeenCalledWith('200px', expect.anything());
expect(handleOpenChange).not.toHaveBeenCalled();
expect(popup).not.toHaveAttribute('data-ending-style');
});

it('clears nested swipe state after an unattributed snap point gesture', async () => {
await render(
<Drawer.Root open swipeDirection="down">
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup data-testid="parent-popup">
<Drawer.Root open snapPoints={['100px', '200px']} swipeDirection="down">
<Drawer.Portal>
<Drawer.Viewport
data-testid="child-viewport"
ref={(element) => setHeight(element, 400)}
>
<Drawer.Popup
data-testid="child-popup"
ref={(element) => setHeight(element, 300)}
>
Child
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>,
);

const parentPopup = screen.getByTestId('parent-popup');
const childPopup = screen.getByTestId('child-popup');
const childViewport = screen.getByTestId('child-viewport');
const originalElementFromPoint = document.elementFromPoint;
document.elementFromPoint = () => childPopup;

try {
fireEvent.pointerDown(childViewport, {
button: 0,
buttons: 1,
pointerId: 1,
clientX: 100,
clientY: 10,
pointerType: 'mouse',
});
fireEvent.pointerMove(childViewport, {
buttons: 1,
pointerId: 1,
clientX: 120,
clientY: 12,
pointerType: 'mouse',
});
fireEvent.pointerMove(childViewport, {
buttons: 1,
pointerId: 1,
clientX: 200,
clientY: 30,
pointerType: 'mouse',
});
await flushMicrotasks();

expect(parentPopup).toHaveAttribute('data-nested-drawer-swiping', '');
expect(parentPopup.style.getPropertyValue('--drawer-swipe-progress')).not.toBe('0');

fireEvent.pointerUp(childViewport, {
pointerId: 1,
clientX: 200,
clientY: 30,
pointerType: 'mouse',
});
await flushMicrotasks();

expect(parentPopup.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
expect(parentPopup).not.toHaveAttribute('data-nested-drawer-swiping');
} finally {
document.elementFromPoint = originalElementFromPoint;
}
});

it('does not resolve snap points before the popup has a measurable height', async () => {
const handleOpenChange = vi.fn();
await render(
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/drawer/viewport/DrawerViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,14 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
return undefined;
}

// An unattributed gesture (e.g. a mostly horizontal flick) may settle on a snap
// point but must not dismiss: `useSwipeDismiss` drops a directionless dismissal,
// stranding the popup visually closed while `open` stays `true`. The hook's
// trailing progress update is deduped here, so reset nested swipe state now.
if (!direction) {
applySwipeProgress(0, true, true);
}

const dragDelta = swipeDirection === 'down' ? deltaY : -deltaY;
const dragDirection = Math.sign(dragDelta);
const releaseDirectionalVelocity =
Expand All @@ -506,6 +514,18 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
: clamp(dragTargetOffset + velocityOffset, 0, popupHeight);
const snapPointEventDetails = createChangeEventDetails(REASONS.swipe, event);
const closeFromSnapPoints = () => {
if (!direction) {
const nearestSnapPoint =
resolvedSnapPoints[
closestSnapPointIndex(
resolvedSnapPoints.map((point) => point.offset),
targetOffset,
)
];
setActiveSnapPoint(nearestSnapPoint.value, snapPointEventDetails);
clearSwipeRelease();
return false;
}
pendingSwipeCloseSnapPointRef.current = activeSnapPoint;
setActiveSnapPoint(null, snapPointEventDetails);
startSwipeRelease(swipeDirection);
Expand Down
Loading