feat: shared layout animation — card expands into detail modal

This commit is contained in:
Your Name
2026-02-15 21:00:29 +02:00
parent 03a22d4e6a
commit 11ad213a1d
2 changed files with 102 additions and 62 deletions

View File

@@ -1,11 +1,5 @@
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { AnimatePresence, motion } from "framer-motion";
import { Separator } from "@/components/ui/separator";
import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
@@ -13,6 +7,7 @@ import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
import { LabelPicker } from "@/components/card-detail/LabelPicker";
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps {
cardId: string | null;
@@ -29,70 +24,115 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const open = cardId != null && card != null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-3xl gap-0 overflow-hidden p-0">
{card && cardId && (
<>
{/* Hidden accessible description */}
<DialogDescription className="sr-only">
Card detail editor
</DialogDescription>
<AnimatePresence>
{open && card && cardId && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
/>
<div className="flex max-h-[80vh] flex-col sm:flex-row">
{/* Left panel: Title + Markdown (60%) */}
<div className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]">
<DialogHeader className="mb-4">
<InlineTitle
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<motion.div
layoutId={`card-${cardId}`}
className="relative w-full max-w-3xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle}
onClick={(e) => e.stopPropagation()}
>
{/* Close on Escape */}
<EscapeHandler onClose={onClose} />
{/* Hidden accessible description */}
<span className="sr-only">Card detail editor</span>
<motion.div
className="flex max-h-[80vh] flex-col sm:flex-row"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Left panel: Title + Markdown (60%) */}
<motion.div
className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<div className="mb-4">
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
/>
</div>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Vertical separator */}
<Separator orientation="vertical" className="hidden sm:block" />
{/* Right sidebar (40%) */}
<motion.div
className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />
<LabelPicker
cardId={cardId}
title={card.title}
updateCard={updateCard}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
</DialogHeader>
<MarkdownEditor cardId={cardId} value={card.description} />
</div>
<Separator />
{/* Vertical separator */}
<Separator orientation="vertical" className="hidden sm:block" />
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
{/* Right sidebar (40%) */}
<div className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0">
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />
<Separator />
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
<Separator />
<Separator />
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
<Separator />
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
<Separator />
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
</motion.div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}
/* ---------- Escape key handler ---------- */
function EscapeHandler({ onClose }: { onClose: () => void }) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return null;
}
/* ---------- Inline editable title ---------- */
interface InlineTitleProps {
@@ -106,7 +146,6 @@ function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) {
const [draft, setDraft] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
// Sync when title changes externally
useEffect(() => {
setDraft(title);
}, [title]);
@@ -152,12 +191,12 @@ function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) {
}
return (
<DialogTitle
<h2
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</DialogTitle>
</h2>
);
}