Browse Source

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.
staging
mortezaei 2 weeks ago
parent
commit
affc5c5c5d
  1. 8
      src/app/globals.css
  2. 68
      src/components/Componentes/terms-sheet.test.tsx
  3. 88
      src/components/Componentes/terms-sheet.tsx

8
src/app/globals.css

@ -439,6 +439,14 @@ body.section-overlay-open .app-shell {
pointer-events: none; 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 Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */
.section-overlay { .section-overlay {
position: fixed; position: fixed;

68
src/components/Componentes/terms-sheet.test.tsx

@ -34,7 +34,7 @@ describe("TermsSheet Component (DraggableScrollableSheet Parity)", () => {
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
const section = dialog.querySelector("section")!; 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();
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(<TermsSheet isOpen={true} />);
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(<TermsSheet isOpen={true} />);
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(<TermsSheet isOpen={true} />);
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", () => { it("grows sheet on wheel event at top of content", () => {
render(<TermsSheet isOpen={true} />); render(<TermsSheet isOpen={true} />);

88
src/components/Componentes/terms-sheet.tsx

@ -243,6 +243,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
const headerRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const restingStateRef = useRef<number>(SHEET_INITIAL_SIZE);
const termsTitle = t["terms & conditions"] || "Terms & Conditions"; const termsTitle = t["terms & conditions"] || "Terms & Conditions";
const gotItLabel = t["Got it"] || "Got it"; const gotItLabel = t["Got it"] || "Got it";
@ -269,7 +270,9 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
isClosingRef.current = false; isClosingRef.current = false;
setIsClosing(false); setIsClosing(false);
sheetSizeRef.current = SHEET_INITIAL_SIZE; sheetSizeRef.current = SHEET_INITIAL_SIZE;
restingStateRef.current = SHEET_INITIAL_SIZE;
if (sheetRef.current) { if (sheetRef.current) {
sheetRef.current.style.transition = "";
sheetRef.current.style.setProperty( sheetRef.current.style.setProperty(
"--sheet-size", "--sheet-size",
SHEET_INITIAL_SIZE.toFixed(4), SHEET_INITIAL_SIZE.toFixed(4),
@ -295,6 +298,7 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
// Drag-to-resize parity with Flutter DraggableScrollableSheet: // Drag-to-resize parity with Flutter DraggableScrollableSheet:
// initialChildSize: 0.75, minChildSize: 0.6, maxChildSize: 1.0, shouldCloseOnMinExtent: true // 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(() => { useEffect(() => {
if (!isOpen || isClosing) return; if (!isOpen || isClosing) return;
@ -310,10 +314,25 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let lastY = 0; let lastY = 0;
let snapTimeout: ReturnType<typeof setTimeout> | null = null;
let wheelSnapTimeout: ReturnType<typeof setTimeout> | null = null;
const contentOverflows = () => const contentOverflows = () =>
content ? content.scrollHeight > content.clientHeight + 1 : false; 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 resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1; const viewportHeight = window.innerHeight || 1;
const nextSize = Math.min( const nextSize = Math.min(
@ -332,6 +351,11 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
active = false; active = false;
return; return;
} }
if (snapTimeout) {
clearTimeout(snapTimeout);
snapTimeout = null;
}
sheet.style.transition = "none";
active = true; active = true;
engaged = fromHeader; engaged = fromHeader;
isHeaderDrag = fromHeader; isHeaderDrag = fromHeader;
@ -385,9 +409,49 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
if (!active || closing) {
active = false;
engaged = false;
isHeaderDrag = false;
return;
}
active = false; active = false;
engaged = false; engaged = false;
isHeaderDrag = 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) => { const handleWheel = (event: WheelEvent) => {
@ -412,6 +476,20 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
} else { } else {
resize(delta); 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); const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true);
@ -436,6 +514,8 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true }); sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => { return () => {
if (snapTimeout) clearTimeout(snapTimeout);
if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout);
if (header) { if (header) {
header.removeEventListener("touchstart", onHeaderTouchStart); header.removeEventListener("touchstart", onHeaderTouchStart);
} }
@ -477,9 +557,15 @@ export function TermsSheet({ isOpen = false, onClose }: TermsSheetProps) {
> >
<section <section
ref={sheetRef} ref={sheetRef}
style={{
height:
"calc(var(--sheet-size, 0.75) * (100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px))))",
maxHeight:
"calc(100svh - max(var(--safe-top, 0px), env(safe-area-inset-top, 0px)))",
}}
className={[ className={[
"flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white shadow-[0_-10px_32px_rgba(0,0,0,0.12)]", "flex w-full max-w-[834px] sm:max-w-[540px] flex-col overflow-hidden rounded-t-[22px] bg-white shadow-[0_-10px_32px_rgba(0,0,0,0.12)]",
"h-[calc(var(--sheet-size,0.75)*100svh)]",
"flutter-draggable-sheet",
isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface", isClosing ? "flutter-sheet-surface-exit" : "flutter-sheet-surface",
].join(" ")} ].join(" ")}
> >

Loading…
Cancel
Save