metis-desk/packages/rdx-ui/src/components/form-dialog.tsx

128 lines
3.9 KiB
TypeScript
Raw Normal View History

import { Button } from "@repo/shadcn-ui/components/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components/dialog";
import { type ReactNode, createContext, useContext, useRef } from "react";
type FormDialogLayout = "content" | "stable" | "picker";
const FormLayoutContext = createContext<FormDialogLayout>("content");
const FormCancelContext = createContext<string | undefined>(undefined);
export function FormDialog({
open,
onOpenChange,
title,
cancelLabel,
children,
wide = false,
2026-10-09 18:30:41 +00:00
size,
formActions = false,
layout = "content",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
cancelLabel: string;
children: ReactNode;
wide?: boolean;
2026-10-09 18:30:41 +00:00
size?: "small" | "medium" | "large";
formActions?: boolean;
layout?: FormDialogLayout;
}) {
const popupRef = useRef<HTMLDivElement>(null);
2026-10-09 18:30:41 +00:00
const width = size ?? (wide ? "large" : "small");
const widths = {
small: "sm:max-w-xl",
medium: "sm:max-w-3xl",
large: "sm:w-[90vw] sm:max-w-[1200px]",
};
2026-10-10 10:01:58 +00:00
// La altura de los diálogos dinámicos depende de la ventana, nunca de sus resultados o pasos.
const height = layout === "content" ? "" : "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]";
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`}
initialFocus={
layout === "picker"
? () =>
popupRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
true
: undefined
}
ref={popupRef}
showCloseButton={true}
>
2026-10-09 18:30:41 +00:00
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
<DialogTitle className="text-lg font-bold">{title}</DialogTitle>
</DialogHeader>
2026-10-09 18:30:41 +00:00
<div
className="@container flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pt-5 pb-5 [&:has([data-slot=form-body])]:overflow-hidden [&:has([data-slot=form-body])]:pb-0"
data-slot="form-dialog-content"
>
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
<FormLayoutContext.Provider value={layout}>{children}</FormLayoutContext.Provider>
2026-10-09 18:30:41 +00:00
</FormCancelContext.Provider>
</div>
{!formActions && (
2026-10-09 18:30:41 +00:00
<DialogFooter className="shrink-0 border-t px-6 py-4">
<DialogClose render={<Button type="button" variant="outline" />}>
{cancelLabel}
</DialogClose>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
export function FormActions({
children,
leadingActions,
cancelDisabled = false,
}: {
children: ReactNode;
leadingActions?: ReactNode;
cancelDisabled?: boolean;
}) {
const cancelLabel = useContext(FormCancelContext);
2026-10-10 10:01:58 +00:00
return (
2026-10-09 18:30:41 +00:00
<DialogFooter className="shrink-0 border-t bg-popover py-4">
{leadingActions && <div className="flex sm:mr-auto">{leadingActions}</div>}
{cancelLabel && (
<DialogClose render={<Button disabled={cancelDisabled} type="button" variant="outline" />}>
{cancelLabel}
</DialogClose>
)}
{children}
</DialogFooter>
);
}
2026-10-09 18:30:41 +00:00
/** El cuerpo reserva espacio simétrico para que el scroll no recorte el anillo de foco. */
2026-10-09 18:30:41 +00:00
export function FormBody({ children }: { children: ReactNode }) {
const layout = useContext(FormLayoutContext);
2026-10-09 18:30:41 +00:00
return (
<div
className={
layout === "picker"
? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
: `@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5 ${layout === "stable" ? "[scrollbar-gutter:stable]" : ""}`
}
data-slot="form-body"
>
2026-10-09 18:30:41 +00:00
{children}
</div>
);
}