feat(omnibox): add visual search shortcut badges to input
Badge System: - Dynamically build CommandBadge array from search shortcuts store - Show prefix badge when input matches a shortcut (e.g., "gh:", "npm:") - Badge displays shortcut description (e.g., "GitHub search") - Badge positioned absolutely with dynamic left offset - Styled with accent background, border, and proper typography Input Padding Logic: - Add normalizePadding() to handle CSS padding strings - Dynamically adjust left padding when badge is active - Calculate padding offset (64px) to make room for badge - Reactive effects to update padding based on active badge Input Styling: - Apply monospace font (JetBrains Mono, Fira Code) - Remove border-radius for sharp corners - Update border width (1px) and colors - Add letter-spacing to input and placeholder - Uppercase placeholder text for consistency Fade Behavior: - Only apply fade effect in find mode when navigating results - Keep omnibox mode unfaded during navigation
- commit
- 329376210e89b731be2a478a68bd77094f5bb6bb
- author
- bamen · · 2025-10-06 18:22 UTC
- committer
- bamen · · 2025-10-06 19:07 UTC
- parent
- 27396fb
- signature
- unsigned
1 file · +126 -28
M gui/src/components/omnibox/OmniboxInput.svelte +126 -28
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | let selectedIndex = $derived(omniboxStore.selectedIndex); | |
| 15 | 15 | let suggestions = $derived(omniboxStore.suggestions); | |
| 16 | 16 | let matches = $derived(omniboxStore.matches); | |
| 17 | + | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |
| 17 | 18 | ||
| 18 | 19 | // Input element ref and responsive styles (props for parent to bind) | |
| 19 | 20 | interface Props { | |
| @@ -28,6 +29,21 @@ | |||
| 28 | 29 | ||
| 29 | 30 | const ACCENT_FALLBACK = 'var(--dynamic-accent)'; | |
| 30 | 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 | + | ||
| 31 | 47 | function resolveAccentColor(): string { | |
| 32 | 48 | if (!inputElement) return ACCENT_FALLBACK; | |
| 33 | 49 | const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent'); | |
| @@ -48,6 +64,50 @@ | |||
| 48 | 64 | mode === 'find' ? 'Find in page…' : 'Type URL or search…' | |
| 49 | 65 | ); | |
| 50 | 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 | + | ||
| 51 | 111 | // Handle input changes | |
| 52 | 112 | function handleInput(event: Event) { | |
| 53 | 113 | const target = event.target as HTMLInputElement; | |
| @@ -133,8 +193,12 @@ | |||
| 133 | 193 | omniboxStore.selectPrevious(); | |
| 134 | 194 | } | |
| 135 | 195 | ||
| 136 | - | // Fade overlay while navigating results | |
| 137 | - | omniboxStore.setFaded(true); | |
| 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 | + | } | |
| 138 | 202 | ||
| 139 | 203 | // For find mode, reveal the current match | |
| 140 | 204 | if (mode === 'find') { | |
| @@ -218,37 +282,50 @@ | |||
| 218 | 282 | } | |
| 219 | 283 | </script> | |
| 220 | 284 | ||
| 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 | - | /> | |
| 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> | |
| 246 | 318 | ||
| 247 | 319 | <style> | |
| 320 | + | .omnibox-input-wrapper { | |
| 321 | + | position: relative; | |
| 322 | + | } | |
| 323 | + | ||
| 248 | 324 | .omnibox-input-field { | |
| 249 | 325 | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 250 | 326 | color: var(--dynamic-text); | |
| 251 | - | border: 2px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 327 | + | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); | |
| 328 | + | border-radius: 0; | |
| 252 | 329 | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; | |
| 253 | 330 | } | |
| 254 | 331 | ||
| @@ -259,4 +336,25 @@ | |||
| 259 | 336 | .omnibox-input-field:focus { | |
| 260 | 337 | color: var(--dynamic-text); | |
| 261 | 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 | + | } | |
| 262 | 360 | </style> | |
| @@ -14,6 +14,7 @@ | |||||
| 14 | let selectedIndex = $derived(omniboxStore.selectedIndex); | 14 | let selectedIndex = $derived(omniboxStore.selectedIndex); | ||
| 15 | let suggestions = $derived(omniboxStore.suggestions); | 15 | let suggestions = $derived(omniboxStore.suggestions); | ||
| 16 | let matches = $derived(omniboxStore.matches); | 16 | let matches = $derived(omniboxStore.matches); | ||
| 17 | + | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |||
| 17 | 18 | ||||
| 18 | // Input element ref and responsive styles (props for parent to bind) | 19 | // Input element ref and responsive styles (props for parent to bind) | ||
| 19 | interface Props { | 20 | interface Props { | ||
| @@ -28,6 +29,21 @@ | |||||
| 28 | 29 | ||||
| 29 | const ACCENT_FALLBACK = 'var(--dynamic-accent)'; | 30 | const ACCENT_FALLBACK = 'var(--dynamic-accent)'; | ||
| 30 | 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 | + | ||||
| 31 | function resolveAccentColor(): string { | 47 | function resolveAccentColor(): string { | ||
| 32 | if (!inputElement) return ACCENT_FALLBACK; | 48 | if (!inputElement) return ACCENT_FALLBACK; | ||
| 33 | const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent'); | 49 | const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent'); | ||
| @@ -48,6 +64,50 @@ | |||||
| 48 | mode === 'find' ? 'Find in page…' : 'Type URL or search…' | 64 | mode === 'find' ? 'Find in page…' : 'Type URL or search…' | ||
| 49 | ); | 65 | ); | ||
| 50 | 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 | + | ||||
| 51 | // Handle input changes | 111 | // Handle input changes | ||
| 52 | function handleInput(event: Event) { | 112 | function handleInput(event: Event) { | ||
| 53 | const target = event.target as HTMLInputElement; | 113 | const target = event.target as HTMLInputElement; | ||
| @@ -133,8 +193,12 @@ | |||||
| 133 | omniboxStore.selectPrevious(); | 193 | omniboxStore.selectPrevious(); | ||
| 134 | } | 194 | } | ||
| 135 | 195 | ||||
| 136 | - | // Fade overlay while navigating results | 196 | + | // Fade overlay while navigating results only for find mode |
| 137 | - | omniboxStore.setFaded(true); | 197 | + | if (mode === 'find') { |
| 198 | + | omniboxStore.setFaded(true); | |||
| 199 | + | } else { | |||
| 200 | + | omniboxStore.setFaded(false); | |||
| 201 | + | } | |||
| 138 | 202 | ||||
| 139 | // For find mode, reveal the current match | 203 | // For find mode, reveal the current match | ||
| 140 | if (mode === 'find') { | 204 | if (mode === 'find') { | ||
| @@ -218,37 +282,50 @@ | |||||
| 218 | } | 282 | } | ||
| 219 | </script> | 283 | </script> | ||
| 220 | 284 | ||||
| 221 | - | <input | 285 | + | <div class="omnibox-input-wrapper"> |
| 222 | - | bind:this={inputElement} | 286 | + | {#if activeBadge} |
| 223 | - | type="text" | 287 | + | <span |
| 224 | - | {placeholder} | 288 | + | class="omnibox-prefix-badge" |
| 225 | - | value={inputValue} | 289 | + | aria-hidden="true" |
| 226 | - | class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none" | 290 | + | style={`left: ${baseLeftPadding};`} |
| 227 | - | style="padding: {responsiveStyles?.inputPadding || '10px 12px'}; | 291 | + | >{activeBadge.label}</span> |
| 228 | - | font-size: {responsiveStyles?.fontSize || '16px'}; | 292 | + | {/if} |
| 229 | - | box-sizing: border-box;" | 293 | + | |
| 230 | - | oninput={handleInput} | 294 | + | <input |
| 231 | - | onkeydown={handleKeyDown} | 295 | + | bind:this={inputElement} |
| 232 | - | onmousedown={handleMouseDown} | 296 | + | type="text" |
| 233 | - | onclick={handleClick} | 297 | + | {placeholder} |
| 234 | - | onfocus={handleFocus} | 298 | + | value={inputValue} |
| 235 | - | onblur={handleBlur} | 299 | + | class="w-full box-border omnibox-input omnibox-input-field focus:outline-none" |
| 236 | - | onmouseenter={handleMouseEnter} | 300 | + | style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`} |
| 237 | - | autocomplete="off" | 301 | + | oninput={handleInput} |
| 238 | - | spellcheck="false" | 302 | + | onkeydown={handleKeyDown} |
| 239 | - | role="combobox" | 303 | + | onmousedown={handleMouseDown} |
| 240 | - | aria-expanded={omniboxStore.hasContent} | 304 | + | onclick={handleClick} |
| 241 | - | aria-controls="omnibox-list" | 305 | + | onfocus={handleFocus} |
| 242 | - | aria-haspopup="listbox" | 306 | + | onblur={handleBlur} |
| 243 | - | aria-owns="omnibox-list" | 307 | + | onmouseenter={handleMouseEnter} |
| 244 | - | aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined} | 308 | + | autocomplete="off" |
| 245 | - | /> | 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> | |||
| 246 | 318 | ||||
| 247 | <style> | 319 | <style> | ||
| 320 | + | .omnibox-input-wrapper { | |||
| 321 | + | position: relative; | |||
| 322 | + | } | |||
| 323 | + | ||||
| 248 | .omnibox-input-field { | 324 | .omnibox-input-field { | ||
| 249 | background: var(--dumber-input-bg, var(--dynamic-bg)); | 325 | background: var(--dumber-input-bg, var(--dynamic-bg)); | ||
| 250 | color: var(--dynamic-text); | 326 | color: var(--dynamic-text); | ||
| 251 | - | border: 2px solid var(--dumber-input-border-color, var(--dynamic-border)); | 327 | + | border: 1px solid var(--dumber-input-border-color, var(--dynamic-border)); |
| 328 | + | border-radius: 0; | |||
| 252 | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; | 329 | transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; | ||
| 253 | } | 330 | } | ||
| 254 | 331 | ||||
| @@ -259,4 +336,25 @@ | |||||
| 259 | .omnibox-input-field:focus { | 336 | .omnibox-input-field:focus { | ||
| 260 | color: var(--dynamic-text); | 337 | color: var(--dynamic-text); | ||
| 261 | } | 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 | + | } | |||
| 262 | </style> | 360 | </style> | ||