1 <script lang="ts" generics="T">
2 import type { Snippet } from 'svelte';
3 import { SearchInput } from '$lib/components/app';
4 import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
5 import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
6 import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
11 selectedIndex: number;
13 showSearchInput: boolean;
14 searchPlaceholder?: string;
15 // Omit to distinguish "haven't searched yet" from "search returned nothing".
16 emptyMessage?: string;
18 inputRef?: HTMLInputElement | null;
19 onSearchClose?: () => void;
20 itemKey: (item: T, index: number) => string;
21 item: Snippet<[T, number, boolean]>;
23 skeletonCount?: number;
25 // Counter bumped by the picker on keyboard nav; scrolls the selected
26 // row into view without scrolling on hover or result replacement.
27 scrollTrigger?: number;
34 searchQuery = $bindable(),
36 searchPlaceholder = 'Search...',
39 inputRef = $bindable(null),
49 let listContainer = $state<HTMLDivElement | null>(null);
51 let listPaddingTop = $derived(
52 showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
55 // selectedIndex/items.length are untracked so hover and result replacement
56 // never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
58 getTrigger: () => scrollTrigger,
59 getContainer: () => listContainer,
60 getIndex: () => selectedIndex,
61 getCount: () => items.length,
68 <div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
71 placeholder={searchPlaceholder}
72 bind:value={searchQuery}
74 onClose={onSearchClose}
79 <div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
84 <div aria-busy="true" aria-live="polite" class="flex flex-col">
85 {#each { length: skeletonCount } as _, rowIndex (rowIndex)}
86 <div class="flex items-start gap-3 rounded-lg px-3 py-2">
87 <div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
88 <div class="flex min-w-0 flex-1 flex-col">
89 <div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
90 <div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
96 {:else if items && items.length === 0}
98 <div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
101 {#each items as itemData, index (itemKey(itemData, index))}
102 {@render item(itemData, index, index === selectedIndex)}