Files
stack/apps/web/src/components/search/SearchInput.tsx
Jason Woltje 3cb6eb7f8b feat(#67): implement search UI with filters and shortcuts
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>
2026-02-02 14:50:25 -06:00

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>
);
}