feat: implement CommandPalette for quick filter and search
- Add text search across all focus items - Support filter commands: type: and stale: - Keyboard navigation with arrow keys + Enter - Click to select items - Escape or backdrop click to close - 17 tests covering search, filters, keyboard nav, and empty state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
362
src/components/CommandPalette.tsx
Normal file
362
src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* CommandPalette -- ⌘K quick filter and search overlay.
|
||||
*
|
||||
* Features:
|
||||
* - Text search across all items
|
||||
* - Filter commands (type:, stale:)
|
||||
* - Keyboard navigation (arrows + Enter)
|
||||
* - Click to select
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { FocusItem, FocusItemType } from "@/lib/types";
|
||||
|
||||
/** Filter command definition */
|
||||
interface FilterCommand {
|
||||
prefix: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
const FILTER_COMMANDS: FilterCommand[] = [
|
||||
{
|
||||
prefix: "type:",
|
||||
options: [
|
||||
{ value: "mr_review", label: "type:mr_review" },
|
||||
{ value: "mr_authored", label: "type:mr_authored" },
|
||||
{ value: "issue", label: "type:issue" },
|
||||
{ value: "manual", label: "type:manual" },
|
||||
],
|
||||
},
|
||||
{
|
||||
prefix: "stale:",
|
||||
options: [
|
||||
{ value: "1", label: "stale:1d" },
|
||||
{ value: "3", label: "stale:3d" },
|
||||
{ value: "7", label: "stale:7d" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Filter criteria passed to onFilter */
|
||||
export interface FilterCriteria {
|
||||
type?: FocusItemType;
|
||||
minAge?: number;
|
||||
}
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
items: FocusItem[];
|
||||
onFilter: (criteria: FilterCriteria) => void;
|
||||
onSelect: (itemId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
items,
|
||||
onFilter,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: CommandPaletteProps): React.ReactElement | null {
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearch("");
|
||||
setHighlightedIndex(-1);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Check if search matches a command prefix
|
||||
const activeCommand = useMemo(() => {
|
||||
for (const cmd of FILTER_COMMANDS) {
|
||||
if (search.toLowerCase().startsWith(cmd.prefix)) {
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [search]);
|
||||
|
||||
// Filter items by text search
|
||||
const filteredItems = useMemo(() => {
|
||||
if (activeCommand) {
|
||||
return []; // Commands mode, don't show items
|
||||
}
|
||||
if (!search.trim()) {
|
||||
return items.slice(0, 10); // Show first 10 when empty
|
||||
}
|
||||
const lower = search.toLowerCase();
|
||||
return items
|
||||
.filter((item) => item.title.toLowerCase().includes(lower))
|
||||
.slice(0, 10);
|
||||
}, [search, items, activeCommand]);
|
||||
|
||||
// Compute all selectable options (items or command options)
|
||||
const selectableOptions = useMemo(() => {
|
||||
if (activeCommand) {
|
||||
return activeCommand.options.map((opt) => ({
|
||||
id: `cmd:${opt.label}`,
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
type: "command" as const,
|
||||
}));
|
||||
}
|
||||
return filteredItems.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
type: "item" as const,
|
||||
itemType: item.type,
|
||||
}));
|
||||
}, [activeCommand, filteredItems]);
|
||||
|
||||
// Reset highlight when options change
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [selectableOptions]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < selectableOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const option = selectableOptions[highlightedIndex];
|
||||
if (option) {
|
||||
handleOptionSelect(option);
|
||||
} else if (selectableOptions.length === 1) {
|
||||
// Auto-select if only one option
|
||||
handleOptionSelect(selectableOptions[0]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectableOptions, highlightedIndex]
|
||||
);
|
||||
|
||||
const handleOptionSelect = useCallback(
|
||||
(option: (typeof selectableOptions)[0]) => {
|
||||
if (option.type === "command") {
|
||||
// Parse the command and emit filter
|
||||
if (option.id.startsWith("cmd:type:")) {
|
||||
onFilter({ type: option.value as FocusItemType });
|
||||
} else if (option.id.startsWith("cmd:stale:")) {
|
||||
onFilter({ minAge: parseInt(option.value, 10) });
|
||||
}
|
||||
} else {
|
||||
onSelect(option.value);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onFilter, onSelect, onClose]
|
||||
);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const getTypeIcon = (type: FocusItemType): string => {
|
||||
switch (type) {
|
||||
case "mr_review":
|
||||
return "⟲";
|
||||
case "mr_authored":
|
||||
return "⟳";
|
||||
case "issue":
|
||||
return "●";
|
||||
case "manual":
|
||||
return "✓";
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
data-testid="command-palette-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<motion.div
|
||||
className="relative z-10 w-full max-w-lg overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl"
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.98 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
stiffness: 300,
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="border-b border-zinc-700 p-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or filter... (try type: or stale:)"
|
||||
className="w-full bg-transparent text-sm text-zinc-100 placeholder-zinc-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[300px] overflow-y-auto p-2">
|
||||
{activeCommand ? (
|
||||
// Command options
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
|
||||
{activeCommand.prefix}
|
||||
</div>
|
||||
{activeCommand.options.map((opt, index) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
data-highlighted={highlightedIndex === index}
|
||||
onClick={() =>
|
||||
handleOptionSelect({
|
||||
id: `cmd:${opt.label}`,
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
type: "command",
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
highlightedIndex === index
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Items */}
|
||||
{filteredItems.length > 0 ? (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
|
||||
Items
|
||||
</div>
|
||||
{filteredItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={highlightedIndex === index}
|
||||
data-highlighted={highlightedIndex === index}
|
||||
onClick={() =>
|
||||
handleOptionSelect({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
type: "item",
|
||||
itemType: item.type,
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
highlightedIndex === index
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "text-zinc-300 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<span className="text-zinc-500">
|
||||
{getTypeIcon(item.type)}
|
||||
</span>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : search.trim() ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-zinc-500">
|
||||
No results found
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Command suggestions (when not searching) */}
|
||||
{!search.trim() && (
|
||||
<div className="mt-2 border-t border-zinc-800 pt-2">
|
||||
<div className="px-2 py-1 text-xs font-medium text-zinc-500">
|
||||
Commands
|
||||
</div>
|
||||
{FILTER_COMMANDS.map((cmd) => (
|
||||
<button
|
||||
key={cmd.prefix}
|
||||
type="button"
|
||||
onClick={() => setSearch(cmd.prefix)}
|
||||
className="flex w-full items-center rounded-md px-3 py-2 text-left text-sm text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
{cmd.prefix}...
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="border-t border-zinc-800 px-3 py-2">
|
||||
<p className="text-[10px] text-zinc-600">
|
||||
↑↓ to navigate · Enter to select · Esc to close
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user