audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: stabilize search modal height to prevent layout jolt (#1301)

The Cmd+K search modal jolted visibly when the user started typing:
hints (~100px) disappeared immediately, nothing rendered during the
150ms debounce window (loading was still false), "no results for X"
briefly flashed, then collapsed again while the fetch was in flight,
then popped open to result height.

two fixes:

1. set `loading=true` synchronously inside `setQuery()` when query>=2
(and semanticLoading when query>=3) so the "no results" branch never
matches during the debounce window before the fetch fires.

2. wrap the body states in `.search-body` with a 104px min-height
(matching the hints' rendered height) and `interpolate-size:
allow-keywords` + `transition: height`. the body no longer collapses
between states, and the growth to result height animates smoothly on
browsers that support interpolate-size (chrome/safari/edge 2024+).
older browsers fall back to instant resize — no regression.

an explicit `.search-progress` placeholder covers the in-between state
when the user has typed 1 char or is waiting on the first response.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
aef77769 a17efc48

+30 -1
+23
frontend/src/lib/components/SearchModal.svelte
··· 171 171 {/if} 172 172 </div> 173 173 174 + <div class="search-body"> 174 175 {#if search.activeResults.length > 0} 175 176 <div class="search-results"> 176 177 {#each search.activeResults as result, index (result.type + '-' + ('did' in result ? result.did : result.id))} ··· 260 261 </div> 261 262 {/if} 262 263 </div> 264 + {:else} 265 + <!-- typing in progress: reserve space so the body doesn't collapse --> 266 + <div class="search-progress" aria-hidden="true"></div> 263 267 {/if} 268 + </div> 264 269 265 270 {#if search.error} 266 271 <div class="search-error">{search.error}</div> ··· 357 362 to { 358 363 transform: rotate(360deg); 359 364 } 365 + } 366 + 367 + /* stable body wrapper: prevents modal from jolting as state changes. 368 + interpolate-size allows transitioning from/to height: auto smoothly 369 + (chrome/safari/edge 2024+). older browsers fall back to instant resize. */ 370 + .search-body { 371 + min-height: 104px; 372 + height: auto; 373 + interpolate-size: allow-keywords; 374 + transition: height 0.22s cubic-bezier(0.16, 1, 0.3, 1); 375 + overflow: hidden; 376 + } 377 + 378 + /* blank placeholder rendered while debouncing/loading — keeps the modal 379 + body from collapsing to zero between "start typing" hints and the 380 + first results arriving */ 381 + .search-progress { 382 + min-height: 104px; 360 383 } 361 384 362 385 .search-results {
+6
frontend/src/lib/search.svelte.ts
··· 177 177 this.error = null; 178 178 179 179 // keyword search: 150ms debounce, min 2 chars 180 + // set loading=true immediately so the body doesn't flash "no results for X" 181 + // during the debounce window before the fetch actually fires 180 182 if (value.length >= 2) { 183 + this.loading = true; 181 184 this.keywordTimeout = setTimeout(() => { 182 185 void this.search(value); 183 186 }, 150); 184 187 } else { 188 + this.loading = false; 185 189 this.results = []; 186 190 this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 187 191 } 188 192 189 193 // semantic search: 500ms debounce, min 3 chars, only if enabled 190 194 if (this.semanticEnabled && value.length >= 3) { 195 + this.semanticLoading = true; 191 196 this.semanticTimeout = setTimeout(() => { 192 197 void this.searchSemantic(value); 193 198 }, 500); 194 199 } else { 200 + this.semanticLoading = false; 195 201 this.semanticResults = []; 196 202 } 197 203 }
+1 -1
loq.toml
··· 108 108 109 109 [[rules]] 110 110 path = "frontend/src/lib/components/SearchModal.svelte" 111 - max_lines = 593 111 + max_lines = 596 112 112 113 113 [[rules]] 114 114 path = "frontend/src/lib/components/TrackActionsMenu.svelte"