feat(omnibox): enhance accessibility with keyboard navigation and blur animation improvements
- commit
- 3c0a915df7c20ea82187377c8d1d59c37ada40fd
- author
- bamen · · 2025-09-15 16:27 UTC
- committer
- bamen · · 2025-09-15 16:38 UTC
- parent
- 4c1a4bb
- signature
- unsigned
4 files · +129 -44
M gui/src/components/omnibox/Omnibox.svelte +87 -19
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | ||
| 19 | 19 | // Component refs | |
| 20 | 20 | let boxElement: HTMLDivElement; | |
| 21 | + | let blurLayerElement: HTMLDivElement; | |
| 21 | 22 | let inputElement = $state<HTMLInputElement>(); | |
| 22 | 23 | ||
| 23 | 24 | // Responsive styling state | |
| @@ -78,23 +79,52 @@ | |||
| 78 | 79 | ||
| 79 | 80 | ||
| 80 | 81 | // Apply faded styling effect (matching original JS implementation) | |
| 82 | + | // NOTE: to avoid text becoming blurry while animating, we animate the | |
| 83 | + | // blur via an absolutely-positioned layer's opacity instead of | |
| 84 | + | // animating `backdrop-filter` on the container. This keeps the text | |
| 85 | + | // on a separate compositing layer and prevents browser antialiasing | |
| 86 | + | // artifacts during transitions. | |
| 81 | 87 | $effect(() => { | |
| 82 | - | if (!boxElement || !inputElement) return; | |
| 88 | + | if (!boxElement || !inputElement || !blurLayerElement) return; | |
| 89 | + | ||
| 90 | + | // Ensure subtle transitions are present on the container so | |
| 91 | + | // background-color (and other cheap properties) animate smoothly. | |
| 92 | + | try { | |
| 93 | + | // container transitions (background & color) | |
| 94 | + | if (!boxElement.style.transition) { | |
| 95 | + | boxElement.style.transition = 'background-color 120ms ease'; | |
| 96 | + | } | |
| 97 | + | } catch (e) { | |
| 98 | + | // defensive: ignore if setting styles fails for some reason | |
| 99 | + | } | |
| 83 | 100 | ||
| 84 | 101 | if (faded) { | |
| 85 | - | // Faded state - semi-transparent with backdrop blur | |
| 86 | - | boxElement.style.background = 'rgba(27,27,27,0.25)'; | |
| 87 | - | boxElement.style.backdropFilter = 'blur(2px) saturate(110%)'; | |
| 88 | - | (boxElement.style as any).webkitBackdropFilter = 'blur(2px) saturate(110%)'; | |
| 102 | + | // Faded state - semi-transparent backgrounds. The blur itself | |
| 103 | + | // is provided by the blur layer whose opacity we animate. | |
| 104 | + | boxElement.style.background = 'rgba(27,27,27,0.35)'; | |
| 89 | 105 | inputElement.style.background = 'rgba(18,18,18,0.35)'; | |
| 90 | 106 | inputElement.style.color = '#eee'; | |
| 107 | + | ||
| 108 | + | // Small black text border/shadow (2px, 75% opacity) when faded. | |
| 109 | + | // Keep this immediate to avoid animating text-shadow (which can | |
| 110 | + | // trigger repaints that blur text). | |
| 111 | + | const textShadow = '1px 1px 1px rgba(0,0,0,0.75)'; | |
| 112 | + | inputElement.style.textShadow = textShadow; | |
| 113 | + | boxElement.style.textShadow = textShadow; | |
| 114 | + | ||
| 115 | + | // Show the blur layer (opacity animation defined in CSS) | |
| 116 | + | blurLayerElement.style.opacity = '1'; | |
| 91 | 117 | } else { | |
| 92 | - | // Normal state - solid background | |
| 118 | + | // Normal state - solid background, hide blur layer. | |
| 93 | 119 | boxElement.style.background = '#1b1b1b'; | |
| 94 | - | boxElement.style.backdropFilter = ''; | |
| 95 | - | (boxElement.style as any).webkitBackdropFilter = ''; | |
| 96 | 120 | inputElement.style.background = '#121212'; | |
| 97 | 121 | inputElement.style.color = '#eee'; | |
| 122 | + | ||
| 123 | + | // Remove text shadow in normal state | |
| 124 | + | inputElement.style.textShadow = ''; | |
| 125 | + | boxElement.style.textShadow = ''; | |
| 126 | + | ||
| 127 | + | blurLayerElement.style.opacity = '0'; | |
| 98 | 128 | } | |
| 99 | 129 | }); | |
| 100 | 130 | ||
| @@ -178,6 +208,14 @@ | |||
| 178 | 208 | }; | |
| 179 | 209 | ||
| 180 | 210 | console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox)); | |
| 211 | + | ||
| 212 | + | // Check for pending suggestions that arrived before API was ready | |
| 213 | + | if ((window as any).__dumber_omnibox_pending_suggestions) { | |
| 214 | + | console.log('🔄 [OMNIBOX] Processing pending suggestions'); | |
| 215 | + | const pending = (window as any).__dumber_omnibox_pending_suggestions; | |
| 216 | + | omniboxBridge.setSuggestions(pending); | |
| 217 | + | delete (window as any).__dumber_omnibox_pending_suggestions; | |
| 218 | + | } | |
| 181 | 219 | } catch (error) { | |
| 182 | 220 | console.error('❌ Failed to set up omnibox global API:', error); | |
| 183 | 221 | } | |
| @@ -220,7 +258,7 @@ | |||
| 220 | 258 | <!-- Main omnibox container --> | |
| 221 | 259 | <div | |
| 222 | 260 | bind:this={boxElement} | |
| 223 | - | class="bg-[#1b1b1b] border border-[#444] rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.6)] text-[#eee]" | |
| 261 | + | class="bg-[#1b1b1b] border border-[#121212] rounded shadow-[0_10px_30px_rgba(0,0,0,0.6)] text-[#eee]" | |
| 224 | 262 | style="position: relative !important; | |
| 225 | 263 | left: 50% !important; | |
| 226 | 264 | transform: translateX(-50%) !important; | |
| @@ -229,7 +267,7 @@ | |||
| 229 | 267 | margin-right: 0 !important; | |
| 230 | 268 | width: {responsiveStyles.width}; | |
| 231 | 269 | padding: {responsiveStyles.padding}; | |
| 232 | - | font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; | |
| 270 | + | font-family: 'Fira sans', system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; | |
| 233 | 271 | pointer-events: auto !important; | |
| 234 | 272 | box-sizing: border-box !important;" | |
| 235 | 273 | onmousedown={handleBoxClick} | |
| @@ -239,18 +277,48 @@ | |||
| 239 | 277 | aria-modal="true" | |
| 240 | 278 | aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'} | |
| 241 | 279 | > | |
| 242 | - | <!-- Input component --> | |
| 243 | - | <OmniboxInput bind:inputElement {responsiveStyles} /> | |
| 244 | - | ||
| 245 | - | <!-- Content based on mode --> | |
| 246 | - | {#if mode === 'omnibox'} | |
| 247 | - | <OmniboxSuggestions /> | |
| 248 | - | {:else if mode === 'find'} | |
| 249 | - | <OmniboxFind /> | |
| 250 | - | {/if} | |
| 280 | + | <!-- Blur layer: absolute full-cover element providing backdrop blur. | |
| 281 | + | We animate its opacity rather than the container's backdrop-filter | |
| 282 | + | to avoid causing text to blur during transitions. --> | |
| 283 | + | <div | |
| 284 | + | bind:this={blurLayerElement} | |
| 285 | + | class="dumber-omnibox-blur-layer" | |
| 286 | + | aria-hidden="true" | |
| 287 | + | style="position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0;" | |
| 288 | + | ></div> | |
| 289 | + | <div class="dumber-omnibox-content"> | |
| 290 | + | <!-- Input component --> | |
| 291 | + | <OmniboxInput bind:inputElement {responsiveStyles} /> | |
| 292 | + | ||
| 293 | + | <!-- Content based on mode --> | |
| 294 | + | {#if mode === 'omnibox'} | |
| 295 | + | <OmniboxSuggestions /> | |
| 296 | + | {:else if mode === 'find'} | |
| 297 | + | <OmniboxFind /> | |
| 298 | + | {/if} | |
| 299 | + | </div> | |
| 251 | 300 | </div> | |
| 252 | 301 | </div> | |
| 253 | 302 | ||
| 254 | 303 | <style> | |
| 304 | + | /* Blur layer styles: use `backdrop-filter` but animate opacity only. */ | |
| 305 | + | .dumber-omnibox-blur-layer { | |
| 306 | + | backdrop-filter: blur(2px) saturate(110%); | |
| 307 | + | -webkit-backdrop-filter: blur(2px) saturate(110%); | |
| 308 | + | background: rgba(0,0,0,0.12); /* slight tint so blur looks natural */ | |
| 309 | + | transition: opacity 160ms ease; | |
| 310 | + | will-change: opacity; | |
| 311 | + | z-index: 0; | |
| 312 | + | mix-blend-mode: normal; | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /* Ensure the actual content (input, suggestions) sits above the blur layer */ | |
| 316 | + | .dumber-omnibox-content { | |
| 317 | + | position: relative; | |
| 318 | + | z-index: 1; | |
| 319 | + | /* promote to its own layer to avoid being rasterized with the blur */ | |
| 320 | + | transform: translateZ(0); | |
| 321 | + | backface-visibility: hidden; | |
| 322 | + | } | |
| 255 | 323 | /* Additional custom styles if needed */ | |
| 256 | 324 | </style>⏎̸ | |
| @@ -18,6 +18,7 @@ | |||||
| 18 | 18 | ||||
| 19 | // Component refs | 19 | // Component refs | ||
| 20 | let boxElement: HTMLDivElement; | 20 | let boxElement: HTMLDivElement; | ||
| 21 | + | let blurLayerElement: HTMLDivElement; | |||
| 21 | let inputElement = $state<HTMLInputElement>(); | 22 | let inputElement = $state<HTMLInputElement>(); | ||
| 22 | 23 | ||||
| 23 | // Responsive styling state | 24 | // Responsive styling state | ||
| @@ -78,23 +79,52 @@ | |||||
| 78 | 79 | ||||
| 79 | 80 | ||||
| 80 | // Apply faded styling effect (matching original JS implementation) | 81 | // Apply faded styling effect (matching original JS implementation) | ||
| 82 | + | // NOTE: to avoid text becoming blurry while animating, we animate the | |||
| 83 | + | // blur via an absolutely-positioned layer's opacity instead of | |||
| 84 | + | // animating `backdrop-filter` on the container. This keeps the text | |||
| 85 | + | // on a separate compositing layer and prevents browser antialiasing | |||
| 86 | + | // artifacts during transitions. | |||
| 81 | $effect(() => { | 87 | $effect(() => { | ||
| 82 | - | if (!boxElement || !inputElement) return; | 88 | + | if (!boxElement || !inputElement || !blurLayerElement) return; |
| 89 | + | ||||
| 90 | + | // Ensure subtle transitions are present on the container so | |||
| 91 | + | // background-color (and other cheap properties) animate smoothly. | |||
| 92 | + | try { | |||
| 93 | + | // container transitions (background & color) | |||
| 94 | + | if (!boxElement.style.transition) { | |||
| 95 | + | boxElement.style.transition = 'background-color 120ms ease'; | |||
| 96 | + | } | |||
| 97 | + | } catch (e) { | |||
| 98 | + | // defensive: ignore if setting styles fails for some reason | |||
| 99 | + | } | |||
| 83 | 100 | ||||
| 84 | if (faded) { | 101 | if (faded) { | ||
| 85 | - | // Faded state - semi-transparent with backdrop blur | 102 | + | // Faded state - semi-transparent backgrounds. The blur itself |
| 86 | - | boxElement.style.background = 'rgba(27,27,27,0.25)'; | 103 | + | // is provided by the blur layer whose opacity we animate. |
| 87 | - | boxElement.style.backdropFilter = 'blur(2px) saturate(110%)'; | 104 | + | boxElement.style.background = 'rgba(27,27,27,0.35)'; |
| 88 | - | (boxElement.style as any).webkitBackdropFilter = 'blur(2px) saturate(110%)'; | |||
| 89 | inputElement.style.background = 'rgba(18,18,18,0.35)'; | 105 | inputElement.style.background = 'rgba(18,18,18,0.35)'; | ||
| 90 | inputElement.style.color = '#eee'; | 106 | inputElement.style.color = '#eee'; | ||
| 107 | + | ||||
| 108 | + | // Small black text border/shadow (2px, 75% opacity) when faded. | |||
| 109 | + | // Keep this immediate to avoid animating text-shadow (which can | |||
| 110 | + | // trigger repaints that blur text). | |||
| 111 | + | const textShadow = '1px 1px 1px rgba(0,0,0,0.75)'; | |||
| 112 | + | inputElement.style.textShadow = textShadow; | |||
| 113 | + | boxElement.style.textShadow = textShadow; | |||
| 114 | + | ||||
| 115 | + | // Show the blur layer (opacity animation defined in CSS) | |||
| 116 | + | blurLayerElement.style.opacity = '1'; | |||
| 91 | } else { | 117 | } else { | ||
| 92 | - | // Normal state - solid background | 118 | + | // Normal state - solid background, hide blur layer. |
| 93 | boxElement.style.background = '#1b1b1b'; | 119 | boxElement.style.background = '#1b1b1b'; | ||
| 94 | - | boxElement.style.backdropFilter = ''; | |||
| 95 | - | (boxElement.style as any).webkitBackdropFilter = ''; | |||
| 96 | inputElement.style.background = '#121212'; | 120 | inputElement.style.background = '#121212'; | ||
| 97 | inputElement.style.color = '#eee'; | 121 | inputElement.style.color = '#eee'; | ||
| 122 | + | ||||
| 123 | + | // Remove text shadow in normal state | |||
| 124 | + | inputElement.style.textShadow = ''; | |||
| 125 | + | boxElement.style.textShadow = ''; | |||
| 126 | + | ||||
| 127 | + | blurLayerElement.style.opacity = '0'; | |||
| 98 | } | 128 | } | ||
| 99 | }); | 129 | }); | ||
| 100 | 130 | ||||
| @@ -178,6 +208,14 @@ | |||||
| 178 | }; | 208 | }; | ||
| 179 | 209 | ||||
| 180 | console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox)); | 210 | console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox)); | ||
| 211 | + | ||||
| 212 | + | // Check for pending suggestions that arrived before API was ready | |||
| 213 | + | if ((window as any).__dumber_omnibox_pending_suggestions) { | |||
| 214 | + | console.log('🔄 [OMNIBOX] Processing pending suggestions'); | |||
| 215 | + | const pending = (window as any).__dumber_omnibox_pending_suggestions; | |||
| 216 | + | omniboxBridge.setSuggestions(pending); | |||
| 217 | + | delete (window as any).__dumber_omnibox_pending_suggestions; | |||
| 218 | + | } | |||
| 181 | } catch (error) { | 219 | } catch (error) { | ||
| 182 | console.error('❌ Failed to set up omnibox global API:', error); | 220 | console.error('❌ Failed to set up omnibox global API:', error); | ||
| 183 | } | 221 | } | ||
| @@ -220,7 +258,7 @@ | |||||
| 220 | <!-- Main omnibox container --> | 258 | <!-- Main omnibox container --> | ||
| 221 | <div | 259 | <div | ||
| 222 | bind:this={boxElement} | 260 | bind:this={boxElement} | ||
| 223 | - | class="bg-[#1b1b1b] border border-[#444] rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.6)] text-[#eee]" | 261 | + | class="bg-[#1b1b1b] border border-[#121212] rounded shadow-[0_10px_30px_rgba(0,0,0,0.6)] text-[#eee]" |
| 224 | style="position: relative !important; | 262 | style="position: relative !important; | ||
| 225 | left: 50% !important; | 263 | left: 50% !important; | ||
| 226 | transform: translateX(-50%) !important; | 264 | transform: translateX(-50%) !important; | ||
| @@ -229,7 +267,7 @@ | |||||
| 229 | margin-right: 0 !important; | 267 | margin-right: 0 !important; | ||
| 230 | width: {responsiveStyles.width}; | 268 | width: {responsiveStyles.width}; | ||
| 231 | padding: {responsiveStyles.padding}; | 269 | padding: {responsiveStyles.padding}; | ||
| 232 | - | font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; | 270 | + | font-family: 'Fira sans', system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; |
| 233 | pointer-events: auto !important; | 271 | pointer-events: auto !important; | ||
| 234 | box-sizing: border-box !important;" | 272 | box-sizing: border-box !important;" | ||
| 235 | onmousedown={handleBoxClick} | 273 | onmousedown={handleBoxClick} | ||
| @@ -239,18 +277,48 @@ | |||||
| 239 | aria-modal="true" | 277 | aria-modal="true" | ||
| 240 | aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'} | 278 | aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'} | ||
| 241 | > | 279 | > | ||
| 242 | - | <!-- Input component --> | 280 | + | <!-- Blur layer: absolute full-cover element providing backdrop blur. |
| 243 | - | <OmniboxInput bind:inputElement {responsiveStyles} /> | 281 | + | We animate its opacity rather than the container's backdrop-filter |
| 244 | - | 282 | + | to avoid causing text to blur during transitions. --> | |
| 245 | - | <!-- Content based on mode --> | 283 | + | <div |
| 246 | - | {#if mode === 'omnibox'} | 284 | + | bind:this={blurLayerElement} |
| 247 | - | <OmniboxSuggestions /> | 285 | + | class="dumber-omnibox-blur-layer" |
| 248 | - | {:else if mode === 'find'} | 286 | + | aria-hidden="true" |
| 249 | - | <OmniboxFind /> | 287 | + | style="position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0;" |
| 250 | - | {/if} | 288 | + | ></div> |
| 289 | + | <div class="dumber-omnibox-content"> | |||
| 290 | + | <!-- Input component --> | |||
| 291 | + | <OmniboxInput bind:inputElement {responsiveStyles} /> | |||
| 292 | + | ||||
| 293 | + | <!-- Content based on mode --> | |||
| 294 | + | {#if mode === 'omnibox'} | |||
| 295 | + | <OmniboxSuggestions /> | |||
| 296 | + | {:else if mode === 'find'} | |||
| 297 | + | <OmniboxFind /> | |||
| 298 | + | {/if} | |||
| 299 | + | </div> | |||
| 251 | </div> | 300 | </div> | ||
| 252 | </div> | 301 | </div> | ||
| 253 | 302 | ||||
| 254 | <style> | 303 | <style> | ||
| 304 | + | /* Blur layer styles: use `backdrop-filter` but animate opacity only. */ | |||
| 305 | + | .dumber-omnibox-blur-layer { | |||
| 306 | + | backdrop-filter: blur(2px) saturate(110%); | |||
| 307 | + | -webkit-backdrop-filter: blur(2px) saturate(110%); | |||
| 308 | + | background: rgba(0,0,0,0.12); /* slight tint so blur looks natural */ | |||
| 309 | + | transition: opacity 160ms ease; | |||
| 310 | + | will-change: opacity; | |||
| 311 | + | z-index: 0; | |||
| 312 | + | mix-blend-mode: normal; | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | /* Ensure the actual content (input, suggestions) sits above the blur layer */ | |||
| 316 | + | .dumber-omnibox-content { | |||
| 317 | + | position: relative; | |||
| 318 | + | z-index: 1; | |||
| 319 | + | /* promote to its own layer to avoid being rasterized with the blur */ | |||
| 320 | + | transform: translateZ(0); | |||
| 321 | + | backface-visibility: hidden; | |||
| 322 | + | } | |||
| 255 | /* Additional custom styles if needed */ | 323 | /* Additional custom styles if needed */ | ||
| 256 | </style>⏎̸ | 324 | </style>⏎̸ | ||
M gui/src/components/omnibox/OmniboxInput.svelte +1 -1
| @@ -189,7 +189,7 @@ | |||
| 189 | 189 | type="text" | |
| 190 | 190 | {placeholder} | |
| 191 | 191 | value={inputValue} | |
| 192 | - | class="w-full box-border bg-[#121212] text-[#eee] border border-[#555] rounded-md focus:outline-none" | |
| 192 | + | class="w-full box-border bg-[#121212] text-[#eee] border border-[#333] rounded focus:outline-none" | |
| 193 | 193 | style="padding: {responsiveStyles?.inputPadding || '10px 12px'}; | |
| 194 | 194 | font-size: {responsiveStyles?.fontSize || '16px'}; | |
| 195 | 195 | box-sizing: border-box" | |
| @@ -189,7 +189,7 @@ | |||||
| 189 | type="text" | 189 | type="text" | ||
| 190 | {placeholder} | 190 | {placeholder} | ||
| 191 | value={inputValue} | 191 | value={inputValue} | ||
| 192 | - | class="w-full box-border bg-[#121212] text-[#eee] border border-[#555] rounded-md focus:outline-none" | 192 | + | class="w-full box-border bg-[#121212] text-[#eee] border border-[#333] rounded focus:outline-none" |
| 193 | style="padding: {responsiveStyles?.inputPadding || '10px 12px'}; | 193 | style="padding: {responsiveStyles?.inputPadding || '10px 12px'}; | ||
| 194 | font-size: {responsiveStyles?.fontSize || '16px'}; | 194 | font-size: {responsiveStyles?.fontSize || '16px'}; | ||
| 195 | box-sizing: border-box" | 195 | box-sizing: border-box" | ||
M gui/src/components/omnibox/OmniboxSuggestions.svelte +10 -1
| @@ -30,6 +30,14 @@ | |||
| 30 | 30 | omniboxStore.close(); | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | // Handle keyboard activation on suggestion item | |
| 34 | + | function handleItemKeyDown(event: KeyboardEvent, suggestion: any) { | |
| 35 | + | if (event.key === 'Enter' || event.key === ' ') { | |
| 36 | + | event.preventDefault(); | |
| 37 | + | handleItemClick(suggestion); | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 33 | 41 | // Scroll list to show selected item | |
| 34 | 42 | function scrollToSelection() { | |
| 35 | 43 | const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`); | |
| @@ -92,10 +100,11 @@ | |||
| 92 | 100 | border-b border-[#2a2a2a] last:border-b-0 | |
| 93 | 101 | {isSelected ? 'bg-[#0a0a0a]' : ''}" | |
| 94 | 102 | role="option" | |
| 95 | - | tabindex="-1" | |
| 103 | + | tabindex={isSelected ? 0 : -1} | |
| 96 | 104 | aria-selected={isSelected} | |
| 97 | 105 | onmouseenter={() => handleItemMouseEnter(index)} | |
| 98 | 106 | onclick={() => handleItemClick(suggestion)} | |
| 107 | + | onkeydown={(e) => handleItemKeyDown(e, suggestion)} | |
| 99 | 108 | > | |
| 100 | 109 | <!-- Favicon chip --> | |
| 101 | 110 | {#if suggestion.favicon} | |
| @@ -30,6 +30,14 @@ | |||||
| 30 | omniboxStore.close(); | 30 | omniboxStore.close(); | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | // Handle keyboard activation on suggestion item | |||
| 34 | + | function handleItemKeyDown(event: KeyboardEvent, suggestion: any) { | |||
| 35 | + | if (event.key === 'Enter' || event.key === ' ') { | |||
| 36 | + | event.preventDefault(); | |||
| 37 | + | handleItemClick(suggestion); | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 33 | // Scroll list to show selected item | 41 | // Scroll list to show selected item | ||
| 34 | function scrollToSelection() { | 42 | function scrollToSelection() { | ||
| 35 | const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`); | 43 | const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`); | ||
| @@ -92,10 +100,11 @@ | |||||
| 92 | border-b border-[#2a2a2a] last:border-b-0 | 100 | border-b border-[#2a2a2a] last:border-b-0 | ||
| 93 | {isSelected ? 'bg-[#0a0a0a]' : ''}" | 101 | {isSelected ? 'bg-[#0a0a0a]' : ''}" | ||
| 94 | role="option" | 102 | role="option" | ||
| 95 | - | tabindex="-1" | 103 | + | tabindex={isSelected ? 0 : -1} |
| 96 | aria-selected={isSelected} | 104 | aria-selected={isSelected} | ||
| 97 | onmouseenter={() => handleItemMouseEnter(index)} | 105 | onmouseenter={() => handleItemMouseEnter(index)} | ||
| 98 | onclick={() => handleItemClick(suggestion)} | 106 | onclick={() => handleItemClick(suggestion)} | ||
| 107 | + | onkeydown={(e) => handleItemKeyDown(e, suggestion)} | |||
| 99 | > | 108 | > | ||
| 100 | <!-- Favicon chip --> | 109 | <!-- Favicon chip --> | ||
| 101 | {#if suggestion.favicon} | 110 | {#if suggestion.favicon} | ||
M gui/src/components/omnibox/messaging.ts +31 -23
| @@ -8,22 +8,30 @@ import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from './types'; | |||
| 8 | 8 | import { omniboxStore } from './stores.svelte.ts'; | |
| 9 | 9 | ||
| 10 | 10 | export class OmniboxBridge implements OmniboxMessageBridge { | |
| 11 | - | /** | |
| 12 | - | * Send message to Go backend via WebKit message handler | |
| 13 | - | */ | |
| 11 | + | // Forward messages to the native WebKit message handler when available | |
| 12 | + | // Fallback: for navigate, perform direct location change | |
| 14 | 13 | postMessage(msg: OmniboxMessage): void { | |
| 15 | - | try { | |
| 16 | - | console.log('📨 Sending message to Go backend:', msg); | |
| 17 | - | const messageHandler = window.webkit?.messageHandlers?.dumber; | |
| 18 | - | if (messageHandler && messageHandler.postMessage) { | |
| 19 | - | const jsonMsg = JSON.stringify(msg); | |
| 20 | - | console.log('📨 JSON message:', jsonMsg); | |
| 21 | - | messageHandler.postMessage(jsonMsg); | |
| 22 | - | } else { | |
| 23 | - | console.warn('WebKit message handler not available'); | |
| 14 | + | console.log('📤 [DEBUG] Posting message to backend:', msg); | |
| 15 | + | // NOTE: Do NOT detach postMessage from its receiver. In WKWebView, | |
| 16 | + | // postMessage must be called on the UserMessageHandler instance. | |
| 17 | + | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 18 | + | if (bridge && typeof bridge.postMessage === 'function') { | |
| 19 | + | try { | |
| 20 | + | console.log('📱 [DEBUG] Using webkit message handler'); | |
| 21 | + | bridge.postMessage(JSON.stringify(msg)); | |
| 22 | + | return; | |
| 23 | + | } catch (e) { | |
| 24 | + | console.warn('postMessage to native handler failed, using fallback:', e); | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | console.log('⚠️ [DEBUG] No webkit bridge available'); | |
| 28 | + | // Fallback navigation if no native bridge is available | |
| 29 | + | if (msg.type === 'navigate' && typeof msg.url === 'string' && msg.url) { | |
| 30 | + | try { | |
| 31 | + | window.location.href = msg.url; | |
| 32 | + | } catch (e) { | |
| 33 | + | console.error('Fallback navigation failed:', e); | |
| 24 | 34 | } | |
| 25 | - | } catch (error) { | |
| 26 | - | console.error('Failed to send message to Go backend:', error); | |
| 27 | 35 | } | |
| 28 | 36 | } | |
| 29 | 37 | ||
| @@ -31,6 +39,7 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||
| 31 | 39 | * Update suggestions from Go backend | |
| 32 | 40 | */ | |
| 33 | 41 | setSuggestions(suggestions: Suggestion[]): void { | |
| 42 | + | console.log('📝 [DEBUG] Received suggestions from backend:', suggestions); | |
| 34 | 43 | omniboxStore.updateSuggestions(suggestions); | |
| 35 | 44 | } | |
| 36 | 45 | ||
| @@ -39,22 +48,21 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||
| 39 | 48 | */ | |
| 40 | 49 | navigate(url: string): void { | |
| 41 | 50 | console.log('🚀 Omnibox navigate called with:', url); | |
| 42 | - | this.postMessage({ | |
| 43 | - | type: 'navigate', | |
| 44 | - | url | |
| 45 | - | }); | |
| 51 | + | this.postMessage({ type: 'navigate', url }); | |
| 46 | 52 | } | |
| 47 | 53 | ||
| 48 | 54 | /** | |
| 49 | 55 | * Handle search query | |
| 50 | 56 | */ | |
| 51 | 57 | query(searchTerm: string, limit?: number): void { | |
| 52 | - | this.postMessage({ | |
| 53 | - | type: 'query', | |
| 54 | - | q: searchTerm, | |
| 55 | - | limit: limit || omniboxStore.config.defaultLimit | |
| 56 | - | }); | |
| 58 | + | const q = searchTerm ?? ''; | |
| 59 | + | const lim = limit || omniboxStore.config.defaultLimit; | |
| 60 | + | console.log('🔍 [DEBUG] Sending query to backend:', { q, limit: lim }); | |
| 61 | + | // Send to native handler; Go will compute suggestions and call setSuggestions | |
| 62 | + | this.postMessage({ type: 'query', q, limit: lim }); | |
| 57 | 63 | } | |
| 64 | + | ||
| 65 | + | // Suggestions are returned via setSuggestions() when native handler responds | |
| 58 | 66 | } | |
| 59 | 67 | ||
| 60 | 68 | // Singleton instance | |
| @@ -8,22 +8,30 @@ import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from './types'; | |||||
| 8 | import { omniboxStore } from './stores.svelte.ts'; | 8 | import { omniboxStore } from './stores.svelte.ts'; | ||
| 9 | 9 | ||||
| 10 | export class OmniboxBridge implements OmniboxMessageBridge { | 10 | export class OmniboxBridge implements OmniboxMessageBridge { | ||
| 11 | - | /** | 11 | + | // Forward messages to the native WebKit message handler when available |
| 12 | - | * Send message to Go backend via WebKit message handler | 12 | + | // Fallback: for navigate, perform direct location change |
| 13 | - | */ | |||
| 14 | postMessage(msg: OmniboxMessage): void { | 13 | postMessage(msg: OmniboxMessage): void { | ||
| 15 | - | try { | 14 | + | console.log('📤 [DEBUG] Posting message to backend:', msg); |
| 16 | - | console.log('📨 Sending message to Go backend:', msg); | 15 | + | // NOTE: Do NOT detach postMessage from its receiver. In WKWebView, |
| 17 | - | const messageHandler = window.webkit?.messageHandlers?.dumber; | 16 | + | // postMessage must be called on the UserMessageHandler instance. |
| 18 | - | if (messageHandler && messageHandler.postMessage) { | 17 | + | const bridge = window.webkit?.messageHandlers?.dumber; |
| 19 | - | const jsonMsg = JSON.stringify(msg); | 18 | + | if (bridge && typeof bridge.postMessage === 'function') { |
| 20 | - | console.log('📨 JSON message:', jsonMsg); | 19 | + | try { |
| 21 | - | messageHandler.postMessage(jsonMsg); | 20 | + | console.log('📱 [DEBUG] Using webkit message handler'); |
| 22 | - | } else { | 21 | + | bridge.postMessage(JSON.stringify(msg)); |
| 23 | - | console.warn('WebKit message handler not available'); | 22 | + | return; |
| 23 | + | } catch (e) { | |||
| 24 | + | console.warn('postMessage to native handler failed, using fallback:', e); | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | console.log('⚠️ [DEBUG] No webkit bridge available'); | |||
| 28 | + | // Fallback navigation if no native bridge is available | |||
| 29 | + | if (msg.type === 'navigate' && typeof msg.url === 'string' && msg.url) { | |||
| 30 | + | try { | |||
| 31 | + | window.location.href = msg.url; | |||
| 32 | + | } catch (e) { | |||
| 33 | + | console.error('Fallback navigation failed:', e); | |||
| 24 | } | 34 | } | ||
| 25 | - | } catch (error) { | |||
| 26 | - | console.error('Failed to send message to Go backend:', error); | |||
| 27 | } | 35 | } | ||
| 28 | } | 36 | } | ||
| 29 | 37 | ||||
| @@ -31,6 +39,7 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||||
| 31 | * Update suggestions from Go backend | 39 | * Update suggestions from Go backend | ||
| 32 | */ | 40 | */ | ||
| 33 | setSuggestions(suggestions: Suggestion[]): void { | 41 | setSuggestions(suggestions: Suggestion[]): void { | ||
| 42 | + | console.log('📝 [DEBUG] Received suggestions from backend:', suggestions); | |||
| 34 | omniboxStore.updateSuggestions(suggestions); | 43 | omniboxStore.updateSuggestions(suggestions); | ||
| 35 | } | 44 | } | ||
| 36 | 45 | ||||
| @@ -39,22 +48,21 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||||
| 39 | */ | 48 | */ | ||
| 40 | navigate(url: string): void { | 49 | navigate(url: string): void { | ||
| 41 | console.log('🚀 Omnibox navigate called with:', url); | 50 | console.log('🚀 Omnibox navigate called with:', url); | ||
| 42 | - | this.postMessage({ | 51 | + | this.postMessage({ type: 'navigate', url }); |
| 43 | - | type: 'navigate', | |||
| 44 | - | url | |||
| 45 | - | }); | |||
| 46 | } | 52 | } | ||
| 47 | 53 | ||||
| 48 | /** | 54 | /** | ||
| 49 | * Handle search query | 55 | * Handle search query | ||
| 50 | */ | 56 | */ | ||
| 51 | query(searchTerm: string, limit?: number): void { | 57 | query(searchTerm: string, limit?: number): void { | ||
| 52 | - | this.postMessage({ | 58 | + | const q = searchTerm ?? ''; |
| 53 | - | type: 'query', | 59 | + | const lim = limit || omniboxStore.config.defaultLimit; |
| 54 | - | q: searchTerm, | 60 | + | console.log('🔍 [DEBUG] Sending query to backend:', { q, limit: lim }); |
| 55 | - | limit: limit || omniboxStore.config.defaultLimit | 61 | + | // Send to native handler; Go will compute suggestions and call setSuggestions |
| 56 | - | }); | 62 | + | this.postMessage({ type: 'query', q, limit: lim }); |
| 57 | } | 63 | } | ||
| 64 | + | ||||
| 65 | + | // Suggestions are returned via setSuggestions() when native handler responds | |||
| 58 | } | 66 | } | ||
| 59 | 67 | ||||
| 60 | // Singleton instance | 68 | // Singleton instance | ||