Line data Source code
1 : /*
2 : SPDX-License-Identifier: MIT
3 :
4 : Copyright (c) 2019 Red Hat, Inc.
5 : */
6 :
7 : /* This is a copy of TypeaheadSelect.tsx from
8 :
9 : https://github.com/patternfly/patternfly-react/blob/v5/packages/react-templates/src/components/Select/TypeaheadSelect.tsx
10 :
11 : We don't use it directly from the @patternfly/react-templates node
12 : module since we want to add features to it, and also to isolate us
13 : from gratuitous upstream changes.
14 :
15 : Our changes:
16 :
17 : - There is a new "selectedIsTrusted" option to say that the the
18 : "selected" value should always be assumed to be right even if it
19 : is not in the list of "selectOptions".
20 :
21 : This option is automatically set to "true" when isCreatable is
22 : true. Thus, when allowing creation of things, you don't need to
23 : put artificial entries into selectOptions for things that don't
24 : yet exist.
25 :
26 : Setting selectIsTrusted to true is also useful when the
27 : "selected" and "selectOptions" properties are produced
28 : asynchronously from each other. Maybe you know "selected" already
29 : but "selectOptions" isn't ready yet.
30 :
31 : - When not actively filtering, the "clear" button is only shown
32 : when there is also a onClearSelection function (and when
33 : something is selected). Without such a function, nothing will
34 : change when hitting the clear button.
35 :
36 : - Allow dividers.
37 :
38 : [
39 : ...
40 : { decorator: "divider", key: "..." },
41 : ...
42 : ]
43 :
44 : - Allow headers.
45 :
46 : [
47 : ...
48 : { decorator: "header", content: _("Nice things"), key: "..." }
49 : { value: "icecream", content: _("Icecream") },
50 : ...
51 : ]
52 :
53 : Note that PatternFly uses SelectGroup and MenuGroup instead of
54 : headers, but their recursive nature makes them harder to
55 : implement here, mostly because of how keyboard navigation is
56 : done. And there is no visual nesting going on anyway. Keeping the
57 : options a flat list is just all around easier.
58 :
59 : - Support for a footer.
60 :
61 : */
62 :
63 : /* eslint-disable */
64 :
65 171 : import cockpit from "cockpit";
66 171 : import React from 'react';
67 : import { MenuToggle, MenuToggleProps, MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
68 : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
69 : import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
70 : import { MenuFooter } from '@patternfly/react-core/dist/esm/components/Menu/index.js';
71 : import { Select, SelectOption, SelectList, SelectOptionProps, SelectProps } from '@patternfly/react-core/dist/esm/components/Select/index.js';
72 : import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
73 : import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon';
74 : import "cockpit-components-select.scss";
75 :
76 171 : const _ = cockpit.gettext;
77 :
78 : export interface TypeaheadSelectDividerOption {
79 : decorator: "divider";
80 :
81 : key: string | number;
82 : };
83 :
84 : export interface TypeaheadSelectHeaderOption {
85 : decorator: "header";
86 :
87 : content: string | number;
88 : key: string | number;
89 : };
90 :
91 : export interface TypeaheadSelectMenuOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
92 : decorator?: undefined;
93 :
94 : /** Content of the select option. */
95 : content: string | number;
96 : /** Value of the select option. */
97 : value: string | number;
98 : /** Indicator for option being selected */
99 : isSelected?: boolean;
100 : }
101 :
102 : export type TypeaheadSelectOption = TypeaheadSelectMenuOption |
103 : TypeaheadSelectDividerOption |
104 : TypeaheadSelectHeaderOption;
105 :
106 : export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
107 : /** @hide Forwarded ref */
108 : innerRef?: React.Ref<any>;
109 : /** Options of the select */
110 : selectOptions: TypeaheadSelectOption[];
111 : /** Is the selected option valid even when it is not in the list? */
112 : selectedIsTrusted?: boolean;
113 : /** Callback triggered on selection. */
114 : onSelect?: (
115 : _event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
116 : selection: string | number
117 : ) => void;
118 : /** Callback triggered when the select opens or closes. */
119 : onToggle?: (nextIsOpen: boolean) => void;
120 : /** Callback triggered when the text in the input field changes. */
121 : onInputChange?: (newValue: string) => void;
122 : /** Function to return items matching the current filter value */
123 : filterFunction?: (filterValue: string, options: TypeaheadSelectOption[]) => TypeaheadSelectOption[];
124 : /** Callback triggered when the clear button is selected */
125 : onClearSelection?: () => void;
126 : /** Placeholder text for the select input. */
127 : placeholder?: string;
128 : /** Flag to indicate if the typeahead select allows new items */
129 : isCreatable?: boolean;
130 : /** Flag to indicate if create option should be at top of typeahead */
131 : isCreateOptionOnTop?: boolean;
132 : /** Message to display to create a new option */
133 : createOptionMessage?: string | ((newValue: string) => string);
134 : /** Message to display when no options are available. */
135 : noOptionsAvailableMessage?: string;
136 : /** Message to display when no options match the filter. */
137 : noOptionsFoundMessage?: string | ((filter: string) => string);
138 : /** Flag indicating the select should be disabled. */
139 : isDisabled?: boolean;
140 : /** Optional footer */
141 : footer?: React.ReactNode;
142 : /** Width of the toggle. */
143 : toggleWidth?: string;
144 : /** Additional props passed to the toggle. */
145 : toggleProps?: MenuToggleProps;
146 : }
147 :
148 2 : const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => {
149 : // Filter by search term, keep headers and dividers
150 2 : const filtered = options.filter((o) => {
151 2 : return o.decorator || String(o.content).toLowerCase().includes(filterValue.toLowerCase());
152 2 : });
153 :
154 : // Remove headers that have nothing following them, and dividers that have nothing in front of them.
155 2 : const filtered2 = filtered.filter((o, i) => {
156 1 : if (o.decorator == "header" && (i >= filtered.length - 1 || filtered[i + 1].decorator))
157 1 : return false;
158 1 : if (o.decorator == "divider" && (i <= 0 || filtered[i - 1].decorator))
159 1 : return false;
160 2 : return true;
161 2 : });
162 :
163 : // If the last item is now a divider, remove it as well.
164 2 : if (filtered2.length > 0 && filtered2[filtered2.length-1].decorator == "divider")
165 1 : filtered2.pop();
166 :
167 2 : return filtered2;
168 2 : };
169 :
170 16 : export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
171 16 : innerRef,
172 16 : selectOptions,
173 16 : selectedIsTrusted,
174 16 : onSelect,
175 16 : onToggle,
176 16 : onInputChange,
177 16 : filterFunction = defaultFilterFunction,
178 16 : onClearSelection,
179 16 : placeholder = _("Select an option"),
180 16 : noOptionsAvailableMessage = _("No results found"),
181 16 : noOptionsFoundMessage = _("No results found"),
182 16 : isCreatable = false,
183 16 : isCreateOptionOnTop = false,
184 16 : createOptionMessage = "",
185 16 : isDisabled = false,
186 16 : footer = null,
187 16 : toggleWidth,
188 16 : toggleProps,
189 16 : ...props
190 16 : }: TypeaheadSelectProps) => {
191 16 : const [isOpen, setIsOpen] = React.useState(false);
192 16 : const [filterValue, setFilterValue] = React.useState<string>('');
193 16 : const [isFiltering, setIsFiltering] = React.useState<boolean>(false);
194 16 : const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
195 16 : const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
196 16 : const textInputRef = React.useRef<HTMLInputElement>();
197 :
198 16 : const NO_RESULTS = 'no results';
199 :
200 2 : if (isCreatable && !createOptionMessage)
201 1 : throw "isCreatable requires createOptionMessage";
202 :
203 16 : if (isCreatable)
204 2 : selectedIsTrusted = true;
205 :
206 4 : const isMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !o.decorator;
207 16 : const isEnabledMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !(o.decorator || o.isDisabled);
208 :
209 16 : const selected = React.useMemo(
210 16 : () => {
211 16 : let res = selectOptions?.find((o): o is TypeaheadSelectMenuOption =>
212 16 : (isEnabledMenu(o) &&
213 13 : (o.value === props.selected || !!o.isSelected)));
214 9 : if (!res && props.selected && selectedIsTrusted)
215 9 : res = { value: props.selected, content: props.selected };
216 16 : return res;
217 16 : },
218 16 : [props.selected, selectOptions]
219 16 : );
220 :
221 16 : const filteredSelections = React.useMemo(() => {
222 16 : let newSelectOptions: TypeaheadSelectOption[] = selectOptions;
223 :
224 : // Filter menu items based on the text input value when one exists
225 3 : if (isFiltering && filterValue) {
226 3 : newSelectOptions = filterFunction(filterValue, selectOptions);
227 :
228 3 : if (
229 3 : isCreatable &&
230 2 : filterValue.trim() &&
231 1 : !newSelectOptions.find((o) => isMenu(o) && String(o.content).toLowerCase() === filterValue.toLowerCase())
232 2 : ) {
233 2 : const createOption = {
234 1 : content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue),
235 2 : value: filterValue
236 2 : };
237 2 : newSelectOptions = isCreateOptionOnTop
238 1 : ? [createOption, ...newSelectOptions]
239 2 : : [...newSelectOptions, createOption];
240 2 : }
241 :
242 : // When no options are found after filtering, display 'No results found'
243 3 : if (!newSelectOptions.length) {
244 3 : newSelectOptions = [
245 3 : {
246 3 : isAriaDisabled: true,
247 3 : isDisabled: true,
248 3 : content:
249 2 : typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(filterValue),
250 3 : value: NO_RESULTS
251 3 : }
252 3 : ];
253 3 : }
254 3 : }
255 :
256 : // When no options are available, display 'No options available'
257 4 : if (!newSelectOptions.length) {
258 4 : newSelectOptions = [
259 4 : {
260 4 : isAriaDisabled: true,
261 4 : isDisabled: true,
262 4 : content: noOptionsAvailableMessage,
263 4 : value: NO_RESULTS
264 4 : }
265 4 : ];
266 4 : }
267 :
268 16 : return newSelectOptions;
269 16 : }, [
270 16 : isFiltering,
271 16 : filterValue,
272 16 : filterFunction,
273 16 : selectOptions,
274 16 : noOptionsFoundMessage,
275 16 : isCreatable,
276 16 : isCreateOptionOnTop,
277 16 : createOptionMessage,
278 16 : noOptionsAvailableMessage
279 16 : ]);
280 :
281 16 : React.useEffect(() => {
282 3 : if (isFiltering) {
283 3 : openMenu();
284 3 : }
285 : // Don't update on openMenu changes
286 : // eslint-disable-next-line react-hooks/exhaustive-deps
287 16 : }, [isFiltering]);
288 :
289 1 : const setActiveAndFocusedItem = (itemIndex: number) => {
290 1 : setFocusedItemIndex(itemIndex);
291 1 : const focusedItem = filteredSelections[itemIndex] as TypeaheadSelectMenuOption;
292 1 : setActiveItemId(String(focusedItem.value));
293 1 : };
294 :
295 4 : const resetActiveAndFocusedItem = () => {
296 4 : setFocusedItemIndex(null);
297 4 : setActiveItemId(null);
298 4 : };
299 :
300 5 : const openMenu = () => {
301 5 : if (!isOpen) {
302 2 : onToggle && onToggle(true);
303 5 : setIsOpen(true);
304 5 : setTimeout(() => {
305 5 : textInputRef.current?.focus();
306 5 : }, 100);
307 5 : }
308 5 : };
309 :
310 4 : const closeMenu = () => {
311 2 : onToggle && onToggle(false);
312 4 : setIsOpen(false);
313 4 : resetActiveAndFocusedItem();
314 4 : setIsFiltering(false);
315 1 : setFilterValue(String(selected?.content ?? ''));
316 4 : };
317 :
318 1 : const onInputClick = () => {
319 1 : if (!isOpen) {
320 1 : openMenu();
321 0 : } else if (isFiltering) {
322 0 : closeMenu();
323 0 : }
324 1 : };
325 :
326 4 : const selectOption = (
327 4 : _event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
328 4 : option: TypeaheadSelectMenuOption
329 4 : ) => {
330 4 : onSelect && onSelect(_event, option.value);
331 4 : closeMenu();
332 4 : };
333 :
334 4 : const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
335 4 : if (value && value !== NO_RESULTS) {
336 4 : const optionToSelect = selectOptions.find(
337 4 : (option): option is TypeaheadSelectMenuOption => isMenu(option) && option.value === value);
338 4 : if (optionToSelect) {
339 4 : selectOption(_event, optionToSelect);
340 1 : } else if (isCreatable) {
341 1 : selectOption(_event, { value, content: value });
342 1 : }
343 4 : }
344 4 : };
345 :
346 2 : const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
347 2 : setIsFiltering(true);
348 0 : setFilterValue(value || '');
349 2 : onInputChange && onInputChange(value);
350 :
351 2 : resetActiveAndFocusedItem();
352 2 : };
353 :
354 1 : const handleMenuArrowKeys = (key: string) => {
355 1 : let indexToFocus = 0;
356 :
357 1 : openMenu();
358 :
359 1 : if (filteredSelections.every(o => !isEnabledMenu(o))) {
360 0 : return;
361 0 : }
362 :
363 1 : if (key === 'ArrowUp') {
364 : // When no index is set or at the first index, focus to the last, otherwise decrement focus index
365 1 : if (focusedItemIndex === null || focusedItemIndex === 0) {
366 1 : indexToFocus = filteredSelections.length - 1;
367 1 : } else {
368 1 : indexToFocus = focusedItemIndex - 1;
369 1 : }
370 :
371 : // Skip non-items
372 1 : while (!isEnabledMenu(filteredSelections[indexToFocus])) {
373 1 : indexToFocus--;
374 0 : if (indexToFocus === -1) {
375 0 : indexToFocus = filteredSelections.length - 1;
376 0 : }
377 1 : }
378 1 : }
379 :
380 1 : if (key === 'ArrowDown') {
381 : // When no index is set or at the last index, focus to the first, otherwise increment focus index
382 1 : if (focusedItemIndex === null || focusedItemIndex === filteredSelections.length - 1) {
383 1 : indexToFocus = 0;
384 1 : } else {
385 1 : indexToFocus = focusedItemIndex + 1;
386 1 : }
387 :
388 : // Skip non-items
389 1 : while (!isEnabledMenu(filteredSelections[indexToFocus])) {
390 1 : indexToFocus++;
391 0 : if (indexToFocus === filteredSelections.length) {
392 0 : indexToFocus = 0;
393 0 : }
394 1 : }
395 1 : }
396 :
397 1 : setActiveAndFocusedItem(indexToFocus);
398 1 : };
399 :
400 2 : const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
401 1 : const focusedItem = (focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null) as TypeaheadSelectMenuOption | null;
402 :
403 2 : switch (event.key) {
404 1 : case 'Enter':
405 1 : if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
406 1 : selectOption(event, focusedItem);
407 1 : }
408 :
409 1 : openMenu();
410 :
411 1 : break;
412 1 : case 'ArrowUp':
413 1 : case 'ArrowDown':
414 1 : event.preventDefault();
415 1 : handleMenuArrowKeys(event.key);
416 1 : break;
417 2 : }
418 2 : };
419 :
420 4 : const onToggleClick = () => {
421 4 : if (!isOpen) {
422 4 : openMenu();
423 1 : } else {
424 1 : closeMenu();
425 1 : }
426 4 : textInputRef.current?.focus();
427 4 : };
428 :
429 2 : const onClearButtonClick = () => {
430 2 : if (selected && onSelect) {
431 2 : onSelect(undefined, selected.value);
432 2 : }
433 2 : setFilterValue('');
434 2 : onInputChange && onInputChange('');
435 2 : setIsFiltering(false);
436 2 : resetActiveAndFocusedItem();
437 2 : textInputRef.current?.focus();
438 2 : onClearSelection && onClearSelection();
439 2 : };
440 :
441 16 : const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
442 16 : <MenuToggle
443 16 : ref={toggleRef}
444 16 : variant="typeahead"
445 16 : aria-label="Typeahead menu toggle"
446 16 : onClick={onToggleClick}
447 16 : isExpanded={isOpen}
448 16 : isDisabled={isDisabled}
449 16 : isFullWidth
450 16 : style={
451 16 : {
452 16 : width: toggleWidth
453 16 : } as React.CSSProperties
454 : }
455 16 : {...toggleProps}
456 : >
457 16 : <TextInputGroup isPlain>
458 16 : <TextInputGroupMain
459 3 : value={isFiltering ? filterValue : (selected?.content ?? '')}
460 16 : onClick={onInputClick}
461 16 : onChange={onTextInputChange}
462 16 : onKeyDown={onInputKeyDown}
463 16 : autoComplete="off"
464 16 : innerRef={textInputRef}
465 16 : placeholder={placeholder}
466 2 : {...(activeItemId && { 'aria-activedescendant': activeItemId })}
467 16 : role="combobox"
468 16 : isExpanded={isOpen}
469 16 : aria-controls="select-typeahead-listbox"
470 16 : />
471 16 : <TextInputGroupUtilities
472 3 : {...(!(isFiltering && filterValue) && !(selected && onClearSelection) ? { style: { display: 'none' } } : {})}
473 : >
474 16 : <Button icon={<RhMicronsCloseIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label="Clear input value" />
475 16 : </TextInputGroupUtilities>
476 16 : </TextInputGroup>
477 16 : </MenuToggle>
478 : );
479 :
480 16 : return (
481 16 : <Select
482 16 : isOpen={isOpen}
483 16 : selected={selected}
484 16 : onSelect={_onSelect}
485 1 : onOpenChange={(isOpen) => !isOpen && closeMenu()}
486 16 : toggle={toggle}
487 16 : variant="typeahead"
488 16 : ref={innerRef}
489 16 : {...props}
490 : >
491 16 : <SelectList>
492 16 : {filteredSelections.map((option, index) => {
493 16 : if (option.decorator == "divider")
494 11 : return <Divider key={option.key} component="li" />;
495 :
496 3 : if (option.decorator == "header") {
497 3 : return (
498 3 : <SelectOption key={option.key}
499 3 : isDisabled
500 3 : className="ct-select-header">
501 3 : {option.content}
502 3 : </SelectOption>
503 : );
504 3 : }
505 :
506 16 : const { content, value, ...props } = option;
507 16 : return (
508 16 : <SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
509 16 : {content}
510 16 : </SelectOption>
511 : );
512 16 : })}
513 16 : </SelectList>
514 1 : { footer && <MenuFooter>{footer}</MenuFooter> }
515 16 : </Select>
516 : );
517 16 : };
518 171 : TypeaheadSelectBase.displayName = 'TypeaheadSelectBase';
519 :
520 16 : export const TypeaheadSelect = React.forwardRef((props: TypeaheadSelectProps, ref: React.Ref<any>) => (
521 16 : <TypeaheadSelectBase {...props} innerRef={ref} />
522 171 : ));
523 :
524 171 : TypeaheadSelect.displayName = 'TypeaheadSelect';
|