From affc5c5c5d8334996769dd672f7a6cb367b11d59 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 5 Sep 2026 00:51:41 +0330 Subject: [PATCH] refactor(ui): implement snapping logic and CSS parity for terms sheet Refactor the `TermsSheet` component to achieve better parity with Flutter's `DraggableScrollableSheet`. This includes implementing a snapping mechanism between two resting states (0.75 and 1.0) and updating the CSS to handle safe area insets and dynamic height calculations. - Add `.flutter-draggable-sheet` class to `globals.css` for height scaling against available screen space below the status bar. - Implement `snapTo` logic in `TermsSheet` to handle transitions between initial and expanded states. - Add `restingStateRef` to track the current snapped position. - Update `TermsSheet` touch event handlers to support snapping based on drag distance and current size. - Add unit tests to verify snapping behavior for pulling up, pulling down, and reaching the minimum size threshold. --- src/app/globals.css | 8 ++ .../Componentes/terms-sheet.test.tsx | 68 +++++++++++++- src/components/Componentes/terms-sheet.tsx | 88 ++++++++++++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index eb4e4fd..fd3e457 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -439,6 +439,14 @@ body.section-overlay-open .app-shell { pointer-events: none; } +/* Flutter DraggableScrollableSheet Parity: + Height is scaled against available screen below system status bar, + stopping strictly underneath safe-top (MediaQuery.padding.top in Flutter). */ +.flutter-draggable-sheet { + height: calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))); + max-height: calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))); +} + /* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */ .section-overlay { position: fixed; diff --git a/src/components/Componentes/terms-sheet.test.tsx b/src/components/Componentes/terms-sheet.test.tsx index a008db6..545ff17 100644 --- a/src/components/Componentes/terms-sheet.test.tsx +++ b/src/components/Componentes/terms-sheet.test.tsx @@ -34,7 +34,7 @@ describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => { expect(dialog).toBeInTheDocument(); const section = dialog.querySelector("section")!; - expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]"); + expect(section).toHaveClass("flutter-draggable-sheet"); expect(screen.getByText("قوانین و مقررات")).toBeInTheDocument(); expect(screen.getByText("متوجه شدم")).toBeInTheDocument(); }); @@ -58,6 +58,72 @@ describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => { ); }); + it("snaps to 1.0 when pulled up significantly and released", () => { + render(); + + const dialog = screen.getByRole("dialog"); + const section = dialog.querySelector("section")!; + const content = screen.getByTestId("terms-sheet-content"); + + Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); + Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); + Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); + + // Pull up 100px from 0.75 + fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchEnd(content); + + // Snaps cleanly to 1.0000 + expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); + }); + + it("snaps back to 0.75 when pulled up slightly and released", () => { + render(); + + const dialog = screen.getByRole("dialog"); + const section = dialog.querySelector("section")!; + const content = screen.getByTestId("terms-sheet-content"); + + Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); + Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); + Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); + + // Pull up only 12px (below 30px threshold) + fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 388 }] }); + fireEvent.touchEnd(content); + + // Snaps back to 0.7500 + expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500"); + }); + + it("snaps from 1.0 down to 0.75 when pulled down and released", () => { + render(); + + const dialog = screen.getByRole("dialog"); + const section = dialog.querySelector("section")!; + const content = screen.getByTestId("terms-sheet-content"); + + Object.defineProperty(content, "scrollHeight", { value: 1200, configurable: true }); + Object.defineProperty(content, "clientHeight", { value: 400, configurable: true }); + Object.defineProperty(content, "scrollTop", { value: 0, configurable: true }); + + // Expand to 1.0 first + fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchEnd(content); + expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); + + // Pull down 50px from 1.0 + fireEvent.touchStart(content, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchMove(content, { touches: [{ clientX: 0, clientY: 350 }] }); + fireEvent.touchEnd(content); + + // Snaps down to 0.7500 + expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500"); + }); + it("grows sheet on wheel event at top of content", () => { render(); diff --git a/src/components/Componentes/terms-sheet.tsx b/src/components/Componentes/terms-sheet.tsx index b5b0a47..d7fa068 100644 --- a/src/components/Componentes/terms-sheet.tsx +++ b/src/components/Componentes/terms-sheet.tsx @@ -243,6 +243,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { const headerRef = useRef(null); const contentRef = useRef(null); const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); + const restingStateRef = useRef(SHEET_INITIAL_SIZE); const termsTitle = t["terms & conditions"] || "Terms & Conditions"; const gotItLabel = t["Got it"] || "Got it"; @@ -269,7 +270,9 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { isClosingRef.current = false; setIsClosing(false); sheetSizeRef.current = SHEET_INITIAL_SIZE; + restingStateRef.current = SHEET_INITIAL_SIZE; if (sheetRef.current) { + sheetRef.current.style.transition = ""; sheetRef.current.style.setProperty( "--sheet-size", SHEET_INITIAL_SIZE.toFixed(4), @@ -295,6 +298,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { // Drag-to-resize parity with Flutter DraggableScrollableSheet: // initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true + // Snapping: Exactly two resting states (0.75 default and 1.0 expanded below status bar). useEffect(() => { if (!isOpen || isClosing) return; @@ -310,10 +314,25 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { let startX = 0; let startY = 0; let lastY = 0; + let snapTimeout: ReturnType | null = null; + let wheelSnapTimeout: ReturnType | null = null; const contentOverflows = () => content ? content.scrollHeight > content.clientHeight + 1 : false; + const snapTo = (targetSize: number) => { + sheetSizeRef.current = targetSize; + restingStateRef.current = targetSize; + sheet.style.transition = "height 260ms cubic-bezier(0.16, 1, 0.3, 1)"; + sheet.style.setProperty("--sheet-size", targetSize.toFixed(4)); + if (snapTimeout) clearTimeout(snapTimeout); + snapTimeout = setTimeout(() => { + if (sheet) { + sheet.style.transition = ""; + } + }, 270); + }; + const resize = (deltaPx: number) => { const viewportHeight = window.innerHeight || 1; const nextSize = Math.min( @@ -332,6 +351,11 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { active = false; return; } + if (snapTimeout) { + clearTimeout(snapTimeout); + snapTimeout = null; + } + sheet.style.transition = "none"; active = true; engaged = fromHeader; isHeaderDrag = fromHeader; @@ -385,9 +409,49 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { }; const handleTouchEnd = () => { + if (!active || closing) { + active = false; + engaged = false; + isHeaderDrag = false; + return; + } active = false; engaged = false; isHeaderDrag = false; + + const currentSize = sheetSizeRef.current; + const baseline = restingStateRef.current; + const totalPulled = startY - lastY; // > 0 finger pulled up, < 0 finger pulled down + + // Flutter shouldCloseOnMinExtent: true when dragged near or below 60% floor + if (currentSize <= SHEET_MIN_SIZE + 0.01) { + closing = true; + closeSheet(); + return; + } + + // Snapping between only two states: 0.75 (default) and 1.0 (expanded) + if (baseline === SHEET_MAX_SIZE) { + // Dragging down from 1.0: if pulled down >= 30px or size <= 0.94, snap to 0.75 + if (totalPulled <= -30 || currentSize <= 0.94) { + snapTo(SHEET_INITIAL_SIZE); + } else { + snapTo(SHEET_MAX_SIZE); + } + } else { + // Dragging from 0.75: + // If pulled down strongly, close + if (totalPulled <= -60) { + closing = true; + closeSheet(); + } else if (totalPulled >= 30 || currentSize >= 0.78) { + // Dragged up enough to expand to 1.0 + snapTo(SHEET_MAX_SIZE); + } else { + // Snap back to initial 0.75 + snapTo(SHEET_INITIAL_SIZE); + } + } }; const handleWheel = (event: WheelEvent) => { @@ -412,6 +476,20 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { } else { resize(delta); } + + if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout); + wheelSnapTimeout = setTimeout(() => { + if (closing) return; + const current = sheetSizeRef.current; + if (current <= SHEET_MIN_SIZE + 0.01) { + closing = true; + closeSheet(); + } else if (current >= 0.82) { + snapTo(SHEET_MAX_SIZE); + } else { + snapTo(SHEET_INITIAL_SIZE); + } + }, 160); }; const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true); @@ -436,6 +514,8 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { + if (snapTimeout) clearTimeout(snapTimeout); + if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout); if (header) { header.removeEventListener("touchstart", onHeaderTouchStart); } @@ -477,9 +557,15 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) { >