2026-10-09 16:00:41 +00:00
|
|
|
import { Button } from "@repo/shadcn-ui/components/button";
|
2026-10-03 10:36:03 +00:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogClose,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
2026-10-09 16:00:41 +00:00
|
|
|
} from "@repo/shadcn-ui/components/dialog";
|
2026-10-21 17:31:09 +00:00
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
2026-10-25 16:47:54 +00:00
|
|
|
import { createContext, useContext } from "react";
|
|
|
|
|
|
|
|
|
|
import { useDialogHelpLayout } from "../hooks/use-dialog-help-layout";
|
|
|
|
|
import { DialogHelpContext, DialogHelpLabelsContext } from "../lib/dialog-help-context";
|
|
|
|
|
import { XIcon } from "../lib/icons";
|
2026-10-03 10:36:03 +00:00
|
|
|
|
2026-10-16 09:41:38 +00:00
|
|
|
type FormDialogLayout = "content" | "stable" | "picker";
|
|
|
|
|
|
|
|
|
|
const FormLayoutContext = createContext<FormDialogLayout>("content");
|
2026-10-15 13:58:01 +00:00
|
|
|
|
2026-10-03 16:23:38 +00:00
|
|
|
const FormCancelContext = createContext<string | undefined>(undefined);
|
|
|
|
|
|
2026-10-03 10:36:03 +00:00
|
|
|
export function FormDialog({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
title,
|
|
|
|
|
cancelLabel,
|
|
|
|
|
children,
|
2026-10-03 16:23:38 +00:00
|
|
|
wide = false,
|
2026-10-09 18:30:41 +00:00
|
|
|
size,
|
2026-10-03 16:23:38 +00:00
|
|
|
formActions = false,
|
2026-10-15 13:58:01 +00:00
|
|
|
layout = "content",
|
2026-10-21 17:31:09 +00:00
|
|
|
finalFocus,
|
2026-10-24 23:19:17 +00:00
|
|
|
initialFocus,
|
2026-10-03 10:36:03 +00:00
|
|
|
}: {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
2026-10-10 16:49:11 +00:00
|
|
|
title: ReactNode;
|
2026-10-03 10:36:03 +00:00
|
|
|
cancelLabel: string;
|
|
|
|
|
children: ReactNode;
|
2026-10-03 16:23:38 +00:00
|
|
|
wide?: boolean;
|
2026-10-09 18:30:41 +00:00
|
|
|
size?: "small" | "medium" | "large";
|
2026-10-03 16:23:38 +00:00
|
|
|
formActions?: boolean;
|
2026-10-16 09:41:38 +00:00
|
|
|
layout?: FormDialogLayout;
|
2026-10-21 17:31:09 +00:00
|
|
|
finalFocus?: ComponentProps<typeof DialogContent>["finalFocus"];
|
2026-10-24 23:19:17 +00:00
|
|
|
initialFocus?: "title" | ComponentProps<typeof DialogContent>["initialFocus"];
|
2026-10-03 10:36:03 +00:00
|
|
|
}) {
|
2026-10-25 16:47:54 +00:00
|
|
|
const help = useDialogHelpLayout(open, onOpenChange);
|
|
|
|
|
const {
|
|
|
|
|
popupRef,
|
|
|
|
|
triggerRef,
|
|
|
|
|
setPopupRef,
|
|
|
|
|
actionsHost,
|
|
|
|
|
setActionsHost,
|
|
|
|
|
contentHost,
|
|
|
|
|
setContentHost,
|
|
|
|
|
setPresentation,
|
|
|
|
|
helpOpen,
|
|
|
|
|
helpMode,
|
|
|
|
|
helpView,
|
|
|
|
|
} = help;
|
|
|
|
|
const labels = useContext(DialogHelpLabelsContext);
|
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
|
|
|
|
2026-10-16 09:41:38 +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)]";
|
2026-10-15 13:58:01 +00:00
|
|
|
|
2026-10-03 10:36:03 +00:00
|
|
|
return (
|
2026-10-25 16:47:54 +00:00
|
|
|
<Dialog
|
|
|
|
|
onOpenChange={help.changeOpen}
|
|
|
|
|
onOpenChangeComplete={help.completeOpenChange}
|
|
|
|
|
open={open}
|
|
|
|
|
>
|
2026-10-03 10:36:03 +00:00
|
|
|
<DialogContent
|
2026-10-25 16:47:54 +00:00
|
|
|
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 motion-reduce:animate-none! motion-reduce:transition-none! ${widths[width]} ${height}`}
|
2026-10-21 17:31:09 +00:00
|
|
|
finalFocus={finalFocus}
|
2026-10-16 09:41:38 +00:00
|
|
|
initialFocus={
|
2026-10-24 23:19:17 +00:00
|
|
|
initialFocus === "title"
|
2026-10-16 09:41:38 +00:00
|
|
|
? () =>
|
2026-10-24 23:19:17 +00:00
|
|
|
popupRef.current?.querySelector<HTMLElement>('[data-slot="dialog-title"]') ?? true
|
|
|
|
|
: (initialFocus ??
|
|
|
|
|
(layout === "picker"
|
|
|
|
|
? () =>
|
|
|
|
|
popupRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
|
|
|
|
|
true
|
|
|
|
|
: undefined))
|
2026-10-16 09:41:38 +00:00
|
|
|
}
|
2026-10-25 16:47:54 +00:00
|
|
|
ref={setPopupRef}
|
|
|
|
|
showCloseButton={false}
|
2026-10-03 10:36:03 +00:00
|
|
|
>
|
2026-10-25 16:47:54 +00:00
|
|
|
<DialogHelpContext.Provider
|
|
|
|
|
value={{
|
|
|
|
|
actionsHost,
|
|
|
|
|
contentHost,
|
|
|
|
|
mode: helpMode,
|
|
|
|
|
open: helpOpen,
|
|
|
|
|
setPresentation,
|
|
|
|
|
triggerRef,
|
|
|
|
|
}}
|
2026-10-09 18:30:41 +00:00
|
|
|
>
|
2026-10-25 16:47:54 +00:00
|
|
|
<DialogHeader className="relative shrink-0 flex-row items-start justify-between gap-3 border-b px-6 py-4">
|
|
|
|
|
<div className="min-w-0 flex-1 py-1">
|
|
|
|
|
<DialogTitle
|
|
|
|
|
className="break-words text-lg font-bold"
|
|
|
|
|
tabIndex={initialFocus === "title" ? -1 : undefined}
|
|
|
|
|
>
|
|
|
|
|
{title}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
|
|
|
<div className="flex empty:hidden" ref={setActionsHost} />
|
|
|
|
|
<DialogClose
|
|
|
|
|
aria-label={labels.closeDialog}
|
|
|
|
|
render={<Button size="icon-sm" type="button" variant="ghost" />}
|
|
|
|
|
>
|
|
|
|
|
<XIcon aria-hidden="true" />
|
|
|
|
|
</DialogClose>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div
|
|
|
|
|
className="relative flex min-h-0 flex-1 overflow-hidden"
|
|
|
|
|
data-help-mode={helpOpen ? helpMode : undefined}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden={helpView}
|
|
|
|
|
className={`@container flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-6 pb-5 ${layout === "picker" ? "pt-3" : "pt-5"} [&:has([data-slot=form-body])]:overflow-hidden [&:has([data-slot=form-body])]:pb-0`}
|
|
|
|
|
data-slot="form-dialog-content"
|
|
|
|
|
inert={helpView}
|
|
|
|
|
style={helpView ? { visibility: "hidden" } : undefined}
|
|
|
|
|
>
|
|
|
|
|
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
|
|
|
|
|
<FormLayoutContext.Provider value={layout}>{children}</FormLayoutContext.Provider>
|
|
|
|
|
</FormCancelContext.Provider>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={help.contentHostClassName} ref={setContentHost} />
|
|
|
|
|
</div>
|
|
|
|
|
{!(formActions || helpView) && (
|
|
|
|
|
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
|
|
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
|
|
|
{cancelLabel}
|
|
|
|
|
</DialogClose>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
)}
|
|
|
|
|
</DialogHelpContext.Provider>
|
2026-10-03 10:36:03 +00:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-03 16:23:38 +00:00
|
|
|
|
2026-10-15 13:58:01 +00:00
|
|
|
export function FormActions({
|
|
|
|
|
children,
|
|
|
|
|
leadingActions,
|
2026-10-15 14:14:26 +00:00
|
|
|
cancelDisabled = false,
|
2026-10-15 13:58:01 +00:00
|
|
|
}: {
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
leadingActions?: ReactNode;
|
2026-10-15 14:14:26 +00:00
|
|
|
cancelDisabled?: boolean;
|
2026-10-15 13:58:01 +00:00
|
|
|
}) {
|
2026-10-03 16:23:38 +00:00
|
|
|
const cancelLabel = useContext(FormCancelContext);
|
2026-10-22 07:35:02 +00:00
|
|
|
const layout = useContext(FormLayoutContext);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-03 16:23:38 +00:00
|
|
|
return (
|
2026-10-22 07:35:02 +00:00
|
|
|
<DialogFooter
|
|
|
|
|
className={`shrink-0 border-t bg-popover ${layout === "picker" ? "flex-row flex-wrap gap-2 py-2 [&>button]:flex-1 sm:[&>button]:flex-none" : "py-4"}`}
|
|
|
|
|
>
|
|
|
|
|
{leadingActions && (
|
|
|
|
|
<div className={`flex sm:mr-auto ${layout === "picker" ? "w-full sm:w-auto" : ""}`}>
|
|
|
|
|
{leadingActions}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-10-03 16:23:38 +00:00
|
|
|
{cancelLabel && (
|
2026-10-15 14:14:26 +00:00
|
|
|
<DialogClose render={<Button disabled={cancelDisabled} type="button" variant="outline" />}>
|
|
|
|
|
{cancelLabel}
|
|
|
|
|
</DialogClose>
|
2026-10-03 16:23:38 +00:00
|
|
|
)}
|
2026-10-09 16:00:41 +00:00
|
|
|
{children}
|
2026-10-03 16:23:38 +00:00
|
|
|
</DialogFooter>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-09 18:30:41 +00:00
|
|
|
|
2026-10-09 19:44:42 +00:00
|
|
|
/** El cuerpo reserva espacio simétrico para que el scroll no recorte el anillo de foco. */
|
2026-10-16 10:28:39 +00:00
|
|
|
export function FormBody({
|
|
|
|
|
children,
|
|
|
|
|
contentLayout = "stack",
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
contentLayout?: "stack" | "fill";
|
|
|
|
|
}) {
|
2026-10-15 13:58:01 +00:00
|
|
|
const layout = useContext(FormLayoutContext);
|
2026-10-16 10:28:39 +00:00
|
|
|
const fills = layout === "picker" || contentLayout === "fill";
|
2026-10-15 13:58:01 +00:00
|
|
|
|
2026-10-09 18:30:41 +00:00
|
|
|
return (
|
2026-10-15 13:58:01 +00:00
|
|
|
<div
|
2026-10-22 07:35:02 +00:00
|
|
|
className={`@container min-h-0 flex-1 px-1 ${layout === "picker" ? "flex flex-col gap-2 overflow-y-auto overscroll-contain pt-1 pb-2 [&>p]:shrink-0" : `overflow-y-auto pt-1 pb-5 ${fills ? "flex flex-col gap-5 [&>*]:shrink-0" : "space-y-5"}`} ${layout === "content" ? "" : "[scrollbar-gutter:stable]"}`}
|
2026-10-15 13:58:01 +00:00
|
|
|
data-slot="form-body"
|
|
|
|
|
>
|
2026-10-09 18:30:41 +00:00
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|