refactor: Redesign DropdownMenuSearchable as content provider

This commit is contained in:
Aleksander Grygier 2026-02-09 15:01:05 +01:00
parent e55ee82f07
commit 184cb50148
5 changed files with 104 additions and 124 deletions

View File

@ -6,7 +6,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { Switch } from '$lib/components/ui/switch';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { McpLogo, SearchInput } from '$lib/components/app';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -243,12 +243,13 @@
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
<SearchInput placeholder="Search servers..." bind:value={mcpSearchQuery} />
</div>
<div class="max-h-64 overflow-y-auto">
{#if filteredMcpServers.length > 0}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage={hasMcpServers ? 'No servers found' : 'No MCP servers configured'}
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
@ -291,27 +292,19 @@
/>
</button>
{/each}
{:else if hasMcpServers}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No servers found
</div>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
{/if}
</div>
</div>
<DropdownMenu.Separator />
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>

View File

@ -68,15 +68,21 @@
</script>
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
<DropdownMenuSearchable
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
{disabled}
onOpenChange={handleDropdownOpen}
<DropdownMenu.Root
onOpenChange={(open) => {
if (!open) {
searchQuery = '';
}
handleDropdownOpen(open);
}}
>
{#snippet trigger()}
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
@ -85,7 +91,15 @@
>
<McpActiveServersAvatars class={className} />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-72 pt-0">
<DropdownMenuSearchable
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
@ -139,5 +153,7 @@
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import {
@ -261,19 +262,14 @@
{@const selectedOption = getDisplayOption()}
{#if isRouter}
<DropdownMenuSearchable
bind:open={isOpen}
onOpenChange={handleOpenChange}
bind:searchValue={searchTerm}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
align="end"
contentClass="w-full max-w-[100vw] sm:w-max sm:max-w-[calc(100vw-2rem)]"
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
disabled={disabled || updating}
>
{#snippet trigger()}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger
disabled={disabled || updating}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<button
type="button"
class={cn(
@ -303,7 +299,16 @@
<ChevronDown class="h-3 w-3.5" />
{/if}
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]">
<DropdownMenuSearchable
bind:searchValue={searchTerm}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
>
<div class="models-list">
{#if !isCurrentModelInCache() && currentModel}
@ -401,8 +406,10 @@
</div>
</div>
{/each}
</div>
</DropdownMenuSearchable>
</div>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<button
class={cn(

View File

@ -1,88 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { cn } from '$lib/components/ui/utils';
import { SearchInput } from '$lib/components/app';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placeholder?: string;
searchValue?: string;
onSearchChange?: (value: string) => void;
onSearchKeyDown?: (event: KeyboardEvent) => void;
align?: 'start' | 'center' | 'end';
contentClass?: string;
emptyMessage?: string;
isEmpty?: boolean;
disabled?: boolean;
trigger: Snippet;
children: Snippet;
footer?: Snippet;
}
let {
open = $bindable(false),
onOpenChange,
placeholder = 'Search...',
searchValue = $bindable(''),
onSearchChange,
onSearchKeyDown,
align = 'start',
contentClass = 'w-72',
emptyMessage = 'No items found',
isEmpty = false,
disabled = false,
trigger,
children,
footer
}: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
if (!newOpen) {
searchValue = '';
onSearchChange?.('');
}
onOpenChange?.(newOpen);
}
</script>
<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{@render trigger()}
</DropdownMenu.Trigger>
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
<SearchInput
{placeholder}
bind:value={searchValue}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
/>
</div>
<DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
<SearchInput
{placeholder}
bind:value={searchValue}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
/>
</div>
<div class="overflow-y-auto">
{@render children()}
<div class={cn('overflow-y-auto')}>
{@render children()}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
{#if footer}
<DropdownMenu.Separator />
{#if footer}
<DropdownMenu.Separator />
{@render footer()}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{@render footer()}
{/if}

View File

@ -7,30 +7,32 @@
*/
/**
* **DropdownMenuSearchable** - Filterable dropdown menu
* **DropdownMenuSearchable** - Searchable content for dropdown menus
*
* Generic dropdown with search input for filtering options.
* Uses Svelte snippets for flexible content rendering.
* Renders a search input with filtered content area, empty state, and optional footer.
* Designed to be injected into any dropdown container (DropdownMenu.Content,
* DropdownMenu.SubContent, etc.) without providing its own Root.
*
* **Features:**
* - Search/filter input with clear on close
* - Search/filter input
* - Keyboard navigation support
* - Custom trigger, content, and footer via snippets
* - Custom content and footer via snippets
* - Empty state message
* - Disabled state
* - Configurable alignment and width
*
* @example
* ```svelte
* <DropdownMenuSearchable
* bind:open
* bind:searchValue
* placeholder="Search..."
* isEmpty={filteredItems.length === 0}
* >
* {#snippet trigger()}<Button>Select</Button>{/snippet}
* {#snippet children()}{#each items as item}<Item {item} />{/each}{/snippet}
* </DropdownMenuSearchable>
* <DropdownMenu.Root>
* <DropdownMenu.Trigger>...</DropdownMenu.Trigger>
* <DropdownMenu.Content class="pt-0">
* <DropdownMenuSearchable
* bind:searchValue
* placeholder="Search..."
* isEmpty={filteredItems.length === 0}
* >
* {#each items as item}<Item {item} />{/each}
* </DropdownMenuSearchable>
* </DropdownMenu.Content>
* </DropdownMenu.Root>
* ```
*/
export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svelte';