567 lines
15 KiB
TypeScript
567 lines
15 KiB
TypeScript
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) {
|
|
setTriggerRect(null);
|
|
setContentSize(null);
|
|
setPosition(null);
|
|
return;
|
|
}
|
|
|
|
// Capture status bar height synchronously before async measurement.
|
|
// This avoids race conditions where StatusBar.currentHeight could change
|
|
// or return null if read after the component re-renders.
|
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
|
let cancelled = false;
|
|
|
|
measureElement(triggerRef.current).then((rect) => {
|
|
if (cancelled) return;
|
|
// On Android with statusBarTranslucent, measureInWindow returns coordinates
|
|
// relative to below the status bar, but Modal content starts from screen top.
|
|
// Add status bar height to align coordinate systems (same as react-native-popover-view).
|
|
setTriggerRect({
|
|
...rect,
|
|
y: rect.y + statusBarHeight,
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [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,
|
|
},
|
|
}));
|