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