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}
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user