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

@@ -52,6 +52,7 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
style={style} style={style}
onClick={handleClick} onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left" className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
layoutId={`card-${card.id}`}
variants={fadeSlideUp} variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"} initial={prefersReducedMotion ? false : "hidden"}
animate="visible" animate="visible"

View File

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