forked from EduCraft/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 { 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>
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user