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 inputValue = $derived(omniboxStore.inputValue);
14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let suggestions = $derived(omniboxStore.suggestions);
16 let matches = $derived(omniboxStore.matches);
17 let searchShortcuts = $derived(omniboxStore.searchShortcuts);
18
19 // Input element ref and responsive styles (props for parent to bind)
20 interface Props {
21 inputElement?: HTMLInputElement;
22 responsiveStyles?: {
23 fontSize: string;
24 inputPadding: string;
25 };
26 }
27
28 let { inputElement = $bindable(), responsiveStyles }: Props = $props();
29
30 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
31
32 interface CommandBadge {
33 prefix: string;
34 label: string;
35 }
36
37 // Build command badges dynamically from search shortcuts
38 let COMMAND_BADGES = $derived(
39 Object.entries(searchShortcuts).map(([key, shortcut]) => ({
40 prefix: `${key}:`,
41 label: shortcut.description || key.toUpperCase()
42 }))
43 );
44
45 const BADGE_LEFT_OFFSET = '64px';
46
47 function resolveAccentColor(): string {
48 if (!inputElement) return ACCENT_FALLBACK;
49 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
50 if (inlineAccent && inlineAccent.trim()) {
51 return inlineAccent.trim();
52 }
53 try {
54 const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
55 if (computedAccent && computedAccent.trim()) {
56 return computedAccent.trim();
57 }
58 } catch { /* no-op */ }
59 return ACCENT_FALLBACK;
60 }
61
62 // Computed placeholder
63 let placeholder = $derived(
64 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
65 );
66
67 const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
68
69 function normalizePadding(padding?: string): [string, string, string, string] {
70 if (!padding) return DEFAULT_PADDING;
71 const parts = padding.trim().split(/\s+/).filter(Boolean);
72 if (parts.length === 0) return DEFAULT_PADDING;
73 const p0 = parts[0] ?? DEFAULT_PADDING[0];
74 const p1 = parts[1] ?? p0;
75 const p2 = parts[2] ?? p0;
76 const p3 = parts[3] ?? p1;
77
78 if (parts.length === 1) return [p0, p0, p0, p0];
79 if (parts.length === 2) return [p0, p1, p0, p1];
80 if (parts.length === 3) return [p0, p1, p2, p1];
81 return [p0, p1, p2, p3];
82 }
83
84 let activeBadge = $state<CommandBadge | null>(null);
85 let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
86 let inputPadding = $state(DEFAULT_PADDING.join(' '));
87 let baseLeftPadding = $state(DEFAULT_PADDING[3]);
88 let inputFontSize = $state(responsiveStyles?.fontSize || '16px');
89
90 $effect(() => {
91 inputFontSize = responsiveStyles?.fontSize || '16px';
92 });
93
94 $effect(() => {
95 basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
96 });
97
98 $effect(() => {
99 const value = inputValue || '';
100 const trimmedValue = value.trimStart();
101 activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
102 });
103
104 $effect(() => {
105 const [top, right, bottom, left] = basePaddingSegments;
106 baseLeftPadding = left;
107 const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
108 inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
109 });
110
111 // Handle input changes
112 function handleInput(event: Event) {
113 const target = event.target as HTMLInputElement;
114 const value = target.value || '';
115
116 omniboxStore.setInputValue(value);
117 omniboxStore.setFaded(false);
118
119 if (mode === 'omnibox') {
120 // Debounced search for omnibox
121 debouncedQuery(value);
122 } else if (mode === 'find') {
123 // Immediate find for search
124 findInPage(value);
125 }
126 }
127
128 // Handle key events
129 function handleKeyDown(event: KeyboardEvent) {
130 switch (event.key) {
131 case 'Escape':
132 event.preventDefault();
133 event.stopPropagation();
134 omniboxStore.close();
135 break;
136
137 case 'Enter':
138 event.preventDefault();
139 event.stopPropagation();
140 handleEnterKey(event);
141 break;
142
143 case 'ArrowDown':
144 case 'ArrowUp':
145 event.preventDefault();
146 event.stopPropagation();
147 handleArrowKeys(event.key);
148 break;
149
150 default:
151 // For normal typing keys, only stop propagation to prevent page handlers
152 // but don't prevent default so typing still works in the input
153 event.stopPropagation();
154 // Any other key should restore full opacity
155 omniboxStore.setFaded(false);
156 break;
157 }
158 }
159
160 // Handle Enter key based on mode
161 function handleEnterKey(event: KeyboardEvent) {
162 if (mode === 'omnibox') {
163 const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;
164 const url = selectedSuggestion?.url || inputValue || '';
165
166 if (url) {
167 omniboxBridge.navigate(url);
168 omniboxStore.close();
169 }
170 } else if (mode === 'find') {
171 if (event.shiftKey) {
172 // Shift+Enter: jump to previous match
173 jumpToMatch(-1);
174 } else if (event.altKey) {
175 // Alt+Enter: center on current match but keep overlay open
176 revealCurrentMatch();
177 } else {
178 // Enter: center and close
179 revealCurrentMatch();
180 omniboxStore.close();
181 }
182 }
183 }
184
185 // Handle arrow key navigation
186 function handleArrowKeys(key: string) {
187 const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
188
189 if (totalItems > 0) {
190 if (key === 'ArrowDown') {
191 omniboxStore.selectNext();
192 } else {
193 omniboxStore.selectPrevious();
194 }
195
196 // Fade overlay while navigating results only for find mode
197 if (mode === 'find') {
198 omniboxStore.setFaded(true);
199 } else {
200 omniboxStore.setFaded(false);
201 }
202
203 // For find mode, reveal the current match
204 if (mode === 'find') {
205 revealCurrentMatch();
206 }
207 }
208 }
209
210 // Jump to match (for find mode)
211 function jumpToMatch(delta: number) {
212 if (mode !== 'find' || matches.length === 0) return;
213
214 const currentIndex = selectedIndex || 0;
215 const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
216
217 omniboxStore.setSelectedIndex(newIndex);
218 revealCurrentMatch();
219 }
220
221 // Reveal current match (for find mode)
222 function revealCurrentMatch() {
223 if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
224
225 // Remove previous active class
226 const prevMatch = matches[omniboxStore.activeIndex];
227 if (prevMatch?.element?.classList) {
228 prevMatch.element.classList.remove('find-active');
229 }
230
231 // Add active class to current match
232 const currentMatch = matches[selectedIndex];
233 if (currentMatch?.element?.classList) {
234 currentMatch.element.classList.add('find-active');
235 omniboxStore.setActiveIndex(selectedIndex);
236
237 // Scroll into view
238 try {
239 currentMatch.element.scrollIntoView({
240 block: 'center',
241 inline: 'nearest'
242 });
243 } catch {
244 currentMatch.element.scrollIntoView();
245 }
246 }
247 }
248
249 // Handle mouse events
250 function handleMouseDown() {
251 omniboxStore.setFaded(false);
252 }
253
254 function handleClick() {
255 omniboxStore.setFaded(false);
256 }
257
258 function handleFocus() {
259 omniboxStore.setFaded(false);
260 if (inputElement) {
261 const accent = resolveAccentColor();
262 inputElement.style.setProperty('--dumber-input-border-color', accent);
263 }
264 }
265
266 function handleBlur() {
267 if (!inputElement) return;
268 const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
269 const fallback = (base && base.trim()) || 'var(--dynamic-border)';
270 inputElement.style.setProperty('--dumber-input-border-color', fallback);
271 }
272
273 function handleMouseEnter() {
274 if (omniboxStore.visible && inputElement) {
275 try {
276 inputElement.focus({ preventScroll: true });
277 inputElement.select();
278 } catch {
279 inputElement.focus();
280 }
281 }
282 }
283</script>
284
285<div class="omnibox-input-wrapper">
286 {#if activeBadge}
287 <span
288 class="omnibox-prefix-badge"
289 aria-hidden="true"
290 style={`left: ${baseLeftPadding};`}
291 >{activeBadge.label}</span>
292 {/if}
293
294 <input
295 bind:this={inputElement}
296 type="text"
297 {placeholder}
298 value={inputValue}
299 class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
300 style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
301 oninput={handleInput}
302 onkeydown={handleKeyDown}
303 onmousedown={handleMouseDown}
304 onclick={handleClick}
305 onfocus={handleFocus}
306 onblur={handleBlur}
307 onmouseenter={handleMouseEnter}
308 autocomplete="off"
309 spellcheck="false"
310 role="combobox"
311 aria-expanded={omniboxStore.hasContent}
312 aria-controls="omnibox-list"
313 aria-haspopup="listbox"
314 aria-owns="omnibox-list"
315 aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
316 />
317</div>
318
319<style>
320 .omnibox-input-wrapper {
321 position: relative;
322 }
323
324 .omnibox-input-field {
325 background: var(--dumber-input-bg, var(--dynamic-bg));
326 color: var(--dynamic-text);
327 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328 border-radius: 0;
329 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
330 }
331
332 .omnibox-input-field::placeholder {
333 color: var(--dynamic-muted);
334 }
335
336 .omnibox-input-field:focus {
337 color: var(--dynamic-text);
338 }
339
340 .omnibox-prefix-badge {
341 position: absolute;
342 top: 50%;
343 transform: translateY(-50%);
344 padding: 0.2rem 0.6rem;
345 border-radius: 0;
346 border: 1px solid var(--dynamic-border, transparent);
347 background: var(--dumber-input-badge-bg, var(--dynamic-accent));
348 color: var(--dumber-input-badge-text, var(--dynamic-bg));
349 font-size: 0.75rem;
350 font-weight: 600;
351 display: inline-flex;
352 align-items: center;
353 gap: 4px;
354 line-height: 1;
355 pointer-events: none;
356 white-space: nowrap;
357 box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
358 z-index: 1;
359 }
360</style>