SearchBar.svelte
1 <script lang="ts"> 2 import type { SearchResponse, IndexedDocument } from '../../lib/types/search'; 3 4 interface Props { 5 placeholder?: string; 6 onSearch?: (query: string) => void; 7 onResultSelect?: (doc: IndexedDocument) => void; 8 results?: SearchResponse | null; 9 loading?: boolean; 10 } 11 12 let { 13 placeholder = 'Search workspaces and tabs...', 14 onSearch, 15 onResultSelect, 16 results = null, 17 loading = false, 18 }: Props = $props(); 19 20 let query = $state(''); 21 let showResults = $state(false); 22 let selectedIndex = $state(-1); 23 24 const hasResults = $derived(results && results.results.length > 0); 25 26 function handleInput(e: Event) { 27 const target = e.target as HTMLInputElement; 28 query = target.value; 29 selectedIndex = -1; 30 31 if (query.length >= 2) { 32 onSearch?.(query); 33 showResults = true; 34 } else { 35 showResults = false; 36 } 37 } 38 39 function handleKeydown(e: KeyboardEvent) { 40 if (!hasResults) return; 41 42 if (e.key === 'ArrowDown') { 43 e.preventDefault(); 44 selectedIndex = Math.min(selectedIndex + 1, (results?.results.length || 0) - 1); 45 } else if (e.key === 'ArrowUp') { 46 e.preventDefault(); 47 selectedIndex = Math.max(selectedIndex - 1, -1); 48 } else if (e.key === 'Enter' && selectedIndex >= 0) { 49 e.preventDefault(); 50 const selected = results?.results[selectedIndex]; 51 if (selected) { 52 selectResult(selected.document); 53 } 54 } else if (e.key === 'Escape') { 55 showResults = false; 56 selectedIndex = -1; 57 } 58 } 59 60 function selectResult(doc: IndexedDocument) { 61 onResultSelect?.(doc); 62 showResults = false; 63 query = ''; 64 selectedIndex = -1; 65 } 66 67 function handleBlur() { 68 // Delay to allow click on result 69 setTimeout(() => { 70 showResults = false; 71 }, 200); 72 } 73 74 function handleFocus() { 75 if (query.length >= 2 && hasResults) { 76 showResults = true; 77 } 78 } 79 80 function clearSearch() { 81 query = ''; 82 showResults = false; 83 selectedIndex = -1; 84 } 85 </script> 86 87 <div class="search-container"> 88 <div class="search-input-wrapper"> 89 <svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 90 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> 91 </svg> 92 93 <input 94 type="text" 95 class="search-input" 96 {placeholder} 97 value={query} 98 oninput={handleInput} 99 onkeydown={handleKeydown} 100 onblur={handleBlur} 101 onfocus={handleFocus} 102 /> 103 104 {#if loading} 105 <div class="search-spinner"></div> 106 {:else if query} 107 <button class="search-clear" onclick={clearSearch} type="button" title="Clear search"> 108 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 109 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 110 </svg> 111 </button> 112 {/if} 113 </div> 114 115 {#if showResults && results} 116 <div class="search-results"> 117 {#if results.results.length === 0} 118 <div class="no-results"> 119 <p>No results found for "{query}"</p> 120 <p class="hint">Try different keywords or check spelling</p> 121 </div> 122 {:else} 123 <div class="results-header"> 124 <span class="results-count">{results.totalMatches} result{results.totalMatches === 1 ? '' : 's'}</span> 125 <span class="results-time">{results.duration.toFixed(1)}ms</span> 126 </div> 127 128 <ul class="results-list"> 129 {#each results.results as result, i (result.document.id)} 130 <li> 131 <button 132 class="result-item" 133 class:selected={i === selectedIndex} 134 onclick={() => selectResult(result.document)} 135 type="button" 136 > 137 <span class="result-type" class:is-workspace={result.document.type === 'workspace'} class:is-resource={result.document.type === 'resource'}> 138 {#if result.document.type === 'workspace'} 139 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 140 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /> 141 </svg> 142 {:else if result.document.type === 'resource'} 143 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 144 <path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd" /> 145 </svg> 146 {:else} 147 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 148 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd" /> 149 </svg> 150 {/if} 151 </span> 152 <div class="result-content"> 153 <span class="result-title">{result.document.title}</span> 154 <span class="result-meta"> 155 {#if result.document.type === 'tab'} 156 <span class="result-item-type-badge is-tab">Tab</span> 157 {:else if result.document.type === 'resource'} 158 <span class="result-item-type-badge is-resource">Resource</span> 159 {/if} 160 {#if result.document.type === 'tab' || result.document.type === 'resource'} 161 {#if result.document.domain} 162 <span class="result-domain">{result.document.domain}</span> 163 {/if} 164 {/if} 165 {#if result.document.parentName && result.document.workspaceName} 166 <span class="result-workspace">{result.document.parentName} › {result.document.workspaceName}</span> 167 {:else if result.document.workspaceName && (result.document.type === 'tab' || result.document.type === 'resource')} 168 <span class="result-workspace">in {result.document.workspaceName}</span> 169 {:else if result.document.workspaceType === 'child' && result.document.parentName} 170 <span class="result-workspace">Child of {result.document.parentName}</span> 171 {:else if result.document.workspaceType} 172 <span class="result-type-badge">{result.document.workspaceType}</span> 173 {/if} 174 </span> 175 </div> 176 <span class="result-score" title="Relevance score"> 177 {Math.round(result.score * 100)}% 178 </span> 179 </button> 180 </li> 181 {/each} 182 </ul> 183 {/if} 184 </div> 185 {/if} 186 </div> 187 188 <style> 189 .search-container { 190 position: relative; 191 width: 100%; 192 } 193 194 .search-input-wrapper { 195 position: relative; 196 display: flex; 197 align-items: center; 198 } 199 200 .search-icon { 201 position: absolute; 202 left: 0.75rem; 203 width: 1.25rem; 204 height: 1.25rem; 205 color: var(--color-text-muted); 206 pointer-events: none; 207 } 208 209 .search-input { 210 width: 100%; 211 padding: 0.625rem 2.5rem 0.625rem 2.5rem; 212 font-size: 0.875rem; 213 background-color: var(--color-bg-light); 214 border: 1px solid rgba(217, 137, 46, 0.2); 215 border-radius: 6px; 216 color: var(--color-text-primary); 217 outline: none; 218 transition: border-color 0.15s, box-shadow 0.15s; 219 } 220 221 .search-input::placeholder { 222 color: var(--color-text-muted); 223 } 224 225 .search-input:focus { 226 border-color: var(--color-phosphor); 227 box-shadow: 0 0 0 2px rgba(217, 137, 46, 0.1); 228 } 229 230 .search-spinner { 231 position: absolute; 232 right: 0.75rem; 233 width: 1rem; 234 height: 1rem; 235 border: 2px solid var(--color-bg-light); 236 border-top-color: var(--color-phosphor); 237 border-radius: 50%; 238 animation: spin 0.6s linear infinite; 239 } 240 241 @keyframes spin { 242 to { transform: rotate(360deg); } 243 } 244 245 .search-clear { 246 position: absolute; 247 right: 0.5rem; 248 padding: 0.25rem; 249 background: transparent; 250 border: none; 251 color: var(--color-text-muted); 252 cursor: pointer; 253 border-radius: 4px; 254 transition: color 0.15s; 255 } 256 257 .search-clear:hover { 258 color: var(--color-text-primary); 259 } 260 261 .search-results { 262 position: absolute; 263 top: calc(100% + 0.5rem); 264 left: 0; 265 right: 0; 266 max-height: 400px; 267 overflow-y: auto; 268 background-color: var(--color-bg-light); 269 border: 1px solid rgba(217, 137, 46, 0.3); 270 border-radius: 6px; 271 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 272 z-index: 50; 273 } 274 275 .no-results { 276 padding: 1.5rem; 277 text-align: center; 278 color: var(--color-text-secondary); 279 } 280 281 .no-results .hint { 282 font-size: 0.75rem; 283 color: var(--color-text-muted); 284 margin-top: 0.25rem; 285 } 286 287 .results-header { 288 display: flex; 289 justify-content: space-between; 290 align-items: center; 291 padding: 0.5rem 0.75rem; 292 font-size: 0.6875rem; 293 color: var(--color-text-muted); 294 border-bottom: 1px solid rgba(217, 137, 46, 0.15); 295 text-transform: uppercase; 296 letter-spacing: 0.05em; 297 } 298 299 .results-list { 300 list-style: none; 301 margin: 0; 302 padding: 0.25rem 0; 303 } 304 305 .result-item { 306 width: 100%; 307 display: flex; 308 align-items: center; 309 gap: 0.75rem; 310 padding: 0.625rem 0.75rem; 311 background: transparent; 312 border: none; 313 cursor: pointer; 314 text-align: left; 315 transition: background-color 0.15s; 316 } 317 318 .result-item:hover, 319 .result-item.selected { 320 background-color: rgba(217, 137, 46, 0.1); 321 } 322 323 .result-type { 324 flex-shrink: 0; 325 color: var(--color-text-muted); 326 } 327 328 .result-type.is-workspace { 329 color: var(--color-phosphor); 330 } 331 332 .result-type.is-resource { 333 color: #8b9cf7; 334 } 335 336 .result-content { 337 flex: 1; 338 min-width: 0; 339 display: flex; 340 flex-direction: column; 341 gap: 0.125rem; 342 } 343 344 .result-title { 345 font-size: 0.875rem; 346 color: var(--color-text-primary); 347 white-space: nowrap; 348 overflow: hidden; 349 text-overflow: ellipsis; 350 } 351 352 .result-meta { 353 display: flex; 354 align-items: center; 355 gap: 0.5rem; 356 font-size: 0.6875rem; 357 color: var(--color-text-muted); 358 } 359 360 .result-domain { 361 padding: 0.125rem 0.375rem; 362 background: rgba(217, 137, 46, 0.15); 363 border-radius: 3px; 364 color: var(--color-phosphor); 365 } 366 367 .result-score { 368 flex-shrink: 0; 369 font-size: 0.625rem; 370 font-family: var(--font-mono); 371 color: var(--color-text-muted); 372 opacity: 0.7; 373 } 374 375 .result-type-badge { 376 padding: 0.0625rem 0.25rem; 377 font-size: 0.5625rem; 378 text-transform: uppercase; 379 letter-spacing: 0.03em; 380 background: rgba(217, 137, 46, 0.1); 381 border-radius: 2px; 382 color: var(--color-text-muted); 383 } 384 385 .result-item-type-badge { 386 padding: 0.0625rem 0.375rem; 387 font-size: 0.5625rem; 388 text-transform: uppercase; 389 letter-spacing: 0.03em; 390 border-radius: 2px; 391 font-weight: 500; 392 } 393 394 .result-item-type-badge.is-tab { 395 background: rgba(34, 197, 94, 0.15); 396 color: #4ade80; 397 } 398 399 .result-item-type-badge.is-resource { 400 background: rgba(139, 156, 247, 0.15); 401 color: #8b9cf7; 402 } 403 </style>