Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/modal-drag-physics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"reshaped": minor
---

Improved the `Modal` swipe gesture physics. On release, the closing or returning animation now continues from the current velocity of the gesture using a critically damped spring, so a quick flick closes the modal right away while a slow release settles smoothly, instead of restarting a fixed-duration transition from zero. Returning to the open position is animated as well, where previously it snapped back, and starting a new swipe while the modal is still animating picks it up from its current position instead of snapping to the position the animation was heading to.
10 changes: 0 additions & 10 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,6 @@ jobs:
- name: Lint CSS and JS code
run: pnpm lint

lint-pr-title:
name: Validate PR title
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build:
runs-on: ubuntu-latest
needs: setup
Expand Down
222 changes: 13 additions & 209 deletions packages/reshaped/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
"use client";

import {
useCallback,
createContext,
useContext,
useEffect,
useMemo,
useState,
useRef,
type FC,
} from "react";
import { createContext, useContext, useEffect, useMemo, useState, useRef, type FC } from "react";
import { classNames } from "@reshaped/utilities";
import { enableScroll, disableScroll } from "@reshaped/utilities/internal";

import Overlay, { type OverlayInstance } from "@/components/Overlay";
import Text from "@/components/Text";
import useElementId from "@/hooks/useElementId";
import useHandlerRef from "@/hooks/useHandlerRef";
import useResponsiveClientValue from "@/hooks/useResponsiveClientValue";
import { responsiveClassNames, responsiveVariables } from "@/utilities/props";
import { resolveMixin } from "@/styles/mixin";
import type * as T from "./Modal.types";
import useModalDrag from "./useModalDrag";
import s from "./Modal.module.css";

const DRAG_THRESHOLD = 32;
const DRAG_OPPOSITE_THRESHOLD = 100;
const DRAG_EDGE_BOUNDARY = 32;
const DRAG_SCROLL_LOCK_THRESHOLD = 8;

const Context = createContext<T.Context>({
id: "",
titleMounted: false,
Expand Down Expand Up @@ -92,46 +77,25 @@ const Modal: FC<T.Props> = (props) => {
className,
attributes,
} = props;
const onCloseRef = useHandlerRef(onClose);
const id = useElementId();
const clientPosition = useResponsiveClientValue(position)!;
const [titleMounted, setTitleMounted] = useState(false);
const [subtitleMounted, setSubtitleMounted] = useState(false);
const [dragging, setDragging] = useState(false);
const internalRootRef = useRef<HTMLDivElement>(null);
const rootRef = attributes?.ref || internalRootRef;
const dragStartCoordinatesRef = useRef({ x: 0, y: 0 });
const dragLastCoordinateRef = useRef(0);
const dragDistanceRef = useRef(0);
const dragFrameRef = useRef<number | null>(null);
const dragPendingDistanceRef = useRef(0);
const dragSizeRef = useRef(1);
const overlayRef = useRef<OverlayInstance>(null);
const dragDirectionRef = useRef(0);
const dragShouldCloseRef = useRef(false);
const mixinStyles = resolveMixin({ padding });
const shouldBeContained = containerRef && contained !== false;

const setDragStyles = useCallback(
(distance: number) => {
const rootEl = rootRef.current;
if (!rootEl) return;

const dragOffset =
Math.abs(distance) < DRAG_THRESHOLD
? 0
: distance + DRAG_THRESHOLD * (clientPosition === "start" ? 1 : -1);

rootEl.style.setProperty("--rs-modal-drag", `${dragOffset}px`);

if (!transparentOverlay && overlayRef.current) {
const progress = Math.min(1, Math.abs(distance) / dragSizeRef.current);
const opacity = Math.max(0, 1 - progress * 0.5);
overlayRef.current.setOpacity(opacity);
}
},
[clientPosition, overlayRef, transparentOverlay, rootRef]
);
const { dragging, handleTouchStart, handleTransitionEnd } = useModalDrag({
position: clientPosition,
rootRef,
overlayRef,
active,
containerRef,
transparentOverlay,
disabled: disableSwipeGesture,
onClose,
});

const value = useMemo(
() => ({
Expand All @@ -144,165 +108,6 @@ const Modal: FC<T.Props> = (props) => {
[id, subtitleMounted, titleMounted]
);

const resetDragData = useCallback(() => {
dragDistanceRef.current = 0;
dragPendingDistanceRef.current = 0;
dragStartCoordinatesRef.current = { x: 0, y: 0 };
dragLastCoordinateRef.current = 0;
dragDirectionRef.current = 0;
dragShouldCloseRef.current = false;
setDragStyles(0);
}, [setDragStyles]);

const unlockDragScroll = useCallback(() => {
rootRef.current?.style.removeProperty("overflow");
}, [rootRef]);

const lockDragScroll = useCallback(() => {
rootRef.current?.style.setProperty("overflow", "hidden");
}, [rootRef]);

const handleDragStart = (e: React.TouchEvent) => {
if (disableSwipeGesture) return;
dragShouldCloseRef.current = false;

// Prevent swipe to close from happening when user is working with text selection
if (window.getSelection()?.toString()) return;

let currentEl = e.target as HTMLElement | null;
const rootEl = rootRef.current;

while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) {
// Prioritize scrolling over modal swiping
if (currentEl.scrollTop !== 0 || currentEl.scrollLeft !== 0) return;
// Start dragging only when starting on static elements
if (currentEl.matches("input,textarea")) return;

currentEl = currentEl ? currentEl.parentElement : null;
}

// Prevent the drag handling when browser is trying to navigate to a previous page
if (clientPosition === "start" && e.targetTouches[0].clientX < DRAG_EDGE_BOUNDARY) return;

if (rootEl) {
const isInline = ["start", "end"].includes(clientPosition);
dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight);
}

if (containerRef?.current) disableScroll();
setDragging(true);
};

// Once modal is closed - reset its drag data
const handleTransitionEnd = (e: React.TransitionEvent) => {
if (active) return;
if (e.propertyName !== "transform") return;
if (e.currentTarget !== e.target) return;

resetDragData();
unlockDragScroll();
};

useEffect(() => {
if (!dragging) return;

const handleDragEnd = () => {
const shouldClose =
clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0;
dragShouldCloseRef.current =
Math.abs(dragDistanceRef.current) > DRAG_THRESHOLD && shouldClose;

if (containerRef?.current) enableScroll();
if (!dragShouldCloseRef.current) unlockDragScroll();
setDragging(false);

if (dragShouldCloseRef.current) {
onCloseRef.current?.({ reason: "drag" });
} else {
resetDragData();
}
};

const handleDrag = (e: TouchEvent) => {
if (!dragging || clientPosition === "center") return;
if (rootRef.current?.scrollTop !== 0 || rootRef.current?.scrollLeft !== 0) return;

const target = e.targetTouches[0];
const coordinate = { x: target.clientX, y: target.clientY };
const key = clientPosition === "bottom" ? "y" : "x";
const oppositeKey = clientPosition === "bottom" ? "x" : "y";

// Save the initial coordinates
if (!dragStartCoordinatesRef.current[key]) {
dragStartCoordinatesRef.current = coordinate;
dragLastCoordinateRef.current = coordinate[key];
}

const next = Math.abs(coordinate[key] - dragStartCoordinatesRef.current[key]);
const nextPerpendicular = Math.abs(
coordinate[oppositeKey] - dragStartCoordinatesRef.current[oppositeKey]
);

// For start/end drawers - ignore the swiping
// If user is scrolling vertically more than swiping
if (
position !== "bottom" &&
(next < nextPerpendicular || nextPerpendicular > DRAG_OPPOSITE_THRESHOLD)
) {
dragLastCoordinateRef.current = coordinate[key];
return;
}

dragDirectionRef.current = coordinate[key] - dragLastCoordinateRef.current;
dragLastCoordinateRef.current = coordinate[key];

const isClosingDirection =
clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0;

if (next > DRAG_SCROLL_LOCK_THRESHOLD && isClosingDirection) {
lockDragScroll();
}

dragDistanceRef.current =
clientPosition === "start"
? Math.min(0, dragDistanceRef.current + dragDirectionRef.current)
: Math.max(0, dragDistanceRef.current + dragDirectionRef.current);

dragPendingDistanceRef.current = dragDistanceRef.current;

if (dragFrameRef.current === null) {
dragFrameRef.current = window.requestAnimationFrame(() => {
setDragStyles(dragPendingDistanceRef.current);
dragFrameRef.current = null;
});
}
};

document.addEventListener("touchmove", handleDrag, { passive: true });
document.addEventListener("touchend", handleDragEnd, { passive: true });

return () => {
if (dragFrameRef.current !== null) {
window.cancelAnimationFrame(dragFrameRef.current);
dragFrameRef.current = null;
}
if (!dragShouldCloseRef.current) unlockDragScroll();
document.removeEventListener("touchmove", handleDrag);
document.removeEventListener("touchend", handleDragEnd);
};
}, [
dragging,
clientPosition,
onCloseRef,
position,
rootRef,
containerRef,
setDragStyles,
resetDragData,
lockDragScroll,
unlockDragScroll,
]);

return (
<Overlay
instanceRef={overlayRef}
Expand All @@ -318,7 +123,7 @@ const Modal: FC<T.Props> = (props) => {
contained={contained}
containerRef={containerRef}
attributes={{
onTouchStart: handleDragStart,
onTouchStart: handleTouchStart,
}}
>
{({ active }) => {
Expand All @@ -335,7 +140,6 @@ const Modal: FC<T.Props> = (props) => {

return (
<Context.Provider value={value}>
{}
<div
{...attributes}
style={
Expand Down
4 changes: 3 additions & 1 deletion packages/reshaped/src/components/Modal/Modal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { OverlayCloseReason, OverlayProps } from "@/components/Overlay";
import type * as G from "@/types/global";
import type { Attributes } from "@/types/global";

export type Position = "center" | "end" | "bottom" | "start" | "full-screen";

export type Context = {
id: string;
titleMounted: boolean;
Expand All @@ -27,7 +29,7 @@ export type Props = {
/** Node for inserting children */
children?: React.ReactNode;
/** Component position on the screen */
position?: G.Responsive<"center" | "end" | "bottom" | "start" | "full-screen">;
position?: G.Responsive<Position>;
/** Component size, literal css value */
size?: G.Responsive<string>;
/** Component padding, unit token multiplier */
Expand Down
Loading
Loading