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-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 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           17 : import cockpit from "cockpit";
      32           17 : 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           17 : 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           10 : export const MultiTypeaheadSelectBase: React.FunctionComponent<MultiTypeaheadSelectProps> = ({
      80           10 :   innerRef,
      81           10 :   options,
      82           10 :   selected,
      83           10 :   onAdd,
      84           10 :   onRemove,
      85           10 :   onToggle,
      86           10 :   onInputChange,
      87           10 :   placeholder = '',
      88            0 :   noOptionsFoundMessage = _filter => _("No results found"),
      89           10 :   isDisabled = false,
      90           10 :   toggleWidth,
      91           10 :   toggleProps,
      92           10 :   ...props
      93           10 : }: MultiTypeaheadSelectProps) => {
      94           10 :   const [isOpen, setIsOpen] = React.useState(false);
      95           10 :   const [inputValue, setInputValue] = React.useState<string>("");
      96           10 :   const [selectOptions, setSelectOptions] = React.useState<MultiTypeaheadSelectOption[]>(options);
      97           10 :   const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
      98           10 :   const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
      99           10 :   const textInputRef = React.useRef<HTMLInputElement>();
     100              : 
     101           10 :   const NO_RESULTS = 'no results';
     102              : 
     103            1 :   const openMenu = () => {
     104            1 :     onToggle && onToggle(true);
     105            1 :     setIsOpen(true);
     106            1 :   };
     107              : 
     108           10 :   React.useEffect(() => {
     109           10 :     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           10 :     setSelectOptions(newSelectOptions);
     137              :     // eslint-disable-next-line react-hooks/exhaustive-deps
     138           10 :   }, [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           10 :   const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
     272           10 :     <MenuToggle
     273           10 :       ref={toggleRef}
     274           10 :       variant="typeahead"
     275           10 :       onClick={onToggleClick}
     276           10 :       isExpanded={isOpen}
     277           10 :       isDisabled={isDisabled}
     278           10 :       isFullWidth
     279           10 :       style={
     280           10 :         {
     281           10 :           width: toggleWidth
     282           10 :         } as React.CSSProperties
     283              :       }
     284           10 :       {...toggleProps}
     285              :     >
     286           10 :       <TextInputGroup isPlain>
     287           10 :         <TextInputGroupMain
     288           10 :           value={inputValue}
     289           10 :           onClick={onInputClick}
     290           10 :           onChange={onTextInputChange}
     291           10 :           onKeyDown={onInputKeyDown}
     292           10 :           autoComplete="off"
     293           10 :           innerRef={textInputRef}
     294           10 :           placeholder={placeholder}
     295            2 :           {...(activeItemId && { 'aria-activedescendant': activeItemId })}
     296           10 :           role="combobox"
     297           10 :           isExpanded={isOpen}
     298           10 :           aria-controls="select-typeahead-listbox"
     299              :         >
     300           10 :             <LabelGroup numLabels={10}>
     301            9 :                 {selected.map((selection) => {
     302            9 :                     const option = options.find((o) => o.value === selection);
     303            9 :                     if (!option)
     304            1 :                         return null;
     305            9 :                     const { content, color } = option;
     306            1 :                     function onClose(ev: React.MouseEvent<Element, MouseEvent>) {
     307            1 :                         ev.stopPropagation();
     308            1 :                         onRemove(selection);
     309            1 :                     }
     310            9 :                     return (
     311            9 :                         <Label key={selection}
     312            4 :                                {...(!option.isDisabled ? { onClose } : { }) }
     313            1 :                                {...(color ? { color } : { }) } >
     314            9 :                             {content}
     315            9 :                         </Label>
     316              :                     );
     317            9 :                 })}
     318           10 :             </LabelGroup>
     319           10 :         </TextInputGroupMain>
     320            2 :         <TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
     321           10 :           <Button icon={<RhMicronsCloseIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label={_("Clear input value")} />
     322           10 :         </TextInputGroupUtilities>
     323           10 :       </TextInputGroup>
     324           10 :     </MenuToggle>
     325              :   );
     326              : 
     327           10 :   return (
     328           10 :     <Select
     329           10 :       isOpen={isOpen}
     330           10 :       selected={selected}
     331           10 :       onSelect={_onSelect}
     332            1 :       onOpenChange={(isOpen) => {
     333            1 :         !isOpen && closeMenu();
     334            1 :       }}
     335           10 :       toggle={toggle}
     336           10 :       variant="typeahead"
     337           10 :       ref={innerRef}
     338           10 :       {...props}
     339              :     >
     340           10 :       <SelectList>
     341           10 :         {selectOptions.map((option, index) => {
     342           10 :           const { content, value, ...props } = option;
     343              : 
     344           10 :           return (
     345           10 :             <SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
     346           10 :               {content}
     347           10 :             </SelectOption>
     348              :           );
     349           10 :         })}
     350           10 :       </SelectList>
     351           10 :     </Select>
     352              :   );
     353           10 : };
     354              : 
     355           17 : MultiTypeaheadSelectBase.displayName = 'MultiTypeaheadSelectBase';
     356              : 
     357           10 : export const MultiTypeaheadSelect = React.forwardRef((props: MultiTypeaheadSelectProps, ref: React.Ref<any>) => (
     358           10 :   <MultiTypeaheadSelectBase {...props} innerRef={ref} />
     359           17 : ));
     360              : 
     361           17 : MultiTypeaheadSelect.displayName = 'MultiTypeaheadSelect';
        

Generated by: LCOV version 2.0-1