Files
app/src/admin/components/FormModal.tsx
BOHA 55fa4045ad feat(modals): FormModal gains submitDisabled + subtitle props
submitDisabled greys out the primary (so callers can gate it on validity
instead of a clickable-but-inert button); subtitle renders a muted line under
the title in the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00

202 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
type ReactNode,
type FormEvent,
Children,
isValidElement,
cloneElement,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
import useModalLock from "../hooks/useModalLock";
import useReducedMotion from "../hooks/useReducedMotion";
export interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string;
/** Optional muted line shown under the title in the header. */
subtitle?: ReactNode;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: boolean;
/** Disable the primary (submit) button — e.g. until the form is valid.
* Keeps the button visibly greyed out instead of clickable-but-inert. */
submitDisabled?: boolean;
hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true
closeOnEsc?: boolean; // default true
/** Slot for footer-left actions (e.g. delete). Rendered to the LEFT of
* the standard cancel/submit group in the modal footer. */
footerLeft?: ReactNode;
/** Hide the close × in the header. Default false. */
hideCloseButton?: boolean;
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
subtitle,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = false,
submitDisabled = false,
hideFooter = false,
size = "md",
closeOnBackdrop = true,
closeOnEsc = true,
footerLeft,
hideCloseButton = false,
}: FormModalProps) {
useModalLock(isOpen);
const reducedMotion = useReducedMotion();
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeOnEsc, loading, onClose]);
const handleBackdropClick = () => {
if (closeOnBackdrop && !loading) onClose();
};
const handlePrimaryClick = (e: FormEvent) => {
e.preventDefault();
if (onSubmit) onSubmit();
};
const titleId = "form-modal-title";
const modalClassName =
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
// Snappier ease curve for modal entry — matches the "industrial/refined"
// tone. Roughly equivalent to the cubic-bezier(0.16, 1, 0.3, 1) used by
// a lot of design systems for "expo out" feel.
const ease = [0.16, 1, 0.3, 1] as const;
const duration = reducedMotion ? 0 : 0.22;
// Stagger the body children slightly for a "drawing-in" reveal. We cap
// the cascade so long forms don't drag on forever.
const childrenArray = Children.toArray(children).filter(isValidElement);
const stagger = reducedMotion ? 0 : Math.min(childrenArray.length, 5) * 0.03;
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<div className="admin-modal-backdrop" onClick={handleBackdropClick} />
<motion.div
className={modalClassName}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration, ease }}
>
<div className="admin-modal-header">
<div className="admin-modal-heading">
<h2 id={titleId} className="admin-modal-title">
{title}
</h2>
{subtitle && <p className="admin-modal-subtitle">{subtitle}</p>}
</div>
{!hideCloseButton && (
<button
type="button"
className="admin-modal-close"
onClick={() => !loading && onClose()}
aria-label="Zavřít"
title="Zavřít (Esc)"
disabled={loading}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
<div className="admin-modal-body">
{reducedMotion || stagger === 0
? children
: Children.map(children, (child, i) => {
if (!isValidElement(child)) return child;
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.2,
delay: 0.04 + i * 0.03,
ease: "easeOut",
}}
>
{cloneElement(child)}
</motion.div>
);
})}
</div>
{!hideFooter && (
<div className="admin-modal-footer">
<div className="admin-modal-footer-left">{footerLeft}</div>
<div className="admin-modal-footer-right">
<button
type="button"
onClick={() => !loading && onClose()}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelLabel}
</button>
{onSubmit && (
<button
type="button"
onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary"
disabled={loading || submitDisabled}
>
{loading ? "Zpracování..." : submitLabel}
</button>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}