diff --git a/packages/react-core/src/demos/TextInputGroupDemo.md b/packages/react-core/src/demos/TextInputGroupDemo.md index b77a5ecced6..f18377f51c4 100644 --- a/packages/react-core/src/demos/TextInputGroupDemo.md +++ b/packages/react-core/src/demos/TextInputGroupDemo.md @@ -24,255 +24,17 @@ Additionally, attributes can be selected by typing the full (case sensitive) nam Attributes can be deselected (returning you to attribute selection mode) by hitting `escape`, or by hitting `backspace` when the only text in the text input is the attribute. -```js -import React from 'react'; -import { - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, - Button, - Menu, - MenuContent, - MenuList, - MenuItem, - Popper, - Chip, - ChipGroup, - Divider -} from '@patternfly/react-core'; -import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; -import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; - -export const KeyValueFiltering = () => { - const [inputValue, setInputValue] = React.useState(''); - const [selectedKey, setSelectedKey] = React.useState(''); - const [menuIsOpen, setMenuIsOpen] = React.useState(false); - const [currentChips, setCurrentChips] = React.useState([]); - - /** key and value data to be shown in the menu */ - const data = { - Cluster: ['acmeqe-managed-1', 'local-cluster'], - Kind: ['Template', 'ReplicationController', 'ReplicaSet', 'Deployment'], - Label: ['release', 'environment', 'partition'], - Name: ['backup-1', 'backup-2', 'production-1', 'production-2', 'testing'], - Namespace: ['default', 'public'], - Status: ['running', 'idle', 'stopped'] - }; - const keyNames = ['Cluster', 'Kind', 'Label', 'Name', 'Namespace', 'Status']; - const [menuItemsText, setMenuItemsText] = React.useState(keyNames); - const [menuItems, setMenuItems] = React.useState([]); - - /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ - const menuRef = React.useRef(); - const textInputGroupRef = React.useRef(); - - /** callback for updating the inputValue state in this component so that the input can be controlled */ - const handleInputChange = (value, _event) => { - setInputValue(value); - }; - - /** callback for removing a chip from the chip selections */ - const deleteChip = chipToDelete => { - const newChips = currentChips.filter(chip => !Object.is(chip, chipToDelete)); - setCurrentChips(newChips); - }; - - /** reset state hooks associated with key selection */ - const clearSelectedKey = () => { - setInputValue(''); - setSelectedKey(''); - setMenuItemsText(keyNames); - }; - - /** callback for clearing all selected chips, the text input, and any selected keys */ - const clearChipsAndInput = () => { - setCurrentChips([]); - clearSelectedKey(); - }; - - React.useEffect(() => { - /** in the menu only show items that include the text in the input */ - const filteredMenuItems = menuItemsText - .filter( - item => - !inputValue || - item.toLowerCase().includes( - inputValue - .toString() - .slice(selectedKey.length && selectedKey.length + 2) - .toLowerCase() - ) - ) - .map((currentValue, index) => ( - - {currentValue} - - )); - - /** in the menu show a disabled "no result" when all menu items are filtered out */ - if (filteredMenuItems.length === 0) { - const noResultItem = ( - - No results found - - ); - setMenuItems([noResultItem]); - return; - } - - /** determine the menu heading text based on key selection; or lack thereof */ - const headingItem = ( - - {selectedKey.length ? `${selectedKey} values` : 'Attributes'} - - ); - - const divider = ; - - setMenuItems([headingItem, divider, ...filteredMenuItems]); - }, [inputValue]); - - /** add selected key/value pair as a chip in the chip group */ - const selectValue = selectedValue => { - setCurrentChips([...currentChips, `${selectedKey}: ${selectedValue}`]); - clearSelectedKey(); - }; - - /** update the input to show the selected key and the menu to show the values associated with that specific key */ - const selectKey = selectedText => { - setInputValue(`${selectedText}: `); - setSelectedKey(selectedText); - setMenuItemsText(data[selectedText]); - }; - - const handleEnter = () => { - /** do nothing if the menu contains no real results */ - if (menuItems.length === 1) { - return; - } - - /** perform the appropriate action based on key selection state */ - if (selectedKey.length) { - selectValue(menuItems[2].props.children); - } else { - selectKey(menuItems[2].props.children); - } - }; - - /** allow the user to backspace at the selected key name to drop the currently selected key */ - const handleBackspace = () => { - if (selectedKey.length && inputValue === `${selectedKey}: `) { - clearSelectedKey(); - } - }; - - /** allow the user to select a key by simply typing it and entering a colon, exact (case sensitive) matches only */ - const handleColon = () => { - if (!selectedKey.length && keyNames.includes(inputValue)) { - selectKey(inputValue); - event.preventDefault(); - } - }; - - /** allow the user to focus on the menu and navigate using the arrow keys */ - const handleArrowKey = () => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); - firstElement && firstElement.focus(); - } - }; - - /** enable keyboard only usage */ - const handleTextInputKeyDown = event => { - switch (event.key) { - case 'Enter': - handleEnter(); - break; - case 'Escape': - clearSelectedKey(); - break; - case 'Backspace': - handleBackspace(); - break; - case ':': - handleColon(); - break; - case 'ArrowUp': - case 'ArrowDown': - handleArrowKey(); - break; - } - }; - - /** perform the proper key or value selection when a menu item is selected */ - const onSelect = (event, _itemId) => { - const selectedText = event.target.innerText; - - if (selectedKey.length) { - selectValue(selectedText); - } else { - selectKey(selectedText); - } - event.stopPropagation(); - textInputGroupRef.current.querySelector('input').focus(); - }; - - /** close the menu when a click occurs outside of the menu or text input group */ - const handleClick = event => { - if ( - menuRef.current && - !menuRef.current.contains(event.target) && - !textInputGroupRef.current.contains(event.target) - ) { - setMenuIsOpen(false); - } - }; +```js file="./examples/TextInputGroup/AttributeValueFiltering.js" +``` +### Auto-complete search - /** only show the search icon when no chips are selected */ - const showSearchIcon = !currentChips.length; +This demo showcases a search input with suggestions, which filters possible selections based on the text you've entered. Unlike the attribute-value filtering demo, it allows creation of new chip items when the text entered is not available in the list of suggestions. - /** only show the clear button when there is something that can be cleared */ - const showClearButton = inputValue || !!currentChips.length; +The current text in the input can be converted to a chip at any time by hitting `enter`. Auto-complete suggestions can be chosen by clicking the corresponding entry in the menu, or by navigating to an entry using the up/down arrow keys and selecting it with `enter`. - const inputGroup = ( -
- - } - value={inputValue} - onChange={handleInputChange} - onFocus={() => setMenuIsOpen(true)} - onKeyDown={handleTextInputKeyDown} - > - - {currentChips.map(currentChip => ( - deleteChip(currentChip)}> - {currentChip} - - ))} - - - - {showClearButton && ( - - )} - - -
- ); +Hitting `escape` while focused on the input or menu will close the menu, and the menu will reopen when text is entered. - const menu = ( -
- - - {menuItems} - - -
- ); +When only one item remains in the suggestion list, tab can be used to auto-complete the typing of that item. - return ; -}; +```js file="./examples/TextInputGroup/AutoCompleteSearch.js" ``` diff --git a/packages/react-core/src/demos/examples/TextInputGroup/AttributeValueFiltering.js b/packages/react-core/src/demos/examples/TextInputGroup/AttributeValueFiltering.js new file mode 100644 index 00000000000..ee83712e92f --- /dev/null +++ b/packages/react-core/src/demos/examples/TextInputGroup/AttributeValueFiltering.js @@ -0,0 +1,250 @@ +import React from 'react'; +import { + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Chip, + ChipGroup, + Divider +} from '@patternfly/react-core'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export const AttributeValueFiltering = () => { + const [inputValue, setInputValue] = React.useState(''); + const [selectedKey, setSelectedKey] = React.useState(''); + const [menuIsOpen, setMenuIsOpen] = React.useState(false); + const [currentChips, setCurrentChips] = React.useState([]); + + /** key and value data to be shown in the menu */ + const data = { + Cluster: ['acmeqe-managed-1', 'local-cluster'], + Kind: ['Template', 'ReplicationController', 'ReplicaSet', 'Deployment'], + Label: ['release', 'environment', 'partition'], + Name: ['backup-1', 'backup-2', 'production-1', 'production-2', 'testing'], + Namespace: ['default', 'public'], + Status: ['running', 'idle', 'stopped'] + }; + const keyNames = ['Cluster', 'Kind', 'Label', 'Name', 'Namespace', 'Status']; + const [menuItemsText, setMenuItemsText] = React.useState(keyNames); + const [menuItems, setMenuItems] = React.useState([]); + + /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ + const menuRef = React.useRef(); + const textInputGroupRef = React.useRef(); + + /** callback for updating the inputValue state in this component so that the input can be controlled */ + const handleInputChange = (value, _event) => { + setInputValue(value); + }; + + /** callback for removing a chip from the chip selections */ + const deleteChip = chipToDelete => { + const newChips = currentChips.filter(chip => !Object.is(chip, chipToDelete)); + setCurrentChips(newChips); + }; + + /** reset state hooks associated with key selection */ + const clearSelectedKey = () => { + setInputValue(''); + setSelectedKey(''); + setMenuItemsText(keyNames); + }; + + /** callback for clearing all selected chips, the text input, and any selected keys */ + const clearChipsAndInput = () => { + setCurrentChips([]); + clearSelectedKey(); + }; + + React.useEffect(() => { + /** in the menu only show items that include the text in the input */ + const filteredMenuItems = menuItemsText + .filter( + item => + !inputValue || + item.toLowerCase().includes( + inputValue + .toString() + .slice(selectedKey.length && selectedKey.length + 2) + .toLowerCase() + ) + ) + .map((currentValue, index) => ( + + {currentValue} + + )); + + /** in the menu show a disabled "no result" when all menu items are filtered out */ + if (filteredMenuItems.length === 0) { + const noResultItem = ( + + No results found + + ); + setMenuItems([noResultItem]); + return; + } + + /** determine the menu heading text based on key selection; or lack thereof */ + const headingItem = ( + + {selectedKey.length ? `${selectedKey} values` : 'Attributes'} + + ); + + const divider = ; + + setMenuItems([headingItem, divider, ...filteredMenuItems]); + }, [inputValue]); + + /** add selected key/value pair as a chip in the chip group */ + const selectValue = selectedValue => { + setCurrentChips([...currentChips, `${selectedKey}: ${selectedValue}`]); + clearSelectedKey(); + }; + + /** update the input to show the selected key and the menu to show the values associated with that specific key */ + const selectKey = selectedText => { + setInputValue(`${selectedText}: `); + setSelectedKey(selectedText); + setMenuItemsText(data[selectedText]); + }; + + const handleEnter = () => { + /** do nothing if the menu contains no real results */ + if (menuItems.length === 1) { + return; + } + + /** perform the appropriate action based on key selection state */ + if (selectedKey.length) { + selectValue(menuItems[2].props.children); + } else { + selectKey(menuItems[2].props.children); + } + }; + + /** allow the user to backspace at the selected key name to drop the currently selected key */ + const handleBackspace = () => { + if (selectedKey.length && inputValue === `${selectedKey}: `) { + clearSelectedKey(); + } + }; + + /** allow the user to select a key by simply typing it and entering a colon, exact (case sensitive) matches only */ + const handleColon = () => { + if (!selectedKey.length && keyNames.includes(inputValue)) { + selectKey(inputValue); + event.preventDefault(); + } + }; + + /** allow the user to focus on the menu and navigate using the arrow keys */ + const handleArrowKey = () => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && firstElement.focus(); + } + }; + + /** enable keyboard only usage */ + const handleTextInputKeyDown = event => { + switch (event.key) { + case 'Enter': + handleEnter(); + break; + case 'Escape': + clearSelectedKey(); + break; + case 'Backspace': + handleBackspace(); + break; + case ':': + handleColon(); + break; + case 'ArrowUp': + case 'ArrowDown': + handleArrowKey(); + break; + } + }; + + /** perform the proper key or value selection when a menu item is selected */ + const onSelect = (event, _itemId) => { + const selectedText = event.target.innerText; + + if (selectedKey.length) { + selectValue(selectedText); + } else { + selectKey(selectedText); + } + event.stopPropagation(); + textInputGroupRef.current.querySelector('input').focus(); + }; + + /** close the menu when a click occurs outside of the menu or text input group */ + const handleClick = event => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !textInputGroupRef.current.contains(event.target) + ) { + setMenuIsOpen(false); + } + }; + + /** only show the search icon when no chips are selected */ + const showSearchIcon = !currentChips.length; + + /** only show the clear button when there is something that can be cleared */ + const showClearButton = inputValue || !!currentChips.length; + + const inputGroup = ( +
+ + } + value={inputValue} + onChange={handleInputChange} + onFocus={() => setMenuIsOpen(true)} + onKeyDown={handleTextInputKeyDown} + > + + {currentChips.map(currentChip => ( + deleteChip(currentChip)}> + {currentChip} + + ))} + + + + {showClearButton && ( + + )} + + +
+ ); + + const menu = ( +
+ + + {menuItems} + + +
+ ); + + return ; +}; diff --git a/packages/react-core/src/demos/examples/TextInputGroup/AutoCompleteSearch.js b/packages/react-core/src/demos/examples/TextInputGroup/AutoCompleteSearch.js new file mode 100644 index 00000000000..90226d7b967 --- /dev/null +++ b/packages/react-core/src/demos/examples/TextInputGroup/AutoCompleteSearch.js @@ -0,0 +1,230 @@ +import React from 'react'; +import { + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, + Menu, + MenuContent, + MenuList, + MenuItem, + Popper, + Chip, + ChipGroup, + Divider +} from '@patternfly/react-core'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export const AutoCompleteSearch = () => { + const [inputValue, setInputValue] = React.useState(''); + const [menuIsOpen, setMenuIsOpen] = React.useState(false); + const [currentChips, setCurrentChips] = React.useState([]); + + /** auto-completing suggestion text items to be shown in the menu */ + const suggestionItems = ['Cluster', 'Kind', 'Label', 'Name', 'Namespace', 'Status']; + const [menuItems, setMenuItems] = React.useState([]); + + /** refs used to detect when clicks occur inside vs outside of the textInputGroup and menu popper */ + const menuRef = React.useRef(); + const textInputGroupRef = React.useRef(); + + /** callback for updating the inputValue state in this component so that the input can be controlled */ + const handleInputChange = (value, _event) => { + setInputValue(value); + }; + + /** callback for removing a chip from the chip selections */ + const deleteChip = chipToDelete => { + const newChips = currentChips.filter(chip => !Object.is(chip, chipToDelete)); + setCurrentChips(newChips); + }; + + /** callback for clearing all selected chips and the text input */ + const clearChipsAndInput = () => { + setCurrentChips([]); + setInputValue(''); + }; + + React.useEffect(() => { + /** in the menu only show items that include the text in the input */ + const filteredMenuItems = suggestionItems + .filter(item => !inputValue || item.toLowerCase().includes(inputValue.toString().toLowerCase())) + .map((currentValue, index) => ( + + {currentValue} + + )); + + /** in the menu show a disabled "no result" when all menu items are filtered out */ + if (filteredMenuItems.length === 0) { + const noResultItem = ( + + No results found + + ); + setMenuItems([noResultItem]); + return; + } + + /** add a heading to the menu */ + const headingItem = ( + + Suggestions + + ); + + const divider = ; + + setMenuItems([headingItem, divider, ...filteredMenuItems]); + }, [inputValue]); + + /** add the given string as a chip in the chip group and clear the input */ + const addChip = newChipText => { + setCurrentChips([...currentChips, `${newChipText}`]); + setInputValue(''); + }; + + /** add the current input value as a chip */ + const handleEnter = () => { + if (inputValue.length) { + addChip(inputValue); + } + }; + + const handleTab = event => { + if (menuItems.length === 3) { + setInputValue(menuItems[2].props.children); + event.preventDefault(); + } + }; + + /** close the menu when escape is hit */ + const handleEscape = () => { + setMenuIsOpen(false); + }; + + /** allow the user to focus on the menu and navigate using the arrow keys */ + const handleArrowKey = () => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); + firstElement && firstElement.focus(); + } + }; + + /** reopen the menu if it's closed and any un-designated keys are hit */ + const handleDefault = () => { + if (!menuIsOpen) { + setMenuIsOpen(true); + } + }; + + /** enable keyboard only usage while focused on the text input */ + const handleTextInputKeyDown = event => { + switch (event.key) { + case 'Enter': + handleEnter(); + break; + case 'Escape': + handleEscape(); + break; + case 'Tab': + handleTab(event); + break; + case 'ArrowUp': + case 'ArrowDown': + handleArrowKey(); + break; + default: + handleDefault(); + } + }; + + /** apply focus to the text input */ + const focusTextInput = () => { + textInputGroupRef.current.querySelector('input').focus(); + }; + + /** add the text of the selected item as a new chip */ + const onSelect = (event, _itemId) => { + const selectedText = event.target.innerText; + addChip(selectedText); + event.stopPropagation(); + focusTextInput(); + }; + + /** close the menu when a click occurs outside of the menu or text input group */ + const handleClick = event => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !textInputGroupRef.current.contains(event.target) + ) { + setMenuIsOpen(false); + } + }; + + /** enable keyboard only usage while focused on the menu */ + const handleMenuKeyDown = event => { + if (event.key === 'Escape') { + setInputValue(''); + focusTextInput(); + setMenuIsOpen(false); + } + }; + + /** only show the search icon when no chips are selected */ + const showSearchIcon = !currentChips.length; + + /** only show the clear button when there is something that can be cleared */ + const showClearButton = inputValue || !!currentChips.length; + + const inputGroup = ( +
+ + } + value={inputValue} + onChange={handleInputChange} + onFocus={() => setMenuIsOpen(true)} + onKeyDown={handleTextInputKeyDown} + > + + {currentChips.map(currentChip => ( + deleteChip(currentChip)}> + {currentChip} + + ))} + + + + {showClearButton && ( + + )} + + +
+ ); + + const menu = ( +
+ + + {menuItems} + + +
+ ); + + return ( + textInputGroupRef.current} + isVisible={menuIsOpen} + onDocumentClick={handleClick} + /> + ); +};