fix ui add keyboard events

This commit is contained in:
Kamil Furtak 2025-02-15 13:51:11 +01:00
parent d56708966a
commit cb58db3bf0

View File

@ -1,5 +1,6 @@
import type { ProviderInfo } from '~/types/model'; import type { ProviderInfo } from '~/types/model';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import type { KeyboardEvent } from 'react';
import type { ModelInfo } from '~/lib/modules/llm/types'; import type { ModelInfo } from '~/lib/modules/llm/types';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
@ -25,7 +26,9 @@ export const ModelSelector = ({
}: ModelSelectorProps) => { }: ModelSelectorProps) => {
const [modelSearchQuery, setModelSearchQuery] = useState(''); const [modelSearchQuery, setModelSearchQuery] = useState('');
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
// Filter models based on search query // Filter models based on search query
const filteredModels = [...modelList] const filteredModels = [...modelList]
@ -36,6 +39,11 @@ export const ModelSelector = ({
model.name.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 // Focus search input when dropdown opens
useEffect(() => { useEffect(() => {
if (isModelDropdownOpen && searchInputRef.current) { if (isModelDropdownOpen && searchInputRef.current) {
@ -43,6 +51,73 @@ export const ModelSelector = ({
} }
}, [isModelDropdownOpen]); }, [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 // Update enabled providers when cookies change
useEffect(() => { useEffect(() => {
// If current provider is disabled, switch to first enabled provider // If current provider is disabled, switch to first enabled provider
@ -100,7 +175,7 @@ export const ModelSelector = ({
))} ))}
</select> </select>
<div className="relative flex-1 lg:max-w-[70%]"> <div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown}>
<div <div
className={classNames( className={classNames(
'w-full p-2 rounded-lg border border-bolt-elements-borderColor', 'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
@ -110,6 +185,10 @@ export const ModelSelector = ({
isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined, isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
)} )}
onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)} onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
role="combobox"
aria-expanded={isModelDropdownOpen}
aria-controls="model-listbox"
aria-haspopup="listbox"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{modelList.find((m) => m.name === model)?.label || 'Select model'}</span> <span>{modelList.find((m) => m.name === model)?.label || 'Select model'}</span>
@ -123,7 +202,11 @@ export const ModelSelector = ({
</div> </div>
{isModelDropdownOpen && ( {isModelDropdownOpen && (
<div className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background shadow-lg"> <div
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background shadow-lg"
role="listbox"
id="model-listbox"
>
<div className="px-2 pb-2"> <div className="px-2 pb-2">
<div className="relative"> <div className="relative">
<input <input
@ -140,6 +223,8 @@ export const ModelSelector = ({
'transition-all', 'transition-all',
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="searchbox"
aria-label="Search models"
/> />
<div className="absolute left-2.5 top-1/2 -translate-y-1/2"> <div className="absolute left-2.5 top-1/2 -translate-y-1/2">
<span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" /> <span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
@ -150,8 +235,6 @@ export const ModelSelector = ({
<div <div
className={classNames( className={classNames(
'max-h-60 overflow-y-auto', 'max-h-60 overflow-y-auto',
//Mobile scrollbar (touch devices)
'sm:scrollbar-none', 'sm:scrollbar-none',
'[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2', '[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor', '[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
@ -159,8 +242,6 @@ export const ModelSelector = ({
'[&::-webkit-scrollbar-thumb]:rounded-full', '[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2', '[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
'[&::-webkit-scrollbar-track]:rounded-full', '[&::-webkit-scrollbar-track]:rounded-full',
//Desktop hover-only scrollbar
'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5', '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]:bg-bolt-elements-borderColor/50',
'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor', 'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
@ -174,12 +255,19 @@ export const ModelSelector = ({
) : ( ) : (
filteredModels.map((modelOption, index) => ( filteredModels.map((modelOption, index) => (
<div <div
ref={(el) => (optionsRef.current[index] = el)}
key={index} key={index}
role="option"
aria-selected={model === modelOption.name}
className={classNames( className={classNames(
'px-3 py-2 text-sm cursor-pointer', 'px-3 py-2 text-sm cursor-pointer',
'hover:bg-bolt-elements-background-depth-3', 'hover:bg-bolt-elements-background-depth-3',
'text-bolt-elements-textPrimary', 'text-bolt-elements-textPrimary',
model === modelOption.name ? 'bg-bolt-elements-background-depth-2' : undefined, '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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -187,6 +275,7 @@ export const ModelSelector = ({
setIsModelDropdownOpen(false); setIsModelDropdownOpen(false);
setModelSearchQuery(''); setModelSearchQuery('');
}} }}
tabIndex={focusedIndex === index ? 0 : -1}
> >
{modelOption.label} {modelOption.label}
</div> </div>