Implements comprehensive search interface for knowledge base: Components: - SearchInput: Debounced search with Cmd+K (Ctrl+K) shortcut - SearchResults: Main results view with highlighted snippets - SearchFilters: Sidebar for filtering by status and tags - Search page: Full search experience at /knowledge/search Features: - Search-as-you-type with 300ms debounce - HTML snippet highlighting (using <mark> from API) - Tag and status filters with PDA-friendly language - Keyboard shortcuts (Cmd+K/Ctrl+K to open, Escape to clear) - No results state with helpful suggestions - Loading states - Visual status indicators (🟢 Active, 🔵 Scheduled, etc.) Navigation: - Added search button to header with keyboard hint - Global Cmd+K shortcut redirects to search page - Added "Knowledge" link to main navigation Infrastructure: - Updated Input component to support forwardRef for proper ref handling - Comprehensive test coverage (100% on main components) - All tests passing (339 passed) - TypeScript strict mode compliant - ESLint compliant Fixes #67 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
113 lines
3.2 KiB
TypeScript
113 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
interface SearchInputProps {
|
|
onSearch: (query: string) => void;
|
|
initialValue?: string;
|
|
isLoading?: boolean;
|
|
debounceMs?: number;
|
|
placeholder?: string;
|
|
}
|
|
|
|
/**
|
|
* Search input component with keyboard shortcuts and debouncing
|
|
* Supports Cmd+K (Mac) and Ctrl+K (Windows/Linux) to focus
|
|
* Press Escape to clear input
|
|
*/
|
|
export function SearchInput({
|
|
onSearch,
|
|
initialValue = "",
|
|
isLoading = false,
|
|
debounceMs = 300,
|
|
placeholder = "Search knowledge entries...",
|
|
}: SearchInputProps): React.JSX.Element {
|
|
const [value, setValue] = useState(initialValue);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Handle keyboard shortcuts
|
|
useEffect((): (() => void) => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
// Cmd+K (Mac) or Ctrl+K (Windows/Linux)
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
// Handle input changes with debouncing
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
const newValue = e.target.value;
|
|
setValue(newValue);
|
|
|
|
// Clear existing timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
// Set new timer - only search for non-empty trimmed values
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
const trimmed = newValue.trim();
|
|
if (trimmed) {
|
|
onSearch(trimmed);
|
|
}
|
|
}, debounceMs);
|
|
};
|
|
|
|
// Handle Escape key to clear input
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
|
if (e.key === "Escape") {
|
|
setValue("");
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Cleanup timer on unmount
|
|
useEffect((): (() => void) => {
|
|
return (): void => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Detect platform for keyboard hint
|
|
const isMac = typeof window !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
const shortcutHint = isMac ? "⌘K" : "Ctrl+K";
|
|
|
|
return (
|
|
<div className="relative w-full">
|
|
<div className="relative">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={value}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
className="w-full pr-20"
|
|
aria-label="Search"
|
|
/>
|
|
{isLoading && (
|
|
<div data-testid="search-loading" className="absolute right-12 top-1/2 -translate-y-1/2">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-600" />
|
|
</div>
|
|
)}
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">
|
|
{shortcutHint}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|