mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-03-10 06:00:19 +00:00
Added keyboard support for toggling the dropdown using Enter or Space keys, improving accessibility. Also set appropriate focus properties by adding tabindex to the combobox element.
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import type { ProviderInfo } from '~/types/model';
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import type { KeyboardEvent } from 'react';
|
|
import type { ModelInfo } from '~/lib/modules/llm/types';
|
|
import { classNames } from '~/utils/classNames';
|
|
import * as React from 'react';
|
|
|
|
interface ModelSelectorProps {
|
|
model?: string;
|
|
setModel?: (model: string) => void;
|
|
provider?: ProviderInfo;
|
|
setProvider?: (provider: ProviderInfo) => void;
|
|
modelList: ModelInfo[];
|
|
providerList: ProviderInfo[];
|
|
apiKeys: Record<string, string>;
|
|
modelLoading?: string;
|
|
}
|
|
|
|
export const ModelSelector = ({
|
|
model,
|
|
setModel,
|
|
provider,
|
|
setProvider,
|
|
modelList,
|
|
providerList,
|
|
modelLoading,
|
|
}: ModelSelectorProps) => {
|
|
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
// Filter models based on search query
|
|
const filteredModels = [...modelList]
|
|
.filter((e) => e.provider === provider?.name && e.name)
|
|
.filter(
|
|
(model) =>
|
|
model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
|
|
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
|
);
|
|
|
|
// Reset focused index when search query changes or dropdown opens/closes
|
|
useEffect(() => {
|
|
setFocusedIndex(-1);
|
|
}, [modelSearchQuery, isModelDropdownOpen]);
|
|
|
|
// Focus search input when dropdown opens
|
|
useEffect(() => {
|
|
if (isModelDropdownOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [isModelDropdownOpen]);
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (!isModelDropdownOpen) {
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setFocusedIndex((prev) => {
|
|
const next = prev + 1;
|
|
|
|
if (next >= filteredModels.length) {
|
|
return 0;
|
|
}
|
|
|
|
return next;
|
|
});
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setFocusedIndex((prev) => {
|
|
const next = prev - 1;
|
|
|
|
if (next < 0) {
|
|
return filteredModels.length - 1;
|
|
}
|
|
|
|
return next;
|
|
});
|
|
break;
|
|
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
|
|
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
|
|
const selectedModel = filteredModels[focusedIndex];
|
|
setModel?.(selectedModel.name);
|
|
setIsModelDropdownOpen(false);
|
|
setModelSearchQuery('');
|
|
}
|
|
|
|
break;
|
|
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
setIsModelDropdownOpen(false);
|
|
setModelSearchQuery('');
|
|
break;
|
|
|
|
case 'Tab':
|
|
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
|
|
setIsModelDropdownOpen(false);
|
|
}
|
|
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Focus the selected option
|
|
useEffect(() => {
|
|
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
|
|
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}, [focusedIndex]);
|
|
|
|
// Update enabled providers when cookies change
|
|
useEffect(() => {
|
|
// If current provider is disabled, switch to first enabled provider
|
|
if (providerList.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (provider && !providerList.map((p) => p.name).includes(provider.name)) {
|
|
const firstEnabledProvider = providerList[0];
|
|
setProvider?.(firstEnabledProvider);
|
|
|
|
// Also update the model to the first available one for the new provider
|
|
const firstModel = modelList.find((m) => m.provider === firstEnabledProvider.name);
|
|
|
|
if (firstModel) {
|
|
setModel?.(firstModel.name);
|
|
}
|
|
}
|
|
}, [providerList, provider, setProvider, modelList, setModel]);
|
|
|
|
if (providerList.length === 0) {
|
|
return (
|
|
<div className="mb-2 p-4 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary">
|
|
<p className="text-center">
|
|
No providers are currently enabled. Please enable at least one provider in the settings to start using the
|
|
chat.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mb-2 flex gap-2 flex-col sm:flex-row">
|
|
<select
|
|
value={provider?.name ?? ''}
|
|
onChange={(e) => {
|
|
const newProvider = providerList.find((p: ProviderInfo) => p.name === e.target.value);
|
|
|
|
if (newProvider && setProvider) {
|
|
setProvider(newProvider);
|
|
}
|
|
|
|
const firstModel = [...modelList].find((m) => m.provider === e.target.value);
|
|
|
|
if (firstModel && setModel) {
|
|
setModel(firstModel.name);
|
|
}
|
|
}}
|
|
className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all"
|
|
>
|
|
{providerList.map((provider: ProviderInfo) => (
|
|
<option key={provider.name} value={provider.name}>
|
|
{provider.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown}>
|
|
<div
|
|
className={classNames(
|
|
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
|
'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
|
|
'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
|
|
'transition-all cursor-pointer',
|
|
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
|
|
)}
|
|
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setIsModelDropdownOpen(!isModelDropdownOpen);
|
|
}
|
|
}}
|
|
role="combobox"
|
|
aria-expanded={isModelDropdownOpen}
|
|
aria-controls="model-listbox"
|
|
aria-haspopup="listbox"
|
|
tabIndex={0}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
|
|
<div
|
|
className={classNames(
|
|
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
|
|
isModelDropdownOpen ? 'rotate-180' : undefined,
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isModelDropdownOpen && (
|
|
<div
|
|
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
|
|
role="listbox"
|
|
id="model-listbox"
|
|
>
|
|
<div className="px-2 pb-2">
|
|
<div className="relative">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={modelSearchQuery}
|
|
onChange={(e) => setModelSearchQuery(e.target.value)}
|
|
placeholder="Search models..."
|
|
className={classNames(
|
|
'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
|
|
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
|
|
'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
|
|
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
|
|
'transition-all',
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="searchbox"
|
|
aria-label="Search models"
|
|
/>
|
|
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
|
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={classNames(
|
|
'max-h-60 overflow-y-auto',
|
|
'sm:scrollbar-none',
|
|
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
|
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
|
|
'[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
|
|
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
|
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
|
|
'[&::-webkit-scrollbar-track]:rounded-full',
|
|
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
|
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
|
|
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
|
|
'sm:[&::-webkit-scrollbar-track]:bg-transparent',
|
|
)}
|
|
>
|
|
{modelLoading === 'all' || modelLoading === provider?.name ? (
|
|
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
|
|
) : filteredModels.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No models found</div>
|
|
) : (
|
|
filteredModels.map((modelOption, index) => (
|
|
<div
|
|
ref={(el) => (optionsRef.current[index] = el)}
|
|
key={index}
|
|
role="option"
|
|
aria-selected={model === modelOption.name}
|
|
className={classNames(
|
|
'px-3 py-2 text-sm cursor-pointer',
|
|
'hover:bg-bolt-elements-background-depth-3',
|
|
'text-bolt-elements-textPrimary',
|
|
'outline-none',
|
|
model === modelOption.name || focusedIndex === index
|
|
? 'bg-bolt-elements-background-depth-2'
|
|
: undefined,
|
|
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setModel?.(modelOption.name);
|
|
setIsModelDropdownOpen(false);
|
|
setModelSearchQuery('');
|
|
}}
|
|
tabIndex={focusedIndex === index ? 0 : -1}
|
|
>
|
|
{modelOption.label}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|