mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
fix ui add keyboard events
This commit is contained in:
parent
d56708966a
commit
cb58db3bf0
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user