Fix web dropdown menu resizing

This commit is contained in:
Mohamed Boudra
2026-05-01 18:32:04 +07:00
parent cb319736a2
commit a9ea9f3e37

View File

@@ -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<ViewStyle>;
}
function renderDropdownSurface(input: {
isWebSurface: boolean;
sharedProps: SharedDropdownContentProps;
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
onExited: () => void;
}): ReactElement {
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
const body = scrollable ? (
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{content}
</ScrollView>
) : (
content
);
if (isWebSurface) {
return <View {...sharedProps}>{body}</View>;
}
return (
<Animated.View
{...sharedProps}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})}
>
{body}
</Animated.View>
);
}
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<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(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 = (
<View collapsable={false} onLayout={handleMeasuredContentLayout}>
{children}
</View>
);
return (
<Modal
visible={modalVisible}
@@ -480,30 +576,16 @@ export function DropdownMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing ? (
<Animated.View
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(setModalVisible)(false);
}
})}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={contentStyle}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{children}
</ScrollView>
</Animated.View>
) : null}
{!closing
? renderDropdownSurface({
isWebSurface: isWeb,
sharedProps: sharedContentProps,
scrollable,
scrollViewportStyle,
content,
onExited: () => setModalVisible(false),
})
: null}
</View>
</Modal>
);