LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-multi-typeahead-select.tsx Coverage Total Hit
Test: cockpit Lines: 85.0 % 234 199
Test Date: 2026-07-17 12:03:54

            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 MultiTypeaheadSelect.tsx from
       8              : 
       9              :        https://github.com/patternfly/patternfly-react/blob/v5/packages/react-templates/src/components/Select/MultiTypeaheadSelect.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              :    - The selection is controlled from the outside and not maintained
      18              :      as internal state. This is how things should work with React.
      19              : 
      20              :    - Changes are announced via incremental onAdd and onRemove
      21              :      handlers.
      22              : 
      23              :    - We use Labels instead of Chips, since we want colors.
      24              : 
      25              :    - The clear button clears the input text, not the selection.
      26              : 
      27              : */
      28              : 
      29              : /* eslint-disable */
      30              : 
      31           24 : import cockpit from "cockpit";
      32           24 : import React from 'react';
      33              : import { MenuToggle, MenuToggleProps, MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
      34              : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
      35              : import { Select, SelectOption, SelectList, SelectOptionProps, SelectProps } from '@patternfly/react-core/dist/esm/components/Select/index.js';
      36              : import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
      37              : import { Label, LabelGroup, LabelProps } from "@patternfly/react-core/dist/esm/components/Label/index.js";
      38              : import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon';
      39              : 
      40              : 
      41           24 : const _ = cockpit.gettext;
      42              : 
      43              : export interface MultiTypeaheadSelectOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
      44              :   /** Content of the select option. */
      45              :   content: string | number;
      46              :   /** Value of the select option. */
      47              :   value: string | number;
      48              :   /** Color */
      49              :   color?: LabelProps["color"];
      50              : }
      51              : 
      52              : export interface MultiTypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
      53              :   /** @hide Forwarded ref */
      54              :   innerRef?: React.Ref<any>;
      55              :   /** Options of the select. */
      56              :   options: MultiTypeaheadSelectOption[];
      57              :   /** Selected values */
      58              :   selected: (string | number)[];
      59              :   /** Callback triggered when an option is added. */
      60              :   onAdd: (value: (string | number)) => void;
      61              :   /** Callback triggered when an option is removed. */
      62              :   onRemove: (value: (string | number)) => void;
      63              :   /** Callback triggered when the select opens or closes. */
      64              :   onToggle?: (nextIsOpen: boolean) => void;
      65              :   /** Callback triggered when the text in the input field changes. */
      66              :   onInputChange?: (newValue: string) => void;
      67              :   /** Placeholder text for the select input. */
      68              :   placeholder?: string;
      69              :   /** Message to display when no options match the filter. */
      70              :   noOptionsFoundMessage?: string | ((filter: string) => string);
      71              :   /** Flag indicating the select should be disabled. */
      72              :   isDisabled?: boolean;
      73              :   /** Width of the toggle. */
      74              :   toggleWidth?: string;
      75              :   /** Additional props passed to the toggle. */
      76              :   toggleProps?: MenuToggleProps;
      77              : }
      78              : 
      79           14 : export const MultiTypeaheadSelectBase: React.FunctionComponent<MultiTypeaheadSelectProps> = ({
      80           14 :   innerRef,
      81           14 :   options,
      82           14 :   selected,
      83           14 :   onAdd,
      84           14 :   onRemove,
      85           14 :   onToggle,
      86           14 :   onInputChange,
      87           14 :   placeholder = '',
      88            0 :   noOptionsFoundMessage = _filter => _("No results found"),
      89           14 :   isDisabled = false,
      90           14 :   toggleWidth,
      91           14 :   toggleProps,
      92           14 :   ...props
      93           14 : }: MultiTypeaheadSelectProps) => {
      94           14 :   const [isOpen, setIsOpen] = React.useState(false);
      95           14 :   const [inputValue, setInputValue] = React.useState<string>("");
      96           14 :   const [selectOptions, setSelectOptions] = React.useState<MultiTypeaheadSelectOption[]>(options);
      97           14 :   const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
      98           14 :   const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
      99           14 :   const textInputRef = React.useRef<HTMLInputElement>();
     100              : 
     101           14 :   const NO_RESULTS = 'no results';
     102              : 
     103            1 :   const openMenu = () => {
     104            1 :     onToggle && onToggle(true);
     105            1 :     setIsOpen(true);
     106            1 :   };
     107              : 
     108           14 :   React.useEffect(() => {
     109           14 :     let newSelectOptions: MultiTypeaheadSelectOption[] = options;
     110              : 
     111              :     // Filter menu items based on the text input value when one exists
     112            2 :     if (inputValue) {
     113            1 :       newSelectOptions = options.filter((option) =>
     114            1 :         String(option.content).toLowerCase().includes(inputValue.toLowerCase())
     115            2 :       );
     116              : 
     117              :       // When no options are found after filtering, display 'No results found'
     118            2 :       if (!newSelectOptions.length) {
     119            2 :         newSelectOptions = [
     120            2 :           {
     121            2 :             isAriaDisabled: true,
     122            2 :             isDisabled: true,
     123            2 :             content:
     124            2 :               typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(inputValue),
     125            2 :             value: NO_RESULTS
     126            2 :           }
     127            2 :         ];
     128            2 :       }
     129              : 
     130              :       // Open the menu when the input value changes and the new value is not empty
     131            1 :       if (!isOpen) {
     132            1 :         openMenu();
     133            1 :       }
     134            2 :     }
     135              : 
     136           14 :     setSelectOptions(newSelectOptions);
     137              :     // eslint-disable-next-line react-hooks/exhaustive-deps
     138           14 :   }, [inputValue, options]);
     139              : 
     140            1 :   const setActiveAndFocusedItem = (itemIndex: number) => {
     141            1 :     setFocusedItemIndex(itemIndex);
     142            1 :     const focusedItem = selectOptions[itemIndex];
     143            1 :     setActiveItemId(focusedItem.value as string);
     144            1 :   };
     145              : 
     146            2 :   const resetActiveAndFocusedItem = () => {
     147            2 :     setFocusedItemIndex(null);
     148            2 :     setActiveItemId(null);
     149            2 :   };
     150              : 
     151            2 :   const closeMenu = () => {
     152            2 :     onToggle && onToggle(false);
     153            2 :     setIsOpen(false);
     154            2 :     resetActiveAndFocusedItem();
     155            2 :     setInputValue('');
     156            2 :   };
     157              : 
     158            1 :   const onInputClick = () => {
     159            1 :     if (!isOpen) {
     160            1 :       openMenu();
     161            0 :     } else if (!inputValue) {
     162            0 :       closeMenu();
     163            0 :     }
     164            1 :   };
     165              : 
     166            2 :   const selectOption = (option: string | number) => {
     167            2 :     if (selected.includes(option))
     168            1 :       onRemove(option);
     169              :     else
     170            2 :       onAdd(option);
     171            2 :   };
     172              : 
     173            2 :   const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
     174            2 :     if (value && value !== NO_RESULTS) {
     175            2 :       selectOption(value);
     176            2 :       closeMenu();
     177            2 :     }
     178            2 :   };
     179              : 
     180            1 :   const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
     181            1 :     setInputValue(value);
     182            1 :     onInputChange && onInputChange(value);
     183              : 
     184            1 :     resetActiveAndFocusedItem();
     185            1 :   };
     186              : 
     187            1 :   const handleMenuArrowKeys = (key: string) => {
     188            1 :     let indexToFocus = 0;
     189              : 
     190            0 :     if (!isOpen) {
     191            0 :       openMenu();
     192            0 :     }
     193              : 
     194            0 :     if (selectOptions.every((option) => option.isDisabled)) {
     195            0 :       return;
     196            0 :     }
     197              : 
     198            1 :     if (key === 'ArrowUp') {
     199              :       // When no index is set or at the first index, focus to the last, otherwise decrement focus index
     200            1 :       if (focusedItemIndex === null || focusedItemIndex === 0) {
     201            1 :         indexToFocus = selectOptions.length - 1;
     202            0 :       } else {
     203            0 :         indexToFocus = focusedItemIndex - 1;
     204            0 :       }
     205              : 
     206              :       // Skip disabled options
     207            0 :       while (selectOptions[indexToFocus].isDisabled) {
     208            0 :         indexToFocus--;
     209            0 :         if (indexToFocus === -1) {
     210            0 :           indexToFocus = selectOptions.length - 1;
     211            0 :         }
     212            0 :       }
     213            1 :     }
     214              : 
     215            1 :     if (key === 'ArrowDown') {
     216              :       // When no index is set or at the last index, focus to the first, otherwise increment focus index
     217            1 :       if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
     218            1 :         indexToFocus = 0;
     219            1 :       } else {
     220            1 :         indexToFocus = focusedItemIndex + 1;
     221            1 :       }
     222              : 
     223              :       // Skip disabled options
     224            0 :       while (selectOptions[indexToFocus].isDisabled) {
     225            0 :         indexToFocus++;
     226            0 :         if (indexToFocus === selectOptions.length) {
     227            0 :           indexToFocus = 0;
     228            0 :         }
     229            0 :       }
     230            1 :     }
     231              : 
     232            1 :     setActiveAndFocusedItem(indexToFocus);
     233            1 :   };
     234              : 
     235            1 :   const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
     236            1 :     const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null;
     237              : 
     238            1 :     switch (event.key) {
     239            1 :       case 'Enter':
     240            1 :         if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
     241            1 :           selectOption(focusedItem?.value);
     242            1 :         }
     243              : 
     244            0 :         if (!isOpen) {
     245            0 :           onToggle && onToggle(true);
     246            0 :           setIsOpen(true);
     247            0 :         }
     248              : 
     249            1 :         break;
     250            1 :       case 'ArrowUp':
     251            1 :       case 'ArrowDown':
     252            1 :         event.preventDefault();
     253            1 :         handleMenuArrowKeys(event.key);
     254            1 :         break;
     255            1 :     }
     256            1 :   };
     257              : 
     258            3 :   const onToggleClick = () => {
     259            2 :     onToggle && onToggle(!isOpen);
     260            3 :     setIsOpen(!isOpen);
     261            3 :     textInputRef?.current?.focus();
     262            3 :   };
     263              : 
     264            0 :   const onClearButtonClick = (_ev: React.MouseEvent) => {
     265            0 :     setInputValue('');
     266            0 :     onInputChange && onInputChange('');
     267            0 :     resetActiveAndFocusedItem();
     268            0 :     textInputRef?.current?.focus();
     269            0 :   };
     270              : 
     271           14 :   const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
     272           14 :     <MenuToggle
     273           14 :       ref={toggleRef}
     274           14 :       variant="typeahead"
     275           14 :       onClick={onToggleClick}
     276           14 :       isExpanded={isOpen}
     277           14 :       isDisabled={isDisabled}
     278           14 :       isFullWidth
     279           14 :       style={
     280           14 :         {
     281           14 :           width: toggleWidth
     282           14 :         } as React.CSSProperties
     283              :       }
     284           14 :       {...toggleProps}
     285              :     >
     286           14 :       <TextInputGroup isPlain>
     287           14 :         <TextInputGroupMain
     288           14 :           value={inputValue}
     289           14 :           onClick={onInputClick}
     290           14 :           onChange={onTextInputChange}
     291           14 :           onKeyDown={onInputKeyDown}
     292           14 :           autoComplete="off"
     293           14 :           innerRef={textInputRef}
     294           14 :           placeholder={placeholder}
     295            2 :           {...(activeItemId && { 'aria-activedescendant': activeItemId })}
     296           14 :           role="combobox"
     297           14 :           isExpanded={isOpen}
     298           14 :           aria-controls="select-typeahead-listbox"
     299              :         >
     300           14 :             <LabelGroup numLabels={10}>
     301           10 :                 {selected.map((selection) => {
     302           10 :                     const option = options.find((o) => o.value === selection);
     303           10 :                     if (!option)
     304            1 :                         return null;
     305           10 :                     const { content, color } = option;
     306            1 :                     function onClose(ev: React.MouseEvent<Element, MouseEvent>) {
     307            1 :                         ev.stopPropagation();
     308            1 :                         onRemove(selection);
     309            1 :                     }
     310           10 :                     return (
     311           10 :                         <Label key={selection}
     312            5 :                                {...(!option.isDisabled ? { onClose } : { }) }
     313            1 :                                {...(color ? { color } : { }) } >
     314           10 :                             {content}
     315           10 :                         </Label>
     316              :                     );
     317           10 :                 })}
     318           14 :             </LabelGroup>
     319           14 :         </TextInputGroupMain>
     320            2 :         <TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
     321           14 :           <Button icon={<RhMicronsCloseIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label={_("Clear input value")} />
     322           14 :         </TextInputGroupUtilities>
     323           14 :       </TextInputGroup>
     324           14 :     </MenuToggle>
     325              :   );
     326              : 
     327           14 :   return (
     328           14 :     <Select
     329           14 :       isOpen={isOpen}
     330           14 :       selected={selected}
     331           14 :       onSelect={_onSelect}
     332            1 :       onOpenChange={(isOpen) => {
     333            1 :         !isOpen && closeMenu();
     334            1 :       }}
     335           14 :       toggle={toggle}
     336           14 :       variant="typeahead"
     337           14 :       ref={innerRef}
     338           14 :       {...props}
     339              :     >
     340           14 :       <SelectList>
     341           14 :         {selectOptions.map((option, index) => {
     342           14 :           const { content, value, ...props } = option;
     343              : 
     344           14 :           return (
     345           14 :             <SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
     346           14 :               {content}
     347           14 :             </SelectOption>
     348              :           );
     349           14 :         })}
     350           14 :       </SelectList>
     351           14 :     </Select>
     352              :   );
     353           14 : };
     354              : 
     355           24 : MultiTypeaheadSelectBase.displayName = 'MultiTypeaheadSelectBase';
     356              : 
     357           14 : export const MultiTypeaheadSelect = React.forwardRef((props: MultiTypeaheadSelectProps, ref: React.Ref<any>) => (
     358           14 :   <MultiTypeaheadSelectBase {...props} innerRef={ref} />
     359           24 : ));
     360              : 
     361           24 : MultiTypeaheadSelect.displayName = 'MultiTypeaheadSelect';
        

Generated by: LCOV version 2.0-1