|
23 | 23 | MENU_OFFSET, |
24 | 24 | VIEWPORT_GUTTER |
25 | 25 | } from '$lib/constants/floating-ui-constraints'; |
| 26 | + import type { ModelOption } from '$lib/types/models'; |
26 | 27 |
|
27 | 28 | interface Props { |
28 | 29 | class?: string; |
|
145 | 146 | return options.some((option) => option.model === currentModel); |
146 | 147 | }); |
147 | 148 |
|
| 149 | + let searchTerm = $state(''); |
| 150 | + let searchInputRef = $state<HTMLInputElement | null>(null); |
| 151 | +
|
| 152 | + let filteredOptions: ModelOption[] = $derived( |
| 153 | + (() => { |
| 154 | + const term = searchTerm.trim().toLowerCase(); |
| 155 | + if (!term) return options; |
| 156 | +
|
| 157 | + return options.filter( |
| 158 | + (option) => |
| 159 | + option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) |
| 160 | + ); |
| 161 | + })() |
| 162 | + ); |
| 163 | +
|
148 | 164 | let isOpen = $state(false); |
149 | 165 | let showModelDialog = $state(false); |
150 | 166 | let container: HTMLDivElement | null = null; |
151 | 167 | let menuRef = $state<HTMLDivElement | null>(null); |
| 168 | + let menuWidth = $state<number | null>(null); |
152 | 169 | let triggerButton = $state<HTMLButtonElement | null>(null); |
153 | 170 | let menuPosition = $state<{ |
154 | 171 | top: number; |
|
186 | 203 | if (loading || updating) return; |
187 | 204 |
|
188 | 205 | isOpen = true; |
| 206 | + searchTerm = ''; |
| 207 | + menuWidth = null; |
189 | 208 | await tick(); |
190 | 209 | updateMenuPosition(); |
191 | 210 | requestAnimationFrame(() => updateMenuPosition()); |
| 211 | + requestAnimationFrame(() => searchInputRef?.focus()); |
192 | 212 |
|
193 | 213 | if (isRouter) { |
194 | 214 | modelsStore.fetchRouterModels().then(() => { |
|
210 | 230 |
|
211 | 231 | isOpen = false; |
212 | 232 | menuPosition = null; |
| 233 | + menuWidth = null; |
| 234 | + searchTerm = ''; |
213 | 235 | } |
214 | 236 |
|
215 | 237 | function handlePointerDown(event: PointerEvent) { |
|
243 | 265 |
|
244 | 266 | if (viewportWidth === 0 || viewportHeight === 0) return; |
245 | 267 |
|
246 | | - // Reset widths before measuring to avoid carrying over constrained sizes |
247 | | - // from previous openings (which could progressively shrink the menu). |
248 | | - menuRef.style.width = ''; |
249 | | - menuRef.style.maxWidth = ''; |
250 | | -
|
251 | | - const scrollWidth = menuRef.scrollWidth; |
252 | 268 | const scrollHeight = menuRef.scrollHeight; |
253 | 269 |
|
254 | 270 | const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2); |
255 | | - const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH); |
256 | | - const safeMaxWidth = |
257 | | - constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth); |
| 271 | + const safeMaxWidth = availableWidth > 0 ? availableWidth : MENU_MAX_WIDTH; |
258 | 272 | const desiredMinWidth = Math.min(160, safeMaxWidth || 160); |
259 | 273 |
|
260 | | - let width = Math.min( |
261 | | - Math.max(triggerRect.width, scrollWidth, desiredMinWidth), |
262 | | - safeMaxWidth || 320 |
263 | | - ); |
| 274 | + if (menuWidth === null) { |
| 275 | + menuRef.style.width = ''; |
| 276 | + menuRef.style.maxWidth = ''; |
| 277 | +
|
| 278 | + const idealWidth = Math.max( |
| 279 | + triggerRect.width, |
| 280 | + Math.min(menuRef.scrollWidth, safeMaxWidth), |
| 281 | + 400 |
| 282 | + ); |
| 283 | +
|
| 284 | + menuWidth = Math.min(Math.max(idealWidth, desiredMinWidth), safeMaxWidth); |
| 285 | + } else if (safeMaxWidth && menuWidth > safeMaxWidth) { |
| 286 | + menuWidth = safeMaxWidth; |
| 287 | + } |
| 288 | +
|
| 289 | + const width = menuWidth ?? desiredMinWidth; |
264 | 290 |
|
265 | 291 | const availableBelow = Math.max( |
266 | 292 | 0, |
|
309 | 335 | metrics = aboveMetrics; |
310 | 336 | } |
311 | 337 |
|
312 | | - let left = triggerRect.right - width; |
| 338 | + const availableRight = viewportWidth - VIEWPORT_GUTTER; |
| 339 | + const rightAligned = Math.min(triggerRect.right, availableRight); |
| 340 | + let left = rightAligned - width; |
313 | 341 | const maxLeft = viewportWidth - VIEWPORT_GUTTER - width; |
314 | | - if (maxLeft < VIEWPORT_GUTTER) { |
315 | | - left = VIEWPORT_GUTTER; |
316 | | - } else { |
317 | | - if (left > maxLeft) { |
318 | | - left = maxLeft; |
319 | | - } |
320 | | - if (left < VIEWPORT_GUTTER) { |
321 | | - left = VIEWPORT_GUTTER; |
322 | | - } |
323 | | - } |
| 342 | + left = Math.min(Math.max(left, VIEWPORT_GUTTER), Math.max(maxLeft, VIEWPORT_GUTTER)); |
324 | 343 |
|
325 | 344 | menuPosition = { |
326 | 345 | top: Math.round(metrics.top), |
|
472 | 491 | style:width={menuPosition ? `${menuPosition.width}px` : undefined} |
473 | 492 | data-placement={menuPosition?.placement ?? 'bottom'} |
474 | 493 | > |
| 494 | + <div class="border-b bg-popover px-3 py-2"> |
| 495 | + <label class="sr-only" for="model-search">Search models</label> |
| 496 | + <input |
| 497 | + id="model-search" |
| 498 | + class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition focus:border-ring focus:outline-none" |
| 499 | + placeholder="Search models" |
| 500 | + bind:value={searchTerm} |
| 501 | + bind:this={searchInputRef} |
| 502 | + aria-label="Search models" |
| 503 | + autocomplete="off" |
| 504 | + type="search" |
| 505 | + /> |
| 506 | + </div> |
| 507 | + |
475 | 508 | <div |
476 | 509 | class="overflow-y-auto py-1" |
477 | 510 | style:max-height={menuPosition && menuPosition.maxHeight > 0 |
|
493 | 526 | </button> |
494 | 527 | <div class="my-1 h-px bg-border"></div> |
495 | 528 | {/if} |
496 | | - {#each options as option (option.id)} |
| 529 | + {#if filteredOptions.length === 0} |
| 530 | + <p class="px-3 py-2 text-sm text-muted-foreground">No models found.</p> |
| 531 | + {/if} |
| 532 | + {#each filteredOptions as option (option.id)} |
497 | 533 | {@const status = getModelStatus(option.model)} |
498 | 534 | {@const isLoaded = status === ServerModelStatus.LOADED} |
499 | 535 | {@const isLoading = status === ServerModelStatus.LOADING} |
|
0 commit comments