feat(hub): add SearchableSelectField component and update RoleCard to use it

This commit is contained in:
2026-07-19 14:32:01 +08:00
parent 854c6189bb
commit 3f3628e4a0
3 changed files with 143 additions and 11 deletions
@@ -5,6 +5,7 @@
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants'; import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte'; import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
@@ -60,10 +61,15 @@
{} as Record<string, typeof TOOL_OPTIONS>, {} as Record<string, typeof TOOL_OPTIONS>,
); );
const modelItems = $derived([ const modelItems = $derived.by(() => {
{ value: '', label: '(使用平台默认模型)' }, const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` }));
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })), const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog];
]); // Keep a previously saved model selectable even if it left the live catalog.
if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) {
items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` });
}
return items;
});
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name }))); const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
@@ -160,7 +166,13 @@
<div class="mt-4"> <div class="mt-4">
<p class="saas-label">默认模型</p> <p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} /> <SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
+24 -6
View File
@@ -406,7 +406,8 @@
resize: vertical; resize: vertical;
} }
.saas-select-trigger { .saas-select-trigger,
.saas-combobox-input {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
@@ -425,14 +426,25 @@
text-align: left; text-align: left;
} }
.saas-combobox-input {
cursor: text;
padding-right: 2.25rem;
}
.saas-combobox-input::placeholder {
color: var(--color-surface-500);
}
.saas-select-trigger:focus-visible, .saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] { .saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600); border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600); box-shadow: inset 0 0 0 1px var(--color-primary-600);
} }
.saas-select-trigger:disabled, .saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] { .saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
@@ -443,9 +455,15 @@
.saas-select-content { .saas-select-content {
z-index: 70; z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem)); max-height: min(
width: var(--bits-select-anchor-width); 18rem,
min-width: var(--bits-select-anchor-width); var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden; overflow: hidden;
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-surface-400); border: 1px solid var(--color-surface-400);