Skip to content

Commit d618987

Browse files
committed
implement SimpleAutocomplete and use it in ModelIdInput
1 parent 188ce5f commit d618987

File tree

2 files changed

+319
-79
lines changed

2 files changed

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

0 commit comments

Comments
 (0)