feat: Phase 2 card interactions - priority picker, context menu, WIP limits, column collapse, checklist reorder

- PriorityPicker component with 5 colored chips in card detail modal
- Card context menu: Move to column, Set priority, Duplicate, Delete
- duplicateCard store action (clones card, inserts after original)
- Column WIP limits with amber/red indicators when at/over limit
- Column collapse/expand to 40px vertical strip
- Checklist item drag reordering with grip handle
- Comment store actions (addComment, deleteComment) for Phase 3
This commit is contained in:
Your Name
2026-02-16 14:46:20 +02:00
parent b51818ada3
commit a17c8b6b62
7 changed files with 526 additions and 142 deletions

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { Plus, ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "framer-motion";
import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";
import { useDroppable } from "@dnd-kit/core";
@@ -9,8 +9,8 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ColumnHeader } from "@/components/board/ColumnHeader";
import { AddCardInput } from "@/components/board/AddCardInput";
import { CardThumbnail } from "@/components/board/CardThumbnail";
@@ -26,11 +26,13 @@ const WIDTH_MAP = {
interface KanbanColumnProps {
column: Column;
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) {
const [showAddCard, setShowAddCard] = useState(false);
const board = useBoardStore((s) => s.board);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
const prefersReducedMotion = useReducedMotion();
const width = WIDTH_MAP[column.width];
@@ -54,31 +56,68 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
data: { type: "column", columnId: column.id },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
width,
};
const borderTop = column.color
? `3px solid oklch(55% 0.12 ${column.color})`
: board?.color
? `3px solid ${board.color}30`
: undefined;
const cardCount = column.cardIds.length;
const wipTint = column.wipLimit != null
? cardCount > column.wipLimit
? "oklch(70% 0.08 25 / 15%)"
: cardCount === column.wipLimit
? "oklch(75% 0.08 70 / 15%)"
: undefined
: undefined;
return (
<motion.section
<motion.div
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
initial={isNew ? { width: 0, opacity: 0 } : false}
exit={{ width: 0, opacity: 0 }}
transition={springs.bouncy}
layout
className="shrink-0 overflow-hidden"
{...attributes}
>
{column.collapsed ? (
<button
onClick={() => toggleColumnCollapse(column.id)}
className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
style={{ borderTop }}
{...listeners}
>
<ChevronRight className="size-3.5 text-pylon-text-secondary" />
<span
className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{column.title}
</span>
<span className="mt-auto font-mono text-xs text-pylon-text-secondary">
{cardCount}
</span>
</button>
) : (
<motion.section
className="group/column flex h-full flex-col overflow-hidden rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
style={{ borderTop, backgroundColor: wipTint }}
variants={fadeSlideUp}
initial={isNew || prefersReducedMotion ? false : undefined}
animate={isNew ? "visible" : undefined}
transition={springs.bouncy}
>
{/* The column header is the drag handle for column reordering */}
<div {...listeners}>
<ColumnHeader column={column} cardCount={column.cardIds.length} boardColor={board?.color} />
<ColumnHeader column={column} cardCount={column.cardIds.length} />
</div>
{/* Card list - wrapped in SortableContext for within-column sorting */}
@@ -86,7 +125,11 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
items={column.cardIds}
strategy={verticalListSortingStrategy}
>
<ScrollArea className="flex-1 overflow-y-auto">
<OverlayScrollbarsComponent
className="flex-1"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<motion.ul
ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col"
@@ -115,7 +158,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</li>
)}
</motion.ul>
</ScrollArea>
</OverlayScrollbarsComponent>
</SortableContext>
{/* Add card section */}
@@ -138,5 +181,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</div>
)}
</motion.section>
)}
</motion.div>
);
}