fix(modals): commit FormModal footerLeft/hideCloseButton enhancements that committed code already depends on
PlanCellModal (and the modal system) reference FormModal's footerLeft slot and hideCloseButton, but those props lived only in an uncommitted working-tree change — so committed code did not typecheck (TS2322) and a build-from-committed (e.g. the v1.9.0 release) silently dropped the footer-left delete button in the plan entry/override edit modal. Commits the enhancements + their CSS so committed == working. NOTE: production v1.9.0 is affected (plan edit-modal delete button missing); needs a patch release. Flagged in the overnight audit report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +249,10 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-modal-title {
|
.admin-modal-title {
|
||||||
@@ -255,6 +261,38 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-modal-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) ease,
|
||||||
|
color var(--motion-fast) ease,
|
||||||
|
border-color var(--motion-fast) ease,
|
||||||
|
transform var(--motion-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-close:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-close:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-modal-body {
|
.admin-modal-body {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -269,10 +307,45 @@
|
|||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-modal-footer-left,
|
||||||
|
.admin-modal-footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-modal-footer-right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.admin-modal-footer-left,
|
||||||
|
.admin-modal-footer-right {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.admin-modal-footer-left .admin-btn,
|
||||||
|
.admin-modal-footer-right .admin-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.admin-modal-footer-left {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
.admin-modal-footer-right {
|
||||||
|
order: 1;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-modal-overlay {
|
.admin-modal-overlay {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import useReducedMotion from "../hooks/useReducedMotion";
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -91,6 +92,7 @@ export default function ConfirmModal({
|
|||||||
confirmVariant,
|
confirmVariant,
|
||||||
loading,
|
loading,
|
||||||
}: ConfirmModalProps) {
|
}: ConfirmModalProps) {
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
@@ -104,6 +106,9 @@ export default function ConfirmModal({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isOpen, loading, onClose]);
|
}, [isOpen, loading, onClose]);
|
||||||
|
|
||||||
|
const duration = reducedMotion ? 0 : 0.22;
|
||||||
|
const ease = [0.16, 1, 0.3, 1] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -112,7 +117,7 @@ export default function ConfirmModal({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -120,10 +125,10 @@ export default function ConfirmModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="confirm-modal-title"
|
aria-labelledby="confirm-modal-title"
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration, ease }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-body admin-confirm-content">
|
<div className="admin-modal-body admin-confirm-content">
|
||||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useEffect, type ReactNode, type FormEvent } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
type FormEvent,
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
cloneElement,
|
||||||
|
} from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
|
import useReducedMotion from "../hooks/useReducedMotion";
|
||||||
|
|
||||||
export interface FormModalProps {
|
export interface FormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,6 +23,11 @@ export interface FormModalProps {
|
|||||||
size?: "sm" | "md" | "lg"; // optional
|
size?: "sm" | "md" | "lg"; // optional
|
||||||
closeOnBackdrop?: boolean; // default true
|
closeOnBackdrop?: boolean; // default true
|
||||||
closeOnEsc?: 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({
|
export default function FormModal({
|
||||||
@@ -30,8 +43,11 @@ export default function FormModal({
|
|||||||
size = "md",
|
size = "md",
|
||||||
closeOnBackdrop = true,
|
closeOnBackdrop = true,
|
||||||
closeOnEsc = true,
|
closeOnEsc = true,
|
||||||
|
footerLeft,
|
||||||
|
hideCloseButton = false,
|
||||||
}: FormModalProps) {
|
}: FormModalProps) {
|
||||||
useModalLock(isOpen);
|
useModalLock(isOpen);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !closeOnEsc) return;
|
if (!isOpen || !closeOnEsc) return;
|
||||||
@@ -59,6 +75,16 @@ export default function FormModal({
|
|||||||
const modalClassName =
|
const modalClassName =
|
||||||
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -67,7 +93,7 @@ export default function FormModal({
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: reducedMotion ? 0 : 0.18 }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-backdrop" onClick={handleBackdropClick} />
|
<div className="admin-modal-backdrop" onClick={handleBackdropClick} />
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -75,21 +101,67 @@ export default function FormModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.96, y: 16 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.97, y: 8 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration, ease }}
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
<div className="admin-modal-header">
|
||||||
<h2 id={titleId} className="admin-modal-title">
|
<h2 id={titleId} className="admin-modal-title">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
{!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>
|
||||||
|
|
||||||
<div className="admin-modal-body">{children}</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 && (
|
{!hideFooter && (
|
||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
|
<div className="admin-modal-footer-left">{footerLeft}</div>
|
||||||
|
<div className="admin-modal-footer-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !loading && onClose()}
|
onClick={() => !loading && onClose()}
|
||||||
@@ -109,6 +181,7 @@ export default function FormModal({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user