Kamil Furtak
commited on
Commit
·
cb58db3
1
Parent(s):
d567089
fix ui add keyboard events
Browse files
app/components/chat/ModelSelector.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import type { ProviderInfo } from '~/types/model';
|
| 2 |
import { useEffect, useState, useRef } from 'react';
|
|
|
|
| 3 |
import type { ModelInfo } from '~/lib/modules/llm/types';
|
| 4 |
import { classNames } from '~/utils/classNames';
|
| 5 |
|
|
@@ -25,7 +26,9 @@ export const ModelSelector = ({
|
|
| 25 |
}: ModelSelectorProps) => {
|
| 26 |
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
| 27 |
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
|
|
|
| 28 |
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 29 |
|
| 30 |
// Filter models based on search query
|
| 31 |
const filteredModels = [...modelList]
|
|
@@ -36,6 +39,11 @@ export const ModelSelector = ({
|
|
| 36 |
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
| 37 |
);
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
// Focus search input when dropdown opens
|
| 40 |
useEffect(() => {
|
| 41 |
if (isModelDropdownOpen && searchInputRef.current) {
|
|
@@ -43,6 +51,73 @@ export const ModelSelector = ({
|
|
| 43 |
}
|
| 44 |
}, [isModelDropdownOpen]);
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
// Update enabled providers when cookies change
|
| 47 |
useEffect(() => {
|
| 48 |
// If current provider is disabled, switch to first enabled provider
|
|
@@ -100,7 +175,7 @@ export const ModelSelector = ({
|
|
| 100 |
))}
|
| 101 |
</select>
|
| 102 |
|
| 103 |
-
<div className="relative flex-1 lg:max-w-[70%]">
|
| 104 |
<div
|
| 105 |
className={classNames(
|
| 106 |
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
|
@@ -110,6 +185,10 @@ export const ModelSelector = ({
|
|
| 110 |
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
|
| 111 |
)}
|
| 112 |
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
>
|
| 114 |
<div className="flex items-center justify-between">
|
| 115 |
<span>{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
|
|
@@ -123,7 +202,11 @@ export const ModelSelector = ({
|
|
| 123 |
</div>
|
| 124 |
|
| 125 |
{isModelDropdownOpen && (
|
| 126 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
<div className="px-2 pb-2">
|
| 128 |
<div className="relative">
|
| 129 |
<input
|
|
@@ -140,6 +223,8 @@ export const ModelSelector = ({
|
|
| 140 |
'transition-all',
|
| 141 |
)}
|
| 142 |
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
|
| 143 |
/>
|
| 144 |
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
| 145 |
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
|
@@ -150,8 +235,6 @@ export const ModelSelector = ({
|
|
| 150 |
<div
|
| 151 |
className={classNames(
|
| 152 |
'max-h-60 overflow-y-auto',
|
| 153 |
-
|
| 154 |
-
//Mobile scrollbar (touch devices)
|
| 155 |
'sm:scrollbar-none',
|
| 156 |
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
| 157 |
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
|
|
@@ -159,8 +242,6 @@ export const ModelSelector = ({
|
|
| 159 |
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
| 160 |
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
|
| 161 |
'[&::-webkit-scrollbar-track]:rounded-full',
|
| 162 |
-
|
| 163 |
-
//Desktop hover-only scrollbar
|
| 164 |
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
| 165 |
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
|
| 166 |
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
|
|
@@ -174,12 +255,19 @@ export const ModelSelector = ({
|
|
| 174 |
) : (
|
| 175 |
filteredModels.map((modelOption, index) => (
|
| 176 |
<div
|
|
|
|
| 177 |
key={index}
|
|
|
|
|
|
|
| 178 |
className={classNames(
|
| 179 |
'px-3 py-2 text-sm cursor-pointer',
|
| 180 |
'hover:bg-bolt-elements-background-depth-3',
|
| 181 |
'text-bolt-elements-textPrimary',
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
)}
|
| 184 |
onClick={(e) => {
|
| 185 |
e.stopPropagation();
|
|
@@ -187,6 +275,7 @@ export const ModelSelector = ({
|
|
| 187 |
setIsModelDropdownOpen(false);
|
| 188 |
setModelSearchQuery('');
|
| 189 |
}}
|
|
|
|
| 190 |
>
|
| 191 |
{modelOption.label}
|
| 192 |
</div>
|
|
|
|
| 1 |
import type { ProviderInfo } from '~/types/model';
|
| 2 |
import { useEffect, useState, useRef } from 'react';
|
| 3 |
+
import type { KeyboardEvent } from 'react';
|
| 4 |
import type { ModelInfo } from '~/lib/modules/llm/types';
|
| 5 |
import { classNames } from '~/utils/classNames';
|
| 6 |
|
|
|
|
| 26 |
}: ModelSelectorProps) => {
|
| 27 |
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
| 28 |
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
| 29 |
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
| 30 |
const searchInputRef = useRef<HTMLInputElement>(null);
|
| 31 |
+
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
|
| 32 |
|
| 33 |
// Filter models based on search query
|
| 34 |
const filteredModels = [...modelList]
|
|
|
|
| 39 |
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
|
| 40 |
);
|
| 41 |
|
| 42 |
+
// Reset focused index when search query changes or dropdown opens/closes
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
setFocusedIndex(-1);
|
| 45 |
+
}, [modelSearchQuery, isModelDropdownOpen]);
|
| 46 |
+
|
| 47 |
// Focus search input when dropdown opens
|
| 48 |
useEffect(() => {
|
| 49 |
if (isModelDropdownOpen && searchInputRef.current) {
|
|
|
|
| 51 |
}
|
| 52 |
}, [isModelDropdownOpen]);
|
| 53 |
|
| 54 |
+
// Handle keyboard navigation
|
| 55 |
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
| 56 |
+
if (!isModelDropdownOpen) {
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
switch (e.key) {
|
| 61 |
+
case 'ArrowDown':
|
| 62 |
+
e.preventDefault();
|
| 63 |
+
setFocusedIndex((prev) => {
|
| 64 |
+
const next = prev + 1;
|
| 65 |
+
|
| 66 |
+
if (next >= filteredModels.length) {
|
| 67 |
+
return 0;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return next;
|
| 71 |
+
});
|
| 72 |
+
break;
|
| 73 |
+
|
| 74 |
+
case 'ArrowUp':
|
| 75 |
+
e.preventDefault();
|
| 76 |
+
setFocusedIndex((prev) => {
|
| 77 |
+
const next = prev - 1;
|
| 78 |
+
|
| 79 |
+
if (next < 0) {
|
| 80 |
+
return filteredModels.length - 1;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return next;
|
| 84 |
+
});
|
| 85 |
+
break;
|
| 86 |
+
|
| 87 |
+
case 'Enter':
|
| 88 |
+
e.preventDefault();
|
| 89 |
+
|
| 90 |
+
if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
|
| 91 |
+
const selectedModel = filteredModels[focusedIndex];
|
| 92 |
+
setModel?.(selectedModel.name);
|
| 93 |
+
setIsModelDropdownOpen(false);
|
| 94 |
+
setModelSearchQuery('');
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
break;
|
| 98 |
+
|
| 99 |
+
case 'Escape':
|
| 100 |
+
e.preventDefault();
|
| 101 |
+
setIsModelDropdownOpen(false);
|
| 102 |
+
setModelSearchQuery('');
|
| 103 |
+
break;
|
| 104 |
+
|
| 105 |
+
case 'Tab':
|
| 106 |
+
if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
|
| 107 |
+
setIsModelDropdownOpen(false);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
break;
|
| 111 |
+
}
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
// Focus the selected option
|
| 115 |
+
useEffect(() => {
|
| 116 |
+
if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
|
| 117 |
+
optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
| 118 |
+
}
|
| 119 |
+
}, [focusedIndex]);
|
| 120 |
+
|
| 121 |
// Update enabled providers when cookies change
|
| 122 |
useEffect(() => {
|
| 123 |
// If current provider is disabled, switch to first enabled provider
|
|
|
|
| 175 |
))}
|
| 176 |
</select>
|
| 177 |
|
| 178 |
+
<div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown}>
|
| 179 |
<div
|
| 180 |
className={classNames(
|
| 181 |
'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
|
|
|
|
| 185 |
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
|
| 186 |
)}
|
| 187 |
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
|
| 188 |
+
role="combobox"
|
| 189 |
+
aria-expanded={isModelDropdownOpen}
|
| 190 |
+
aria-controls="model-listbox"
|
| 191 |
+
aria-haspopup="listbox"
|
| 192 |
>
|
| 193 |
<div className="flex items-center justify-between">
|
| 194 |
<span>{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
|
|
|
|
| 202 |
</div>
|
| 203 |
|
| 204 |
{isModelDropdownOpen && (
|
| 205 |
+
<div
|
| 206 |
+
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background shadow-lg"
|
| 207 |
+
role="listbox"
|
| 208 |
+
id="model-listbox"
|
| 209 |
+
>
|
| 210 |
<div className="px-2 pb-2">
|
| 211 |
<div className="relative">
|
| 212 |
<input
|
|
|
|
| 223 |
'transition-all',
|
| 224 |
)}
|
| 225 |
onClick={(e) => e.stopPropagation()}
|
| 226 |
+
role="searchbox"
|
| 227 |
+
aria-label="Search models"
|
| 228 |
/>
|
| 229 |
<div className="absolute left-2.5 top-1/2 -translate-y-1/2">
|
| 230 |
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
|
|
|
|
| 235 |
<div
|
| 236 |
className={classNames(
|
| 237 |
'max-h-60 overflow-y-auto',
|
|
|
|
|
|
|
| 238 |
'sm:scrollbar-none',
|
| 239 |
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
|
| 240 |
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
|
|
|
|
| 242 |
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
| 243 |
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
|
| 244 |
'[&::-webkit-scrollbar-track]:rounded-full',
|
|
|
|
|
|
|
| 245 |
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
|
| 246 |
'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
|
| 247 |
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
|
|
|
|
| 255 |
) : (
|
| 256 |
filteredModels.map((modelOption, index) => (
|
| 257 |
<div
|
| 258 |
+
ref={(el) => (optionsRef.current[index] = el)}
|
| 259 |
key={index}
|
| 260 |
+
role="option"
|
| 261 |
+
aria-selected={model === modelOption.name}
|
| 262 |
className={classNames(
|
| 263 |
'px-3 py-2 text-sm cursor-pointer',
|
| 264 |
'hover:bg-bolt-elements-background-depth-3',
|
| 265 |
'text-bolt-elements-textPrimary',
|
| 266 |
+
'outline-none',
|
| 267 |
+
model === modelOption.name || focusedIndex === index
|
| 268 |
+
? 'bg-bolt-elements-background-depth-2'
|
| 269 |
+
: undefined,
|
| 270 |
+
focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
|
| 271 |
)}
|
| 272 |
onClick={(e) => {
|
| 273 |
e.stopPropagation();
|
|
|
|
| 275 |
setIsModelDropdownOpen(false);
|
| 276 |
setModelSearchQuery('');
|
| 277 |
}}
|
| 278 |
+
tabIndex={focusedIndex === index ? 0 : -1}
|
| 279 |
>
|
| 280 |
{modelOption.label}
|
| 281 |
</div>
|