FactuGES_ERP/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx

141 lines
5.3 KiB
TypeScript
Raw Normal View History

2025-10-18 23:05:02 +00:00
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@repo/shadcn-ui/components";
// hooks/use-pinned-preview-sheet.ts
import * as React from "react";
type UsePinnedPreviewSheetOptions<T> = {
/** Persiste el pin en localStorage (clave). Si omites, no persiste. */
persistKey?: string;
/** Anchura del panel anclado (Tailwind). */
pinnedWidthClass?: string; // p.ej. "w-[420px]"
/** Título del Sheet (no anclado). */
title?: string | ((item: T | null) => string);
};
export type PinnedPreviewSheetAPI<T> = {
/** Estado */
isOpen: boolean;
isPinned: boolean;
item: T | null;
/** Acciones */
open: (item: T) => void;
close: () => void;
togglePin: () => void;
setItem: (item: T | null) => void;
/** Renderizado: coloca este nodo cerca del listado (al final de la página/feature) */
PreviewContainer: React.FC<{
/** Render del cuerpo del preview */
children: (item: T) => React.ReactNode;
/** Cabecera opcional (si quieres sustituir el SheetHeader) */
header?: React.ReactNode;
}>;
};
export function usePinnedPreviewSheet<T = unknown>(
opts?: UsePinnedPreviewSheetOptions<T>
): PinnedPreviewSheetAPI<T> {
const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {};
const [isOpen, setOpen] = React.useState(false);
const [item, setItem] = React.useState<T | null>(null);
const [isPinned, setPinned] = React.useState<boolean>(() => {
if (!persistKey) return false;
try {
return localStorage.getItem(persistKey) === "1";
} catch {
return false;
}
});
const open = React.useCallback((next: T) => {
setItem(next);
setOpen(true);
}, []);
const close = React.useCallback(() => {
if (isPinned) return; // Anclado: no cerrar
setOpen(false);
}, [isPinned]);
const togglePin = React.useCallback(() => {
setPinned((p) => {
const next = !p;
if (persistKey) {
try {
localStorage.setItem(persistKey, next ? "1" : "0");
} catch { }
}
// Si se fija el pin y hay item, abre “estático”; si se desancla, mostramos Sheet si había abierto
if (!next && item) setOpen(true);
return next;
});
}, [persistKey, item]);
const HeaderTitle = React.useMemo(() => {
if (!item) return "";
if (typeof title === "function") return title(item);
return title ?? "";
}, [item, title]);
const PreviewContainer: PinnedPreviewSheetAPI<T>["PreviewContainer"] = React.useCallback(
({ children, header }) => {
if (!item) return null;
// Modo anclado: aside fijo sin overlay, no bloquea scroll, accesible.
if (isPinned) {
return (
<aside
aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${pinnedWidthClass} bg-background border-l z-30 flex flex-col`}
>
<div className="flex items-center justify-between p-3 border-b">
{header ?? (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{HeaderTitle}</span>
<span className="text-xs text-muted-foreground">(anclado)</span>
</div>
)}
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Desanclar panel"
>
Desanclar
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">{children(item)}</div>
</aside>
);
}
// Modo Sheet (deslizable desde la derecha)
return (
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={pinnedWidthClass}>
{header ?? (
<SheetHeader>
<SheetTitle className="text-base">{HeaderTitle}</SheetTitle>
</SheetHeader>
)}
<div className="mt-3">{children(item)}</div>
<div className="mt-4">
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Anclar panel"
>
Anclar
</button>
</div>
</SheetContent>
</Sheet>
);
},
[HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin]
);
return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer };
}