|
|
@ -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} |
|
|
|