237 lines
6.5 KiB
TypeScript
237 lines
6.5 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Dimensions, Platform, StatusBar, type View } from "react-native";
|
|
|
|
export type Placement = "top" | "bottom" | "left" | "right";
|
|
export type Alignment = "start" | "center" | "end";
|
|
|
|
interface Rect {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
interface FloatingStyles {
|
|
position: "absolute";
|
|
top: number;
|
|
left: number;
|
|
}
|
|
|
|
interface GeometryResult {
|
|
popoverOrigin: { x: number; y: number };
|
|
placement: Placement;
|
|
availableSize: { width: number; height: number };
|
|
}
|
|
|
|
function measureElement(element: View): Promise<Rect> {
|
|
return new Promise((resolve) => {
|
|
element.measureInWindow((x, y, width, height) => {
|
|
resolve({ x, y, width, height });
|
|
});
|
|
});
|
|
}
|
|
|
|
function computeGeometry({
|
|
fromRect,
|
|
contentSize,
|
|
displayArea,
|
|
placement,
|
|
alignment,
|
|
offset,
|
|
padding,
|
|
}: {
|
|
fromRect: Rect;
|
|
contentSize: { width: number; height: number };
|
|
displayArea: Rect;
|
|
placement: Placement;
|
|
alignment: Alignment;
|
|
offset: number;
|
|
padding: number;
|
|
}): GeometryResult {
|
|
const { width: contentWidth, height: contentHeight } = contentSize;
|
|
|
|
// Calculate available space in each direction
|
|
const spaceTop = fromRect.y - displayArea.y - padding;
|
|
const spaceBottom = displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - padding;
|
|
const spaceLeft = fromRect.x - displayArea.x - padding;
|
|
const spaceRight = displayArea.x + displayArea.width - (fromRect.x + fromRect.width) - padding;
|
|
|
|
// Determine actual placement (may flip if not enough space)
|
|
let actualPlacement = placement;
|
|
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
|
actualPlacement = "top";
|
|
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
|
actualPlacement = "bottom";
|
|
} else if (placement === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
|
|
actualPlacement = "right";
|
|
} else if (placement === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
|
|
actualPlacement = "left";
|
|
}
|
|
|
|
let x: number;
|
|
let y: number;
|
|
|
|
// Position based on placement
|
|
if (actualPlacement === "bottom") {
|
|
y = fromRect.y + fromRect.height + offset;
|
|
} else if (actualPlacement === "top") {
|
|
y = fromRect.y - contentHeight - offset;
|
|
} else if (actualPlacement === "left") {
|
|
x = fromRect.x - contentWidth - offset;
|
|
} else {
|
|
x = fromRect.x + fromRect.width + offset;
|
|
}
|
|
|
|
// Alignment on cross axis
|
|
if (actualPlacement === "top" || actualPlacement === "bottom") {
|
|
if (alignment === "start") {
|
|
x = fromRect.x;
|
|
} else if (alignment === "end") {
|
|
x = fromRect.x + fromRect.width - contentWidth;
|
|
} else {
|
|
x = fromRect.x + (fromRect.width - contentWidth) / 2;
|
|
}
|
|
} else {
|
|
if (alignment === "start") {
|
|
y = fromRect.y;
|
|
} else if (alignment === "end") {
|
|
y = fromRect.y + fromRect.height - contentHeight;
|
|
} else {
|
|
y = fromRect.y + (fromRect.height - contentHeight) / 2;
|
|
}
|
|
}
|
|
|
|
// Constrain to display area (shift)
|
|
const minX = displayArea.x + padding;
|
|
const maxX = displayArea.x + displayArea.width - contentWidth - padding;
|
|
const minY = displayArea.y + padding;
|
|
const maxY = displayArea.y + displayArea.height - contentHeight - padding;
|
|
|
|
x = Math.max(minX, Math.min(maxX, x!));
|
|
y = Math.max(minY, Math.min(maxY, y!));
|
|
|
|
// Calculate available size
|
|
const availableWidth = displayArea.width - padding * 2;
|
|
const availableHeight =
|
|
actualPlacement === "bottom"
|
|
? displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - offset - padding
|
|
: actualPlacement === "top"
|
|
? fromRect.y - displayArea.y - offset - padding
|
|
: displayArea.height - padding * 2;
|
|
|
|
return {
|
|
popoverOrigin: { x, y },
|
|
placement: actualPlacement,
|
|
availableSize: {
|
|
width: Math.max(0, availableWidth),
|
|
height: Math.max(0, availableHeight),
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface UseDropdownFloatingOptions {
|
|
open: boolean;
|
|
placement?: Placement;
|
|
alignment?: Alignment;
|
|
offset?: number;
|
|
padding?: number;
|
|
referenceEl: View | null;
|
|
}
|
|
|
|
export interface UseDropdownFloatingReturn {
|
|
floatingRef: (el: View | null) => void;
|
|
floatingStyles: FloatingStyles;
|
|
update: () => void;
|
|
onLayout: () => void;
|
|
availableSize: { width: number; height: number } | null;
|
|
actualPlacement: Placement;
|
|
}
|
|
|
|
export function useDropdownFloating({
|
|
open,
|
|
placement = "bottom",
|
|
alignment = "start",
|
|
offset = 8,
|
|
padding = 8,
|
|
referenceEl,
|
|
}: UseDropdownFloatingOptions): UseDropdownFloatingReturn {
|
|
const floatingElRef = useRef<View | null>(null);
|
|
const [geometry, setGeometry] = useState<GeometryResult | null>(null);
|
|
|
|
const displayArea = useMemo(() => {
|
|
const { width, height } = Dimensions.get("window");
|
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
|
return {
|
|
x: 0,
|
|
y: statusBarHeight,
|
|
width,
|
|
height: height - statusBarHeight,
|
|
};
|
|
}, []);
|
|
|
|
const update = useCallback(async () => {
|
|
if (!referenceEl || !floatingElRef.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const [fromRect, contentRect] = await Promise.all([
|
|
measureElement(referenceEl),
|
|
measureElement(floatingElRef.current),
|
|
]);
|
|
|
|
const result = computeGeometry({
|
|
fromRect,
|
|
contentSize: { width: contentRect.width, height: contentRect.height },
|
|
displayArea,
|
|
placement,
|
|
alignment,
|
|
offset,
|
|
padding,
|
|
});
|
|
|
|
setGeometry(result);
|
|
} catch (e) {
|
|
console.warn("[useDropdownFloating] measure failed:", e);
|
|
}
|
|
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
|
|
|
|
const floatingRef = useCallback((el: View | null) => {
|
|
floatingElRef.current = el;
|
|
}, []);
|
|
|
|
// Track when floating element is ready
|
|
const [floatingReady, setFloatingReady] = useState(false);
|
|
|
|
const handleFloatingLayout = useCallback(() => {
|
|
setFloatingReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setGeometry(null);
|
|
setFloatingReady(false);
|
|
return;
|
|
}
|
|
|
|
if (floatingReady && referenceEl && floatingElRef.current) {
|
|
update();
|
|
}
|
|
}, [open, floatingReady, referenceEl, update]);
|
|
|
|
const floatingStyles: FloatingStyles = {
|
|
position: "absolute",
|
|
top: geometry?.popoverOrigin.y ?? 0,
|
|
left: geometry?.popoverOrigin.x ?? 0,
|
|
};
|
|
|
|
return {
|
|
floatingRef,
|
|
floatingStyles,
|
|
update,
|
|
onLayout: handleFloatingLayout,
|
|
availableSize: geometry?.availableSize ?? null,
|
|
actualPlacement: geometry?.placement ?? placement,
|
|
};
|
|
}
|