Fix web dropdown menu resizing
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ interface Rect {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DropdownMenuContextValue {
|
interface DropdownMenuContextValue {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
@@ -161,6 +167,55 @@ function computePosition({
|
|||||||
return { x, y, actualPlacement };
|
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({
|
export function DropdownMenu({
|
||||||
open,
|
open,
|
||||||
defaultOpen,
|
defaultOpen,
|
||||||
@@ -314,8 +369,10 @@ export function DropdownMenuContent({
|
|||||||
width,
|
width,
|
||||||
minWidth = 180,
|
minWidth = 180,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
|
maxHeight,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
horizontalPadding = 16,
|
horizontalPadding = 16,
|
||||||
|
scrollable = false,
|
||||||
testID,
|
testID,
|
||||||
}: PropsWithChildren<{
|
}: PropsWithChildren<{
|
||||||
side?: Placement;
|
side?: Placement;
|
||||||
@@ -324,8 +381,10 @@ export function DropdownMenuContent({
|
|||||||
width?: number;
|
width?: number;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
horizontalPadding?: number;
|
horizontalPadding?: number;
|
||||||
|
scrollable?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const { open, setOpen, triggerRef, flushPendingSelect } =
|
const { open, setOpen, triggerRef, flushPendingSelect } =
|
||||||
@@ -334,9 +393,23 @@ export function DropdownMenuContent({
|
|||||||
const webScrollbarStyle = useWebScrollbarStyle();
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
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 [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
|
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
|
// Keep Modal mounted during exit animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -395,7 +468,7 @@ export function DropdownMenuContent({
|
|||||||
|
|
||||||
// Calculate position when we have both measurements
|
// Calculate position when we have both measurements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!triggerRect || !contentSize) return;
|
if (!triggerRect || !visibleContentSize) return;
|
||||||
|
|
||||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
// measureInWindow returns screen coordinates including status bar
|
// measureInWindow returns screen coordinates including status bar
|
||||||
@@ -409,7 +482,7 @@ export function DropdownMenuContent({
|
|||||||
|
|
||||||
const result = computePosition({
|
const result = computePosition({
|
||||||
triggerRect,
|
triggerRect,
|
||||||
contentSize,
|
contentSize: visibleContentSize,
|
||||||
displayArea,
|
displayArea,
|
||||||
placement: side,
|
placement: side,
|
||||||
alignment: align,
|
alignment: align,
|
||||||
@@ -420,12 +493,17 @@ export function DropdownMenuContent({
|
|||||||
const x = fullWidth ? horizontalPadding : result.x;
|
const x = fullWidth ? horizontalPadding : result.x;
|
||||||
setPosition({ x, y: result.y });
|
setPosition({ x, y: result.y });
|
||||||
setActualPlacement(result.actualPlacement);
|
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 } } }) => {
|
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||||
const { width: w, height: h } = event.nativeEvent.layout;
|
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,
|
actualPlacement,
|
||||||
align,
|
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;
|
if (!modalVisible) return null;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<View collapsable={false} onLayout={handleMeasuredContentLayout}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={modalVisible}
|
visible={modalVisible}
|
||||||
@@ -480,30 +576,16 @@ export function DropdownMenuContent({
|
|||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
testID={testID ? `${testID}-backdrop` : undefined}
|
testID={testID ? `${testID}-backdrop` : undefined}
|
||||||
/>
|
/>
|
||||||
{!closing ? (
|
{!closing
|
||||||
<Animated.View
|
? renderDropdownSurface({
|
||||||
entering={contentEntering}
|
isWebSurface: isWeb,
|
||||||
exiting={contentExiting.withCallback((finished) => {
|
sharedProps: sharedContentProps,
|
||||||
"worklet";
|
scrollable,
|
||||||
if (finished) {
|
scrollViewportStyle,
|
||||||
runOnJS(setModalVisible)(false);
|
content,
|
||||||
}
|
onExited: () => setModalVisible(false),
|
||||||
})}
|
})
|
||||||
collapsable={false}
|
: null}
|
||||||
testID={testID}
|
|
||||||
onLayout={handleContentLayout}
|
|
||||||
style={contentStyle}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
bounces={false}
|
|
||||||
showsVerticalScrollIndicator
|
|
||||||
style={webScrollbarStyle}
|
|
||||||
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollView>
|
|
||||||
</Animated.View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user