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:
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -3424,6 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
|
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"paste",
|
"paste",
|
||||||
|
"serde_json",
|
||||||
"specta-macros",
|
"specta-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ dirs = "5"
|
|||||||
notify = "7"
|
notify = "7"
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
specta = { version = "=2.0.0-rc.22", features = ["derive"] }
|
specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] }
|
||||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||||
specta-typescript = "0.0.9"
|
specta-typescript = "0.0.9"
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ pub fn run() {
|
|||||||
tracing::info!("Starting Mission Control");
|
tracing::info!("Starting Mission Control");
|
||||||
|
|
||||||
// Build tauri-specta builder for type-safe IPC
|
// Build tauri-specta builder for type-safe IPC
|
||||||
|
// Note: read_state/write_state/clear_state use serde_json::Value which doesn't
|
||||||
|
// implement specta::Type, so they're excluded from the builder but kept in
|
||||||
|
// the invoke_handler via generate_handler!
|
||||||
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||||
commands::greet,
|
commands::greet,
|
||||||
commands::get_lore_status,
|
commands::get_lore_status,
|
||||||
@@ -116,9 +119,6 @@ pub fn run() {
|
|||||||
commands::sync_now,
|
commands::sync_now,
|
||||||
commands::reconcile,
|
commands::reconcile,
|
||||||
commands::quick_capture,
|
commands::quick_capture,
|
||||||
commands::read_state,
|
|
||||||
commands::write_state,
|
|
||||||
commands::clear_state,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Export TypeScript bindings in debug builds
|
// Export TypeScript bindings in debug builds
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
tests/components/CommandPalette.test.tsx
Normal file
295
tests/components/CommandPalette.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* Tests for CommandPalette component.
|
||||||
|
*
|
||||||
|
* TDD: These tests define the expected behavior before implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
|
import type { FocusItem } from "@/lib/types";
|
||||||
|
|
||||||
|
const mockItems: FocusItem[] = [
|
||||||
|
{
|
||||||
|
id: "mr-1",
|
||||||
|
title: "Fix auth token refresh",
|
||||||
|
type: "mr_review",
|
||||||
|
project: "platform/core",
|
||||||
|
url: "https://gitlab.com/platform/core/-/merge_requests/1",
|
||||||
|
iid: 1,
|
||||||
|
updatedAt: "2026-02-25T10:00:00Z",
|
||||||
|
contextQuote: null,
|
||||||
|
requestedBy: "alice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mr-2",
|
||||||
|
title: "Update README",
|
||||||
|
type: "mr_authored",
|
||||||
|
project: "platform/docs",
|
||||||
|
url: "https://gitlab.com/platform/docs/-/merge_requests/2",
|
||||||
|
iid: 2,
|
||||||
|
updatedAt: "2026-02-20T10:00:00Z",
|
||||||
|
contextQuote: null,
|
||||||
|
requestedBy: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
title: "Auth bug in production",
|
||||||
|
type: "issue",
|
||||||
|
project: "platform/core",
|
||||||
|
url: "https://gitlab.com/platform/core/-/issues/42",
|
||||||
|
iid: 42,
|
||||||
|
updatedAt: "2026-02-24T10:00:00Z",
|
||||||
|
contextQuote: null,
|
||||||
|
requestedBy: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manual-1",
|
||||||
|
title: "Review quarterly goals",
|
||||||
|
type: "manual",
|
||||||
|
project: "personal",
|
||||||
|
url: "",
|
||||||
|
iid: 0,
|
||||||
|
updatedAt: null,
|
||||||
|
contextQuote: null,
|
||||||
|
requestedBy: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("CommandPalette", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
items: mockItems,
|
||||||
|
onFilter: vi.fn(),
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onClose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders when open", () => {
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText(/Search or filter/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when closed", () => {
|
||||||
|
render(<CommandPalette {...defaultProps} isOpen={false} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape key", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<CommandPalette {...defaultProps} onClose={onClose} />);
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when clicking backdrop", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<CommandPalette {...defaultProps} onClose={onClose} />);
|
||||||
|
|
||||||
|
// Click the backdrop (outer div)
|
||||||
|
await user.click(screen.getByTestId("command-palette-backdrop"));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("text search", () => {
|
||||||
|
it("filters items by text", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "auth");
|
||||||
|
|
||||||
|
// Should show items containing "auth"
|
||||||
|
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Auth bug in production")).toBeInTheDocument();
|
||||||
|
// Should not show items without "auth"
|
||||||
|
expect(screen.queryByText("Update README")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Review quarterly goals")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all items when search is empty", () => {
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
// Should show all items (or top 10)
|
||||||
|
expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Update README")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects item and closes on click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<CommandPalette
|
||||||
|
{...defaultProps}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Fix auth token refresh"));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("mr-1");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects item on Enter when typing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<CommandPalette
|
||||||
|
{...defaultProps}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type to filter to one item
|
||||||
|
await user.type(screen.getByRole("textbox"), "README");
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("mr-2");
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filter commands", () => {
|
||||||
|
it("shows filter command options when typing type:", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "type:");
|
||||||
|
|
||||||
|
expect(screen.getByText("type:mr_review")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("type:issue")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("type:manual")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by type when type: command used", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<CommandPalette
|
||||||
|
{...defaultProps}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "type:");
|
||||||
|
await user.click(screen.getByText("type:mr_review"));
|
||||||
|
|
||||||
|
expect(onFilter).toHaveBeenCalledWith({ type: "mr_review" });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows staleness filter options when typing stale:", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "stale:");
|
||||||
|
|
||||||
|
expect(screen.getByText("stale:1d")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("stale:3d")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("stale:7d")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by staleness when stale: command used", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<CommandPalette
|
||||||
|
{...defaultProps}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "stale:");
|
||||||
|
await user.click(screen.getByText("stale:7d"));
|
||||||
|
|
||||||
|
expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows command suggestions before typing a command", () => {
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
// Commands section header should exist
|
||||||
|
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||||
|
// Command buttons should be present
|
||||||
|
expect(screen.getByText("type:...")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("stale:...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("keyboard navigation", () => {
|
||||||
|
it("navigates down with arrow key", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
await user.click(input);
|
||||||
|
await user.keyboard("{ArrowDown}");
|
||||||
|
|
||||||
|
// First item should be highlighted
|
||||||
|
const items = screen.getAllByRole("option");
|
||||||
|
expect(items[0]).toHaveAttribute("data-highlighted", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates up with arrow key", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
await user.click(input);
|
||||||
|
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowUp}");
|
||||||
|
|
||||||
|
// First item should be highlighted again
|
||||||
|
const items = screen.getAllByRole("option");
|
||||||
|
expect(items[0]).toHaveAttribute("data-highlighted", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects highlighted item on Enter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(<CommandPalette {...defaultProps} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
await user.click(input);
|
||||||
|
await user.keyboard("{ArrowDown}{Enter}");
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("mr-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty state", () => {
|
||||||
|
it("shows no results message when no items match", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CommandPalette {...defaultProps} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByRole("textbox"), "xyznonexistent");
|
||||||
|
|
||||||
|
expect(screen.getByText(/No results found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user