From cb58db3bf02cdc0749cd59129358f9f1354c6178 Mon Sep 17 00:00:00 2001 From: Kamil Furtak Date: Sat, 15 Feb 2025 13:51:11 +0100 Subject: [PATCH] fix ui add keyboard events --- app/components/chat/ModelSelector.tsx | 103 ++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 4cad4d44..13d2622a 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -1,5 +1,6 @@ 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'; @@ -25,7 +26,9 @@ export const ModelSelector = ({ }: ModelSelectorProps) => { const [modelSearchQuery, setModelSearchQuery] = useState(''); const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); const searchInputRef = useRef(null); + const optionsRef = useRef<(HTMLDivElement | null)[]>([]); // Filter models based on search query const filteredModels = [...modelList] @@ -36,6 +39,11 @@ export const ModelSelector = ({ 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) { @@ -43,6 +51,73 @@ export const ModelSelector = ({ } }, [isModelDropdownOpen]); + // Handle keyboard navigation + const handleKeyDown = (e: KeyboardEvent) => { + 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 @@ -100,7 +175,7 @@ export const ModelSelector = ({ ))} -
+
setIsModelDropdownOpen(!isModelDropdownOpen)} + role="combobox" + aria-expanded={isModelDropdownOpen} + aria-controls="model-listbox" + aria-haspopup="listbox" >
{modelList.find((m) => m.name === model)?.label || 'Select model'} @@ -123,7 +202,11 @@ export const ModelSelector = ({
{isModelDropdownOpen && ( -
+
e.stopPropagation()} + role="searchbox" + aria-label="Search models" />
@@ -150,8 +235,6 @@ export const ModelSelector = ({
(
(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', - 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) => { e.stopPropagation(); @@ -187,6 +275,7 @@ export const ModelSelector = ({ setIsModelDropdownOpen(false); setModelSearchQuery(''); }} + tabIndex={focusedIndex === index ? 0 : -1} > {modelOption.label}