diff --git a/src/components/Componentes/question-sheet.test.tsx b/src/components/Componentes/question-sheet.test.tsx index 2dd0d90..9697931 100644 --- a/src/components/Componentes/question-sheet.test.tsx +++ b/src/components/Componentes/question-sheet.test.tsx @@ -254,7 +254,7 @@ describe("QuestionSheet component", () => { expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus(); expect( screen.getByRole("dialog").querySelector("section"), - ).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]"); + ).toHaveClass("flutter-draggable-sheet"); }); it("grows the sheet when an overflowing list is pulled past its top", () => { @@ -302,6 +302,132 @@ describe("QuestionSheet component", () => { ); }); + it("snaps to 1.0 when pulled up significantly and released", () => { + const question = { + id: "q_snap_up", + title: "کشور", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: Array.from({ length: 7 }, (_, index) => ({ + id: `country-${index}`, + value: `country-${index}`, + label: `کشور ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + + const section = screen.getByRole("dialog").querySelector("section")!; + const list = screen.getByTestId("question-sheet-list"); + Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: 400, configurable: true }); + + // Pull up 100px from 0.75 and release + fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchEnd(list); + + // Snaps to 1.0000 + expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); + }); + + it("snaps back to 0.75 when pulled up slightly and released", () => { + const question = { + id: "q_snap_back", + title: "کشور", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: Array.from({ length: 7 }, (_, index) => ({ + id: `country-${index}`, + value: `country-${index}`, + label: `کشور ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + + const section = screen.getByRole("dialog").querySelector("section")!; + const list = screen.getByTestId("question-sheet-list"); + Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: 400, configurable: true }); + + // Pull up only 12px and release + fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 388 }] }); + fireEvent.touchEnd(list); + + // 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", () => { + const question = { + id: "q_snap_down", + title: "کشور", + type: "dropdown", + required: true, + extras: { placeHolder: "انتخاب کشور" }, + options: Array.from({ length: 7 }, (_, index) => ({ + id: `country-${index}`, + value: `country-${index}`, + label: `کشور ${index + 1}`, + order: index + 1, + })), + ui_config: {}, + } as QuestionField; + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /انتخاب کشور/i })); + + const section = screen.getByRole("dialog").querySelector("section")!; + const list = screen.getByTestId("question-sheet-list"); + Object.defineProperty(list, "scrollHeight", { value: 800, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: 400, configurable: true }); + + // Expand to 1.0 first + fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 400 }] }); + fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchEnd(list); + expect(section.style.getPropertyValue("--sheet-size")).toBe("1.0000"); + + // Pull down 50px from 1.0 and release + fireEvent.touchStart(list, { touches: [{ clientX: 0, clientY: 300 }] }); + fireEvent.touchMove(list, { touches: [{ clientX: 0, clientY: 350 }] }); + fireEvent.touchEnd(list); + + // Snaps down to 0.7500 + expect(section.style.getPropertyValue("--sheet-size")).toBe("0.7500"); + }); + it("grows the sheet on wheel overscroll at the top of the list", () => { const question = { id: "q_wheel", @@ -478,7 +604,7 @@ describe("QuestionSheet component", () => { const section = screen.getByRole("dialog").querySelector("section"); expect(section).toHaveClass("h-auto", "max-h-[82svh]"); - expect(section).not.toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]"); + expect(section).not.toHaveClass("flutter-draggable-sheet"); expect(screen.queryByPlaceholderText("جستجو...")).toBeNull(); }); @@ -509,7 +635,7 @@ describe("QuestionSheet component", () => { fireEvent.click(screen.getByRole("button", { name: "انتخاب" })); const section = screen.getByRole("dialog").querySelector("section"); - expect(section).toHaveClass("h-[calc(var(--sheet-size,0.75)*100svh)]"); + expect(section).toHaveClass("flutter-draggable-sheet"); expect(section).not.toHaveClass("h-auto"); expect(screen.getByPlaceholderText("جستجو...")).toBeDefined(); }); diff --git a/src/components/Componentes/question-sheet.tsx b/src/components/Componentes/question-sheet.tsx index 433ff24..ac6405a 100644 --- a/src/components/Componentes/question-sheet.tsx +++ b/src/components/Componentes/question-sheet.tsx @@ -73,8 +73,10 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const [isClosing, setIsClosing] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const listRef = useRef(null); + const headerRef = useRef(null); const sheetRef = useRef(null); const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); + const restingStateRef = useRef(SHEET_INITIAL_SIZE); const [localSelectedList, setLocalSelectedList] = useState(selectedList); const localSelectedListRef = useRef(selectedList); @@ -112,12 +114,34 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { const openSheet = useCallback(() => { if (disabled) return; 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), + ); + } setLocalSelectedList(selectedList); localSelectedListRef.current = selectedList; setIsOpen(true); setIsClosing(false); }, [disabled, selectedList]); + useEffect(() => { + if (isOpen) { + 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), + ); + } + } + }, [isOpen]); + useSheetScrollLock(isOpen, { onBack: closeSheet }); // Handle escape key @@ -339,26 +363,42 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }; }, [isCompact, isClosing, isOpen]); - // Drag-to-resize parity with najm's DraggableScrollableSheet: pulling the - // option list past its top grows the sheet itself (continuous, no snapping); - // pulling down at the top shrinks it, and crossing the 60% floor closes it. - // Height is applied imperatively via --sheet-size so gestures never trigger - // React re-renders of the (potentially hundreds of) option rows. + // Drag-to-resize parity with najm's 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 || !showSearch) return; const list = listRef.current; const sheet = sheetRef.current; - if (!list || !sheet) return; + const header = headerRef.current; + if (!sheet) return; let active = false; let engaged = false; let closing = false; + let isHeaderDrag = false; let startX = 0; let startY = 0; let lastY = 0; - - const listOverflows = () => list.scrollHeight > list.clientHeight + 1; + let snapTimeout: ReturnType | null = null; + let wheelSnapTimeout: ReturnType | null = null; + + const listOverflows = () => + list ? list.scrollHeight > list.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; @@ -373,13 +413,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { sheet.style.setProperty("--sheet-size", nextSize.toFixed(4)); }; - const handleTouchStart = (event: TouchEvent) => { + const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => { if (event.touches.length !== 1) { active = false; return; } + if (snapTimeout) { + clearTimeout(snapTimeout); + snapTimeout = null; + } + sheet.style.transition = "none"; active = true; - engaged = false; + engaged = fromHeader; + isHeaderDrag = fromHeader; startX = event.touches[0].clientX; startY = event.touches[0].clientY; lastY = startY; @@ -397,7 +443,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { // preventDefault on micro-movements kills the synthetic tap on options. if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return; if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return; - const atTop = list.scrollTop <= 0; + const atTop = !list || list.scrollTop <= 0; const canGrow = pulled > 0 && atTop && @@ -408,14 +454,17 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { engaged = true; } - const atTop = list.scrollTop <= 0; + const atTop = !list || list.scrollTop <= 0; const size = sheetSizeRef.current; - const consume = - deltaY > 0 + const consume = isHeaderDrag + ? true + : deltaY > 0 ? atTop && size < SHEET_MAX_SIZE - 0.001 : atTop; if (!consume) return; // sheet is at its ceiling — the list scrolls - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } if (deltaY > 0) { resize(deltaY); @@ -428,21 +477,64 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { }; 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) => { if (closing) return; const delta = event.deltaY; - const atTop = list.scrollTop <= 0; + const atTop = !list || list.scrollTop <= 0; const size = sheetSizeRef.current; const consume = delta > 0 ? atTop && listOverflows() && size < SHEET_MAX_SIZE - 0.001 : atTop; if (!consume) return; - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } if (delta > 0) { resize(delta); @@ -452,20 +544,57 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { } 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); }; - list.addEventListener("touchstart", handleTouchStart, { passive: true }); - list.addEventListener("touchmove", handleTouchMove, { passive: false }); - list.addEventListener("touchend", handleTouchEnd, { passive: true }); - list.addEventListener("touchcancel", handleTouchEnd, { passive: true }); - list.addEventListener("wheel", handleWheel, { passive: false }); + const onHeaderTouchStart = (e: TouchEvent) => handleTouchStart(e, true); + const onListTouchStart = (e: TouchEvent) => handleTouchStart(e, false); + + if (header) { + header.addEventListener("touchstart", onHeaderTouchStart, { + passive: true, + }); + } + if (list) { + list.addEventListener("touchstart", onListTouchStart, { passive: true }); + list.addEventListener("touchmove", handleTouchMove, { passive: false }); + list.addEventListener("touchend", handleTouchEnd, { passive: true }); + list.addEventListener("touchcancel", handleTouchEnd, { passive: true }); + list.addEventListener("wheel", handleWheel, { passive: false }); + } + sheet.addEventListener("touchmove", handleTouchMove, { passive: false }); + sheet.addEventListener("touchend", handleTouchEnd, { passive: true }); + sheet.addEventListener("touchcancel", handleTouchEnd, { passive: true }); return () => { - list.removeEventListener("touchstart", handleTouchStart); - list.removeEventListener("touchmove", handleTouchMove); - list.removeEventListener("touchend", handleTouchEnd); - list.removeEventListener("touchcancel", handleTouchEnd); - list.removeEventListener("wheel", handleWheel); + if (snapTimeout) clearTimeout(snapTimeout); + if (wheelSnapTimeout) clearTimeout(wheelSnapTimeout); + if (header) { + header.removeEventListener("touchstart", onHeaderTouchStart); + } + if (list) { + list.removeEventListener("touchstart", onListTouchStart); + list.removeEventListener("touchmove", handleTouchMove); + list.removeEventListener("touchend", handleTouchEnd); + list.removeEventListener("touchcancel", handleTouchEnd); + list.removeEventListener("wheel", handleWheel); + } + sheet.removeEventListener("touchmove", handleTouchMove); + sheet.removeEventListener("touchend", handleTouchEnd); + sheet.removeEventListener("touchcancel", handleTouchEnd); }; }, [isOpen, isClosing, showSearch, closeSheet]); @@ -664,15 +793,25 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) { > - + {(t as any)[question.title] || question.title}