fix: mobile keyboard opens when tapping search (#457)

iOS/mobile browsers only open keyboard when focus() is called directly
in a user gesture handler. Previously, focus happened in a Svelte $effect
after state change, which broke the gesture chain.

Fix: Always render SearchModal (hidden via CSS), register input ref with
search state, and focus directly in search.open() before state change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 2000e592 b6671a6a

Changed files
+33 -15
frontend
+21 -15
frontend/src/lib/components/SearchModal.svelte
··· 2 2 import { goto } from '$app/navigation'; 3 3 import { browser } from '$app/environment'; 4 4 import { search, type SearchResult } from '$lib/search.svelte'; 5 - import { fade } from 'svelte/transition'; 6 5 import { onMount, onDestroy } from 'svelte'; 7 6 8 7 let inputRef: HTMLInputElement | null = $state(null); ··· 20 19 } 21 20 }); 22 21 23 - // focus input when modal opens 22 + // register input ref with search state for direct focus (mobile keyboard fix) 24 23 $effect(() => { 25 - if (search.isOpen && inputRef && browser) { 26 - // small delay to ensure modal is rendered 27 - window.requestAnimationFrame(() => { 28 - inputRef?.focus(); 29 - }); 24 + if (inputRef) { 25 + search.setInputRef(inputRef); 30 26 } 31 27 }); 32 28 ··· 127 123 }); 128 124 </script> 129 125 130 - {#if search.isOpen} 131 - <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 132 - <div 133 - class="search-backdrop" 134 - onclick={handleBackdropClick} 135 - transition:fade={{ duration: 150 }} 136 - > 126 + <!-- always render for mobile keyboard focus, use CSS to show/hide --> 127 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 128 + <div 129 + class="search-backdrop" 130 + class:open={search.isOpen} 131 + onclick={handleBackdropClick} 132 + > 137 133 <div class="search-modal" role="dialog" aria-modal="true" aria-label="search"> 138 134 <div class="search-input-wrapper"> 139 135 <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> ··· 251 247 {/if} 252 248 </div> 253 249 </div> 254 - {/if} 255 250 256 251 <style> 257 252 .search-backdrop { ··· 265 260 align-items: flex-start; 266 261 justify-content: center; 267 262 padding-top: 15vh; 263 + /* hidden by default */ 264 + opacity: 0; 265 + visibility: hidden; 266 + pointer-events: none; 267 + transition: opacity 0.15s, visibility 0.15s; 268 + } 269 + 270 + .search-backdrop.open { 271 + opacity: 1; 272 + visibility: visible; 273 + pointer-events: auto; 268 274 } 269 275 270 276 .search-modal {
+12
frontend/src/lib/search.svelte.ts
··· 64 64 error = $state<string | null>(null); 65 65 selectedIndex = $state(0); 66 66 67 + // reference to input element for direct focus (mobile keyboard workaround) 68 + inputRef: HTMLInputElement | null = null; 69 + 67 70 // debounce timer 68 71 private searchTimeout: ReturnType<typeof setTimeout> | null = null; 69 72 73 + setInputRef(el: HTMLInputElement | null) { 74 + this.inputRef = el; 75 + } 76 + 70 77 open() { 78 + // focus input FIRST (before state change) for mobile keyboard to open 79 + // iOS/mobile browsers only open keyboard when focus() is in direct user gesture 80 + if (this.inputRef) { 81 + this.inputRef.focus(); 82 + } 71 83 this.isOpen = true; 72 84 this.query = ''; 73 85 this.results = [];