Files
openpylon/src/components/card-detail/CardDetailModal.tsx
Your Name 36dbcf6f69 fix card click delay when reduced motion is active
Skip layoutId and use instant transitions on the card detail modal
when reduced motion is on. The shared layout animation kept the z-50
overlay in the DOM during its exit spring, blocking all card clicks
until it settled.
2026-02-16 18:51:35 +02:00

333 lines
11 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
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 { PriorityPicker } from "@/components/card-detail/PriorityPicker";
import { CommentsSection } from "@/components/card-detail/CommentsSection";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps {
cardId: string | null;
onClose: () => void;
}
export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const card = useBoardStore((s) =>
cardId ? s.board?.cards[cardId] ?? null : null
);
const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
const updateCard = useBoardStore((s) => s.updateCard);
const open = cardId != null && card != null;
const prefersReducedMotion = useReducedMotion();
const instant = { duration: 0 };
return (
<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={prefersReducedMotion ? instant : { duration: 0.2 }}
onClick={onClose}
/>
{/* Modal */}
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
layoutId={prefersReducedMotion ? undefined : `card-${cardId}`}
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
initial={prefersReducedMotion ? { opacity: 0 } : undefined}
animate={prefersReducedMotion ? { opacity: 1 } : undefined}
exit={prefersReducedMotion ? { opacity: 0 } : undefined}
transition={prefersReducedMotion ? instant : springs.gentle}
onClick={(e) => e.stopPropagation()}
>
<EscapeHandler onClose={onClose} />
<span className="sr-only">Card detail editor</span>
{/* Header: cover color background + title + close */}
<div
className="relative flex items-center gap-3 px-6 py-4"
style={{
backgroundColor: card.coverColor
? `oklch(55% 0.12 ${card.coverColor})`
: undefined,
}}
>
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
hasColor={card.coverColor != null}
/>
<button
onClick={onClose}
className={`absolute right-4 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors ${
card.coverColor
? "text-white/70 hover:bg-white/20 hover:text-white"
: "text-pylon-text-secondary hover:bg-pylon-column hover:text-pylon-text"
}`}
aria-label="Close"
>
<X className="size-5" />
</button>
</div>
{/* Dashboard grid body */}
<OverlayScrollbarsComponent
className="max-h-[calc(85vh-4rem)]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<motion.div
className="grid grid-cols-2 gap-4 p-5"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
>
{/* Row 1: Labels + Due Date */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
</motion.div>
{/* Row 2: Checklist + Description */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Row 3: Priority + Cover */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<PriorityPicker cardId={cardId} priority={card.priority} />
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CoverColorPicker
cardId={cardId}
coverColor={card.coverColor}
/>
</motion.div>
{/* Row 4: Attachments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
/>
</motion.div>
{/* Row 5: Comments (full width) */}
<motion.div
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<CommentsSection cardId={cardId} comments={card.comments} />
</motion.div>
</motion.div>
</OverlayScrollbarsComponent>
</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 {
cardId: string;
title: string;
updateCard: (cardId: string, updates: { title: string }) => void;
hasColor: boolean;
}
function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setDraft(title);
}, [title]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
function handleSave() {
const trimmed = draft.trim();
if (trimmed && trimmed !== title) {
updateCard(cardId, { title: trimmed });
} else {
setDraft(title);
}
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
setDraft(title);
setEditing(false);
}
}
const textColor = hasColor ? "text-white" : "text-pylon-text";
const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent";
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
}`}
/>
);
}
return (
<h2
onClick={() => setEditing(true)}
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
>
{title}
</h2>
);
}
/* ---------- Cover color picker ---------- */
function CoverColorPicker({
cardId,
coverColor,
}: {
cardId: string;
coverColor: string | null;
}) {
const updateCard = useBoardStore((s) => s.updateCard);
const presets = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Cover
</h4>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateCard(cardId, { coverColor: null })}
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
title="None"
>
&times;
</button>
{presets.map(({ hue, label }) => (
<button
key={hue}
onClick={() => updateCard(cardId, { coverColor: hue })}
className="size-6 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline:
coverColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</div>
);
}