Browse Source

refactor: replace class-based sheet sizing with snap-to-state logic in question-sheet component

staging
mortezaei 2 weeks ago
parent
commit
98f3bb2c69
  1. 132
      src/components/Componentes/question-sheet.test.tsx
  2. 197
      src/components/Componentes/question-sheet.tsx

132
src/components/Componentes/question-sheet.test.tsx

@ -254,7 +254,7 @@ describe("QuestionSheet component", () => {
expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus(); expect(screen.getByPlaceholderText("جستجو...")).not.toHaveFocus();
expect( expect(
screen.getByRole("dialog").querySelector("section"), 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", () => { 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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test" questions={[question]}>
<QuestionSheet question={question} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
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", () => { it("grows the sheet on wheel overscroll at the top of the list", () => {
const question = { const question = {
id: "q_wheel", id: "q_wheel",
@ -478,7 +604,7 @@ describe("QuestionSheet component", () => {
const section = screen.getByRole("dialog").querySelector("section"); const section = screen.getByRole("dialog").querySelector("section");
expect(section).toHaveClass("h-auto", "max-h-[82svh]"); 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(); expect(screen.queryByPlaceholderText("جستجو...")).toBeNull();
}); });
@ -509,7 +635,7 @@ describe("QuestionSheet component", () => {
fireEvent.click(screen.getByRole("button", { name: "انتخاب" })); fireEvent.click(screen.getByRole("button", { name: "انتخاب" }));
const section = screen.getByRole("dialog").querySelector("section"); 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(section).not.toHaveClass("h-auto");
expect(screen.getByPlaceholderText("جستجو...")).toBeDefined(); expect(screen.getByPlaceholderText("جستجو...")).toBeDefined();
}); });

197
src/components/Componentes/question-sheet.tsx

@ -73,8 +73,10 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLElement>(null); const sheetRef = useRef<HTMLElement>(null);
const sheetSizeRef = useRef(SHEET_INITIAL_SIZE); const sheetSizeRef = useRef(SHEET_INITIAL_SIZE);
const restingStateRef = useRef<number>(SHEET_INITIAL_SIZE);
const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList); const [localSelectedList, setLocalSelectedList] = useState<string[]>(selectedList);
const localSelectedListRef = useRef<string[]>(selectedList); const localSelectedListRef = useRef<string[]>(selectedList);
@ -112,12 +114,34 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
const openSheet = useCallback(() => { const openSheet = useCallback(() => {
if (disabled) return; if (disabled) return;
sheetSizeRef.current = SHEET_INITIAL_SIZE; 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); setLocalSelectedList(selectedList);
localSelectedListRef.current = selectedList; localSelectedListRef.current = selectedList;
setIsOpen(true); setIsOpen(true);
setIsClosing(false); setIsClosing(false);
}, [disabled, selectedList]); }, [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 }); useSheetScrollLock(isOpen, { onBack: closeSheet });
// Handle escape key // Handle escape key
@ -339,26 +363,42 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}; };
}, [isCompact, isClosing, isOpen]); }, [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(() => { useEffect(() => {
if (!isOpen || isClosing || !showSearch) return; if (!isOpen || isClosing || !showSearch) return;
const list = listRef.current; const list = listRef.current;
const sheet = sheetRef.current; const sheet = sheetRef.current;
if (!list || !sheet) return;
const header = headerRef.current;
if (!sheet) return;
let active = false; let active = false;
let engaged = false; let engaged = false;
let closing = false; let closing = false;
let isHeaderDrag = false;
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let lastY = 0; let lastY = 0;
const listOverflows = () => list.scrollHeight > list.clientHeight + 1;
let snapTimeout: ReturnType<typeof setTimeout> | null = null;
let wheelSnapTimeout: ReturnType<typeof setTimeout> | 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 resize = (deltaPx: number) => {
const viewportHeight = window.innerHeight || 1; const viewportHeight = window.innerHeight || 1;
@ -373,13 +413,19 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
sheet.style.setProperty("--sheet-size", nextSize.toFixed(4)); sheet.style.setProperty("--sheet-size", nextSize.toFixed(4));
}; };
const handleTouchStart = (event: TouchEvent) => {
const handleTouchStart = (event: TouchEvent, fromHeader: boolean) => {
if (event.touches.length !== 1) { if (event.touches.length !== 1) {
active = false; active = false;
return; return;
} }
if (snapTimeout) {
clearTimeout(snapTimeout);
snapTimeout = null;
}
sheet.style.transition = "none";
active = true; active = true;
engaged = false;
engaged = fromHeader;
isHeaderDrag = fromHeader;
startX = event.touches[0].clientX; startX = event.touches[0].clientX;
startY = event.touches[0].clientY; startY = event.touches[0].clientY;
lastY = startY; lastY = startY;
@ -397,7 +443,7 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
// preventDefault on micro-movements kills the synthetic tap on options. // preventDefault on micro-movements kills the synthetic tap on options.
if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return; if (Math.abs(pulled) < GESTURE_ENGAGE_PX) return;
if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return; if (Math.abs(touch.clientX - startX) >= Math.abs(pulled)) return;
const atTop = list.scrollTop <= 0;
const atTop = !list || list.scrollTop <= 0;
const canGrow = const canGrow =
pulled > 0 && pulled > 0 &&
atTop && atTop &&
@ -408,14 +454,17 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
engaged = true; engaged = true;
} }
const atTop = list.scrollTop <= 0;
const atTop = !list || list.scrollTop <= 0;
const size = sheetSizeRef.current; const size = sheetSizeRef.current;
const consume =
deltaY > 0
const consume = isHeaderDrag
? true
: deltaY > 0
? atTop && size < SHEET_MAX_SIZE - 0.001 ? atTop && size < SHEET_MAX_SIZE - 0.001
: atTop; : atTop;
if (!consume) return; // sheet is at its ceiling — the list scrolls if (!consume) return; // sheet is at its ceiling — the list scrolls
event.preventDefault();
if (event.cancelable) {
event.preventDefault();
}
if (deltaY > 0) { if (deltaY > 0) {
resize(deltaY); resize(deltaY);
@ -428,21 +477,64 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
if (!active || closing) {
active = false;
engaged = false;
isHeaderDrag = false;
return;
}
active = false; active = false;
engaged = 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) => { const handleWheel = (event: WheelEvent) => {
if (closing) return; if (closing) return;
const delta = event.deltaY; const delta = event.deltaY;
const atTop = list.scrollTop <= 0;
const atTop = !list || list.scrollTop <= 0;
const size = sheetSizeRef.current; const size = sheetSizeRef.current;
const consume = const consume =
delta > 0 delta > 0
? atTop && listOverflows() && size < SHEET_MAX_SIZE - 0.001 ? atTop && listOverflows() && size < SHEET_MAX_SIZE - 0.001
: atTop; : atTop;
if (!consume) return; if (!consume) return;
event.preventDefault();
if (event.cancelable) {
event.preventDefault();
}
if (delta > 0) { if (delta > 0) {
resize(delta); resize(delta);
@ -452,20 +544,57 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
} 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);
}; };
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 () => { 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]); }, [isOpen, isClosing, showSearch, closeSheet]);
@ -664,15 +793,25 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
> >
<section <section
ref={sheetRef} ref={sheetRef}
style={{
height: !showSearch
? undefined
: "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 pb-[env(safe-area-inset-bottom)] 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 pb-[env(safe-area-inset-bottom)] shadow-[0_-10px_32px_rgba(0,0,0,0.12)]",
!showSearch !showSearch
? "h-auto max-h-[82svh]" ? "h-auto max-h-[82svh]"
: "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(" ")}
> >
<div className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]">
<div
ref={headerRef}
className="flex items-center justify-between gap-3 px-5 pt-2.5 pb-3 border-b border-[#F2F4F7]"
>
<div className="flex-1 min-w-0 flex flex-col text-start"> <div className="flex-1 min-w-0 flex flex-col text-start">
<h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start"> <h3 className="flex-1 text-[17px] font-bold leading-snug text-[#181818] line-clamp-2 break-words text-start">
{(t as any)[question.title] || question.title} {(t as any)[question.title] || question.title}

Loading…
Cancel
Save