import { forwardRef, useEffect, useRef, useState } from 'react'; import { css } from '@patternfly/react-styles'; import { Button, ButtonVariant } from '../Button'; import { Badge } from '../Badge'; import { Icon } from '../Icon'; import AngleUpIcon from '@patternfly/react-icons/dist/esm/icons/angle-up-icon'; import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon'; import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import RhMicronsCaretRightIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-caret-right-icon'; import RhMicronsCaretDownFillIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-caret-down-fill-icon'; import { AdvancedSearchMenu } from './AdvancedSearchMenu'; import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '../TextInputGroup'; import { InputGroup, InputGroupItem } from '../InputGroup'; import { Popper } from '../../helpers'; import { useHasAnimations } from '../../helpers'; import textInputGroupStyles from '@patternfly/react-styles/css/components/TextInputGroup/text-input-group'; import inputGroupStyles from '@patternfly/react-styles/css/components/InputGroup/input-group'; /** Properties for adding search attributes to an advanced search input. These properties must * be passed in as an object within an array to the search input component's attribute property. */ export interface SearchInputSearchAttribute { /** The search attribute's value to be provided in the search input's query string. * It should have no spaces and be unique for every attribute. */ attr: string; /** The search attribute's display name. It is used to label the field in the advanced * search menu. */ display: React.ReactNode; } /** Properties for creating an expandable search input. These properties should be passed into * the search input component's expandableInput property. */ export interface SearchInputExpandable { /** Flag to indicate if the search input is expanded. */ isExpanded: boolean; /** Callback function to toggle the expandable search input. */ onToggleExpand: (event: React.SyntheticEvent, isExpanded: boolean) => void; /** An accessible label for the expandable search input toggle. */ toggleAriaLabel: string; /** Flag indicating animations should be enabled when the search input expands and collapses. Note: this will change the component's DOM structure. In a future breaking change release, this will become the default behavior and will no longer be needed. */ hasAnimations?: boolean; } /** The main search input component. */ export interface SearchInputProps extends Omit, 'onChange' | 'results' | 'ref'> { /** Delimiter in the query string for pairing attributes with search values. * Required whenever attributes are passed as props. */ advancedSearchDelimiter?: string; /** The container to append the menu to. * If your menu is being cut off you can append it to an element higher up the DOM tree. * Some examples: * appendTo={() => document.body} * appendTo={document.getElementById('target')} */ appendTo?: HTMLElement | (() => HTMLElement) | 'inline'; /** An accessible label for the search input. */ 'aria-label'?: string; /** Flag to indicate utilities should be displayed. By default if this prop is undefined or false, utilities will only be displayed when the search input has a value. */ areUtilitiesDisplayed?: boolean; /** Array of attribute values used for dynamically generated advanced search. */ attributes?: string[] | SearchInputSearchAttribute[]; /** Additional classes added to the search input. */ className?: string; /** Object that makes the search input expandable/collapsible. */ expandableInput?: SearchInputExpandable; /* Additional elements added after the attributes in the form. * The new form elements can be wrapped in a form group component for automatic formatting. */ formAdditionalItems?: React.ReactNode; /** Attribute label for strings unassociated with one of the provided listed attributes. */ hasWordsAttrLabel?: React.ReactNode; /** A suggestion for autocompleting. */ hint?: string; /** Id for the search input */ searchInputId?: string; /** @hide A reference object to attach to the input box. */ innerRef?: React.RefObject; /** A flag for controlling the open state of a custom advanced search implementation. */ isAdvancedSearchOpen?: boolean; /** Flag indicating if search input is disabled. */ isDisabled?: boolean; /** Flag indicating if the next navigation button is disabled. */ isNextNavigationButtonDisabled?: boolean; /** Flag indicating if the previous navigation button is disabled. */ isPreviousNavigationButtonDisabled?: boolean; /** Accessible label for the button to navigate to next result. */ nextNavigationButtonAriaLabel?: string; /** A callback for when the input value changes. */ onChange?: (event: React.FormEvent, value: string) => void; /** A callback for when the user clicks the clear button. */ onClear?: (event: React.SyntheticEvent) => void; /** A callback for when the user clicks to navigate to next result. */ onNextClick?: (event: React.SyntheticEvent) => void; /** A callback for when the user clicks to navigate to previous result. */ onPreviousClick?: (event: React.SyntheticEvent) => void; /** A callback for when the search button is clicked. */ onSearch?: ( event: React.SyntheticEvent, value: string, attrValueMap: { [key: string]: string } ) => void; /** A callback for when the open advanced search button is clicked. */ onToggleAdvancedSearch?: (event: React.SyntheticEvent, isOpen?: boolean) => void; /** Accessible label for the button which opens the advanced search form menu. */ openMenuButtonAriaLabel?: string; /** Placeholder text of the search input. */ placeholder?: string; /** Accessible label for the button to navigate to previous result. */ previousNavigationButtonAriaLabel?: string; /** z-index of the advanced search form when appendTo is not inline. */ zIndex?: number; /** Label for the button which resets the advanced search form and clears the search input. */ resetButtonLabel?: string; /** The number of search results returned. Either a total number of results, * or a string representing the current result over the total number of results. i.e. "1 / 5". */ resultsCount?: number | string; /** Screenreader text that will appear after resultsCount to give context for what that value represents to assistive technologies. */ resultsCountContext?: string; /** Label for the button which calls the onSearch event handler. */ submitSearchButtonLabel?: string; /** Value of the search input. */ value?: string; /** Name attribute for the search input */ name?: string; /** Additional props to spread to the search input element. */ inputProps?: any; } const SearchInputBase: React.FunctionComponent = ({ className, searchInputId, value = '', attributes = [] as string[], formAdditionalItems, hasWordsAttrLabel = 'Has words', advancedSearchDelimiter, placeholder, hint, onChange, onSearch, onClear, onToggleAdvancedSearch, isAdvancedSearchOpen = false, resultsCount, resultsCountContext = ' results', onNextClick, onPreviousClick, innerRef, expandableInput, 'aria-label': ariaLabel = 'Search input', resetButtonLabel = 'Reset', openMenuButtonAriaLabel = 'Open advanced search', previousNavigationButtonAriaLabel = 'Previous', isPreviousNavigationButtonDisabled = false, isNextNavigationButtonDisabled = false, nextNavigationButtonAriaLabel = 'Next', submitSearchButtonLabel = 'Search', isDisabled = false, appendTo, zIndex = 9999, name, areUtilitiesDisplayed, inputProps, ...props }: SearchInputProps) => { const [isSearchMenuOpen, setIsSearchMenuOpen] = useState(false); const [searchValue, setSearchValue] = useState(value); const searchInputRef = useRef(null); const ref = useRef(null); const searchInputInputRef = innerRef || ref; const searchInputExpandableToggleRef = useRef(null); const triggerRef = useRef(null); const popperRef = useRef(null); const [focusAfterExpandChange, setFocusAfterExpandChange] = useState(false); const { isExpanded, onToggleExpand, toggleAriaLabel, hasAnimations: hasAnimationsProp } = expandableInput || {}; const hasAnimations = useHasAnimations(hasAnimationsProp); useEffect(() => { // this effect and the focusAfterExpandChange variable are needed to focus the input/toggle as needed when the // expansion toggle is fired without focusing on mount if (!focusAfterExpandChange) { return; } else if (isExpanded) { searchInputInputRef?.current?.focus(); } else { if (!hasAnimations) { searchInputExpandableToggleRef?.current?.focus(); } } if (!hasAnimations) { setFocusAfterExpandChange(false); } }, [focusAfterExpandChange, isExpanded, searchInputInputRef, searchInputExpandableToggleRef]); useEffect(() => { setSearchValue(value); }, [value]); useEffect(() => { if (attributes.length > 0 && !advancedSearchDelimiter) { // eslint-disable-next-line no-console console.error( 'An advancedSearchDelimiter prop is required when advanced search attributes are provided using the attributes prop' ); } }); useEffect(() => { setIsSearchMenuOpen(isAdvancedSearchOpen); }, [isAdvancedSearchOpen]); const onChangeHandler = (event: React.FormEvent, value: string) => { if (onChange) { onChange(event, value); } setSearchValue(value); }; const onToggle = (e: React.SyntheticEvent) => { const isOpen = !isSearchMenuOpen; setIsSearchMenuOpen(isOpen); if (onToggleAdvancedSearch) { onToggleAdvancedSearch(e, isOpen); } }; const onSearchHandler = (event: React.SyntheticEvent) => { event.preventDefault(); if (onSearch) { onSearch(event, value, getAttrValueMap()); } setIsSearchMenuOpen(false); }; const splitStringExceptInQuotes = (str: string) => { let quoteType: string; return str.match(/\\?.|^$/g).reduce( (p: any, c: string) => { if (c === "'" || c === '"') { if (!quoteType) { quoteType = c; } if (c === quoteType) { p.quote = !p.quote; } } else if (!p.quote && c === ' ') { p.a.push(''); } else { p.a[p.a.length - 1] += c.replace(/\\(.)/, '$1'); } return p; }, { a: [''] } ).a; }; const getAttrValueMap = () => { const attrValue: { [key: string]: string } = {}; const pairs = splitStringExceptInQuotes(searchValue); pairs.map((pair: string) => { const splitPair = pair.split(advancedSearchDelimiter); if (splitPair.length === 2) { attrValue[splitPair[0]] = splitPair[1].replace(/(^'|'$)/g, ''); } else if (splitPair.length === 1) { attrValue.haswords = attrValue.hasOwnProperty('haswords') ? `${attrValue.haswords} ${splitPair[0]}` : splitPair[0]; } }); return attrValue; }; const onEnter = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { onSearchHandler(event); } }; const onClearInput = (e: React.SyntheticEvent) => { if (onClear) { onClear(e); } if (searchInputInputRef && searchInputInputRef.current) { searchInputInputRef.current.focus(); } }; const onExpandHandler = (event: React.SyntheticEvent) => { setSearchValue(''); onToggleExpand(event, isExpanded); setFocusAfterExpandChange(true); }; const renderUtilities = value && (resultsCount || (!!onNextClick && !!onPreviousClick) || (!!onClear && !expandableInput)); const buildTextInputGroup = ({ ...searchInputProps } = {}) => ( } innerRef={searchInputInputRef} value={searchValue} placeholder={placeholder} aria-label={ariaLabel} onKeyDown={onEnter} onChange={onChangeHandler} name={name} inputId={searchInputId} inputProps={inputProps} /> {(renderUtilities || areUtilitiesDisplayed) && ( {resultsCount && ( {resultsCount} )} {!!onNextClick && !!onPreviousClick && (
)} {!!onClear && !expandableInput && (