feat: shared layout animation — card expands into detail modal
This commit is contained in:
@@ -52,6 +52,7 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
|
||||
layoutId={`card-${card.id}`}
|
||||
variants={fadeSlideUp}
|
||||
initial={prefersReducedMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
|
||||
@@ -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,34 +24,65 @@ 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 && (
|
||||
<AnimatePresence>
|
||||
{open && card && cardId && (
|
||||
<>
|
||||
{/* Hidden accessible description */}
|
||||
<DialogDescription className="sr-only">
|
||||
Card detail editor
|
||||
</DialogDescription>
|
||||
{/* 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">
|
||||
{/* 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%) */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]">
|
||||
<DialogHeader className="mb-4">
|
||||
<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}
|
||||
/>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor cardId={cardId} value={card.description} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Vertical separator */}
|
||||
<Separator orientation="vertical" className="hidden sm:block" />
|
||||
|
||||
{/* 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">
|
||||
<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 />
|
||||
@@ -84,15 +110,29 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
cardId={cardId}
|
||||
attachments={card.attachments}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user