Skip to content

Commit d1311aa

Browse files
webui: replaced the Flowbite selector with a native Svelte dropdown
1 parent 7bd01b5 commit d1311aa

File tree

1 file changed

+322
-35
lines changed

1 file changed

+322
-35
lines changed

tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte

Lines changed: 322 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script lang="ts">
2-
import { onMount } from 'svelte';
3-
import { Loader2 } from '@lucide/svelte';
4-
import * as Select from '$lib/components/ui/select';
2+
import { onDestroy, onMount, tick } from 'svelte';
3+
import { ChevronDown, Loader2 } from '@lucide/svelte';
54
import { cn } from '$lib/components/ui/utils';
65
import {
76
fetchModels,
@@ -27,15 +26,75 @@
2726
let activeId = $derived(selectedModelId());
2827
2928
let isMounted = $state(false);
29+
let isOpen = $state(false);
30+
let container: HTMLDivElement | null = null;
31+
let triggerButton = $state<HTMLButtonElement | null>(null);
32+
let menuRef = $state<HTMLDivElement | null>(null);
33+
let menuPosition = $state<{
34+
top: number;
35+
left: number;
36+
width: number;
37+
placement: 'top' | 'bottom';
38+
maxHeight: number;
39+
} | null>(null);
40+
let removePositionListeners: (() => void) | null = null;
41+
let lockedWidth: number | null = null;
3042
31-
onMount(async () => {
32-
try {
33-
await fetchModels();
34-
} catch (error) {
35-
console.error('Unable to load models:', error);
36-
} finally {
37-
isMounted = true;
43+
function portalToBody(node: HTMLElement) {
44+
if (typeof document === 'undefined') return;
45+
46+
const target = document.body;
47+
if (!target) return;
48+
49+
target.appendChild(node);
50+
51+
return {
52+
destroy() {
53+
if (node.parentNode === target) {
54+
target.removeChild(node);
55+
}
56+
}
57+
};
58+
}
59+
60+
onMount(() => {
61+
let disposed = false;
62+
63+
(async () => {
64+
try {
65+
await fetchModels();
66+
} catch (error) {
67+
console.error('Unable to load models:', error);
68+
} finally {
69+
if (!disposed) {
70+
isMounted = true;
71+
}
72+
}
73+
})();
74+
75+
function handlePointerDown(event: PointerEvent) {
76+
if (!container) return;
77+
78+
const target = event.target as Node | null;
79+
if (target && !container.contains(target) && !(menuRef && menuRef.contains(target))) {
80+
closeMenu();
81+
}
82+
}
83+
84+
function handleKeydown(event: KeyboardEvent) {
85+
if (event.key === 'Escape') {
86+
closeMenu();
87+
}
3888
}
89+
90+
document.addEventListener('pointerdown', handlePointerDown);
91+
document.addEventListener('keydown', handleKeydown);
92+
93+
return () => {
94+
disposed = true;
95+
document.removeEventListener('pointerdown', handlePointerDown);
96+
document.removeEventListener('keydown', handleKeydown);
97+
};
3998
});
4099
41100
async function handleSelect(value: string | undefined) {
@@ -54,6 +113,188 @@
54113
}
55114
}
56115
116+
const VIEWPORT_GUTTER = 8;
117+
const MENU_OFFSET = 6;
118+
const MENU_MAX_WIDTH = 320;
119+
120+
function cleanupPositionListeners() {
121+
removePositionListeners?.();
122+
removePositionListeners = null;
123+
}
124+
125+
function setupPositionListeners() {
126+
cleanupPositionListeners();
127+
128+
const handleResize = () => updateMenuPosition();
129+
const handleScroll = () => updateMenuPosition();
130+
131+
window.addEventListener('resize', handleResize);
132+
window.addEventListener('scroll', handleScroll, true);
133+
134+
removePositionListeners = () => {
135+
window.removeEventListener('resize', handleResize);
136+
window.removeEventListener('scroll', handleScroll, true);
137+
removePositionListeners = null;
138+
};
139+
}
140+
141+
async function openMenu() {
142+
if (loading || updating) return;
143+
144+
isOpen = true;
145+
await tick();
146+
updateMenuPosition();
147+
requestAnimationFrame(() => updateMenuPosition());
148+
setupPositionListeners();
149+
}
150+
151+
function toggleOpen() {
152+
if (loading || updating) return;
153+
154+
if (isOpen) {
155+
closeMenu();
156+
} else {
157+
void openMenu();
158+
}
159+
}
160+
161+
function closeMenu() {
162+
if (!isOpen) return;
163+
164+
isOpen = false;
165+
menuPosition = null;
166+
lockedWidth = null;
167+
cleanupPositionListeners();
168+
}
169+
170+
async function handleOptionSelect(optionId: string) {
171+
try {
172+
await handleSelect(optionId);
173+
} finally {
174+
closeMenu();
175+
}
176+
}
177+
178+
$effect(() => {
179+
if (loading || updating) {
180+
closeMenu();
181+
}
182+
});
183+
184+
$effect(() => {
185+
const optionCount = options.length;
186+
if (!isOpen || optionCount < 0) return;
187+
188+
queueMicrotask(() => updateMenuPosition());
189+
});
190+
191+
function updateMenuPosition() {
192+
if (!isOpen || !triggerButton || !menuRef) return;
193+
194+
const triggerRect = triggerButton.getBoundingClientRect();
195+
const viewportWidth = window.innerWidth;
196+
const viewportHeight = window.innerHeight;
197+
198+
if (viewportWidth === 0 || viewportHeight === 0) return;
199+
200+
const scrollWidth = menuRef.scrollWidth;
201+
const scrollHeight = menuRef.scrollHeight;
202+
203+
const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2);
204+
const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH);
205+
const safeMaxWidth =
206+
constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
207+
const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
208+
209+
let width = lockedWidth;
210+
if (width === null) {
211+
const naturalWidth = Math.min(scrollWidth, safeMaxWidth);
212+
const baseWidth = Math.max(triggerRect.width, naturalWidth, desiredMinWidth);
213+
width = Math.min(baseWidth, safeMaxWidth || baseWidth);
214+
lockedWidth = width;
215+
} else {
216+
width = Math.min(Math.max(width, desiredMinWidth), safeMaxWidth || width);
217+
}
218+
219+
if (width > 0) {
220+
menuRef.style.width = `${width}px`;
221+
}
222+
223+
const availableBelow = Math.max(
224+
0,
225+
viewportHeight - VIEWPORT_GUTTER - triggerRect.bottom - MENU_OFFSET
226+
);
227+
const availableAbove = Math.max(0, triggerRect.top - VIEWPORT_GUTTER - MENU_OFFSET);
228+
const viewportAllowance = Math.max(0, viewportHeight - VIEWPORT_GUTTER * 2);
229+
const fallbackAllowance = Math.max(1, viewportAllowance > 0 ? viewportAllowance : scrollHeight);
230+
231+
function computePlacement(placement: 'top' | 'bottom') {
232+
const available = placement === 'bottom' ? availableBelow : availableAbove;
233+
const allowedHeight =
234+
available > 0 ? Math.min(available, fallbackAllowance) : fallbackAllowance;
235+
const maxHeight = Math.min(scrollHeight, allowedHeight);
236+
const height = Math.max(0, maxHeight);
237+
238+
let top: number;
239+
if (placement === 'bottom') {
240+
const rawTop = triggerRect.bottom + MENU_OFFSET;
241+
const minTop = VIEWPORT_GUTTER;
242+
const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
243+
if (maxTop < minTop) {
244+
top = minTop;
245+
} else {
246+
top = Math.min(Math.max(rawTop, minTop), maxTop);
247+
}
248+
} else {
249+
const rawTop = triggerRect.top - MENU_OFFSET - height;
250+
const minTop = VIEWPORT_GUTTER;
251+
const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
252+
if (maxTop < minTop) {
253+
top = minTop;
254+
} else {
255+
top = Math.max(Math.min(rawTop, maxTop), minTop);
256+
}
257+
}
258+
259+
return { placement, top, height, maxHeight };
260+
}
261+
262+
const belowMetrics = computePlacement('bottom');
263+
const aboveMetrics = computePlacement('top');
264+
265+
let metrics = belowMetrics;
266+
if (scrollHeight > belowMetrics.maxHeight && aboveMetrics.maxHeight > belowMetrics.maxHeight) {
267+
metrics = aboveMetrics;
268+
}
269+
270+
menuRef.style.maxHeight = metrics.maxHeight > 0 ? `${Math.round(metrics.maxHeight)}px` : '';
271+
272+
let left = triggerRect.right - width;
273+
const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
274+
if (maxLeft < VIEWPORT_GUTTER) {
275+
left = VIEWPORT_GUTTER;
276+
} else {
277+
if (left > maxLeft) {
278+
left = maxLeft;
279+
}
280+
if (left < VIEWPORT_GUTTER) {
281+
left = VIEWPORT_GUTTER;
282+
}
283+
}
284+
285+
menuPosition = {
286+
top: Math.round(metrics.top),
287+
left: Math.round(left),
288+
width: Math.round(width),
289+
placement: metrics.placement,
290+
maxHeight: Math.round(metrics.maxHeight)
291+
};
292+
}
293+
294+
onDestroy(() => {
295+
cleanupPositionListeners();
296+
});
297+
57298
function getDisplayOption(): ModelOption | undefined {
58299
if (activeId) {
59300
return options.find((option) => option.id === activeId);
@@ -63,7 +304,10 @@
63304
}
64305
</script>
65306

66-
<div class={cn('flex max-w-[200px] min-w-[120px] flex-col items-end gap-1', className)}>
307+
<div
308+
class={cn('relative z-10 flex max-w-[200px] min-w-[120px] flex-col items-end gap-1', className)}
309+
bind:this={container}
310+
>
67311
{#if loading && options.length === 0 && !isMounted}
68312
<div class="flex items-center gap-2 text-xs text-muted-foreground">
69313
<Loader2 class="h-4 w-4 animate-spin" />
@@ -74,34 +318,77 @@
74318
{:else}
75319
{@const selectedOption = getDisplayOption()}
76320

77-
<Select.Root
78-
type="single"
79-
value={selectedOption?.id ?? ''}
80-
onValueChange={handleSelect}
81-
disabled={loading || updating}
82-
>
83-
<Select.Trigger variant="plain" size="sm" class="hover:text-foreground">
84-
<span class="max-w-[160px] truncate text-right"
85-
>{selectedOption?.name || 'Select model'}</span
86-
>
321+
<div class="relative w-full">
322+
<button
323+
type="button"
324+
class={cn(
325+
'flex w-full items-center justify-end gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
326+
isOpen ? 'text-foreground' : ''
327+
)}
328+
aria-haspopup="listbox"
329+
aria-expanded={isOpen}
330+
onclick={toggleOpen}
331+
bind:this={triggerButton}
332+
disabled={loading || updating}
333+
>
334+
<span class="max-w-[160px] truncate text-right font-medium">
335+
{selectedOption?.name || 'Select model'}
336+
</span>
87337

88338
{#if updating}
89339
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
340+
{:else}
341+
<ChevronDown
342+
class={cn(
343+
'h-4 w-4 text-muted-foreground transition-transform',
344+
isOpen ? 'rotate-180 text-foreground' : ''
345+
)}
346+
/>
90347
{/if}
91-
</Select.Trigger>
92-
93-
<Select.Content class="z-[100000]">
94-
{#each options as option (option.id)}
95-
<Select.Item value={option.id} label={option.name}>
96-
<span class="text-sm font-medium">{option.name}</span>
97-
98-
{#if option.description}
99-
<span class="text-xs text-muted-foreground">{option.description}</span>
100-
{/if}
101-
</Select.Item>
102-
{/each}
103-
</Select.Content>
104-
</Select.Root>
348+
</button>
349+
350+
{#if isOpen}
351+
<div
352+
bind:this={menuRef}
353+
use:portalToBody
354+
class={cn(
355+
'fixed z-[1000] overflow-hidden rounded-md border bg-popover shadow-lg transition-opacity',
356+
menuPosition ? 'opacity-100' : 'pointer-events-none opacity-0'
357+
)}
358+
role="listbox"
359+
style:top={menuPosition ? `${menuPosition.top}px` : undefined}
360+
style:left={menuPosition ? `${menuPosition.left}px` : undefined}
361+
style:width={menuPosition ? `${menuPosition.width}px` : undefined}
362+
data-placement={menuPosition?.placement ?? 'bottom'}
363+
>
364+
<div
365+
class="overflow-y-auto py-1"
366+
style:max-height={menuPosition && menuPosition.maxHeight > 0
367+
? `${menuPosition.maxHeight}px`
368+
: undefined}
369+
>
370+
{#each options as option (option.id)}
371+
<button
372+
type="button"
373+
class={cn(
374+
'flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none',
375+
option.id === selectedOption?.id ? 'bg-accent text-accent-foreground' : ''
376+
)}
377+
role="option"
378+
aria-selected={option.id === selectedOption?.id}
379+
onclick={() => handleOptionSelect(option.id)}
380+
>
381+
<span class="font-medium">{option.name}</span>
382+
383+
{#if option.description}
384+
<span class="text-xs text-muted-foreground">{option.description}</span>
385+
{/if}
386+
</button>
387+
{/each}
388+
</div>
389+
</div>
390+
{/if}
391+
</div>
105392
{/if}
106393

107394
{#if error}

0 commit comments

Comments
 (0)