104 lines
3.8 KiB
TypeScript
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 };
|
|
}
|