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 ( {message ? ( {message} ) : null} {!hideCancel && ( {cancelLabel} )} {confirmLabel} ); }