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"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"serde_json",
|
||||
"specta-macros",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
@@ -30,7 +30,7 @@ dirs = "5"
|
||||
notify = "7"
|
||||
tauri-plugin-global-shortcut = "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"] }
|
||||
specta-typescript = "0.0.9"
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ pub fn run() {
|
||||
tracing::info!("Starting Mission Control");
|
||||
|
||||
// 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![
|
||||
commands::greet,
|
||||
commands::get_lore_status,
|
||||
@@ -116,9 +119,6 @@ pub fn run() {
|
||||
commands::sync_now,
|
||||
commands::reconcile,
|
||||
commands::quick_capture,
|
||||
commands::read_state,
|
||||
commands::write_state,
|
||||
commands::clear_state,
|
||||
]);
|
||||
|
||||
// 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