1<!--
2 Omnibox Suggestions Component
3
4 Displays search suggestions with favicons and URL highlighting
5-->
6<script lang="ts">
7 import { omniboxStore } from './stores.svelte.ts';
8 import { omniboxBridge } from './messaging';
9
10 // Reactive state
11 let suggestions = $derived(omniboxStore.suggestions);
12 let favorites = $derived(omniboxStore.favorites);
13 let viewMode = $derived(omniboxStore.viewMode);
14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let inputValue = $derived(omniboxStore.inputValue);
16 let hasContent = $state(false);
17
18 // Debug effect to log favorites changes
19 $effect(() => {
20 console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length);
21 });
22
23 // Computed: Get current list based on view mode with local filtering for favorites
24 let currentList = $derived.by(() => {
25 const list = viewMode === 'history' ? suggestions : favorites;
26
27 // In favorites view with input, filter locally
28 if (viewMode === 'favorites' && inputValue && inputValue.trim()) {
29 const query = inputValue.toLowerCase().trim();
30 return list.filter(fav =>
31 fav.url.toLowerCase().includes(query) ||
32 (fav.title && fav.title.toLowerCase().includes(query))
33 );
34 }
35
36 return list;
37 });
38
39 // Update hasContent - always show in omnibox mode to display header
40 $effect(() => {
41 hasContent = omniboxStore.mode === 'omnibox';
42 });
43
44 // Check if a URL is in favorites
45 function isFavorited(url: string): boolean {
46 return favorites.some(fav => fav.url === url);
47 }
48
49 // Handle suggestion item mouse enter
50 function handleItemMouseEnter(index: number) {
51 omniboxStore.setSelectedIndex(index);
52 omniboxStore.setFaded(false);
53 scrollToSelection();
54 }
55
56 // Handle suggestion item click
57 function handleItemClick(suggestion: any) {
58 omniboxBridge.navigate(suggestion.url);
59 omniboxStore.close();
60 }
61
62 // Handle keyboard activation on suggestion item
63 function handleItemKeyDown(event: KeyboardEvent, suggestion: any) {
64 if (event.key === 'Enter' || event.key === ' ') {
65 event.preventDefault();
66 handleItemClick(suggestion);
67 }
68 }
69
70 // Scroll list to show selected item
71 function scrollToSelection() {
72 const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`);
73 if (selectedItem && selectedItem.scrollIntoView) {
74 try {
75 selectedItem.scrollIntoView({ block: 'nearest' });
76 } catch {
77 selectedItem.scrollIntoView();
78 }
79 }
80 }
81
82 // Parse URL for display
83 function parseUrl(url: string) {
84 try {
85 const urlObj = new URL(url, window.location.href);
86 return {
87 domain: urlObj.hostname,
88 path: (urlObj.pathname || '') + (urlObj.search || '') + (urlObj.hash || '')
89 };
90 } catch {
91 return {
92 domain: url || '',
93 path: ''
94 };
95 }
96 }
97
98 // Handle favicon error
99 function handleFaviconError(event: Event) {
100 const target = event.target as HTMLImageElement;
101 const chip = target.parentElement;
102 if (chip) {
103 chip.style.display = 'none';
104 }
105 }
106
107 // Watch for selection changes to scroll
108 $effect(() => {
109 if (selectedIndex >= 0) {
110 scrollToSelection();
111 }
112 });
113</script>
114
115{#if hasContent}
116 <div
117 id="omnibox-list"
118 class="suggestion-list"
119 role="listbox"
120 aria-label="Search suggestions"
121 >
122 <!-- View mode indicator -->
123 <div class="view-mode-header">
124 <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
125 History ({suggestions.length})
126 </span>
127 <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
128 Favorites ({favorites.length})
129 </span>
130 <span class="view-hint">Tab to switch</span>
131 </div>
132
133 {#if currentList.length === 0}
134 <!-- Empty state -->
135 {#if viewMode === 'favorites'}
136 <!-- Always show empty state for favorites -->
137 <div class="empty-state">
138 <span class="empty-icon">⭐</span>
139 <span class="empty-text">No favorites yet</span>
140 <span class="empty-hint">Press Space on any item in History to add it here</span>
141 </div>
142 {:else if inputValue && inputValue.trim()}
143 <!-- Only show "No results found" for history when user has typed something -->
144 <div class="empty-state">
145 <span class="empty-icon">🔍</span>
146 <span class="empty-text">No results found</span>
147 </div>
148 {/if}
149 {:else}
150 {#each currentList as item, index (item.url)}
151 {@const { domain, path } = parseUrl(item.url)}
152 {@const isSelected = index === selectedIndex}
153 {@const favicon = item.favicon || item.favicon_url || ''}
154 {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155 {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null}
156
157 <div
158 id="omnibox-item-{index}"
159 class:suggestion-item={true}
160 class:selected={isSelected}
161 class:favorited={isFav}
162 role="option"
163 tabindex={isSelected ? 0 : -1}
164 aria-selected={isSelected}
165 onmouseenter={() => handleItemMouseEnter(index)}
166 onclick={() => handleItemClick(item)}
167 onkeydown={(e) => handleItemKeyDown(e, item)}
168 >
169 <!-- Favicon chip -->
170 {#if favicon}
171 <div class="suggestion-favicon">
172 <img
173 src={favicon}
174 alt=""
175 class="suggestion-favicon-img"
176 loading="lazy"
177 onerror={handleFaviconError}
178 />
179 </div>
180 {/if}
181
182 <!-- URL text with gradient fade -->
183 <div class="suggestion-text">
184 <!-- Domain -->
185 <span class="suggestion-domain">
186 {domain}
187 </span>
188
189 <!-- Separator -->
190 <span class="suggestion-separator">
191 {' | '}
192 </span>
193
194 <!-- Path -->
195 <span class="suggestion-path">
196 {path || '/'}
197 </span>
198 </div>
199
200 <!-- Keyboard shortcut hint (Ctrl+1-9, Ctrl+0 for 10th) -->
201 {#if shortcutNumber !== null}
202 <div class="suggestion-shortcut-hint" aria-hidden="true">
203 {shortcutNumber}
204 </div>
205 {/if}
206 </div>
207 {/each}
208 {/if}
209 </div>
210{/if}
211
212
213
214<style>
215 .suggestion-list {
216 margin-top: 0.5rem;
217 max-height: 50vh;
218 overflow-y: auto;
219 border-top: 1px solid var(--dynamic-border);
220 background: var(--dynamic-bg);
221 border-radius: 0 0 2px 2px;
222 }
223
224 .view-mode-header {
225 display: flex;
226 gap: 0.5rem;
227 align-items: center;
228 padding: 0.5rem 0.85rem;
229 border-bottom: 1px solid var(--dynamic-border);
230 background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text));
231 font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
232 }
233
234 .view-tab {
235 padding: 0.25rem 0.75rem;
236 border-radius: 2px;
237 font-size: 0.85rem;
238 font-weight: 500;
239 color: var(--dynamic-muted);
240 transition: all 100ms ease;
241 }
242
243 .view-tab.active {
244 color: var(--dynamic-accent);
245 background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent);
246 }
247
248 .view-hint {
249 margin-left: auto;
250 font-size: 0.75rem;
251 color: var(--dynamic-muted);
252 opacity: 0.7;
253 }
254
255 .empty-state {
256 display: flex;
257 flex-direction: column;
258 align-items: center;
259 justify-content: center;
260 padding: 2rem 1rem;
261 gap: 0.5rem;
262 color: var(--dynamic-muted);
263 text-align: center;
264 }
265
266 .empty-icon {
267 font-size: 2rem;
268 opacity: 0.5;
269 }
270
271 .empty-text {
272 font-size: 0.95rem;
273 font-weight: 500;
274 color: var(--dynamic-text);
275 opacity: 0.7;
276 }
277
278 .empty-hint {
279 font-size: 0.8rem;
280 opacity: 0.6;
281 max-width: 300px;
282 }
283
284 .suggestion-item {
285 display: flex;
286 gap: 0.75rem;
287 align-items: center;
288 padding: 0.85rem 0.85rem;
289 border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
290 cursor: pointer;
291 transition: background-color 100ms ease, border-left-color 100ms ease;
292 letter-spacing: normal;
293 border-left: 3px solid transparent;
294 position: relative;
295 font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
296 }
297
298 .suggestion-item:last-child {
299 border-bottom: none;
300 }
301
302 /* Favorited items get a permanent yellow left border */
303 .suggestion-item.favorited {
304 border-left-color: #f59e0b;
305 }
306
307 .suggestion-item.selected,
308 .suggestion-item:hover,
309 .suggestion-item:focus-visible {
310 background: color-mix(in srgb, var(--dynamic-accent) 15%, var(--dynamic-bg));
311 border-left-color: var(--dynamic-accent);
312 color: var(--dynamic-text);
313 outline: none;
314 }
315
316 /* Keep yellow border even when selected/hover for favorited items */
317 .suggestion-item.favorited.selected,
318 .suggestion-item.favorited:hover,
319 .suggestion-item.favorited:focus-visible {
320 border-left-color: #f59e0b;
321 }
322
323 .suggestion-item.selected {
324 font-weight: 500;
325 }
326
327 .suggestion-item .suggestion-domain {
328 color: var(--dynamic-text);
329 opacity: 0.95;
330 }
331
332 .suggestion-item .suggestion-separator {
333 color: var(--dynamic-muted);
334 }
335
336 .suggestion-item .suggestion-path {
337 color: var(--dynamic-muted);
338 }
339
340 .suggestion-favicon {
341 width: 24px;
342 height: 24px;
343 border: none;
344 border-radius: 3px;
345 display: flex;
346 align-items: center;
347 justify-content: center;
348 background: transparent;
349 flex-shrink: 0;
350 overflow: hidden;
351 }
352
353 .suggestion-favicon-img {
354 width: 24px;
355 height: 24px;
356 object-fit: contain;
357 image-rendering: auto;
358 }
359
360 .suggestion-text {
361 flex: 1;
362 min-width: 0;
363 display: flex;
364 gap: 0.75rem;
365 align-items: center;
366 white-space: nowrap;
367 overflow: hidden;
368 text-overflow: ellipsis;
369 mask-image: linear-gradient(90deg, black 85%, transparent 100%);
370 -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%);
371 }
372
373 .suggestion-shortcut-hint {
374 position: absolute;
375 right: 0.85rem;
376 top: 50%;
377 transform: translateY(-50%);
378 font-size: 0.75rem;
379 font-weight: 600;
380 color: var(--dynamic-muted);
381 opacity: 0.6;
382 background: var(--dynamic-bg);
383 padding: 0.25rem 0.5rem;
384 border-radius: 3px;
385 border: 1px solid var(--dynamic-border);
386 z-index: 10;
387 pointer-events: none;
388 transition: opacity 100ms ease, color 100ms ease;
389 }
390
391 .suggestion-item.selected .suggestion-shortcut-hint,
392 .suggestion-item:hover .suggestion-shortcut-hint {
393 opacity: 0.9;
394 color: var(--dynamic-accent);
395 }
396</style>