import React, { useRef, useState } from "react";
import {
type LayoutChangeEvent,
PanResponder,
Platform,
Pressable,
Text as RNText,
type TextStyle,
useWindowDimensions,
View,
type ViewStyle,
} from "react-native";
import * as DialogPrimitive from "@rn-primitives/dialog";
import { Ionicons } from "@expo/vector-icons";
import { useColorScheme } from "nativewind";
import { cn } from "../utils/cn";
// ── Main Dialog ──
interface DialogProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
showCloseButton?: boolean;
draggable?: boolean;
maxWidth?: number;
/**
* When false, clicking the backdrop does NOT dismiss the dialog —
* required for any dialog that holds an unrecoverable input
* (master password, step-up confirmation, half-typed import). An
* accidental drag-then-click on the backdrop otherwise loses the
* user's typing with no undo. Defaults to true to keep the simple
* "click outside to cancel" pattern for non-input dialogs.
*/
dismissOnBackdrop?: boolean;
}
export function Dialog({
visible,
onClose,
title,
children,
showCloseButton = true,
draggable = false,
maxWidth = 400,
dismissOnBackdrop = true,
}: DialogProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const { width: winW, height: winH } = useWindowDimensions();
// Drag state
const [offset, setOffset] = useState({ x: 0, y: 0 });
const offsetRef = useRef({ x: 0, y: 0 });
const dragStart = useRef({ x: 0, y: 0 });
// Reset position when the dialog opens.
const prevVisible = useRef(visible);
if (visible && !prevVisible.current) {
offsetRef.current = { x: 0, y: 0 };
setOffset({ x: 0, y: 0 });
}
prevVisible.current = visible;
const winWRef = useRef(winW);
const winHRef = useRef(winH);
const maxWidthRef = useRef(maxWidth);
winWRef.current = winW;
winHRef.current = winH;
maxWidthRef.current = maxWidth;
const dialogLayoutRef = useRef({ width: 0, height: 0 });
// Measured height also lives in state: the centering transform reads it at
// render time, and a ref alone never re-renders after onLayout — so tall
// dialogs (e.g. the legal popup) stayed pinned at the fallback height and
// rendered vertically off-center until a drag forced a re-render.
const [dialogH, setDialogH] = useState(0);
const onDialogLayout = React.useCallback((e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
dialogLayoutRef.current = { width, height };
if (height > 0) {
setDialogH(height);
}
}, []);
const clampOffset = (raw: { x: number; y: number }) => {
const w = winWRef.current;
const h = winHRef.current;
const dw = dialogLayoutRef.current.width || Math.min(maxWidthRef.current, w * 0.9);
const dh = dialogLayoutRef.current.height || 200;
const pad = 0.25;
const maxX = (w - dw * pad) / 2;
const maxY = (h - dh * pad) / 2;
return {
x: Math.max(-maxX, Math.min(maxX, raw.x)),
y: Math.max(-maxY, Math.min(maxY, raw.y)),
};
};
const activePanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (evt) => {
if (Platform.OS === "web") {
const webEvent = evt.nativeEvent as unknown as { target?: HTMLElement };
const target = webEvent.target;
if (target && typeof target.closest === "function") {
const interactive = target.closest(
'input, textarea, select, button, a, label, [role="button"], [role="textbox"], [contenteditable="true"]'
);
if (interactive) {
return false;
}
}
}
return true;
},
onMoveShouldSetPanResponder: (_, gesture) =>
Math.abs(gesture.dx) > 8 || Math.abs(gesture.dy) > 8,
onPanResponderGrant: () => {
dragStart.current = { x: offsetRef.current.x, y: offsetRef.current.y };
},
onPanResponderMove: (_, gesture) => {
const next = {
x: dragStart.current.x + gesture.dx,
y: dragStart.current.y + gesture.dy,
};
const clamped = clampOffset(next);
offsetRef.current = clamped;
setOffset(clamped);
},
})
).current;
return (
{
if (!open) {
onClose();
}
}}
>
}
>
{/* Radix accessibility: always render a Title. */}
{title ? null : (
}
>
Dialog
)}
{title || showCloseButton ? (
void }) => {
e.preventDefault?.();
},
}
: {})}
style={
draggable
? ({
cursor: "grab",
userSelect: "none",
WebkitUserDrag: "none",
userDrag: "none",
} as unknown as ViewStyle)
: undefined
}
>
{title ? (
{title}
) : null}
{showCloseButton ? (
) : null}
) : null}
{children}
);
}
// ── Sub-components ──
interface DialogContentProps {
children: React.ReactNode;
className?: string;
}
export function DialogContent({ children, className }: DialogContentProps) {
return {children};
}
interface DialogFooterProps {
children: React.ReactNode;
className?: string;
style?: ViewStyle;
}
export function DialogFooter({ children, className, style }: DialogFooterProps) {
return (
{children}
);
}
interface DialogButtonProps {
onPress: () => void;
variant?: "primary" | "secondary" | "ghost" | "destructive";
children: React.ReactNode;
accessibilityLabel?: string;
accessibilityRole?: "button" | "link" | "none";
}
export function DialogButton({
onPress,
variant = "primary",
children,
accessibilityLabel,
accessibilityRole,
}: DialogButtonProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const base = "rounded-lg px-4 py-2.5 active:opacity-70";
const variantClass = (() => {
switch (variant) {
case "primary":
return isDark ? "bg-white" : "bg-black";
case "secondary":
return cn("border", isDark ? "border-neutral-600" : "border-neutral-300");
case "ghost":
return "";
case "destructive":
return isDark ? "bg-red-900" : "bg-red-500";
}
})();
const textColor = (() => {
switch (variant) {
case "primary":
return isDark ? "#000000" : "#FFFFFF";
case "secondary":
case "ghost":
return isDark ? "#FFFFFF" : "#000000";
case "destructive":
return "#FFFFFF";
}
})();
return (
{children}
);
}
// ── ConfirmDialog ──
//
// Cross-platform replacement for `window.confirm` / `Alert.alert`. The
// browser-native variants look out of place in our UI and split web vs.
// native flows; this gives both platforms one consistent prompt.
interface ConfirmDialogProps {
visible: boolean;
title: string;
message?: string;
/** Confirm-button label. Defaults to "Confirm". */
confirmLabel?: string;
/** Cancel-button label. Defaults to "Cancel". */
cancelLabel?: string;
/** Render the confirm button in the destructive (red) style. */
destructive?: boolean;
/** Hide the cancel button — useful for one-button info prompts. */
hideCancel?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
visible,
title,
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
destructive = false,
hideCancel = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
);
}