1<!--
2 Omnibox Input Component
3
4 Input field with keyboard handling and debounced search
5-->
6<script lang="ts">
7 import { omniboxStore } from './stores.svelte.ts';
8 import { debouncedQuery, omniboxBridge } from './messaging';
9 import { findInPage } from './find';
10
11 // Reactive state
12 let mode = $derived(omniboxStore.mode);
13 let viewMode = $derived(omniboxStore.viewMode);
14 let inputValue = $derived(omniboxStore.inputValue);
15 let selectedIndex = $derived(omniboxStore.selectedIndex);
16 let suggestions = $derived(omniboxStore.suggestions);
17 let favorites = $derived(omniboxStore.favorites);
18 let matches = $derived(omniboxStore.matches);
19 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
20
21 // Input element ref and responsive styles (props for parent to bind)
22 interface Props {
23 inputElement?: HTMLInputElement;
24 responsiveStyles?: {
25 fontSize: string;
26 inputPadding: string;
27 };
28 }
29
30 let { inputElement = $bindable(), responsiveStyles }: Props = $props();
31
32 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
33
34 interface CommandBadge {
35 prefix: string;
36 label: string;
37 }
38
39 // Build command badges dynamically from search shortcuts
40 let COMMAND_BADGES = $derived(
41 Object.entries(searchShortcuts).map(([key, shortcut]) => ({
42 prefix: `${key}:`,
43 label: shortcut.description || key.toUpperCase()
44 }))
45 );
46
47 const BADGE_LEFT_OFFSET = '64px';
48
49 function resolveAccentColor(): string {
50 if (!inputElement) return ACCENT_FALLBACK;
51 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
52 if (inlineAccent && inlineAccent.trim()) {
53 return inlineAccent.trim();
54 }
55 try {
56 const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
57 if (computedAccent && computedAccent.trim()) {
58 return computedAccent.trim();
59 }
60 } catch { /* no-op */ }
61 return ACCENT_FALLBACK;
62 }
63
64 // Computed placeholder
65 let placeholder = $derived(
66 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
67 );
68
69 const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
70
71 function normalizePadding(padding?: string): [string, string, string, string] {
72 if (!padding) return DEFAULT_PADDING;
73 const parts = padding.trim().split(/\s+/).filter(Boolean);
74 if (parts.length === 0) return DEFAULT_PADDING;
75 const p0 = parts[0] ?? DEFAULT_PADDING[0];
76 const p1 = parts[1] ?? p0;
77 const p2 = parts[2] ?? p0;
78 const p3 = parts[3] ?? p1;
79
80 if (parts.length === 1) return [p0, p0, p0, p0];
81 if (parts.length === 2) return [p0, p1, p0, p1];
82 if (parts.length === 3) return [p0, p1, p2, p1];
83 return [p0, p1, p2, p3];
84 }
85
86 let activeBadge = $state<CommandBadge | null>(null);
87 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
88 let inputPadding = $state(DEFAULT_PADDING.join(' '));
89 let baseLeftPadding = $state(DEFAULT_PADDING[3]);
90 let inputFontSize = $state(responsiveStyles?.fontSize || '16px');
91
92 $effect(() => {
93 inputFontSize = responsiveStyles?.fontSize || '16px';
94 });
95
96 $effect(() => {
97 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
98 });
99
100 $effect(() => {
101 const value = inputValue || '';
102 const trimmedValue = value.trimStart();
103 activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
104 });
105
106 $effect(() => {
107 const [top, right, bottom, left] = basePaddingSegments;
108 baseLeftPadding = left;
109 const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
110 inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
111 });
112
113 // Handle input changes
114 function handleInput(event: Event) {
115 const target = event.target as HTMLInputElement;
116 const value = target.value || '';
117
118 omniboxStore.setInputValue(value);
119 omniboxStore.setFaded(false);
120
121 if (mode === 'omnibox') {
122 // Only query backend if we're in history view
123 // In favorites view, filtering happens locally via computed state
124 if (viewMode === 'history') {
125 debouncedQuery(value);
126 }
127 } else if (mode === 'find') {
128 // Immediate find for search
129 findInPage(value);
130 }
131 }
132
133 // Handle key events
134 function handleKeyDown(event: KeyboardEvent) {
135 switch (event.key) {
136 case 'Escape':
137 event.preventDefault();
138 event.stopPropagation();
139
140 if (inputValue && inputValue.trim() !== '') {
141 // Input has text - clear it but keep omnibox open
142 omniboxStore.setInputValue('');
143
144 // In find mode, clear the search highlights
145 if (mode === 'find') {
146 findInPage('');
147 }
148 } else {
149 // Input is empty - close omnibox immediately
150 omniboxStore.close();
151 }
152 break;
153
154 case 'Enter':
155 event.preventDefault();
156 event.stopPropagation();
157 handleEnterKey(event);
158 break;
159
160 case 'Tab':
161 // Only handle Tab in omnibox mode (not in find mode)
162 if (mode === 'omnibox') {
163 event.preventDefault();
164 event.stopPropagation();
165 // Toggle between history and favorites views
166 const newViewMode = viewMode === 'history' ? 'favorites' : 'history';
167 omniboxStore.setViewMode(newViewMode);
168 }
169 break;
170
171 case ' ':
172 // Only handle Space in omnibox mode when an item is selected
173 if (mode === 'omnibox' && selectedIndex >= 0) {
174 event.preventDefault();
175 event.stopPropagation();
176 handleSpaceKey();
177 }
178 break;
179
180 case 'ArrowDown':
181 case 'ArrowUp':
182 event.preventDefault();
183 event.stopPropagation();
184 handleArrowKeys(event.key);
185 break;
186
187 default:
188 // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results
189 // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)
190 if (event.ctrlKey && mode === 'omnibox') {
191 const code = event.code;
192 let targetIndex = -1;
193
194 // Map physical keys: Digit1->0, Digit2->1, ..., Digit9->8, Digit0->9
195 if (code >= 'Digit1' && code <= 'Digit9') {
196 targetIndex = parseInt(code.substring(5), 10) - 1;
197 } else if (code === 'Digit0') {
198 targetIndex = 9;
199 }
200
201 if (targetIndex >= 0) {
202 event.preventDefault();
203 event.stopPropagation();
204 handleNumberKey(targetIndex);
205 return;
206 }
207 }
208
209 // For normal typing keys, only stop propagation to prevent page handlers
210 // but don't prevent default so typing still works in the input
211 event.stopPropagation();
212 // Any other key should restore full opacity
213 omniboxStore.setFaded(false);
214 break;
215 }
216 }
217
218 // Handle Enter key based on mode
219 function handleEnterKey(event: KeyboardEvent) {
220 if (mode === 'omnibox') {
221 // Get selected item from current view (history or favorites)
222 const currentList = viewMode === 'history' ? suggestions : favorites;
223 const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null;
224 const url = selectedItem?.url || inputValue || '';
225
226 if (url) {
227 omniboxBridge.navigate(url);
228 omniboxStore.close();
229 }
230 } else if (mode === 'find') {
231 if (event.shiftKey) {
232 // Shift+Enter: jump to previous match
233 jumpToMatch(-1);
234 } else if (event.altKey) {
235 // Alt+Enter: center on current match but keep overlay open
236 revealCurrentMatch();
237 } else {
238 // Enter: center and close
239 revealCurrentMatch();
240 omniboxStore.close();
241 }
242 }
243 }
244
245 // Handle Space key to toggle favorite
246 function handleSpaceKey() {
247 if (mode !== 'omnibox' || selectedIndex < 0) return;
248
249 // Get the selected item based on current view mode
250 const item = viewMode === 'history'
251 ? suggestions[selectedIndex]
252 : favorites[selectedIndex];
253
254 if (!item || !item.url) return;
255
256 // Extract title and favicon
257 const title = 'title' in item ? item.title : '';
258 const faviconURL = item.favicon_url || item.favicon || '';
259
260 // Toggle the favorite status
261 omniboxBridge.toggleFavorite(item.url, title, faviconURL);
262
263 // Refresh the favorites list after toggling
264 setTimeout(() => {
265 omniboxBridge.fetchFavorites();
266 }, 100);
267 }
268
269 // Handle Ctrl+Number key for quick navigation
270 function handleNumberKey(targetIndex: number) {
271 if (mode !== 'omnibox') return;
272
273 // Get the current list based on view mode
274 const currentList = viewMode === 'history' ? suggestions : favorites;
275
276 // Check if the target index is valid
277 if (targetIndex >= currentList.length) return;
278
279 const item = currentList[targetIndex];
280 if (!item || !item.url) return;
281
282 // Navigate to the item and close omnibox
283 omniboxBridge.navigate(item.url);
284 omniboxStore.close();
285 }
286
287 // Handle arrow key navigation
288 function handleArrowKeys(key: string) {
289 let totalItems: number;
290 if (mode === 'omnibox') {
291 // Get count from current view (history or favorites)
292 totalItems = viewMode === 'history' ? suggestions.length : favorites.length;
293 } else {
294 totalItems = matches.length;
295 }
296
297 if (totalItems > 0) {
298 if (key === 'ArrowDown') {
299 omniboxStore.selectNext();
300 } else {
301 omniboxStore.selectPrevious();
302 }
303
304 // Fade overlay while navigating results only for find mode
305 if (mode === 'find') {
306 omniboxStore.setFaded(true);
307 } else {
308 omniboxStore.setFaded(false);
309 }
310
311 // For find mode, reveal the current match
312 if (mode === 'find') {
313 revealCurrentMatch();
314 }
315 }
316 }
317
318 // Jump to match (for find mode)
319 function jumpToMatch(delta: number) {
320 if (mode !== 'find' || matches.length === 0) return;
321
322 const currentIndex = selectedIndex || 0;
323 const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
324
325 omniboxStore.setSelectedIndex(newIndex);
326 revealCurrentMatch();
327 }
328
329 // Reveal current match (for find mode)
330 function revealCurrentMatch() {
331 if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
332
333 // Remove previous active class
334 const prevMatch = matches[omniboxStore.activeIndex];
335 if (prevMatch?.element?.classList) {
336 prevMatch.element.classList.remove('find-active');
337 }
338
339 // Add active class to current match
340 const currentMatch = matches[selectedIndex];
341 if (currentMatch?.element?.classList) {
342 currentMatch.element.classList.add('find-active');
343 omniboxStore.setActiveIndex(selectedIndex);
344
345 // Scroll into view
346 try {
347 currentMatch.element.scrollIntoView({
348 block: 'center',
349 inline: 'nearest'
350 });
351 } catch {
352 currentMatch.element.scrollIntoView();
353 }
354 }
355 }
356
357 // Handle mouse events
358 function handleMouseDown() {
359 omniboxStore.setFaded(false);
360 }
361
362 function handleClick() {
363 omniboxStore.setFaded(false);
364 }
365
366 function handleFocus() {
367 omniboxStore.setFaded(false);
368 if (inputElement) {
369 const accent = resolveAccentColor();
370 inputElement.style.setProperty('--dumber-input-border-color', accent);
371 }
372 }
373
374 function handleBlur() {
375 if (!inputElement) return;
376 const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
377 const fallback = (base && base.trim()) || 'var(--dynamic-border)';
378 inputElement.style.setProperty('--dumber-input-border-color', fallback);
379 }
380
381 function handleMouseEnter() {
382 if (omniboxStore.visible && inputElement) {
383 try {
384 inputElement.focus({ preventScroll: true });
385 inputElement.select();
386 } catch {
387 inputElement.focus();
388 }
389 }
390 }
391</script>
392
393<div class="omnibox-input-wrapper">
394 {#if activeBadge}
395 <span
396 class="omnibox-prefix-badge"
397 aria-hidden="true"
398 style={`left: ${baseLeftPadding};`}
399 >{activeBadge.label}</span>
400 {/if}
401
402 <input
403 bind:this={inputElement}
404 type="text"
405 {placeholder}
406 value={inputValue}
407 class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
408 style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
409 oninput={handleInput}
410 onkeydown={handleKeyDown}
411 onmousedown={handleMouseDown}
412 onclick={handleClick}
413 onfocus={handleFocus}
414 onblur={handleBlur}
415 onmouseenter={handleMouseEnter}
416 autocomplete="off"
417 spellcheck="false"
418 role="combobox"
419 aria-expanded={omniboxStore.hasContent}
420 aria-controls="omnibox-list"
421 aria-haspopup="listbox"
422 aria-owns="omnibox-list"
423 aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
424 />
425</div>
426
427<style>
428 .omnibox-input-wrapper {
429 position: relative;
430 }
431
432 .omnibox-input-field {
433 background: var(--dumber-input-bg, var(--dynamic-bg));
434 color: var(--dynamic-text);
435 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
436 border-radius: 2px;
437 box-shadow:
438 inset 0 1px 2px rgba(0, 0, 0, 0.15),
439 inset 0 0 0 1px rgba(0, 0, 0, 0.03);
440 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
441 font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
442 letter-spacing: normal;
443 }
444
445 .omnibox-input-field::placeholder {
446 color: var(--dynamic-muted);
447 letter-spacing: normal;
448 }
449
450 .omnibox-input-field:focus {
451 color: var(--dynamic-text);
452 border-color: var(--dynamic-accent);
453 box-shadow:
454 inset 0 1px 2px rgba(0, 0, 0, 0.15),
455 0 0 0 1px var(--dynamic-accent);
456 }
457
458 .omnibox-prefix-badge {
459 position: absolute;
460 top: 50%;
461 transform: translateY(-50%);
462 padding: 0.2rem 0.6rem;
463 border-radius: 0;
464 border: 1px solid var(--dynamic-border, transparent);
465 background: var(--dumber-input-badge-bg, var(--dynamic-accent));
466 color: var(--dumber-input-badge-text, var(--dynamic-bg));
467 font-size: 0.75rem;
468 font-weight: 600;
469 display: inline-flex;
470 align-items: center;
471 gap: 4px;
472 line-height: 1;
473 pointer-events: none;
474 white-space: nowrap;
475 box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
476 z-index: 1;
477 }
478</style>