/ src / components / search / SearchBar.svelte
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>