Improve model selector with searchable dropdown and keyboard navigation
- Replace inline input with button showing current model - Add searchable dropdown with comprehensive filtering (name, provider, IDs) - Fix Cmd+Shift+M keyboard shortcut to open dropdown - Add arrow key navigation with visual highlights - Auto-focus search field when dropdown opens - Use Kobalte Combobox with proper structure for better accessibility
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Provider, Model } from "../types/session"
|
||||
import type { Model } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
interface ModelSelectorProps {
|
||||
@@ -14,11 +14,15 @@ interface ModelSelectorProps {
|
||||
|
||||
interface FlatModel extends Model {
|
||||
providerName: string
|
||||
key: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export default function ModelSelector(props: ModelSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
let inputRef!: HTMLInputElement
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
let triggerRef!: HTMLButtonElement
|
||||
let searchInputRef!: HTMLInputElement
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
@@ -26,16 +30,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const handleFocus = (e: FocusEvent) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
|
||||
const allModels = createMemo<FlatModel[]>(() =>
|
||||
instanceProviders().flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
...m,
|
||||
providerName: p.name,
|
||||
key: `${m.providerId}/${m.id}`,
|
||||
searchText: `${m.name} ${p.name} ${m.providerId} ${m.id} ${m.providerId}/${m.id}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -46,29 +47,38 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
|
||||
const handleChange = async (value: FlatModel | null) => {
|
||||
if (!value) return
|
||||
|
||||
if (value.providerId !== props.currentModel.providerId || value.id !== props.currentModel.modelId) {
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
|
||||
const customFilter = (option: FlatModel, inputValue: string) => {
|
||||
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
setTimeout(() => {
|
||||
searchInputRef?.focus()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Combobox
|
||||
<Combobox<FlatModel>
|
||||
value={currentModelValue()}
|
||||
onChange={handleChange}
|
||||
onOpenChange={setIsOpen}
|
||||
options={allModels()}
|
||||
optionValue={(m) => `${m.providerId}/${m.id}`}
|
||||
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
|
||||
optionValue="key"
|
||||
optionTextValue="searchText"
|
||||
optionLabel="name"
|
||||
placeholder="Search models..."
|
||||
defaultFilter="contains"
|
||||
triggerMode="focus"
|
||||
allowsEmptyCollection={false}
|
||||
defaultFilter={customFilter}
|
||||
allowsEmptyCollection
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item
|
||||
item={itemProps.item}
|
||||
class="px-3 py-2 cursor-pointer hover:bg-gray-100 data-[highlighted]:bg-blue-100 rounded outline-none flex items-start gap-2"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-blue-50 rounded outline-none data-[highlighted]:bg-blue-100 flex items-start gap-2"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
|
||||
@@ -87,16 +97,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control
|
||||
data-model-selector
|
||||
class="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]"
|
||||
>
|
||||
<Combobox.Input
|
||||
ref={inputRef}
|
||||
onFocus={handleFocus}
|
||||
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
|
||||
/>
|
||||
<Combobox.Trigger class="flex items-center justify-center">
|
||||
<Combobox.Control class="relative" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector />
|
||||
<Combobox.Trigger
|
||||
ref={triggerRef}
|
||||
class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[140px]"
|
||||
>
|
||||
<span class="text-gray-700">Model: {currentModelValue()?.name ?? "None"}</span>
|
||||
<Combobox.Icon>
|
||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||
</Combobox.Icon>
|
||||
@@ -104,8 +111,15 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]">
|
||||
<Combobox.Listbox class="max-h-80 overflow-auto" />
|
||||
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg overflow-hidden z-50 min-w-[300px]">
|
||||
<div class="p-2 border-b border-gray-200">
|
||||
<Combobox.Input
|
||||
ref={searchInputRef}
|
||||
class="w-full px-3 py-1.5 text-xs border border-gray-300 rounded outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
</div>
|
||||
<Combobox.Listbox class="max-h-64 overflow-auto p-1" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
|
||||
Reference in New Issue
Block a user