diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 6d7d136f9..837a89dce 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -28,6 +28,7 @@ import { import Animated, { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; +import { isWeb } from "@/constants/platform"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -46,6 +47,11 @@ interface Rect { height: number; } +interface Size { + width: number; + height: number; +} + interface DropdownMenuContextValue { open: boolean; setOpen: (open: boolean) => void; @@ -161,6 +167,55 @@ function computePosition({ return { x, y, actualPlacement }; } +interface SharedDropdownContentProps { + collapsable: false; + testID?: string; + style: StyleProp; +} + +function renderDropdownSurface(input: { + isWebSurface: boolean; + sharedProps: SharedDropdownContentProps; + scrollable: boolean; + scrollViewportStyle: StyleProp; + content: ReactElement; + onExited: () => void; +}): ReactElement { + const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input; + + const body = scrollable ? ( + + {content} + + ) : ( + content + ); + + if (isWebSurface) { + return {body}; + } + + return ( + { + "worklet"; + if (finished) { + runOnJS(onExited)(); + } + })} + > + {body} + + ); +} + export function DropdownMenu({ open, defaultOpen, @@ -314,8 +369,10 @@ export function DropdownMenuContent({ width, minWidth = 180, maxWidth, + maxHeight, fullWidth = false, horizontalPadding = 16, + scrollable = false, testID, }: PropsWithChildren<{ side?: Placement; @@ -324,8 +381,10 @@ export function DropdownMenuContent({ width?: number; minWidth?: number; maxWidth?: number; + maxHeight?: number; fullWidth?: boolean; horizontalPadding?: number; + scrollable?: boolean; testID?: string; }>): ReactElement | null { const { open, setOpen, triggerRef, flushPendingSelect } = @@ -334,9 +393,23 @@ export function DropdownMenuContent({ const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); - const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [contentSize, setContentSize] = useState(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [actualPlacement, setActualPlacement] = useState(side); + const visibleContentSize = useMemo(() => { + if (!contentSize) return null; + if (!scrollable) return contentSize; + + const { height: screenHeight } = Dimensions.get("window"); + const viewportMaxHeight = Math.max(screenHeight - 16, 0); + const resolvedMaxHeight = + typeof maxHeight === "number" ? Math.min(maxHeight, viewportMaxHeight) : viewportMaxHeight; + + return { + width: contentSize.width, + height: Math.min(contentSize.height, resolvedMaxHeight), + }; + }, [contentSize, scrollable, maxHeight]); // Keep Modal mounted during exit animation useEffect(() => { @@ -395,7 +468,7 @@ export function DropdownMenuContent({ // Calculate position when we have both measurements useEffect(() => { - if (!triggerRect || !contentSize) return; + if (!triggerRect || !visibleContentSize) return; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); // measureInWindow returns screen coordinates including status bar @@ -409,7 +482,7 @@ export function DropdownMenuContent({ const result = computePosition({ triggerRect, - contentSize, + contentSize: visibleContentSize, displayArea, placement: side, alignment: align, @@ -420,12 +493,17 @@ export function DropdownMenuContent({ const x = fullWidth ? horizontalPadding : result.x; setPosition({ x, y: result.y }); setActualPlacement(result.actualPlacement); - }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); + }, [triggerRect, visibleContentSize, side, align, offset, fullWidth, horizontalPadding]); - const handleContentLayout = useCallback( + const handleMeasuredContentLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { const { width: w, height: h } = event.nativeEvent.layout; - setContentSize({ width: w, height: h }); + setContentSize((current) => { + if (current && current.width === w && current.height === h) { + return current; + } + return { width: w, height: h }; + }); }, [], ); @@ -460,9 +538,27 @@ export function DropdownMenuContent({ actualPlacement, align, ]); + const sharedContentProps = useMemo( + () => ({ + collapsable: false as const, + testID, + style: contentStyle, + }), + [testID, contentStyle], + ); + const scrollViewportStyle = useMemo( + () => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null], + [visibleContentSize, webScrollbarStyle], + ); if (!modalVisible) return null; + const content = ( + + {children} + + ); + return ( - {!closing ? ( - { - "worklet"; - if (finished) { - runOnJS(setModalVisible)(false); - } - })} - collapsable={false} - testID={testID} - onLayout={handleContentLayout} - style={contentStyle} - > - - {children} - - - ) : null} + {!closing + ? renderDropdownSurface({ + isWebSurface: isWeb, + sharedProps: sharedContentProps, + scrollable, + scrollViewportStyle, + content, + onExited: () => setModalVisible(false), + }) + : null} );