forked from bai/curriculum-project-hub
feat(hub): add SearchableSelectField component and update RoleCard to use it
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
|
||||
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
@@ -60,10 +61,15 @@
|
||||
{} as Record<string, typeof TOOL_OPTIONS>,
|
||||
);
|
||||
|
||||
const modelItems = $derived([
|
||||
{ value: '', label: '(使用平台默认模型)' },
|
||||
...models.map((m) => ({ value: m.id, label: `${m.label}(${m.id})` })),
|
||||
]);
|
||||
const modelItems = $derived.by(() => {
|
||||
const fromCatalog = 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 })));
|
||||
|
||||
@@ -160,7 +166,13 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="saas-label">默认模型</p>
|
||||
<SelectField items={modelItems} bind:value={defaultModel} />
|
||||
<SearchableSelectField
|
||||
items={modelItems}
|
||||
bind:value={defaultModel}
|
||||
placeholder="选择模型…"
|
||||
searchPlaceholder="搜索模型名称或 ID"
|
||||
emptyText="无匹配模型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -406,7 +406,8 @@
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.saas-select-trigger {
|
||||
.saas-select-trigger,
|
||||
.saas-combobox-input {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
@@ -425,14 +426,25 @@
|
||||
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[data-state='open'] {
|
||||
.saas-select-trigger[data-state='open'],
|
||||
.saas-combobox-input:focus {
|
||||
border-color: var(--color-primary-600);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-600);
|
||||
}
|
||||
|
||||
.saas-select-trigger:disabled,
|
||||
.saas-select-trigger[data-disabled] {
|
||||
.saas-select-trigger[data-disabled],
|
||||
.saas-combobox-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -443,9 +455,15 @@
|
||||
|
||||
.saas-select-content {
|
||||
z-index: 70;
|
||||
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
|
||||
width: var(--bits-select-anchor-width);
|
||||
min-width: var(--bits-select-anchor-width);
|
||||
max-height: min(
|
||||
18rem,
|
||||
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;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--color-surface-400);
|
||||
|
||||
Reference in New Issue
Block a user