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:
Your Name
2026-02-16 14:52:08 +02:00
parent e535177914
commit 6340beb5d0
17 changed files with 791 additions and 140 deletions

View File

@@ -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); }}
/>
</>
);