feat: Phase 3 - filter bar, keyboard navigation, notifications, comments
- 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
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
import { staggerContainer } from "@/lib/motion";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
ColumnOverlay,
|
||||
} from "@/components/board/DragOverlayContent";
|
||||
import { CardDetailModal } from "@/components/card-detail/CardDetailModal";
|
||||
import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar";
|
||||
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
||||
import type { Board } from "@/types/board";
|
||||
|
||||
function findColumnByCardId(board: Board, cardId: string) {
|
||||
@@ -63,9 +66,77 @@ export function BoardView() {
|
||||
const moveColumn = useBoardStore((s) => s.moveColumn);
|
||||
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
||||
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick);
|
||||
const [addingColumn, setAddingColumn] = useState(false);
|
||||
const [newColumnTitle, setNewColumnTitle] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||
|
||||
// Track columns that existed on initial render (for stagger vs instant appearance)
|
||||
const initialColumnIds = useRef<Set<string> | null>(null);
|
||||
if (initialColumnIds.current === null && board) {
|
||||
initialColumnIds.current = new Set(board.columns.map((c) => c.id));
|
||||
}
|
||||
|
||||
function handleCardClick(cardId: string) {
|
||||
setSelectedCardId(cardId);
|
||||
setFocusedCardId(null);
|
||||
}
|
||||
|
||||
function filterCards(cardIds: string[]): string[] {
|
||||
if (!isFilterActive(filters) || !board) return cardIds;
|
||||
return cardIds.filter((id) => {
|
||||
const card = board.cards[id];
|
||||
if (!card) return false;
|
||||
if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false;
|
||||
if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false;
|
||||
if (filters.priority !== "all" && card.priority !== filters.priority) return false;
|
||||
if (filters.dueDate !== "all") {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (filters.dueDate === "none" && card.dueDate != null) return false;
|
||||
if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false;
|
||||
if (filters.dueDate === "today") {
|
||||
if (!card.dueDate) return false;
|
||||
const d = new Date(card.dueDate);
|
||||
if (d.toDateString() !== today.toDateString()) return false;
|
||||
}
|
||||
if (filters.dueDate === "week") {
|
||||
if (!card.dueDate) return false;
|
||||
const d = new Date(card.dueDate);
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
if (d < today || d > weekEnd) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcut: "/" to open filter bar
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.preventDefault();
|
||||
setShowFilterBar(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, []);
|
||||
|
||||
// Listen for toggle-filter-bar custom event from TopBar
|
||||
useEffect(() => {
|
||||
function handleToggleFilter() {
|
||||
setShowFilterBar((prev) => !prev);
|
||||
}
|
||||
document.addEventListener("toggle-filter-bar", handleToggleFilter);
|
||||
return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter);
|
||||
}, []);
|
||||
|
||||
// Listen for custom event to open card detail from command palette
|
||||
useEffect(() => {
|
||||
@@ -105,6 +176,15 @@ export function BoardView() {
|
||||
addColumn(trimmed);
|
||||
setNewColumnTitle("");
|
||||
inputRef.current?.focus();
|
||||
// Force OverlayScrollbars to detect the new content and scroll to show it
|
||||
setTimeout(() => {
|
||||
const instance = osRef.current?.osInstance();
|
||||
if (instance) {
|
||||
instance.update(true);
|
||||
const viewport = instance.elements().viewport;
|
||||
viewport.scrollTo({ left: viewport.scrollWidth, behavior: "smooth" });
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,168 +199,174 @@ export function BoardView() {
|
||||
|
||||
// --- Drag handlers ---
|
||||
|
||||
// Debounce cross-column moves to prevent oscillation crashes
|
||||
const lastCrossColumnMoveRef = useRef<number>(0);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
lastCrossColumnMoveRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const type = active.data.current?.type as "card" | "column" | undefined;
|
||||
setActiveId(active.id as string);
|
||||
setActiveType(type ?? null);
|
||||
lastCrossColumnMoveRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || !board) return;
|
||||
if (!over) return;
|
||||
|
||||
// Always read fresh state to avoid stale-closure bugs
|
||||
const currentBoard = useBoardStore.getState().board;
|
||||
if (!currentBoard) return;
|
||||
|
||||
const activeType = active.data.current?.type;
|
||||
if (activeType !== "card") return; // Only handle card cross-column moves here
|
||||
if (activeType !== "card") return;
|
||||
|
||||
const activeCardId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
if (overId === activeCardId) return;
|
||||
|
||||
// Determine the source column
|
||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
||||
const activeColumn = findColumnByCardId(currentBoard, activeCardId);
|
||||
if (!activeColumn) return;
|
||||
|
||||
// Determine the target column
|
||||
let overColumn: ReturnType<typeof findColumnByCardId>;
|
||||
let overIndex: number;
|
||||
|
||||
// Check if we're hovering over a card
|
||||
const overType = over.data.current?.type;
|
||||
if (overType === "card") {
|
||||
overColumn = findColumnByCardId(board, overId);
|
||||
overColumn = findColumnByCardId(currentBoard, overId);
|
||||
if (!overColumn) return;
|
||||
overIndex = overColumn.cardIds.indexOf(overId);
|
||||
} else if (overType === "column") {
|
||||
// Hovering over the droppable area of a column
|
||||
const columnId = over.data.current?.columnId as string | undefined;
|
||||
if (columnId) {
|
||||
overColumn = board.columns.find((c) => c.id === columnId);
|
||||
overColumn = currentBoard.columns.find((c) => c.id === columnId);
|
||||
} else {
|
||||
overColumn = board.columns.find((c) => c.id === overId);
|
||||
overColumn = currentBoard.columns.find((c) => c.id === overId);
|
||||
}
|
||||
if (!overColumn) return;
|
||||
overIndex = overColumn.cardIds.length; // Append to end
|
||||
overIndex = overColumn.cardIds.length;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only move if we're going to a different column or different position
|
||||
// Only move cross-column (within-column handled by sortable transforms + dragEnd)
|
||||
if (activeColumn.id === overColumn.id) return;
|
||||
|
||||
// Debounce: prevent rapid oscillation between columns
|
||||
const now = Date.now();
|
||||
if (now - lastCrossColumnMoveRef.current < 100) return;
|
||||
lastCrossColumnMoveRef.current = now;
|
||||
|
||||
moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex);
|
||||
},
|
||||
[board, moveCard]
|
||||
[moveCard]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
try {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !board) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
// Always read fresh state
|
||||
const currentBoard = useBoardStore.getState().board;
|
||||
if (!over || !currentBoard) return;
|
||||
|
||||
const type = active.data.current?.type;
|
||||
const type = active.data.current?.type;
|
||||
|
||||
if (type === "column") {
|
||||
// Column reordering
|
||||
const activeColumnId = active.id as string;
|
||||
const overColumnId = over.id as string;
|
||||
if (type === "column") {
|
||||
const activeColumnId = active.id as string;
|
||||
const overColumnId = over.id as string;
|
||||
|
||||
if (activeColumnId !== overColumnId) {
|
||||
const fromIndex = board.columns.findIndex(
|
||||
(c) => c.id === activeColumnId
|
||||
);
|
||||
const toIndex = board.columns.findIndex(
|
||||
(c) => c.id === overColumnId
|
||||
);
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
moveColumn(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
} else if (type === "card") {
|
||||
// Card reordering within same column (cross-column already handled in onDragOver)
|
||||
const activeCardId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
||||
if (!activeColumn) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const overType = over.data.current?.type;
|
||||
|
||||
if (overType === "card") {
|
||||
const overColumn = findColumnByCardId(board, overId);
|
||||
if (!overColumn) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeColumn.id === overColumn.id) {
|
||||
// Within same column, reorder
|
||||
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
||||
const newIndex = activeColumn.cardIds.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
||||
if (activeColumnId !== overColumnId) {
|
||||
const fromIndex = currentBoard.columns.findIndex(
|
||||
(c) => c.id === activeColumnId
|
||||
);
|
||||
const toIndex = currentBoard.columns.findIndex(
|
||||
(c) => c.id === overColumnId
|
||||
);
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
moveColumn(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
} else if (overType === "column") {
|
||||
// Dropped on an empty column droppable
|
||||
const columnId = over.data.current?.columnId as string | undefined;
|
||||
const targetColumnId = columnId ?? (over.id as string);
|
||||
const targetColumn = board.columns.find(
|
||||
(c) => c.id === targetColumnId
|
||||
);
|
||||
} else if (type === "card") {
|
||||
const activeCardId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
||||
moveCard(
|
||||
activeCardId,
|
||||
activeColumn.id,
|
||||
targetColumn.id,
|
||||
targetColumn.cardIds.length
|
||||
const activeColumn = findColumnByCardId(currentBoard, activeCardId);
|
||||
if (!activeColumn) return;
|
||||
|
||||
const overType = over.data.current?.type;
|
||||
|
||||
if (overType === "card") {
|
||||
const overColumn = findColumnByCardId(currentBoard, overId);
|
||||
if (!overColumn) return;
|
||||
|
||||
if (activeColumn.id === overColumn.id) {
|
||||
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
||||
const newIndex = activeColumn.cardIds.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
||||
}
|
||||
}
|
||||
} else if (overType === "column") {
|
||||
const columnId = over.data.current?.columnId as string | undefined;
|
||||
const targetColumnId = columnId ?? (over.id as string);
|
||||
const targetColumn = currentBoard.columns.find(
|
||||
(c) => c.id === targetColumnId
|
||||
);
|
||||
|
||||
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
||||
moveCard(
|
||||
activeCardId,
|
||||
activeColumn.id,
|
||||
targetColumn.id,
|
||||
targetColumn.cardIds.length
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearDragState();
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
},
|
||||
[board, moveCard, moveColumn]
|
||||
[moveCard, moveColumn, clearDragState]
|
||||
);
|
||||
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
const handleDragEndWithAnnouncement = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
// Read board BEFORE handleDragEnd potentially modifies it
|
||||
const currentBoard = useBoardStore.getState().board;
|
||||
handleDragEnd(event);
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && board) {
|
||||
if (over && currentBoard) {
|
||||
const type = active.data.current?.type;
|
||||
if (type === "card") {
|
||||
const card = board.cards[active.id as string];
|
||||
const card = currentBoard.cards[active.id as string];
|
||||
const targetCol = over.data.current?.type === "column"
|
||||
? board.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
|
||||
: findColumnByCardId(board, over.id as string);
|
||||
? currentBoard.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
|
||||
: findColumnByCardId(currentBoard, over.id as string);
|
||||
if (card && targetCol) {
|
||||
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
|
||||
}
|
||||
} else if (type === "column") {
|
||||
const col = board.columns.find((c) => c.id === (active.id as string));
|
||||
const col = currentBoard.columns.find((c) => c.id === (active.id as string));
|
||||
if (col) {
|
||||
setAnnouncement(`Reordered column "${col.title}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleDragEnd, board]
|
||||
[handleDragEnd]
|
||||
);
|
||||
|
||||
if (!board) {
|
||||
@@ -311,31 +397,53 @@ export function BoardView() {
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showFilterBar && board && (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
|
||||
boardLabels={board.labels}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEndWithAnnouncement}
|
||||
onDragCancel={clearDragState}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<OverlayScrollbarsComponent
|
||||
ref={osRef}
|
||||
className="h-full"
|
||||
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
|
||||
defer
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-full overflow-x-auto"
|
||||
className="flex h-full"
|
||||
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
|
||||
variants={staggerContainer(0.06)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
onCardClick={setSelectedCardId}
|
||||
/>
|
||||
))}
|
||||
<AnimatePresence>
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
|
||||
focusedCardId={focusedCardId}
|
||||
onCardClick={handleCardClick}
|
||||
isNew={!initialColumnIds.current?.has(column.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add column button / inline input */}
|
||||
<div className="shrink-0">
|
||||
@@ -384,6 +492,7 @@ export function BoardView() {
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</SortableContext>
|
||||
|
||||
{/* Drag overlay - renders a styled copy of the dragged item */}
|
||||
@@ -400,7 +509,7 @@ export function BoardView() {
|
||||
|
||||
<CardDetailModal
|
||||
cardId={selectedCardId}
|
||||
onClose={() => setSelectedCardId(null)}
|
||||
onClose={() => { setSelectedCardId(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { format } from "date-fns";
|
||||
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
|
||||
@@ -65,9 +65,10 @@ interface CardThumbnailProps {
|
||||
boardLabels: Label[];
|
||||
columnId: string;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: CardThumbnailProps) {
|
||||
export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocused }: CardThumbnailProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
const {
|
||||
@@ -82,6 +83,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
||||
data: { type: "card", columnId },
|
||||
});
|
||||
|
||||
const cardRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const hasDueDate = card.dueDate != null;
|
||||
const dueDateStatus = getDueDateStatus(card.dueDate);
|
||||
|
||||
@@ -111,7 +120,10 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<motion.button
|
||||
ref={setNodeRef}
|
||||
ref={(node) => {
|
||||
setNodeRef(node);
|
||||
(cardRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
|
||||
}}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -119,7 +131,9 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
||||
opacity: getAgingOpacity(card.updatedAt),
|
||||
}}
|
||||
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 ${
|
||||
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
|
||||
}`}
|
||||
layoutId={`card-${card.id}`}
|
||||
variants={fadeSlideUp}
|
||||
initial={prefersReducedMotion ? false : "hidden"}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { Column, ColumnWidth } from "@/types/board";
|
||||
interface ColumnHeaderProps {
|
||||
column: Column;
|
||||
cardCount: number;
|
||||
filteredCount?: number;
|
||||
}
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
@@ -35,7 +36,7 @@ const COLOR_PRESETS = [
|
||||
{ hue: "0", label: "Slate" },
|
||||
];
|
||||
|
||||
export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
||||
export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(column.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -107,7 +108,7 @@ export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
|
||||
? "text-[oklch(65%_0.15_70)]"
|
||||
: "text-pylon-text-secondary"
|
||||
}`}>
|
||||
{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
|
||||
{filteredCount != null ? `${filteredCount} of ` : ""}{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
146
src/components/board/FilterBar.tsx
Normal file
146
src/components/board/FilterBar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { springs } from "@/lib/motion";
|
||||
import { X, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Label, Priority } from "@/types/board";
|
||||
|
||||
export interface FilterState {
|
||||
text: string;
|
||||
labels: string[];
|
||||
dueDate: "all" | "overdue" | "week" | "today" | "none";
|
||||
priority: "all" | Priority;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTER: FilterState = {
|
||||
text: "",
|
||||
labels: [],
|
||||
dueDate: "all",
|
||||
priority: "all",
|
||||
};
|
||||
|
||||
export function isFilterActive(f: FilterState): boolean {
|
||||
return f.text !== "" || f.labels.length > 0 || f.dueDate !== "all" || f.priority !== "all";
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: FilterState;
|
||||
onChange: (filters: FilterState) => void;
|
||||
onClose: () => void;
|
||||
boardLabels: Label[];
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBarProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [textDraft, setTextDraft] = useState(filters.text);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(value: string) => {
|
||||
setTextDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onChange({ ...filters, text: value });
|
||||
}, 200);
|
||||
},
|
||||
[filters, onChange]
|
||||
);
|
||||
|
||||
function toggleLabel(labelId: string) {
|
||||
const labels = filters.labels.includes(labelId)
|
||||
? filters.labels.filter((l) => l !== labelId)
|
||||
: [...filters.labels, labelId];
|
||||
onChange({ ...filters, labels });
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setTextDraft("");
|
||||
onChange(EMPTY_FILTER);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={springs.snappy}
|
||||
className="overflow-hidden border-b border-border bg-pylon-surface"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
{/* Text search */}
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-pylon-column px-2 py-1">
|
||||
<Search className="size-3.5 text-pylon-text-secondary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={textDraft}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
placeholder="Search cards..."
|
||||
className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label filter chips */}
|
||||
{boardLabels.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{boardLabels.map((label) => (
|
||||
<button
|
||||
key={label.id}
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
className={`rounded-full px-2 py-0.5 text-xs transition-all ${
|
||||
filters.labels.includes(label.id)
|
||||
? "text-white"
|
||||
: "opacity-40 hover:opacity-70"
|
||||
}`}
|
||||
style={{ backgroundColor: label.color }}
|
||||
>
|
||||
{label.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Due date filter */}
|
||||
<select
|
||||
value={filters.dueDate}
|
||||
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
|
||||
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||
>
|
||||
<option value="all">All dates</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="week">Due this week</option>
|
||||
<option value="today">Due today</option>
|
||||
<option value="none">No date</option>
|
||||
</select>
|
||||
|
||||
{/* Priority filter */}
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
|
||||
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||
>
|
||||
<option value="all">All priorities</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="none">No priority</option>
|
||||
</select>
|
||||
|
||||
{/* Spacer + clear + close */}
|
||||
<div className="flex-1" />
|
||||
{isFilterActive(filters) && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAll} className="text-xs text-pylon-text-secondary">
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +25,13 @@ const WIDTH_MAP = {
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: Column;
|
||||
filteredCardIds?: string[];
|
||||
focusedCardId?: string | null;
|
||||
onCardClick?: (cardId: string) => void;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) {
|
||||
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);
|
||||
@@ -62,6 +64,8 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
||||
? `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
|
||||
@@ -117,7 +121,7 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
||||
>
|
||||
{/* The column header is the drag handle for column reordering */}
|
||||
<div {...listeners}>
|
||||
<ColumnHeader column={column} cardCount={column.cardIds.length} />
|
||||
<ColumnHeader column={column} cardCount={cardCount} filteredCount={isFiltering ? displayCardIds.length : undefined} />
|
||||
</div>
|
||||
|
||||
{/* Card list - wrapped in SortableContext for within-column sorting */}
|
||||
@@ -138,7 +142,7 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{column.cardIds.map((cardId) => {
|
||||
{displayCardIds.map((cardId) => {
|
||||
const card = board?.cards[cardId];
|
||||
if (!card) return null;
|
||||
return (
|
||||
@@ -148,13 +152,14 @@ export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps)
|
||||
boardLabels={board?.labels ?? []}
|
||||
columnId={column.id}
|
||||
onCardClick={onCardClick}
|
||||
isFocused={focusedCardId === cardId}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{column.cardIds.length === 0 && (
|
||||
{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">
|
||||
Drop or add a card
|
||||
{isFiltering ? "No matching cards" : "Drop or add a card"}
|
||||
</li>
|
||||
)}
|
||||
</motion.ul>
|
||||
|
||||
Reference in New Issue
Block a user