FactuGES_ERP/modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx
2025-10-19 21:04:16 +02:00

104 lines
3.8 KiB
TypeScript

import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from "react-dom";
export type UseInvoicePreviewOptions<T> = {
persistKey?: string; // clave para guardar el pin en localStorage
pinnedWidthClass?: string; // ancho al anclar (Tailwind), p.ej. "w-[500px]"
onOpenChange?: (open: boolean) => void; // callback opcional
};
export type InvoicePreviewHook<T> = {
isOpen: boolean;
isPinned: boolean;
item: T | null;
open: (item: T) => void;
close: () => void;
togglePin: () => void;
/** Añade margen derecho al listado si está anclado */
containerClassName: string;
/** Renderiza el preview en un portal (body). Debes pasar tu Card como children render-prop */
PreviewPortal: FC<{
children: (p: {
item: T;
isOpen: boolean;
isPinned: boolean;
onClose: () => void;
onTogglePin: () => void;
}) => ReactNode
}>;
};
export function useInvoicePreview<T = unknown>(
opts?: UseInvoicePreviewOptions<T>
): InvoicePreviewHook<T> {
const { persistKey = "invoice-preview-pin", pinnedWidthClass = "w-[500px]", onOpenChange } = opts ?? {};
const [isOpen, setOpen] = useState(false);
const [item, setItem] = useState<T | null>(null);
const [isPinned, setPinned] = useState<boolean>(() => {
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// Guardar y restaurar foco al cerrar (mejor accesibilidad)
const lastFocusedRef = useRef<HTMLElement | null>(null);
const rememberFocus = () => { lastFocusedRef.current = (document.activeElement as HTMLElement) ?? null; };
const restoreFocus = () => { lastFocusedRef.current?.focus?.(); };
const open = useCallback((next: T) => {
rememberFocus();
setItem(next);
setOpen(true);
onOpenChange?.(true);
}, [onOpenChange]);
const close = useCallback(() => {
if (isPinned) return; // anclado no se cierra
setOpen(false);
onOpenChange?.(false);
setTimeout(restoreFocus, 0);
}, [isPinned, onOpenChange]);
const togglePin = useCallback(() => {
setPinned((prev) => {
const next = !prev;
try { localStorage.setItem(persistKey, next ? "1" : "0"); } catch { }
return next;
});
}, [persistKey]);
// Bloqueo de scroll cuando está abierto y NO anclado
useEffect(() => {
if (isOpen && !isPinned) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [isOpen, isPinned]);
// Cerrar con ESC (solo si no está anclado)
useEffect(() => {
if (!isOpen || isPinned) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isOpen, isPinned, close]);
const containerClassName = isPinned ? `mr-[--preview-width] [--preview-width:theme(spacing.0)] ${pinnedWidthClass ? "" : ""}` : "";
// Nota: preferimos aplicar el margen directamente en el layout (ver uso abajo)
const PreviewPortal: InvoicePreviewHook<T>["PreviewPortal"] = useCallback(({ children }) => {
if (!item) return null;
const node = children({
item,
isOpen,
isPinned,
onClose: close,
onTogglePin: togglePin,
});
return createPortal(node as ReactNode, document.body);
}, [item, isOpen, isPinned, close, togglePin]);
return { isOpen, isPinned, item, open, close, togglePin, containerClassName, PreviewPortal };
}