revenge-plugins/plugins/server-drawer/src/components/ContextMenuModal.tsx

216 lines
8.5 KiB
TypeScript

import React from "react";
import { View, Text, Pressable, Image, Modal, StyleSheet, Dimensions, Animated } from "react-native";
import { semanticColors } from "@vendetta/ui";
import { resolveSemanticColor } from "../lib/color";
import { lazy } from "../lib/lazy";
import { rawFindByValidProps } from "../lib/rawFind";
import { getHaptic } from "../lib/commonModules";
const getTextStyleSheet = lazy(() => rawFindByValidProps<any>({
TextStyleSheet: (v) => v?.["text-md/bold"] !== undefined,
})?.TextStyleSheet);
function resolve(token: any, fallback: string): string {
return resolveSemanticColor(token) ?? fallback;
}
const EDGE = 12;
const PAD = 12;
const ITEM_H = 42;
const MIN_W = 220;
const DIVIDER_H = 4;
const RADIUS = 16;
const ICON_SIZE = 20;
const SPRING_CONFIG = { tension: 200, friction: 20 };
export interface ContextMenuItem {
label: string;
action?: () => void;
danger?: boolean;
iconSource?: number;
IconComponent?: React.ComponentType<any>;
}
export function ContextMenuModal({
visible,
items,
title,
anchorX,
anchorY,
onClose,
}: {
visible: boolean;
items: ContextMenuItem[];
title: string;
anchorX: number;
anchorY: number;
onClose: () => void;
}) {
const TextStyleSheet = getTextStyleSheet();
const Haptic = getHaptic();
const { width: winW, height: winH } = Dimensions.get("window");
const titleH = title ? PAD + 20 + DIVIDER_H : 0;
const menuH = titleH + items.length * ITEM_H;
let left = anchorX;
let top = anchorY + 48 + 10;
if (left + MIN_W > winW - EDGE) left = winW - MIN_W - EDGE;
if (left < EDGE) left = EDGE;
if (top + menuH > winH - EDGE) top = anchorY - menuH - 10;
if (top < EDGE) top = EDGE;
const bgContainer = resolve(semanticColors?.BACKGROUND_SURFACE_HIGHEST, "#1e1f22");
const borderColor = resolve(semanticColors?.BORDER_SUBTLE, "#3f4147");
const textColor = resolve(semanticColors?.TEXT_STRONG, "#f2f3f5");
const pressedColor = resolve(semanticColors?.BACKGROUND_MOD_SUBTLE, "rgba(255,255,255,0.06)");
const backdropOpacity = React.useRef(new Animated.Value(0)).current;
const menuScale = React.useRef(new Animated.Value(0.5)).current;
const menuOpacity = React.useRef(new Animated.Value(0)).current;
const [show, setShow] = React.useState(false);
const closing = React.useRef(false);
React.useEffect(() => {
if (visible && !closing.current) {
setShow(true);
backdropOpacity.setValue(0);
menuScale.setValue(0.5);
menuOpacity.setValue(0);
Animated.parallel([
Animated.timing(backdropOpacity, { toValue: 1, duration: 150, useNativeDriver: true }),
Animated.spring(menuScale, { toValue: 1, useNativeDriver: true, ...SPRING_CONFIG }),
Animated.spring(menuOpacity, { toValue: 1, useNativeDriver: true, ...SPRING_CONFIG }),
]).start();
}
}, [visible]);
const handleClose = React.useCallback(() => {
if (closing.current) return;
closing.current = true;
Animated.parallel([
Animated.timing(backdropOpacity, { toValue: 0, duration: 120, useNativeDriver: true }),
Animated.timing(menuScale, { toValue: 0.8, duration: 120, useNativeDriver: true }),
Animated.timing(menuOpacity, { toValue: 0, duration: 120, useNativeDriver: true }),
]).start(() => {
closing.current = false;
setShow(false);
onClose();
});
}, [onClose]);
if (!show) return null;
return (
<Modal transparent visible={show} onRequestClose={handleClose} statusBarTranslucent>
<Pressable style={localStyles.root} onPress={handleClose}>
<Animated.View style={[localStyles.backdrop, { opacity: backdropOpacity }]} />
<Animated.View
style={[
localStyles.container,
{
left,
top,
backgroundColor: bgContainer,
borderColor,
minWidth: MIN_W,
opacity: menuOpacity,
transform: [{ scale: menuScale }],
},
]}
>
{title ? (
<View>
<Text
style={[
TextStyleSheet?.["text-md/bold"],
{ color: textColor, paddingHorizontal: PAD, paddingTop: 13, paddingBottom: 12 },
]}
>
{title}
</Text>
<View style={{ borderBottomWidth: DIVIDER_H, borderBottomColor: borderColor }} />
</View>
) : null}
{items.map((item, i) => {
const isFirst = i === 0 && !title;
const isLast = i === items.length - 1;
const hasIcon = !!(item.iconSource || item.IconComponent);
return (
<Pressable
key={i}
style={({ pressed }) => ({
paddingHorizontal: PAD,
paddingVertical: 10,
minHeight: ITEM_H,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
backgroundColor: pressed ? pressedColor : "transparent",
borderTopLeftRadius: isFirst ? RADIUS : 0,
borderTopRightRadius: isFirst ? RADIUS : 0,
borderBottomLeftRadius: isLast ? RADIUS : 0,
borderBottomRightRadius: isLast ? RADIUS : 0,
borderBottomWidth: !isFirst && !isLast ? 1 : 0,
borderBottomColor: borderColor,
})}
onPress={() => {
Haptic?.triggerHapticFeedback?.(Haptic.HapticFeedbackTypes.IMPACT_LIGHT);
handleClose();
item.action?.();
}}
>
<Text
style={[
TextStyleSheet?.["text-md/medium"],
{
flexShrink: 1,
color: textColor,
},
]}
>
{item.label}
</Text>
{hasIcon ? (
<View style={{ width: ICON_SIZE, height: ICON_SIZE, alignItems: "center", justifyContent: "center" }}>
{item.IconComponent ? (
<item.IconComponent size="sm" color={textColor} />
) : item.iconSource ? (
<Image source={item.iconSource} style={{ width: ICON_SIZE, height: ICON_SIZE, tintColor: textColor }} />
) : null}
</View>
) : null}
</Pressable>
);
})}
</Animated.View>
</Pressable>
</Modal>
);
}
const localStyles = StyleSheet.create({
root: {
flex: 1,
},
backdrop: {
...StyleSheet.absoluteFill,
backgroundColor: "rgba(0,0,0,0.55)",
},
container: {
position: "absolute",
borderWidth: 1,
borderRadius: RADIUS,
overflow: "hidden",
elevation: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.24,
shadowRadius: 24,
},
});