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-06-01 17:00:21

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

Generated by: LCOV version 2.0-1