OmniboxInput.svelte RawHistoryFolder
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
18 // Input element ref and responsive styles (props for parent to bind)
19 interface Props {
20 inputElement?: HTMLInputElement;
21 responsiveStyles?: {
22 fontSize: string;
23 inputPadding: string;
24 };
25 }
26
27 let { inputElement = $bindable(), responsiveStyles }: Props = $props();
28
29 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
30
31 function resolveAccentColor(): string {
32 if (!inputElement) return ACCENT_FALLBACK;
33 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
34 if (inlineAccent && inlineAccent.trim()) {
35 return inlineAccent.trim();
36 }
37 try {
38 const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
39 if (computedAccent && computedAccent.trim()) {
40 return computedAccent.trim();
41 }
42 } catch { /* no-op */ }
43 return ACCENT_FALLBACK;
44 }
45
46 // Computed placeholder
47 let placeholder = $derived(
48 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
49 );
50
51 // Handle input changes
52 function handleInput(event: Event) {
53 const target = event.target as HTMLInputElement;
54 const value = target.value || '';
55
56 omniboxStore.setInputValue(value);
57 omniboxStore.setFaded(false);
58
59 if (mode === 'omnibox') {
60 // Debounced search for omnibox
61 debouncedQuery(value);
62 } else if (mode === 'find') {
63 // Immediate find for search
64 findInPage(value);
65 }
66 }
67
68 // Handle key events
69 function handleKeyDown(event: KeyboardEvent) {
70 switch (event.key) {
71 case 'Escape':
72 event.preventDefault();
73 event.stopPropagation();
74 omniboxStore.close();
75 break;
76
77 case 'Enter':
78 event.preventDefault();
79 event.stopPropagation();
80 handleEnterKey(event);
81 break;
82
83 case 'ArrowDown':
84 case 'ArrowUp':
85 event.preventDefault();
86 event.stopPropagation();
87 handleArrowKeys(event.key);
88 break;
89
90 default:
91 // For normal typing keys, only stop propagation to prevent page handlers
92 // but don't prevent default so typing still works in the input
93 event.stopPropagation();
94 // Any other key should restore full opacity
95 omniboxStore.setFaded(false);
96 break;
97 }
98 }
99
100 // Handle Enter key based on mode
101 function handleEnterKey(event: KeyboardEvent) {
102 if (mode === 'omnibox') {
103 const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;
104 const url = selectedSuggestion?.url || inputValue || '';
105
106 if (url) {
107 omniboxBridge.navigate(url);
108 omniboxStore.close();
109 }
110 } else if (mode === 'find') {
111 if (event.shiftKey) {
112 // Shift+Enter: jump to previous match
113 jumpToMatch(-1);
114 } else if (event.altKey) {
115 // Alt+Enter: center on current match but keep overlay open
116 revealCurrentMatch();
117 } else {
118 // Enter: center and close
119 revealCurrentMatch();
120 omniboxStore.close();
121 }
122 }
123 }
124
125 // Handle arrow key navigation
126 function handleArrowKeys(key: string) {
127 const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
128
129 if (totalItems > 0) {
130 if (key === 'ArrowDown') {
131 omniboxStore.selectNext();
132 } else {
133 omniboxStore.selectPrevious();
134 }
135
136 // Fade overlay while navigating results
137 omniboxStore.setFaded(true);
138
139 // For find mode, reveal the current match
140 if (mode === 'find') {
141 revealCurrentMatch();
142 }
143 }
144 }
145
146 // Jump to match (for find mode)
147 function jumpToMatch(delta: number) {
148 if (mode !== 'find' || matches.length === 0) return;
149
150 const currentIndex = selectedIndex || 0;
151 const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
152
153 omniboxStore.setSelectedIndex(newIndex);
154 revealCurrentMatch();
155 }
156
157 // Reveal current match (for find mode)
158 function revealCurrentMatch() {
159 if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
160
161 // Remove previous active class
162 const prevMatch = matches[omniboxStore.activeIndex];
163 if (prevMatch?.element?.classList) {
164 prevMatch.element.classList.remove('find-active');
165 }
166
167 // Add active class to current match
168 const currentMatch = matches[selectedIndex];
169 if (currentMatch?.element?.classList) {
170 currentMatch.element.classList.add('find-active');
171 omniboxStore.setActiveIndex(selectedIndex);
172
173 // Scroll into view
174 try {
175 currentMatch.element.scrollIntoView({
176 block: 'center',
177 inline: 'nearest'
178 });
179 } catch {
180 currentMatch.element.scrollIntoView();
181 }
182 }
183 }
184
185 // Handle mouse events
186 function handleMouseDown() {
187 omniboxStore.setFaded(false);
188 }
189
190 function handleClick() {
191 omniboxStore.setFaded(false);
192 }
193
194 function handleFocus() {
195 omniboxStore.setFaded(false);
196 if (inputElement) {
197 const accent = resolveAccentColor();
198 inputElement.style.setProperty('--dumber-input-border-color', accent);
199 }
200 }
201
202 function handleBlur() {
203 if (!inputElement) return;
204 const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
205 const fallback = (base && base.trim()) || 'var(--dynamic-border)';
206 inputElement.style.setProperty('--dumber-input-border-color', fallback);
207 }
208
209 function handleMouseEnter() {
210 if (omniboxStore.visible && inputElement) {
211 try {
212 inputElement.focus({ preventScroll: true });
213 inputElement.select();
214 } catch {
215 inputElement.focus();
216 }
217 }
218 }
219</script>
220
221<input
222 bind:this={inputElement}
223 type="text"
224 {placeholder}
225 value={inputValue}
226 class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none"
227 style="padding: {responsiveStyles?.inputPadding || '10px 12px'};
228 font-size: {responsiveStyles?.fontSize || '16px'};
229 box-sizing: border-box;"
230 oninput={handleInput}
231 onkeydown={handleKeyDown}
232 onmousedown={handleMouseDown}
233 onclick={handleClick}
234 onfocus={handleFocus}
235 onblur={handleBlur}
236 onmouseenter={handleMouseEnter}
237 autocomplete="off"
238 spellcheck="false"
239 role="combobox"
240 aria-expanded={omniboxStore.hasContent}
241 aria-controls="omnibox-list"
242 aria-haspopup="listbox"
243 aria-owns="omnibox-list"
244 aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
245/>
246
247<style>
248 .omnibox-input-field {
249 background: var(--dumber-input-bg, var(--dynamic-bg));
250 color: var(--dynamic-text);
251 border: 2px solid var(--dumber-input-border-color, var(--dynamic-border));
252 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
253 }
254
255 .omnibox-input-field::placeholder {
256 color: var(--dynamic-muted);
257 }
258
259 .omnibox-input-field:focus {
260 color: var(--dynamic-text);
261 }
262</style>