Skip to content

Commit 78ab85b

Browse files
authored
Merge pull request #1 from dlqqq/litellm_model_id_custom_autocomplete
Implement `SimpleAutocomplete` and use it in `ModelIdInput`
2 parents 188ce5f + f229750 commit 78ab85b

File tree

2 files changed

+334
-79
lines changed

2 files changed

+334
-79
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import React, {
2+
useState,
3+
useRef,
4+
useEffect,
5+
useMemo,
6+
useCallback
7+
} from 'react';
8+
import {
9+
TextField,
10+
MenuItem,
11+
Paper,
12+
Popper,
13+
ClickAwayListener,
14+
TextFieldProps
15+
} from '@mui/material';
16+
import { styled } from '@mui/material/styles';
17+
18+
const StyledPopper = styled(Popper)(({ theme }) => ({
19+
zIndex: theme.zIndex.modal,
20+
'& .MuiPaper-root': {
21+
maxHeight: '200px',
22+
overflow: 'auto',
23+
border: `1px solid ${theme.palette.divider}`,
24+
boxShadow: theme.shadows[8]
25+
}
26+
}));
27+
28+
export type AutocompleteOption = {
29+
label: string;
30+
value: string;
31+
};
32+
33+
export type SimpleAutocompleteProps = {
34+
/**
35+
* List of options to show. Each option value should be unique.
36+
*/
37+
options: AutocompleteOption[];
38+
/**
39+
* (optional) Controls the value of the `Autocomplete` component.
40+
*/
41+
value?: string;
42+
/**
43+
* (optional) Callback fired when the input changes.
44+
*/
45+
onChange?: (value: string) => void;
46+
/**
47+
* (optional) Placeholder string shown in the text input while it is empty.
48+
* This can be used to provide a short example blurb.
49+
*/
50+
placeholder?: string;
51+
/**
52+
* (optional) Function that filters the list of options based on the input
53+
* value. By default, options whose labels do not contain the input value as a
54+
* substring are filtered and hidden. The default filter only filters the list
55+
* of options if the input contains >1 non-whitespace character.
56+
*/
57+
optionsFilter?: (
58+
options: AutocompleteOption[],
59+
inputValue: string
60+
) => AutocompleteOption[];
61+
/**
62+
* (optional) Additional props passed directly to the `TextField` child
63+
* component.
64+
*/
65+
textFieldProps?: Omit<TextFieldProps, 'value' | 'onChange'>;
66+
/**
67+
* (optional) Controls the number of options shown in the autocomplete menu.
68+
* Defaults to unlimited.
69+
*/
70+
maxOptions?: number;
71+
/**
72+
* (optional) If true, the component will treat options as case-sensitive when
73+
* the default options filter is used (i.e. `props.optionsFilter` is unset).
74+
*/
75+
caseSensitive?: boolean;
76+
/**
77+
* (optional) If true, the component will bold the substrings matching the
78+
* current input on each option. The input must contain >1 non-whitespace
79+
* character for this prop to take effect.
80+
*/
81+
boldMatches?: boolean;
82+
};
83+
84+
function defaultOptionsFilter(
85+
options: AutocompleteOption[],
86+
inputValue: string,
87+
caseSensitive = false
88+
): AutocompleteOption[] {
89+
// Do nothing if the input contains <=1 non-whitespace character
90+
if (inputValue.trim().length <= 1) {
91+
return options;
92+
}
93+
94+
const searchValue = caseSensitive ? inputValue : inputValue.toLowerCase();
95+
96+
return options.filter(option => {
97+
const optionLabel = caseSensitive
98+
? option.label
99+
: option.label.toLowerCase();
100+
return optionLabel.includes(searchValue);
101+
});
102+
}
103+
104+
function highlightMatches(
105+
text: string,
106+
searchValue: string,
107+
caseSensitive = false
108+
): React.ReactNode {
109+
// Do nothing if the input contains <=1 non-whitespace character
110+
if (searchValue.trim().length <= 1) {
111+
return text;
112+
}
113+
114+
const searchText = caseSensitive ? searchValue : searchValue.toLowerCase();
115+
const targetText = caseSensitive ? text : text.toLowerCase();
116+
117+
const parts: React.ReactNode[] = [];
118+
let lastIndex = 0;
119+
let matchIndex = targetText.indexOf(searchText);
120+
121+
while (matchIndex !== -1) {
122+
if (matchIndex > lastIndex) {
123+
parts.push(text.slice(lastIndex, matchIndex));
124+
}
125+
126+
parts.push(
127+
<strong key={`${matchIndex}-${searchText}`}>
128+
{text.slice(matchIndex, matchIndex + searchText.length)}
129+
</strong>
130+
);
131+
132+
lastIndex = matchIndex + searchText.length;
133+
matchIndex = targetText.indexOf(searchText, lastIndex);
134+
}
135+
136+
if (lastIndex < text.length) {
137+
parts.push(text.slice(lastIndex));
138+
}
139+
140+
return parts.length > 1 ? <>{parts}</> : text;
141+
}
142+
143+
/**
144+
* A simple `Autocomplete` component with an emphasis on being bug-free and
145+
* performant. Notes:
146+
*
147+
* - By default, options are filtered using case-insensitive substring matching.
148+
*
149+
* - Clicking an option sets the value of this component and fires
150+
* `props.onChange()` if passed. It is treated identically to a user typing the
151+
* option literally.
152+
*
153+
* - Matched substrings will be shown in bold on each option when the
154+
* `boldMatches` prop is set.
155+
*/
156+
export function SimpleAutocomplete(
157+
props: SimpleAutocompleteProps
158+
): React.ReactElement {
159+
const [inputValue, setInputValue] = useState(props.value || '');
160+
const [isOpen, setIsOpen] = useState(false);
161+
const [focusedIndex, setFocusedIndex] = useState(-1);
162+
const textFieldRef = useRef<HTMLDivElement>(null);
163+
const inputRef = useRef<HTMLInputElement>(null);
164+
165+
// Filter and limit options
166+
const filteredOptions = useMemo(() => {
167+
const filterFn = props.optionsFilter || defaultOptionsFilter;
168+
const filtered = filterFn(props.options, inputValue, props.caseSensitive);
169+
return filtered.slice(0, props.maxOptions ?? props.options.length);
170+
}, [
171+
props.options,
172+
inputValue,
173+
props.optionsFilter,
174+
props.maxOptions,
175+
props.caseSensitive
176+
]);
177+
178+
// Sync external value changes
179+
useEffect(() => {
180+
setInputValue(props.value || '');
181+
}, [props.value]);
182+
183+
// Determine if menu should be open
184+
const shouldShowMenu = isOpen && filteredOptions.length > 0;
185+
186+
const handleInputChange = useCallback(
187+
(event: React.ChangeEvent<HTMLInputElement>): void => {
188+
const newValue = event.target.value;
189+
setInputValue(newValue);
190+
setFocusedIndex(-1);
191+
192+
if (!isOpen && newValue.trim() !== '') {
193+
setIsOpen(true);
194+
}
195+
196+
if (props.onChange) {
197+
props.onChange(newValue);
198+
}
199+
},
200+
[isOpen, props.onChange]
201+
);
202+
203+
const handleInputFocus = useCallback((): void => {
204+
setIsOpen(true);
205+
}, []);
206+
207+
const handleOptionClick = useCallback(
208+
(option: AutocompleteOption): void => {
209+
setInputValue(option.value);
210+
setIsOpen(false);
211+
setFocusedIndex(-1);
212+
213+
if (props.onChange) {
214+
props.onChange(option.value);
215+
}
216+
217+
if (inputRef.current) {
218+
inputRef.current.blur();
219+
}
220+
},
221+
[props.onChange]
222+
);
223+
224+
const handleKeyDown = useCallback(
225+
(event: React.KeyboardEvent): void => {
226+
if (!shouldShowMenu) {
227+
return;
228+
}
229+
230+
switch (event.key) {
231+
case 'ArrowDown':
232+
event.preventDefault();
233+
setFocusedIndex(prev => {
234+
return prev < filteredOptions.length - 1 ? prev + 1 : 0;
235+
});
236+
break;
237+
238+
case 'ArrowUp':
239+
event.preventDefault();
240+
setFocusedIndex(prev => {
241+
return prev > 0 ? prev - 1 : filteredOptions.length - 1;
242+
});
243+
break;
244+
245+
case 'Enter':
246+
event.preventDefault();
247+
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
248+
handleOptionClick(filteredOptions[focusedIndex]);
249+
}
250+
break;
251+
252+
case 'Escape':
253+
setIsOpen(false);
254+
setFocusedIndex(-1);
255+
break;
256+
}
257+
},
258+
[shouldShowMenu, filteredOptions, focusedIndex, handleOptionClick]
259+
);
260+
261+
const handleClickAway = useCallback((): void => {
262+
setIsOpen(false);
263+
setFocusedIndex(-1);
264+
}, []);
265+
266+
return (
267+
<ClickAwayListener onClickAway={handleClickAway}>
268+
<div style={{ position: 'relative', width: '100%' }}>
269+
<TextField
270+
{...props.textFieldProps}
271+
ref={textFieldRef}
272+
inputRef={inputRef}
273+
value={inputValue}
274+
onChange={handleInputChange}
275+
onFocus={handleInputFocus}
276+
onKeyDown={handleKeyDown}
277+
placeholder={props.placeholder}
278+
fullWidth
279+
/>
280+
281+
<StyledPopper
282+
open={shouldShowMenu}
283+
anchorEl={textFieldRef.current}
284+
placement="bottom-start"
285+
style={{ width: textFieldRef.current?.offsetWidth }}
286+
>
287+
<Paper>
288+
{filteredOptions.map((option, index) => {
289+
const displayLabel = props.boldMatches
290+
? highlightMatches(
291+
option.label,
292+
inputValue,
293+
props.caseSensitive
294+
)
295+
: option.label;
296+
297+
return (
298+
<MenuItem
299+
key={`${option.value}-${index}`}
300+
selected={index === focusedIndex}
301+
onClick={() => {
302+
handleOptionClick(option);
303+
}}
304+
sx={{
305+
'&.Mui-selected': {
306+
backgroundColor: 'action.hover'
307+
},
308+
'&.Mui-selected:hover': {
309+
backgroundColor: 'action.selected'
310+
}
311+
}}
312+
>
313+
{displayLabel}
314+
</MenuItem>
315+
);
316+
})}
317+
</Paper>
318+
</StyledPopper>
319+
</div>
320+
</ClickAwayListener>
321+
);
322+
}

0 commit comments

Comments
 (0)