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 620 : import cockpit from "cockpit";
66 620 : 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 620 : 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 9 : const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => {
149 : // Filter by search term, keep headers and dividers
150 7 : const filtered = options.filter((o) => {
151 7 : return o.decorator || String(o.content).toLowerCase().includes(filterValue.toLowerCase());
152 7 : });
153 :
154 : // Remove headers that have nothing following them, and dividers that have nothing in front of them.
155 7 : 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 7 : return true;
161 7 : });
162 :
163 : // If the last item is now a divider, remove it as well.
164 7 : if (filtered2.length > 0 && filtered2[filtered2.length-1].decorator == "divider")
165 1 : filtered2.pop();
166 :
167 9 : return filtered2;
168 9 : };
169 :
170 34 : export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
171 34 : innerRef,
172 34 : selectOptions,
173 34 : selectedIsTrusted,
174 34 : onSelect,
175 34 : onToggle,
176 34 : onInputChange,
177 34 : filterFunction = defaultFilterFunction,
178 34 : onClearSelection,
179 34 : placeholder = _("Select an option"),
180 34 : noOptionsAvailableMessage = _("No results found"),
181 34 : noOptionsFoundMessage = _("No results found"),
182 34 : isCreatable = false,
183 34 : isCreateOptionOnTop = false,
184 34 : createOptionMessage = "",
185 34 : isDisabled = false,
186 34 : footer = null,
187 34 : toggleWidth,
188 34 : toggleProps,
189 34 : ...props
190 34 : }: TypeaheadSelectProps) => {
191 34 : const [isOpen, setIsOpen] = React.useState(false);
192 34 : const [filterValue, setFilterValue] = React.useState<string>('');
193 34 : const [isFiltering, setIsFiltering] = React.useState<boolean>(false);
194 34 : const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
195 34 : const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
196 34 : const textInputRef = React.useRef<HTMLInputElement>();
197 :
198 34 : const NO_RESULTS = 'no results';
199 :
200 16 : if (isCreatable && !createOptionMessage)
201 2 : throw "isCreatable requires createOptionMessage";
202 :
203 34 : if (isCreatable)
204 16 : selectedIsTrusted = true;
205 :
206 12 : const isMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !o.decorator;
207 32 : const isEnabledMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !(o.decorator || o.isDisabled);
208 :
209 34 : const selected = React.useMemo(
210 34 : () => {
211 32 : let res = selectOptions?.find((o): o is TypeaheadSelectMenuOption =>
212 32 : (isEnabledMenu(o) &&
213 28 : (o.value === props.selected || !!o.isSelected)));
214 18 : if (!res && props.selected && selectedIsTrusted)
215 18 : res = { value: props.selected, content: props.selected };
216 34 : return res;
217 34 : },
218 34 : [props.selected, selectOptions]
219 34 : );
220 :
221 34 : const filteredSelections = React.useMemo(() => {
222 34 : let newSelectOptions: TypeaheadSelectOption[] = selectOptions;
223 :
224 : // Filter menu items based on the text input value when one exists
225 11 : if (isFiltering && filterValue) {
226 11 : newSelectOptions = filterFunction(filterValue, selectOptions);
227 :
228 11 : if (
229 11 : isCreatable &&
230 7 : filterValue.trim() &&
231 3 : !newSelectOptions.find((o) => isMenu(o) && String(o.content).toLowerCase() === filterValue.toLowerCase())
232 7 : ) {
233 7 : const createOption = {
234 2 : content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue),
235 7 : value: filterValue
236 7 : };
237 7 : newSelectOptions = isCreateOptionOnTop
238 2 : ? [createOption, ...newSelectOptions]
239 7 : : [...newSelectOptions, createOption];
240 7 : }
241 :
242 : // When no options are found after filtering, display 'No results found'
243 6 : if (!newSelectOptions.length) {
244 6 : newSelectOptions = [
245 6 : {
246 6 : isAriaDisabled: true,
247 6 : isDisabled: true,
248 6 : content:
249 3 : typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(filterValue),
250 6 : value: NO_RESULTS
251 6 : }
252 6 : ];
253 6 : }
254 11 : }
255 :
256 : // When no options are available, display 'No options available'
257 21 : if (!newSelectOptions.length) {
258 21 : newSelectOptions = [
259 21 : {
260 21 : isAriaDisabled: true,
261 21 : isDisabled: true,
262 21 : content: noOptionsAvailableMessage,
263 21 : value: NO_RESULTS
264 21 : }
265 21 : ];
266 21 : }
267 :
268 34 : return newSelectOptions;
269 34 : }, [
270 34 : isFiltering,
271 34 : filterValue,
272 34 : filterFunction,
273 34 : selectOptions,
274 34 : noOptionsFoundMessage,
275 34 : isCreatable,
276 34 : isCreateOptionOnTop,
277 34 : createOptionMessage,
278 34 : noOptionsAvailableMessage
279 34 : ]);
280 :
281 34 : React.useEffect(() => {
282 11 : if (isFiltering) {
283 11 : openMenu();
284 11 : }
285 : // Don't update on openMenu changes
286 : // eslint-disable-next-line react-hooks/exhaustive-deps
287 34 : }, [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 13 : const resetActiveAndFocusedItem = () => {
296 13 : setFocusedItemIndex(null);
297 13 : setActiveItemId(null);
298 13 : };
299 :
300 15 : const openMenu = () => {
301 15 : if (!isOpen) {
302 5 : onToggle && onToggle(true);
303 15 : setIsOpen(true);
304 15 : setTimeout(() => {
305 15 : textInputRef.current?.focus();
306 15 : }, 100);
307 15 : }
308 15 : };
309 :
310 13 : const closeMenu = () => {
311 5 : onToggle && onToggle(false);
312 13 : setIsOpen(false);
313 13 : resetActiveAndFocusedItem();
314 13 : setIsFiltering(false);
315 2 : setFilterValue(String(selected?.content ?? ''));
316 13 : };
317 :
318 3 : const onInputClick = () => {
319 3 : if (!isOpen) {
320 3 : openMenu();
321 1 : } else if (isFiltering) {
322 1 : closeMenu();
323 1 : }
324 3 : };
325 :
326 13 : const selectOption = (
327 13 : _event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
328 13 : option: TypeaheadSelectMenuOption
329 13 : ) => {
330 13 : onSelect && onSelect(_event, option.value);
331 13 : closeMenu();
332 13 : };
333 :
334 13 : const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
335 13 : if (value && value !== NO_RESULTS) {
336 13 : const optionToSelect = selectOptions.find(
337 12 : (option): option is TypeaheadSelectMenuOption => isMenu(option) && option.value === value);
338 10 : if (optionToSelect) {
339 10 : selectOption(_event, optionToSelect);
340 2 : } else if (isCreatable) {
341 5 : selectOption(_event, { value, content: value });
342 5 : }
343 13 : }
344 13 : };
345 :
346 9 : const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
347 9 : setIsFiltering(true);
348 0 : setFilterValue(value || '');
349 5 : onInputChange && onInputChange(value);
350 :
351 9 : resetActiveAndFocusedItem();
352 9 : };
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 9 : const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
401 1 : const focusedItem = (focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null) as TypeaheadSelectMenuOption | null;
402 :
403 9 : 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 9 : }
418 9 : };
419 :
420 8 : const onToggleClick = () => {
421 7 : if (!isOpen) {
422 7 : openMenu();
423 1 : } else {
424 2 : closeMenu();
425 2 : }
426 8 : textInputRef.current?.focus();
427 8 : };
428 :
429 3 : const onClearButtonClick = () => {
430 3 : if (selected && onSelect) {
431 3 : onSelect(undefined, selected.value);
432 3 : }
433 3 : setFilterValue('');
434 3 : onInputChange && onInputChange('');
435 3 : setIsFiltering(false);
436 3 : resetActiveAndFocusedItem();
437 3 : textInputRef.current?.focus();
438 3 : onClearSelection && onClearSelection();
439 3 : };
440 :
441 34 : const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
442 34 : <MenuToggle
443 34 : ref={toggleRef}
444 34 : variant="typeahead"
445 34 : aria-label="Typeahead menu toggle"
446 34 : onClick={onToggleClick}
447 34 : isExpanded={isOpen}
448 34 : isDisabled={isDisabled}
449 34 : isFullWidth
450 34 : style={
451 34 : {
452 34 : width: toggleWidth
453 34 : } as React.CSSProperties
454 : }
455 34 : {...toggleProps}
456 : >
457 34 : <TextInputGroup isPlain>
458 34 : <TextInputGroupMain
459 11 : value={isFiltering ? filterValue : (selected?.content ?? '')}
460 34 : onClick={onInputClick}
461 34 : onChange={onTextInputChange}
462 34 : onKeyDown={onInputKeyDown}
463 34 : autoComplete="off"
464 34 : innerRef={textInputRef}
465 34 : placeholder={placeholder}
466 3 : {...(activeItemId && { 'aria-activedescendant': activeItemId })}
467 34 : role="combobox"
468 34 : isExpanded={isOpen}
469 34 : aria-controls="select-typeahead-listbox"
470 34 : />
471 34 : <TextInputGroupUtilities
472 11 : {...(!(isFiltering && filterValue) && !(selected && onClearSelection) ? { style: { display: 'none' } } : {})}
473 : >
474 34 : <Button icon={<RhMicronsCloseIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label="Clear input value" />
475 34 : </TextInputGroupUtilities>
476 34 : </TextInputGroup>
477 34 : </MenuToggle>
478 : );
479 :
480 34 : return (
481 34 : <Select
482 34 : isOpen={isOpen}
483 34 : selected={selected}
484 34 : onSelect={_onSelect}
485 1 : onOpenChange={(isOpen) => !isOpen && closeMenu()}
486 34 : toggle={toggle}
487 34 : variant="typeahead"
488 34 : ref={innerRef}
489 34 : {...props}
490 : >
491 34 : <SelectList>
492 34 : {filteredSelections.map((option, index) => {
493 34 : if (option.decorator == "divider")
494 16 : return <Divider key={option.key} component="li" />;
495 :
496 7 : if (option.decorator == "header") {
497 7 : return (
498 7 : <SelectOption key={option.key}
499 7 : isDisabled
500 7 : className="ct-select-header">
501 7 : {option.content}
502 7 : </SelectOption>
503 : );
504 7 : }
505 :
506 34 : const { content, value, ...props } = option;
507 34 : return (
508 34 : <SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
509 34 : {content}
510 34 : </SelectOption>
511 : );
512 34 : })}
513 34 : </SelectList>
514 2 : { footer && <MenuFooter>{footer}</MenuFooter> }
515 34 : </Select>
516 : );
517 34 : };
518 620 : TypeaheadSelectBase.displayName = 'TypeaheadSelectBase';
519 :
520 34 : export const TypeaheadSelect = React.forwardRef((props: TypeaheadSelectProps, ref: React.Ref<any>) => (
521 34 : <TypeaheadSelectBase {...props} innerRef={ref} />
522 620 : ));
523 :
524 620 : TypeaheadSelect.displayName = 'TypeaheadSelect';
|