diff --git a/script/lint/lint_core_config.js b/script/lint/lint_core_config.js
index 73326542755..92c4b51c47d 100644
--- a/script/lint/lint_core_config.js
+++ b/script/lint/lint_core_config.js
@@ -31,6 +31,7 @@ module.exports = {
'checkbox',
'clearbutton',
'clearfix',
+ 'combobox',
'comms',
'Composable',
'consts',
@@ -88,6 +89,7 @@ module.exports = {
'labelledby',
'lang',
'ldap',
+ 'listbox',
'listitem',
'loc',
'locs',
diff --git a/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js
new file mode 100644
index 00000000000..aad82b9845b
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/common/AutocompleteInput/AutocompleteInput.js
@@ -0,0 +1,317 @@
+import React, { useEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Select,
+ SelectOption,
+ SelectList,
+ MenuToggle,
+ TextInputGroup,
+ TextInputGroupMain,
+ TextInputGroupUtilities,
+ HelperTextItem,
+ HelperText,
+} from '@patternfly/react-core';
+import { sprintf, translate as __ } from 'foremanReact/common/I18n';
+
+const DEFAULT_PLACEHOLDER = __('Start typing to search');
+export const AutocompleteInputComponent = ({
+ selected,
+ onSelect,
+ onChange,
+ options,
+ name,
+ placeholder,
+ validationStatus,
+ validationMsg,
+ isDisabled,
+}) => {
+ if (validationStatus === 'error') validationStatus = 'danger';
+ const NO_RESULTS = __('No matches found');
+ const noOptions = [
+ { value: '', isAriaDisabled: true, disabled: true, label: NO_RESULTS },
+ ];
+
+ const displayOptions = options.length < 1 ? noOptions : options;
+
+ const displayValue =
+ typeof selected === 'string' || typeof selected === 'number'
+ ? displayOptions.find(o => o.value === selected)?.label || selected
+ : selected?.label || selected || '';
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [inputValue, setInputValue] = useState(displayValue);
+ const [filterValue, setFilterValue] = useState('');
+ const [selectOptions, setSelectOptions] = useState(displayOptions);
+ const [focusedItemIndex, setFocusedItemIndex] = useState(null);
+ const [activeItemId, setActiveItemId] = useState(null);
+ const textInputRef = useRef(null);
+ const wrapperRef = useRef(null);
+
+ useEffect(() => {
+ let newSelectOptions = displayOptions;
+ if (filterValue) {
+ newSelectOptions = displayOptions.filter(menuItem =>
+ String(menuItem.label)
+ .toLowerCase()
+ .includes(filterValue.toLowerCase())
+ );
+ if (!newSelectOptions.length) {
+ newSelectOptions = [
+ {
+ isAriaDisabled: true,
+ label: sprintf(__('No results found for %s'), filterValue),
+ value: NO_RESULTS,
+ },
+ ];
+ }
+ if (!isOpen) {
+ setIsOpen(true);
+ }
+ }
+ setSelectOptions(newSelectOptions);
+
+ /* eslint-disable react-hooks/exhaustive-deps */
+ }, [filterValue]);
+
+ const createItemId = value => `select-typeahead-${value}`;
+ const setActiveAndFocusedItem = itemIndex => {
+ setFocusedItemIndex(itemIndex);
+ const focusedItem = selectOptions[itemIndex];
+ setActiveItemId(createItemId(focusedItem.value));
+ };
+ const resetActiveAndFocusedItem = () => {
+ setFocusedItemIndex(null);
+ setActiveItemId(null);
+ };
+ const closeMenu = () => {
+ setIsOpen(false);
+ resetActiveAndFocusedItem();
+ };
+
+ const handleBlurCapture = e => {
+ const next = e.relatedTarget;
+ if (!wrapperRef.current?.contains(next)) {
+ closeMenu();
+ }
+ setInputValue(displayValue);
+ setFilterValue(displayValue);
+ };
+
+ const onInputClick = () => {
+ if (!isOpen) {
+ setIsOpen(true);
+ } else if (!inputValue) {
+ closeMenu();
+ }
+ };
+ const selectOption = (value, content) => {
+ setInputValue(String(content));
+ setFilterValue('');
+ onSelect(value);
+ closeMenu();
+ };
+ const onSelectLocal = (_event, value) => {
+ if (value && value !== NO_RESULTS) {
+ const optionText = selectOptions.find(option => option.value === value)
+ ?.label;
+ selectOption(value, optionText);
+ }
+ };
+ const onTextInputChange = (_event, value) => {
+ onChange(value);
+ setInputValue(value);
+ setFilterValue(value);
+ resetActiveAndFocusedItem();
+ };
+ const handleMenuArrowKeys = key => {
+ let indexToFocus = 0;
+ if (!isOpen) {
+ setIsOpen(true);
+ }
+ if (selectOptions.every(option => option.isDisabled)) {
+ return;
+ }
+ if (key === 'ArrowUp') {
+ if (focusedItemIndex === null || focusedItemIndex === 0) {
+ indexToFocus = selectOptions.length - 1;
+ } else {
+ indexToFocus = focusedItemIndex - 1;
+ }
+ while (selectOptions[indexToFocus].isDisabled) {
+ indexToFocus--;
+ if (indexToFocus === -1) {
+ indexToFocus = selectOptions.length - 1;
+ }
+ }
+ }
+ if (key === 'ArrowDown') {
+ if (
+ focusedItemIndex === null ||
+ focusedItemIndex === selectOptions.length - 1
+ ) {
+ indexToFocus = 0;
+ } else {
+ indexToFocus = focusedItemIndex + 1;
+ }
+ while (selectOptions[indexToFocus].isDisabled) {
+ indexToFocus++;
+ if (indexToFocus === selectOptions.length) {
+ indexToFocus = 0;
+ }
+ }
+ }
+ setActiveAndFocusedItem(indexToFocus);
+ };
+ const onInputKeyDown = event => {
+ const focusedItem =
+ focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null;
+ // eslint-disable-next-line default-case
+ switch (event.key) {
+ case 'Enter':
+ if (
+ isOpen &&
+ focusedItem &&
+ focusedItem.value !== NO_RESULTS &&
+ !focusedItem.isAriaDisabled
+ ) {
+ selectOption(focusedItem.value, focusedItem.label);
+ }
+ if (!isOpen) {
+ setIsOpen(true);
+ }
+ break;
+ case 'ArrowUp':
+ case 'ArrowDown':
+ event.preventDefault();
+ handleMenuArrowKeys(event.key);
+ break;
+ }
+ };
+ const onToggleClick = () => {
+ setIsOpen(!isOpen);
+ // eslint-disable-next-line no-unused-expressions
+ textInputRef?.current?.focus();
+ };
+
+ const toggle = toggleRef => (
+