- FilterBar component with text search, label chips, due date and priority dropdowns - "/" keyboard shortcut and toolbar button to toggle filter bar - Keyboard card navigation with J/K/H/L keys, Enter to open, Escape to clear - Focus ring on keyboard-selected cards with auto-scroll - Desktop notifications for due/overdue cards via tauri-plugin-notification - CommentsSection component with add/delete and relative timestamps - Filtered card count display in column headers
193 lines
6.6 KiB
TypeScript
193 lines
6.6 KiB
TypeScript
import { useState } from "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";
|
|
import {
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
useSortable,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ColumnHeader } from "@/components/board/ColumnHeader";
|
|
import { AddCardInput } from "@/components/board/AddCardInput";
|
|
import { CardThumbnail } from "@/components/board/CardThumbnail";
|
|
import { useBoardStore } from "@/stores/board-store";
|
|
import type { Column } from "@/types/board";
|
|
|
|
const WIDTH_MAP = {
|
|
narrow: 180,
|
|
standard: 280,
|
|
wide: 360,
|
|
} as const;
|
|
|
|
interface KanbanColumnProps {
|
|
column: Column;
|
|
filteredCardIds?: string[];
|
|
focusedCardId?: string | null;
|
|
onCardClick?: (cardId: string) => void;
|
|
isNew?: boolean;
|
|
}
|
|
|
|
export function KanbanColumn({ column, filteredCardIds, focusedCardId, 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];
|
|
|
|
// Make the column itself sortable (for column reordering)
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef: setSortableNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: column.id,
|
|
data: { type: "column" },
|
|
});
|
|
|
|
// Make the column a droppable target so empty columns can receive cards
|
|
const { setNodeRef: setDroppableNodeRef } = useDroppable({
|
|
id: `column-droppable-${column.id}`,
|
|
data: { type: "column", columnId: column.id },
|
|
});
|
|
|
|
const borderTop = column.color
|
|
? `3px solid oklch(55% 0.12 ${column.color})`
|
|
: board?.color
|
|
? `3px solid ${board.color}30`
|
|
: undefined;
|
|
|
|
const displayCardIds = filteredCardIds ?? column.cardIds;
|
|
const isFiltering = filteredCardIds != null;
|
|
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.div
|
|
ref={setSortableNodeRef}
|
|
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}
|
|
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={cardCount} filteredCount={isFiltering ? displayCardIds.length : undefined} />
|
|
</div>
|
|
|
|
{/* Card list - wrapped in SortableContext for within-column sorting */}
|
|
<SortableContext
|
|
items={column.cardIds}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<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"
|
|
style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
|
|
variants={staggerContainer(0.03)}
|
|
initial="hidden"
|
|
animate="visible"
|
|
>
|
|
{displayCardIds.map((cardId) => {
|
|
const card = board?.cards[cardId];
|
|
if (!card) return null;
|
|
return (
|
|
<li key={cardId}>
|
|
<CardThumbnail
|
|
card={card}
|
|
boardLabels={board?.labels ?? []}
|
|
columnId={column.id}
|
|
onCardClick={onCardClick}
|
|
isFocused={focusedCardId === cardId}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
{displayCardIds.length === 0 && (
|
|
<li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
|
|
{isFiltering ? "No matching cards" : "Drop or add a card"}
|
|
</li>
|
|
)}
|
|
</motion.ul>
|
|
</OverlayScrollbarsComponent>
|
|
</SortableContext>
|
|
|
|
{/* Add card section */}
|
|
{showAddCard ? (
|
|
<AddCardInput
|
|
columnId={column.id}
|
|
onClose={() => setShowAddCard(false)}
|
|
/>
|
|
) : (
|
|
<div className="p-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowAddCard(true)}
|
|
className="w-full justify-start text-pylon-text-secondary hover:text-pylon-text"
|
|
>
|
|
<Plus className="size-4" />
|
|
Add card
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</motion.section>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|