Update files

This commit is contained in:
Mohamed Boudra
2026-01-24 14:08:01 +07:00
parent b919a3cdd9
commit 657644b5e7
9 changed files with 1250 additions and 710 deletions
@@ -0,0 +1,259 @@
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 () => {
console.log("[useDropdownFloating] update called", {
hasReferenceEl: !!referenceEl,
hasFloatingEl: !!floatingElRef.current,
});
if (!referenceEl || !floatingElRef.current) {
return;
}
try {
const [fromRect, contentRect] = await Promise.all([
measureElement(referenceEl),
measureElement(floatingElRef.current),
]);
console.log("[useDropdownFloating] measured", {
fromRect,
contentRect,
displayArea,
});
const result = computeGeometry({
fromRect,
contentSize: { width: contentRect.width, height: contentRect.height },
displayArea,
placement,
alignment,
offset,
padding,
});
console.log("[useDropdownFloating] geometry result", result);
setGeometry(result);
} catch (e) {
console.warn("[useDropdownFloating] measure failed:", e);
}
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
const floatingRef = useCallback((el: View | null) => {
console.log("[useDropdownFloating] floatingRef called", { hasEl: !!el });
floatingElRef.current = el;
}, []);
// Track when floating element is ready
const [floatingReady, setFloatingReady] = useState(false);
const handleFloatingLayout = useCallback(() => {
console.log("[useDropdownFloating] handleFloatingLayout called");
setFloatingReady(true);
}, []);
useEffect(() => {
console.log("[useDropdownFloating] useEffect", {
open,
floatingReady,
hasReferenceEl: !!referenceEl,
hasFloatingEl: !!floatingElRef.current,
});
if (!open) {
setGeometry(null);
setFloatingReady(false);
return;
}
if (floatingReady && referenceEl && floatingElRef.current) {
console.log("[useDropdownFloating] calling update from useEffect");
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,
};
}
@@ -0,0 +1,557 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
Dimensions,
Platform,
StatusBar,
type PressableProps,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
type Placement = "top" | "bottom" | "left" | "right";
type Alignment = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type DropdownMenuContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
};
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
const ctx = useContext(DropdownMenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <DropdownMenu />`);
}
return ctx;
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const { width: contentWidth, height: contentHeight } = contentSize;
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualPlacement = "top";
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualPlacement = "bottom";
}
let x: number;
let y: number;
// Position based on placement
if (actualPlacement === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
} else if (actualPlacement === "top") {
y = triggerRect.y - contentHeight - offset;
} else if (actualPlacement === "left") {
x = triggerRect.x - contentWidth - offset;
y = triggerRect.y;
} else {
x = triggerRect.x + triggerRect.width + offset;
y = triggerRect.y;
}
// Alignment
if (actualPlacement === "top" || actualPlacement === "bottom") {
if (alignment === "start") {
x = triggerRect.x;
} else if (alignment === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!));
return { x, y, actualPlacement };
}
export function DropdownMenu({
open,
defaultOpen,
onOpenChange,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const value = useMemo<DropdownMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
}),
[isOpen, setIsOpen]
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
}
export function DropdownMenuTrigger({
children,
disabled,
...props
}: PropsWithChildren<PressableProps>): ReactElement {
const { setOpen, open, triggerRef } = useDropdownMenuContext("DropdownMenuTrigger");
const handlePress = useCallback(() => {
if (disabled) return;
setOpen(!open);
}, [disabled, open, setOpen]);
return (
<Pressable
{...props}
ref={triggerRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
>
{children}
</Pressable>
);
}
export function DropdownMenuContent({
children,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
fullWidth = false,
horizontalPadding = 16,
testID,
}: PropsWithChildren<{
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
fullWidth?: boolean;
horizontalPadding?: number;
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
// Measure trigger when opening
useEffect(() => {
if (open && triggerRef.current) {
measureElement(triggerRef.current).then((rect) => {
// On Android with statusBarTranslucent, measureInWindow returns coordinates
// from screen top, but Modal content area also starts from screen top.
// However, the measured y doesn't account for status bar offset correctly,
// so we need to add it (same as react-native-popover-view does)
const statusBarAdjustment = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const adjustedRect = {
...rect,
y: rect.y + statusBarAdjustment,
};
setTriggerRect(adjustedRect);
});
} else {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
}
}, [open, triggerRef]);
// Calculate position when we have both measurements
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// measureInWindow returns screen coordinates including status bar
// Modal also uses full screen coordinates, so displayArea should start at 0
const displayArea = {
x: 0,
y: 0,
width: screenWidth,
height: screenHeight,
};
const result = computePosition({
triggerRect,
contentSize,
displayArea,
placement: side,
alignment: align,
offset,
});
// For fullWidth, x is simply the horizontal padding to center on screen
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
}, []);
if (!open) return null;
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return (
<Modal
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Menu backdrop"
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
export function DropdownMenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
return (
<View style={[styles.labelContainer, style]} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
}
export function DropdownMenuHint({
children,
testID,
}: PropsWithChildren<{ testID?: string }>): ReactElement {
return (
<View style={styles.hintContainer} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
export function DropdownMenuItem({
children,
description,
onSelect,
disabled,
destructive,
selected,
showSelectedCheck = false,
selectedVariant = "default",
leading,
trailing,
closeOnSelect = true,
testID,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
disabled?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
selectedVariant?: "default" | "accent";
leading?: ReactElement | null;
trailing?: ReactElement | null;
closeOnSelect?: boolean;
testID?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
return (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={disabled}
onPress={() => {
if (disabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}}
style={({ pressed }) => [
styles.item,
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
destructive ? styles.itemDestructive : null,
disabled ? styles.itemDisabled : null,
pressed && !disabled ? styles.itemPressed : null,
]}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leading ? <View style={styles.leadingSlot}>{leading}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive ? styles.itemTextDestructive : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
]}
>
{children}
</Text>
{description ? (
<Text
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
]}
>
{description}
</Text>
) : null}
</View>
{trailing ? <View style={styles.trailingSlot}>{trailing}</View> : null}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
item: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemPressed: {
backgroundColor: theme.colors.surface2,
},
itemSelected: {
backgroundColor: theme.colors.surface2,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
},
itemDisabled: {
opacity: 0.5,
},
itemDestructive: {
backgroundColor: "rgba(248, 81, 73, 0.08)",
},
itemText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSelectedAccent: {
color: theme.colors.accentForeground,
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
itemDescriptionSelectedAccent: {
color: theme.colors.accentForeground,
opacity: 0.85,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
},
}));