Skip to content

Commit 09b1682

Browse files
Templates(TypeaheadSelect): Fix handling of selection and dropdown opening (#10847)
1 parent ae52b0c commit 09b1682

File tree

5 files changed

+120
-143
lines changed

5 files changed

+120
-143
lines changed

packages/react-templates/src/components/Select/TypeaheadSelect.tsx

Lines changed: 69 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,20 @@ import {
1515
} from '@patternfly/react-core';
1616
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
1717

18-
export interface TypeaheadSelectOption extends Omit<SelectOptionProps, 'content'> {
18+
export interface TypeaheadSelectOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
1919
/** Content of the select option. */
2020
content: string | number;
2121
/** Value of the select option. */
2222
value: string | number;
23+
/** Indicator for option being selected */
24+
isSelected?: boolean;
2325
}
2426

2527
export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
2628
/** @hide Forwarded ref */
2729
innerRef?: React.Ref<any>;
28-
/** Initial options of the select. */
29-
initialOptions: TypeaheadSelectOption[];
30+
/** Options of the select */
31+
selectOptions: TypeaheadSelectOption[];
3032
/** Callback triggered on selection. */
3133
onSelect?: (
3234
_event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
@@ -36,6 +38,8 @@ export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSe
3638
onToggle?: (nextIsOpen: boolean) => void;
3739
/** Callback triggered when the text in the input field changes. */
3840
onInputChange?: (newValue: string) => void;
41+
/** Function to return items matching the current filter value */
42+
filterFunction?: (filterValue: string, options: TypeaheadSelectOption[]) => TypeaheadSelectOption[];
3943
/** Callback triggered when the clear button is selected */
4044
onClearSelection?: () => void;
4145
/** Placeholder text for the select input. */
@@ -61,12 +65,16 @@ export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSe
6165
const defaultNoOptionsFoundMessage = (filter: string) => `No results found for "${filter}"`;
6266
const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`;
6367

68+
const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) =>
69+
options.filter((o) => String(o.content).toLowerCase().includes(filterValue.toLowerCase()));
70+
6471
export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
6572
innerRef,
66-
initialOptions,
73+
selectOptions,
6774
onSelect,
6875
onToggle,
6976
onInputChange,
77+
filterFunction = defaultFilterFunction,
7078
onClearSelection,
7179
placeholder = 'Select an option',
7280
noOptionsAvailableMessage = 'No options are available',
@@ -80,31 +88,30 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
8088
...props
8189
}: TypeaheadSelectProps) => {
8290
const [isOpen, setIsOpen] = React.useState(false);
83-
const [selected, setSelected] = React.useState<string>(String(initialOptions.find((o) => o.selected)?.content ?? ''));
84-
const [inputValue, setInputValue] = React.useState<string>(
85-
String(initialOptions.find((o) => o.selected)?.content ?? '')
86-
);
8791
const [filterValue, setFilterValue] = React.useState<string>('');
88-
const [selectOptions, setSelectOptions] = React.useState<TypeaheadSelectOption[]>(initialOptions);
92+
const [isFiltering, setIsFiltering] = React.useState<boolean>(false);
8993
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
9094
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
9195
const textInputRef = React.useRef<HTMLInputElement>();
9296

9397
const NO_RESULTS = 'no results';
9498

95-
React.useEffect(() => {
96-
let newSelectOptions: TypeaheadSelectOption[] = initialOptions;
99+
const selected = React.useMemo(
100+
() => selectOptions?.find((option) => option.value === props.selected || option.isSelected),
101+
[props.selected, selectOptions]
102+
);
103+
104+
const filteredSelections = React.useMemo(() => {
105+
let newSelectOptions: TypeaheadSelectOption[] = selectOptions;
97106

98107
// Filter menu items based on the text input value when one exists
99-
if (filterValue) {
100-
newSelectOptions = initialOptions.filter((option) =>
101-
String(option.content).toLowerCase().includes(filterValue.toLowerCase())
102-
);
108+
if (isFiltering && filterValue) {
109+
newSelectOptions = filterFunction(filterValue, selectOptions);
103110

104111
if (
105112
isCreatable &&
106-
filterValue &&
107-
!initialOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase())
113+
filterValue.trim() &&
114+
!newSelectOptions.find((o) => String(o.content).toLowerCase() === filterValue.toLowerCase())
108115
) {
109116
const createOption = {
110117
content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue),
@@ -126,9 +133,6 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
126133
}
127134
];
128135
}
129-
130-
// Open the menu when the input value changes and the new value is not empty
131-
openMenu();
132136
}
133137

134138
// When no options are available, display 'No options available'
@@ -142,10 +146,12 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
142146
];
143147
}
144148

145-
setSelectOptions(newSelectOptions);
149+
return newSelectOptions;
146150
}, [
151+
isFiltering,
147152
filterValue,
148-
initialOptions,
153+
filterFunction,
154+
selectOptions,
149155
noOptionsFoundMessage,
150156
isCreatable,
151157
isCreateOptionOnTop,
@@ -154,14 +160,12 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
154160
]);
155161

156162
React.useEffect(() => {
157-
// If the selected option changed and the current input value is the previously selected item, update the displayed value.
158-
const selectedOption = initialOptions.find((o) => o.selected);
159-
if (inputValue === selected && selectedOption?.value !== selected) {
160-
setInputValue(String(selectedOption?.content ?? ''));
163+
if (isFiltering) {
164+
openMenu();
161165
}
162-
// Only update when options change
166+
// Don't update on openMenu changes
163167
// eslint-disable-next-line react-hooks/exhaustive-deps
164-
}, [initialOptions]);
168+
}, [isFiltering]);
165169

166170
const setActiveAndFocusedItem = (itemIndex: number) => {
167171
setFocusedItemIndex(itemIndex);
@@ -178,23 +182,24 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
178182
if (!isOpen) {
179183
onToggle && onToggle(true);
180184
setIsOpen(true);
185+
setTimeout(() => {
186+
textInputRef.current?.focus();
187+
}, 100);
181188
}
182189
};
183190

184191
const closeMenu = () => {
185192
onToggle && onToggle(false);
186193
setIsOpen(false);
187194
resetActiveAndFocusedItem();
188-
const option = initialOptions.find((o) => o.value === selected);
189-
if (option) {
190-
setInputValue(String(option.content));
191-
}
195+
setIsFiltering(false);
196+
setFilterValue(String(selected?.content ?? ''));
192197
};
193198

194199
const onInputClick = () => {
195200
if (!isOpen) {
196201
openMenu();
197-
} else if (!inputValue) {
202+
} else if (isFiltering) {
198203
closeMenu();
199204
}
200205
};
@@ -204,25 +209,24 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
204209
option: TypeaheadSelectOption
205210
) => {
206211
onSelect && onSelect(_event, option.value);
207-
208-
setInputValue(String(option.content));
209-
setFilterValue('');
210-
setSelected(String(option.value));
211-
212212
closeMenu();
213213
};
214214

215215
const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
216216
if (value && value !== NO_RESULTS) {
217217
const optionToSelect = selectOptions.find((option) => option.value === value);
218-
selectOption(_event, optionToSelect);
218+
if (optionToSelect) {
219+
selectOption(_event, optionToSelect);
220+
} else if (isCreatable) {
221+
selectOption(_event, { value, content: value });
222+
}
219223
}
220224
};
221225

222226
const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
223-
setInputValue(value);
227+
setIsFiltering(true);
228+
setFilterValue(value || '');
224229
onInputChange && onInputChange(value);
225-
setFilterValue(value);
226230

227231
resetActiveAndFocusedItem();
228232
};
@@ -232,39 +236,39 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
232236

233237
openMenu();
234238

235-
if (selectOptions.every((option) => option.isDisabled)) {
239+
if (filteredSelections.every((option) => option.isDisabled)) {
236240
return;
237241
}
238242

239243
if (key === 'ArrowUp') {
240244
// When no index is set or at the first index, focus to the last, otherwise decrement focus index
241245
if (focusedItemIndex === null || focusedItemIndex === 0) {
242-
indexToFocus = selectOptions.length - 1;
246+
indexToFocus = filteredSelections.length - 1;
243247
} else {
244248
indexToFocus = focusedItemIndex - 1;
245249
}
246250

247251
// Skip disabled options
248-
while (selectOptions[indexToFocus].isDisabled) {
252+
while (filteredSelections[indexToFocus].isDisabled) {
249253
indexToFocus--;
250254
if (indexToFocus === -1) {
251-
indexToFocus = selectOptions.length - 1;
255+
indexToFocus = filteredSelections.length - 1;
252256
}
253257
}
254258
}
255259

256260
if (key === 'ArrowDown') {
257261
// When no index is set or at the last index, focus to the first, otherwise increment focus index
258-
if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
262+
if (focusedItemIndex === null || focusedItemIndex === filteredSelections.length - 1) {
259263
indexToFocus = 0;
260264
} else {
261265
indexToFocus = focusedItemIndex + 1;
262266
}
263267

264268
// Skip disabled options
265-
while (selectOptions[indexToFocus].isDisabled) {
269+
while (filteredSelections[indexToFocus].isDisabled) {
266270
indexToFocus++;
267-
if (indexToFocus === selectOptions.length) {
271+
if (indexToFocus === filteredSelections.length) {
268272
indexToFocus = 0;
269273
}
270274
}
@@ -274,7 +278,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
274278
};
275279

276280
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
277-
const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null;
281+
const focusedItem = focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null;
278282

279283
switch (event.key) {
280284
case 'Enter':
@@ -294,16 +298,21 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
294298
};
295299

296300
const onToggleClick = () => {
297-
onToggle && onToggle(!isOpen);
298-
setIsOpen(!isOpen);
301+
if (!isOpen) {
302+
openMenu();
303+
} else {
304+
closeMenu();
305+
}
299306
textInputRef.current?.focus();
300307
};
301308

302309
const onClearButtonClick = () => {
303-
setSelected('');
304-
setInputValue('');
305-
onInputChange && onInputChange('');
310+
if (selected && onSelect) {
311+
onSelect(undefined, selected.value);
312+
}
306313
setFilterValue('');
314+
onInputChange && onInputChange('');
315+
setIsFiltering(false);
307316
resetActiveAndFocusedItem();
308317
textInputRef.current?.focus();
309318
onClearSelection && onClearSelection();
@@ -327,7 +336,7 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
327336
>
328337
<TextInputGroup isPlain>
329338
<TextInputGroupMain
330-
value={inputValue}
339+
value={isFiltering ? filterValue : (selected?.content ?? '')}
331340
onClick={onInputClick}
332341
onChange={onTextInputChange}
333342
onKeyDown={onInputKeyDown}
@@ -339,8 +348,9 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
339348
isExpanded={isOpen}
340349
aria-controls="select-typeahead-listbox"
341350
/>
342-
343-
<TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
351+
<TextInputGroupUtilities
352+
{...(!(isFiltering && filterValue) && !selected ? { style: { display: 'none' } } : {})}
353+
>
344354
<Button variant="plain" onClick={onClearButtonClick} aria-label="Clear input value">
345355
<TimesIcon aria-hidden />
346356
</Button>
@@ -354,16 +364,14 @@ export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps>
354364
isOpen={isOpen}
355365
selected={selected}
356366
onSelect={_onSelect}
357-
onOpenChange={(isOpen) => {
358-
!isOpen && closeMenu();
359-
}}
367+
onOpenChange={(isOpen) => !isOpen && closeMenu()}
360368
toggle={toggle}
361369
shouldFocusFirstItemOnOpen={false}
362370
ref={innerRef}
363371
{...props}
364372
>
365373
<SelectList>
366-
{selectOptions.map((option, index) => {
374+
{filteredSelections.map((option, index) => {
367375
const { content, value, ...props } = option;
368376

369377
return (

0 commit comments

Comments
 (0)