diff --git a/src-docs/src/views/search_bar/props_info.js b/src-docs/src/views/search_bar/props_info.js index 9b685d8b5eb..06b8a927e2d 100644 --- a/src-docs/src/views/search_bar/props_info.js +++ b/src-docs/src/views/search_bar/props_info.js @@ -36,6 +36,11 @@ export const propsInfo = { required: false, type: { name: '#SearchFilters[]' }, }, + hint: { + description: 'Renders a hint below the search bar', + required: false, + type: { name: '#Hint' }, + }, }, }, }, @@ -504,4 +509,22 @@ export const propsInfo = { }, }, }, + + Hint: { + __docgenInfo: { + _euiObjectType: 'type', + props: { + content: { + description: 'The hint content to render', + required: true, + type: { name: 'React.ReactNode' }, + }, + popOverProps: { + description: 'Optional configuration for the hint popover.', + required: false, + type: { name: 'EuiInputPopoverProps' }, + }, + }, + }, + }, }; diff --git a/src-docs/src/views/search_bar/search_bar.js b/src-docs/src/views/search_bar/search_bar.js index 1aff47dd97c..c586d45b537 100644 --- a/src-docs/src/views/search_bar/search_bar.js +++ b/src-docs/src/views/search_bar/search_bar.js @@ -64,6 +64,7 @@ export const SearchBar = () => { const [query, setQuery] = useState(initialQuery); const [error, setError] = useState(null); const [incremental, setIncremental] = useState(false); + const [showHint, setShowHint] = useState(false); const onChange = ({ query, error }) => { if (error) { @@ -75,7 +76,11 @@ export const SearchBar = () => { }; const toggleIncremental = () => { - setIncremental(!incremental); + setIncremental((prev) => !prev); + }; + + const toggleHint = () => { + setShowHint((prev) => !prev); }; const renderSearch = () => { @@ -176,6 +181,19 @@ export const SearchBar = () => { }} filters={filters} onChange={onChange} + hint={ + showHint + ? { + content: ( + + Type search terms, e.g. visualization or{' '} + -dashboard + + ), + popoverProps: { panelStyle: { backgroundColor: '#f7f8fc' } }, + } + : undefined + } /> ); }; @@ -292,9 +310,9 @@ export const SearchBar = () => { return ( - - {renderSearch()} - + {renderSearch()} + + { onChange={toggleIncremental} /> + + + + {content} diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx index df8d99d8a42..6f6f8685c04 100644 --- a/src/components/popover/popover.tsx +++ b/src/components/popover/popover.tsx @@ -625,9 +625,10 @@ export class EuiPopover extends Component { container, focusTrapProps, initialFocus: initialFocusProp, - tabIndex: tabIndexProp, + tabIndex: _tabIndexProp, ...rest } = this.props; + const tabIndexProp = panelProps?.tabIndex ?? _tabIndexProp; const styles = euiPopoverStyles(); const popoverStyles = [styles.euiPopover, { display }]; diff --git a/src/components/search_bar/__snapshots__/search_box.test.tsx.snap b/src/components/search_bar/__snapshots__/search_box.test.tsx.snap index 7b0248a3764..778ef5654f0 100644 --- a/src/components/search_bar/__snapshots__/search_box.test.tsx.snap +++ b/src/components/search_bar/__snapshots__/search_box.test.tsx.snap @@ -12,6 +12,7 @@ exports[`EuiSearchBox render - custom placeholder and incremental 1`] = ` inputRef={[Function]} isClearable={true} isLoading={false} + onFocus={[Function]} onSearch={[Function]} placeholder="..." /> @@ -29,6 +30,7 @@ exports[`EuiSearchBox render - no config 1`] = ` inputRef={[Function]} isClearable={true} isLoading={false} + onFocus={[Function]} onSearch={[Function]} placeholder="Search..." /> diff --git a/src/components/search_bar/search_bar.test.tsx b/src/components/search_bar/search_bar.test.tsx index 12656467f23..0bc04ca33e1 100644 --- a/src/components/search_bar/search_bar.test.tsx +++ b/src/components/search_bar/search_bar.test.tsx @@ -7,7 +7,9 @@ */ /* eslint-disable react/no-multi-comp */ -import React from 'react'; +import React, { useState } from 'react'; +import { act } from 'react-dom/test-utils'; + import { requiredProps } from '../../test'; import { mount, shallow } from 'enzyme'; import { EuiSearchBar } from './search_bar'; @@ -105,4 +107,81 @@ describe('SearchBar', () => { expect(queryText).toBe('status:inactive'); }); }); + + describe('hint', () => { + test('renders a hint below the search bar on focus', () => { + const component = mount( + Hello from hint, + }} + /> + ); + + const getHint = () => component.find('[data-test-subj="myHint"]'); + + let hint = getHint(); + expect(hint.length).toBe(0); + + act(() => { + component.find('input[data-test-subj="searchbar"]').simulate('focus'); + }); + component.update(); + + hint = getHint(); + expect(hint.length).toBe(1); + expect(hint.text()).toBe('Hello from hint'); + }); + + test('control the visibility of the hint', () => { + const TestComp = () => { + const [isHintVisible, setIsHintVisible] = useState(false); + + return ( + <> + Hello from hint, + popoverProps: { + isOpen: isHintVisible, + }, + }} + /> + + + ); + }; + + const component = mount(); + const getHint = () => component.find('[data-test-subj="myHint"]'); + + let hint = getHint(); + expect(hint.length).toBe(0); + + act(() => { + component.find('input[data-test-subj="searchbar"]').simulate('focus'); + }); + component.update(); + + hint = getHint(); + expect(hint.length).toBe(0); // Not visible on focus as it is controlled + + act(() => { + component.find('[data-test-subj="showHintBtn"]').simulate('click'); + }); + component.update(); + + hint = getHint(); + expect(hint.length).toBe(1); + expect(hint.text()).toBe('Hello from hint'); + }); + }); }); diff --git a/src/components/search_bar/search_bar.tsx b/src/components/search_bar/search_bar.tsx index 0b93e45483b..bab5a1e8d49 100644 --- a/src/components/search_bar/search_bar.tsx +++ b/src/components/search_bar/search_bar.tsx @@ -7,6 +7,8 @@ */ import React, { Component, ReactElement } from 'react'; + +import { htmlIdGenerator } from '../../services/accessibility'; import { isString } from '../../services/predicate'; import { EuiFlexGroup, EuiFlexItem } from '../flex'; import { EuiSearchBox, SchemaType } from './search_box'; @@ -14,6 +16,7 @@ import { EuiSearchFilters, SearchFilterConfig } from './search_filters'; import { Query } from './query'; import { CommonProps } from '../common'; import { EuiFieldSearchProps } from '../form/field_search'; +import { EuiInputPopoverProps } from '../popover'; export { Query, AST as Ast } from './query'; @@ -35,6 +38,24 @@ interface ArgsWithError { export type EuiSearchBarOnChangeArgs = ArgsWithQuery | ArgsWithError; +type HintPopOverProps = Partial< + Pick< + EuiInputPopoverProps, + | 'isOpen' + | 'closePopover' + | 'fullWidth' + | 'disableFocusTrap' + | 'panelClassName' + | 'panelPaddingSize' + | 'panelStyle' + | 'panelProps' + | 'popoverScreenReaderText' + | 'repositionOnScroll' + | 'zIndex' + | 'data-test-subj' + > +>; + export interface EuiSearchBarProps extends CommonProps { onChange?: (args: EuiSearchBarOnChangeArgs) => void | boolean; @@ -77,6 +98,14 @@ export interface EuiSearchBarProps extends CommonProps { * Date formatter to use when parsing date values */ dateFormat?: object; + + /** + * Hint to render below the search bar + */ + hint?: { + content: React.ReactNode; + popoverProps?: HintPopOverProps; + }; } const parseQuery = ( @@ -99,14 +128,16 @@ interface State { query: Query; queryText: string; error: null | Error; + isHintVisible: boolean; } -// `state.query` is never null, but can be passed as `null` to `notifyControllingParent` -// when `error` is not null. -type StateWithOptionalQuery = Omit & { query: Query | null }; +type NotifyControllingParent = Pick & { + query: Query | null; // `state.query` is never null, but can be passed as `null` when an error is present +}; export class EuiSearchBar extends Component { static Query = Query; + hintId = htmlIdGenerator('__hint')(); constructor(props: EuiSearchBarProps) { super(props); @@ -115,6 +146,7 @@ export class EuiSearchBar extends Component { query, queryText: query.text, error: null, + isHintVisible: false, }; } @@ -135,12 +167,13 @@ export class EuiSearchBar extends Component { query, queryText: query.text, error: null, + isHintVisible: prevState.isHintVisible, }; } return null; } - notifyControllingParent(newState: StateWithOptionalQuery) { + notifyControllingParent(newState: NotifyControllingParent) { const { onChange } = this.props; if (!onChange) { return; @@ -204,12 +237,18 @@ export class EuiSearchBar extends Component { } render() { - const { query, queryText, error } = this.state; + const { + query, + queryText, + error, + isHintVisible: isHintVisibleState, + } = this.state; const { box: { schema, ...box } = { schema: '' }, // strip `schema` out to prevent passing it to EuiSearchBox filters, toolsLeft, toolsRight, + hint, } = this.props; const toolsLeftEl = this.renderTools(toolsLeft); @@ -226,6 +265,8 @@ export class EuiSearchBar extends Component { const toolsRightEl = this.renderTools(toolsRight); + const isHintVisible = hint?.popoverProps?.isOpen ?? isHintVisibleState; + return ( {toolsLeftEl} @@ -236,6 +277,19 @@ export class EuiSearchBar extends Component { onSearch={this.onSearch} isInvalid={error != null} title={error ? error.message : undefined} + aria-describedby={isHintVisible ? `${this.hintId}` : undefined} + hint={ + hint + ? { + isVisible: isHintVisible, + setIsVisible: (isVisible: boolean) => { + this.setState({ isHintVisible: isVisible }); + }, + id: this.hintId, + ...hint, + } + : undefined + } /> {filtersBar} diff --git a/src/components/search_bar/search_box.tsx b/src/components/search_bar/search_box.tsx index 2c0ea14f77c..85a78d3a726 100644 --- a/src/components/search_bar/search_box.tsx +++ b/src/components/search_bar/search_box.tsx @@ -8,6 +8,8 @@ import React, { Component } from 'react'; import { EuiFieldSearch, EuiFieldSearchProps } from '../form'; +import { EuiInputPopover } from '../popover'; +import { EuiSearchBarProps } from './search_bar'; export interface SchemaType { strict?: boolean; @@ -19,6 +21,11 @@ export interface EuiSearchBoxProps extends EuiFieldSearchProps { query: string; // This is optional in EuiFieldSearchProps onSearch: (queryText: string) => void; + hint?: { + id: string; + isVisible: boolean; + setIsVisible: (isVisible: boolean) => void; + } & EuiSearchBarProps['hint']; } type DefaultProps = Pick; @@ -39,7 +46,7 @@ export class EuiSearchBox extends Component { } render() { - const { query, incremental, ...rest } = this.props; + const { query, incremental, hint, ...rest } = this.props; let ariaLabel; if (incremental) { @@ -50,15 +57,44 @@ export class EuiSearchBox extends Component { 'This is a search bar. After typing your query, hit enter to filter the results lower in the page.'; } - return ( + const search = ( (this.inputElement = input)} fullWidth defaultValue={query} incremental={incremental} aria-label={ariaLabel} + onFocus={() => { + hint?.setIsVisible(true); + }} {...rest} /> ); + + if (hint) { + return ( + { + hint.setIsVisible(false); + }} + panelProps={{ + 'aria-live': undefined, + 'aria-modal': undefined, + role: undefined, + tabIndex: -1, + id: hint.id, + }} + {...hint.popoverProps} + > + {hint.content} + + ); + } + + return search; } } diff --git a/upcoming_changelogs/6319.md b/upcoming_changelogs/6319.md new file mode 100644 index 00000000000..22529d88a38 --- /dev/null +++ b/upcoming_changelogs/6319.md @@ -0,0 +1 @@ +- Added the `hint` prop to the ``. This prop lets the consumer render a hint below the search bar that will be displayed on focus.