141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|