LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-typeahead-select.tsx Coverage Total Hit
Test: cockpit Lines: 96.2 % 315 303
Test Date: 2026-07-02 14:11:36

            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';
        

Generated by: LCOV version 2.0-1