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

128 lines
4.5 KiB
TypeScript

import { Sheet, SheetContent } from "@repo/shadcn-ui/components";
// hooks/use-pinned-preview-sheet.ts
import * as React from "react";
import { createPortal } from 'react-dom';
type UsePinnedPreviewSheetOptions<T> = {
persistKey?: string; // clave localStorage para “pin”
widthClass?: string; // ancho del panel: p. ej. "w-[500px]"
onOpenChange?: (open: boolean) => void;
title?: string | ((item: T | null) => string); // Título del Sheet (no anclado)
};
export type PinnedPreviewSheet<T> = {
/** Estado */
isOpen: boolean;
isPinned: boolean;
item: T | null;
open: (item: T) => void;
close: () => void;
togglePin: () => void;
/** Añade margen al contenedor de la lista cuando está anclado */
listRightMarginClass: string;
/** Renderiza el panel (Sheet o aside) */
Preview: React.FC<{
children: (ctx: {
item: T;
isPinned: boolean;
close: () => void;
togglePin: () => void;
}) => React.ReactNode
}>;
};
export function usePinnedPreviewSheet<T = unknown>({
persistKey = "preview-pin",
widthClass = "w-[500px]",
onOpenChange,
title,
}: UsePinnedPreviewSheetOptions<T> = {}): PinnedPreviewSheet<T> {
const [isOpen, setOpen] = React.useState(false);
const [item, setItem] = React.useState<T | null>(null);
const [isPinned, setPinned] = React.useState<boolean>(() => {
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// recordar/restaurar foco para accesibilidad
const lastFocused = React.useRef<HTMLElement | null>(null);
const rememberFocus = () => { lastFocused.current = document.activeElement as HTMLElement | null; };
const restoreFocus = () => { lastFocused.current?.focus?.(); };
const open = React.useCallback((next: T) => {
rememberFocus();
setItem(next);
setOpen(true);
onOpenChange?.(true);
}, [onOpenChange]);
const close = React.useCallback(() => {
if (isPinned) return;
setOpen(false);
onOpenChange?.(false);
setTimeout(restoreFocus, 0);
}, [isPinned, onOpenChange]);
const togglePin = React.useCallback(() => {
setPinned((p) => {
const n = !p;
try { localStorage.setItem(persistKey, n ? "1" : "0"); } catch { }
return n;
});
}, [persistKey]);
// Bloquea scroll solo en modo Sheet
React.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 en modo Sheet
React.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 listRightMarginClass = isPinned ? "mr-[500px]" : ""; // ajusta si cambias widthClass
const HeaderTitle = React.useMemo(() => {
if (!item) return "";
if (typeof title === "function") return title(item);
return title ?? "";
}, [item, title]);
const Preview: PinnedPreviewSheet<T>["Preview"] = React.useCallback(({ children }) => {
if (!item) return null;
// Panel anclado: aside estático sin overlay
if (isPinned) {
return createPortal(
<aside
aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${widthClass} bg-background border-l z-40`}
>
{children({ item, isPinned: true, close, togglePin })}
</aside>,
document.body
);
}
// Panel no anclado: Sheet de shadcn/ui (con overlay y accesibilidad)
return createPortal(
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={`${widthClass} p-0`}>
{children({ item, isPinned: false, close, togglePin })}
</SheetContent>
</Sheet>,
document.body
);
}, [item, isPinned, isOpen, widthClass, close, togglePin]);
return { isOpen, isPinned, item, open, close, togglePin, listRightMarginClass, Preview };
}