👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

highlight search error

+155 -30
+124
src/components/HighlightedSearchInput.tsx
··· 1 + import { Search } from "lucide-react"; 2 + import { forwardRef, useImperativeHandle, useRef, useState } from "react"; 3 + 4 + export interface InputHighlight { 5 + start: number; 6 + end: number; 7 + className?: string; 8 + } 9 + 10 + export interface InputError { 11 + message: string; 12 + start: number; 13 + end: number; 14 + } 15 + 16 + interface HighlightedSearchInputProps { 17 + defaultValue?: string; 18 + highlights?: InputHighlight[]; 19 + errors?: InputError[]; 20 + onChange: (value: string) => void; 21 + placeholder?: string; 22 + className?: string; 23 + } 24 + 25 + export interface HighlightedSearchInputHandle { 26 + focus: () => void; 27 + value: string; 28 + setValue: (value: string) => void; 29 + } 30 + 31 + export 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-slate-800 ${ 81 + hasError 82 + ? "border-red-500" 83 + : "border-gray-300 dark:border-slate-700 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 + });
+1
src/lib/search/index.ts
··· 109 109 export type { CompiledSearch as SearchResult }; 110 110 111 111 export { describeQuery } from "./describe"; 112 + export { IS_PREDICATE_NAMES } from "./fields"; 112 113 export { tokenize } from "./lexer"; 113 114 export { compile } from "./matcher"; 114 115
+22 -30
src/routes/cards/index.tsx
··· 1 1 import { useQueries, useQuery } from "@tanstack/react-query"; 2 2 import { createFileRoute } from "@tanstack/react-router"; 3 3 import { useWindowVirtualizer } from "@tanstack/react-virtual"; 4 - import { 5 - AlertCircle, 6 - ArrowUpDown, 7 - ChevronDown, 8 - Loader2, 9 - Search, 10 - } from "lucide-react"; 4 + import { AlertCircle, ArrowUpDown, ChevronDown, Loader2 } from "lucide-react"; 11 5 import { useEffect, useMemo, useRef, useState } from "react"; 12 6 import { CardSkeleton, CardThumbnail } from "@/components/CardImage"; 13 7 import { ClientDate } from "@/components/ClientDate"; 8 + import { 9 + HighlightedSearchInput, 10 + type HighlightedSearchInputHandle, 11 + } from "@/components/HighlightedSearchInput"; 14 12 import { OracleText } from "@/components/OracleText"; 15 13 import { SearchPrimer } from "@/components/SearchPrimer"; 16 14 import { ··· 190 188 isPending: isDebouncing, 191 189 flush: flushDebounce, 192 190 } = useDebounce(search.q || "", 400); 193 - const searchInputRef = useRef<HTMLInputElement>(null); 191 + const searchInputRef = useRef<HighlightedSearchInputHandle>(null); 194 192 const lastSyncedQuery = useRef<string>(search.q || ""); 195 193 196 194 // Sync URL → input only for external changes (back button, link clicks) ··· 202 200 } 203 201 // External change - sync input and skip debounce 204 202 if (searchInputRef.current) { 205 - searchInputRef.current.value = urlQuery; 203 + searchInputRef.current.setValue(urlQuery); 206 204 } 207 205 lastSyncedQuery.current = urlQuery; 208 206 flushDebounce(); ··· 402 400 </div> 403 401 </div> 404 402 405 - <div className="relative"> 406 - <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 407 - <input 408 - ref={searchInputRef} 409 - type="text" 410 - placeholder="Search by name or try t:creature cmc<=3" 411 - defaultValue={search.q} 412 - onChange={(e) => { 413 - lastSyncedQuery.current = e.target.value; 414 - navigate({ 415 - search: (prev) => ({ ...prev, q: e.target.value }), 416 - replace: true, 417 - }); 418 - }} 419 - className={`w-full pl-12 pr-4 py-3 bg-gray-100 dark:bg-slate-800 border rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none transition-colors ${ 420 - hasError 421 - ? "border-red-500 focus:border-red-500" 422 - : "border-gray-300 dark:border-slate-700 focus:border-cyan-500" 423 - }`} 424 - /> 425 - </div> 403 + <HighlightedSearchInput 404 + ref={searchInputRef} 405 + placeholder="Search by name or try t:creature cmc<=3" 406 + defaultValue={search.q} 407 + errors={ 408 + isDebouncing ? [] : firstPage?.error ? [firstPage.error] : [] 409 + } 410 + onChange={(value) => { 411 + lastSyncedQuery.current = value; 412 + navigate({ 413 + search: (prev) => ({ ...prev, q: value }), 414 + replace: true, 415 + }); 416 + }} 417 + /> 426 418 427 419 {hasError && firstPage?.error && ( 428 420 <div className="mt-2 flex items-start gap-2 text-sm text-red-500 dark:text-red-400">
+8
src/styles.css
··· 105 105 background: var(--color-gray-500); 106 106 } 107 107 108 + /* Hide scrollbar utility */ 109 + .scrollbar-none { 110 + scrollbar-width: none; 111 + } 112 + .scrollbar-none::-webkit-scrollbar { 113 + display: none; 114 + } 115 + 108 116 /* Shimmer animation for loading indicator */ 109 117 @keyframes shimmer { 110 118 0%, 100% {