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:
90
src/hooks/useKeyboardNavigation.ts
Normal file
90
src/hooks/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Board } from "@/types/board";
|
||||
|
||||
export function useKeyboardNavigation(
|
||||
board: Board | null,
|
||||
onOpenCard: (cardId: string) => void
|
||||
) {
|
||||
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!board) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const isNav = ["j", "k", "h", "l", "arrowdown", "arrowup", "arrowleft", "arrowright", "enter", "escape"].includes(key);
|
||||
if (!isNav) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (key === "escape") {
|
||||
setFocusedCardId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "enter" && focusedCardId) {
|
||||
onOpenCard(focusedCardId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build navigation grid
|
||||
const columns = board.columns.filter((c) => !c.collapsed && c.cardIds.length > 0);
|
||||
if (columns.length === 0) return;
|
||||
|
||||
// Find current position
|
||||
let colIdx = -1;
|
||||
let cardIdx = -1;
|
||||
if (focusedCardId) {
|
||||
for (let ci = 0; ci < columns.length; ci++) {
|
||||
const idx = columns[ci].cardIds.indexOf(focusedCardId);
|
||||
if (idx !== -1) {
|
||||
colIdx = ci;
|
||||
cardIdx = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing focused, focus first card
|
||||
if (colIdx === -1) {
|
||||
setFocusedCardId(columns[0].cardIds[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "j" || key === "arrowdown") {
|
||||
const col = columns[colIdx];
|
||||
const next = Math.min(cardIdx + 1, col.cardIds.length - 1);
|
||||
setFocusedCardId(col.cardIds[next]);
|
||||
} else if (key === "k" || key === "arrowup") {
|
||||
const col = columns[colIdx];
|
||||
const next = Math.max(cardIdx - 1, 0);
|
||||
setFocusedCardId(col.cardIds[next]);
|
||||
} else if (key === "l" || key === "arrowright") {
|
||||
const nextCol = Math.min(colIdx + 1, columns.length - 1);
|
||||
const targetIdx = Math.min(cardIdx, columns[nextCol].cardIds.length - 1);
|
||||
setFocusedCardId(columns[nextCol].cardIds[targetIdx]);
|
||||
} else if (key === "h" || key === "arrowleft") {
|
||||
const prevCol = Math.max(colIdx - 1, 0);
|
||||
const targetIdx = Math.min(cardIdx, columns[prevCol].cardIds.length - 1);
|
||||
setFocusedCardId(columns[prevCol].cardIds[targetIdx]);
|
||||
}
|
||||
},
|
||||
[board, focusedCardId, onOpenCard]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Clear focus when a card is removed
|
||||
useEffect(() => {
|
||||
if (focusedCardId && board && !board.cards[focusedCardId]) {
|
||||
setFocusedCardId(null);
|
||||
}
|
||||
}, [board, focusedCardId]);
|
||||
|
||||
return { focusedCardId, setFocusedCardId };
|
||||
}
|
||||
Reference in New Issue
Block a user