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 className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background shadow-lg">
 
 
 
 
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
- model === modelOption.name ? 'bg-bolt-elements-background-depth-2' : undefined,
 
 
 
 
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>