👁️
at dev 124 lines 3.1 kB view raw
1import { Search } from "lucide-react"; 2import { forwardRef, useImperativeHandle, useRef, useState } from "react"; 3 4export interface InputHighlight { 5 start: number; 6 end: number; 7 className?: string; 8} 9 10export interface InputError { 11 message: string; 12 start: number; 13 end: number; 14} 15 16interface HighlightedSearchInputProps { 17 defaultValue?: string; 18 highlights?: InputHighlight[]; 19 errors?: InputError[]; 20 onChange: (value: string) => void; 21 placeholder?: string; 22 className?: string; 23} 24 25export interface HighlightedSearchInputHandle { 26 focus: () => void; 27 value: string; 28 setValue: (value: string) => void; 29} 30 31export const HighlightedSearchInput = forwardRef< 32 HighlightedSearchInputHandle, 33 HighlightedSearchInputProps 34>(function HighlightedSearchInput( 35 { 36 defaultValue = "", 37 highlights = [], 38 errors = [], 39 onChange, 40 placeholder, 41 className = "", 42 }, 43 ref, 44) { 45 const inputRef = useRef<HTMLInputElement>(null); 46 const [text, setText] = useState(defaultValue); 47 48 useImperativeHandle(ref, () => ({ 49 focus: () => inputRef.current?.focus(), 50 get value() { 51 return inputRef.current?.value ?? ""; 52 }, 53 setValue: (value: string) => { 54 if (inputRef.current) { 55 inputRef.current.value = value; 56 setText(value); 57 } 58 }, 59 })); 60 61 const hasError = errors.length > 0; 62 63 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 64 setText(e.target.value); 65 onChange(e.target.value); 66 }; 67 68 // Combine all highlights - passed-in and errors 69 const allHighlights = [ 70 ...highlights, 71 ...errors.map((err) => ({ 72 start: err.start, 73 end: err.end, 74 className: "bg-red-200 dark:bg-red-900/60", 75 })), 76 ]; 77 78 return ( 79 <div 80 className={`relative flex items-center rounded-lg border transition-colors bg-gray-100 dark:bg-zinc-800 ${ 81 hasError 82 ? "border-red-500" 83 : "border-gray-300 dark:border-zinc-600 focus-within:border-cyan-500" 84 } ${className}`} 85 > 86 {/* Search icon - fixed, doesn't scroll */} 87 <div className="flex-shrink-0 pl-4"> 88 <Search className="w-5 h-5 text-gray-400" /> 89 </div> 90 91 {/* Scrollable area - hidden scrollbar */} 92 <div className="flex-1 overflow-x-auto scrollbar-none"> 93 <div 94 className="relative font-mono" 95 style={{ minWidth: `calc(${Math.max(text.length, 20)}ch + 1.5rem)` }} 96 > 97 {/* Highlight underlay - background colors at ch positions */} 98 {allHighlights.length > 0 && 99 allHighlights.map((hl) => ( 100 <span 101 key={`${hl.start}-${hl.end}`} 102 className={`absolute top-1/2 -translate-y-1/2 h-[1.2em] rounded-sm pointer-events-none ${hl.className ?? ""}`} 103 style={{ 104 left: `calc(${hl.start}ch + 0.75rem)`, 105 width: `${hl.end - hl.start}ch`, 106 }} 107 aria-hidden="true" 108 /> 109 ))} 110 111 {/* Input - visible text, transparent background */} 112 <input 113 ref={inputRef} 114 type="text" 115 placeholder={placeholder} 116 defaultValue={defaultValue} 117 onChange={handleChange} 118 className="relative w-full font-mono px-3 py-3 bg-transparent text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none" 119 /> 120 </div> 121 </div> 122 </div> 123 ); 124});