diff --git a/packages/react-core/src/components/SearchInput/SearchInput.tsx b/packages/react-core/src/components/SearchInput/SearchInput.tsx new file mode 100644 index 00000000000..7e10c513b92 --- /dev/null +++ b/packages/react-core/src/components/SearchInput/SearchInput.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import styles from '@patternfly/react-styles/css/components/SearchInput/search-input'; +import { css } from '@patternfly/react-styles'; +import { Button, ButtonVariant } from '../Button'; +import { Badge } from '../Badge'; +import AngleDownIcon from '@patternfly/react-icons/dist/js/icons/angle-down-icon'; +import AngleUpIcon from '@patternfly/react-icons/dist/js/icons/angle-up-icon'; +import TimesIcon from '@patternfly/react-icons/dist/js/icons/times-icon'; +import SearchIcon from '@patternfly/react-icons/dist/js/icons/search-icon'; + +export interface SearchInputProps extends Omit, 'onChange' | 'results'> { + /** Additional classes added to the banner */ + className?: string; + /** Value of the search input */ + value?: 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; + /** An accessible label for the search input */ + 'aria-label'?: string; + /** placeholder text of the search input */ + placeholder?: string; + /** A callback for when the input value changes. */ + onChange?: (value: string, event: React.FormEvent) => void; + /** A callback for when the user clicks the clear button */ + onClear?: (event: React.SyntheticEvent) => void; + /** Function called when user clicks to navigate to next result */ + onNextClick?: (event: React.SyntheticEvent) => void; + /** Function called when user clicks to navigate to previous result */ + onPreviousClick?: (event: React.SyntheticEvent) => void; +} + +export const SearchInput: React.FunctionComponent = ({ + className, + value = '', + placeholder, + onChange, + onClear, + resultsCount, + onNextClick, + onPreviousClick, + 'aria-label': ariaLabel = 'Search input', + ...props +}: SearchInputProps) => { + const onChangeHandler = (event: React.ChangeEvent) => { + if (onChange) { + onChange(event.currentTarget.value, event); + } + }; + + return ( +
+ + + + + + + {value && ( + + {resultsCount && ( + + {resultsCount} + + )} + {!!onNextClick && !!onPreviousClick && ( + + + + + )} + + + + + )} +
+ ); +}; +SearchInput.displayName = 'SearchInput'; diff --git a/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx b/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx new file mode 100644 index 00000000000..2a7f147d1f8 --- /dev/null +++ b/packages/react-core/src/components/SearchInput/__tests__/SearchInput.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { SearchInput } from '../SearchInput'; + + +const props = { + onChange: jest.fn(), + value: 'test input', + onNextClick: jest.fn(), + onPreviousClick: jest.fn(), + onClear: jest.fn() +}; + +test('input passes value and event to onChange handler', () => { + const newValue = 'new test input'; + const event = { + currentTarget: { value: newValue } + }; + const view = shallow(); + + view.find('input').simulate('change', event); + expect(props.onChange).toBeCalledWith(newValue, event); +}); + +test('simple search input', () => { + const view = mount(); + expect(view.find('input')).toMatchSnapshot(); +}); + +test('result count', () => { + const view = mount(); + expect(view.find('.pf-c-badge')).toMatchSnapshot(); +}); + +test('navigable search results', () => { + const view = mount( + ); + expect(view.find('.pf-c-search-input__nav')).toMatchSnapshot(); + expect(view.find('.pf-c-badge')).toMatchSnapshot(); + + view.find('.pf-c-button').at(0).simulate('click', {}); + expect(props.onPreviousClick).toBeCalled(); + view.find('.pf-c-button').at(1).simulate('click', {}); + expect(props.onNextClick).toBeCalled(); + view.find('.pf-c-button').at(2).simulate('click', {}); + expect(props.onClear).toBeCalled(); +}); + + + diff --git a/packages/react-core/src/components/SearchInput/__tests__/__snapshots__/SearchInput.test.tsx.snap b/packages/react-core/src/components/SearchInput/__tests__/__snapshots__/SearchInput.test.tsx.snap new file mode 100644 index 00000000000..3c0735224c4 --- /dev/null +++ b/packages/react-core/src/components/SearchInput/__tests__/__snapshots__/SearchInput.test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`navigable search results 1`] = ` + + + + + + +`; + +exports[`navigable search results 2`] = ` + + 3 / 7 + +`; + +exports[`result count 1`] = ` + + 3 + +`; + +exports[`simple search input 1`] = ` + +`; diff --git a/packages/react-core/src/components/SearchInput/examples/SearchInput.md b/packages/react-core/src/components/SearchInput/examples/SearchInput.md new file mode 100644 index 00000000000..abbbd296b2e --- /dev/null +++ b/packages/react-core/src/components/SearchInput/examples/SearchInput.md @@ -0,0 +1,146 @@ +--- +title: 'Search Input' +section: components +cssPrefix: 'pf-c-search-input' +typescript: true +propComponents: ['SearchInput'] +--- +import { SearchInput } from '@patternfly/react-core'; + +## Examples +```js title=Basic +import React from 'react'; +import { SearchInput } from '@patternfly/react-core'; + +class BasicSearchInput extends React.Component { + constructor(props) { + super(props); + this.state = { + value: '' + }; + + this.onChange = (value, event) => { + this.setState({ + value: value + }); + }; + } + + render() { + return ( + this.onChange('', evt)} + /> + ); + } +} + +``` + +```js title=Match-with-result-count +import React from 'react'; +import { SearchInput } from '@patternfly/react-core'; + +class SearchInputWithResultCount extends React.Component { + constructor(props) { + super(props); + this.state = { + value: '', + resultsCount: 0 + }; + + this.onChange = (value, event) => { + this.setState({ + value: value, + resultsCount: 3 + }); + }; + + this.onClear = (event) => { + this.setState({ + value: '', + resultsCount: 0 + }); + } + } + + render() { + return ( + + ); + } +} + +``` + +```js title=Match-with-navigable-options +import React from 'react'; +import { SearchInput } from '@patternfly/react-core'; + +class SearchInputWithNavigableOptions extends React.Component { + constructor(props) { + super(props); + this.state = { + value: '', + resultsCount: 0, + currentResult: 1 + }; + + this.onChange = (value, event) => { + this.setState({ + value: value, + resultsCount: 3 + }); + }; + + this.onClear = (event) => { + this.setState({ + value: '', + resultsCount: 0, + currentResult: 1 + }); + } + + this.onNext = (event) => { + this.setState(prevState => { + const newCurrentResult = prevState.currentResult + 1; + return { + currentResult: newCurrentResult <= prevState.resultsCount ? newCurrentResult : prevState.resultsCount + } + }); + } + + this.onPrevious = (event) => { + this.setState(prevState => { + const newCurrentResult = prevState.currentResult - 1; + return { + currentResult: newCurrentResult > 0 ? newCurrentResult : 1 + } + }); + } + } + + render() { + return ( + + ); + } +} +``` diff --git a/packages/react-core/src/components/SearchInput/index.ts b/packages/react-core/src/components/SearchInput/index.ts new file mode 100644 index 00000000000..c068bf432f5 --- /dev/null +++ b/packages/react-core/src/components/SearchInput/index.ts @@ -0,0 +1 @@ +export * from './SearchInput'; diff --git a/packages/react-core/src/components/index.ts b/packages/react-core/src/components/index.ts index bc83888fafc..22bf28bdd66 100644 --- a/packages/react-core/src/components/index.ts +++ b/packages/react-core/src/components/index.ts @@ -41,6 +41,7 @@ export * from './Pagination'; export * from './Popover'; export * from './Progress'; export * from './Radio'; +export * from './SearchInput'; export * from './Select'; export * from './SimpleList'; export * from './SkipToContent'; diff --git a/packages/react-integration/cypress/integration/searchinput.spec.ts b/packages/react-integration/cypress/integration/searchinput.spec.ts new file mode 100644 index 00000000000..aa5805af515 --- /dev/null +++ b/packages/react-integration/cypress/integration/searchinput.spec.ts @@ -0,0 +1,37 @@ +describe('Search Input Demo Test', () => { + it('Navigate to demo section', () => { + cy.visit('http://localhost:3000/'); + cy.get('#search-input-demo-nav-item-link').click(); + cy.url().should('eq', 'http://localhost:3000/search-input-demo-nav-link'); + }); + + it('Verify search input and its handlers work', () => { + cy.get('.pf-c-search-input__count').should('not.exist'); + cy.get('.pf-c-search-input__clear').should('not.exist'); + cy.get('.pf-c-search-input__nav').should('not.exist'); + + cy.get('.pf-c-search-input__text-input').type('Hello world'); + cy.get('.pf-c-search-input__text-input').should('have.value', 'Hello world'); + + cy.get('.pf-c-search-input__count').should('be.visible'); + cy.get('.pf-c-search-input__clear').should('be.visible'); + cy.get('.pf-c-search-input__nav').should('be.visible'); + + cy.get('.pf-c-badge').should('have.text', '1 / 3'); + cy.get('.pf-c-search-input__nav button') + .last() + .click(); + cy.get('.pf-c-badge').should('have.text', '2 / 3'); + cy.get('.pf-c-search-input__nav button') + .first() + .click(); + cy.get('.pf-c-badge').should('have.text', '1 / 3'); + + cy.get('.pf-c-search-input__clear').click(); + cy.get('.pf-c-search-input__text-input').should('not.have.value', 'Hello world'); + + cy.get('.pf-c-search-input__count').should('not.exist'); + cy.get('.pf-c-search-input__clear').should('not.exist'); + cy.get('.pf-c-search-input__nav').should('not.exist'); + }); +}); diff --git a/packages/react-integration/demo-app-ts/src/Demos.ts b/packages/react-integration/demo-app-ts/src/Demos.ts index cb1223ad6c9..9d7c17838ea 100644 --- a/packages/react-integration/demo-app-ts/src/Demos.ts +++ b/packages/react-integration/demo-app-ts/src/Demos.ts @@ -481,6 +481,11 @@ export const Demos: DemoInterface[] = [ name: 'Radio Demo', componentType: Examples.RadioDemo }, + { + id: 'search-input-demo', + name: 'Search Input Demo', + componentType: Examples.SearchInputDemo + }, { id: 'select-demo', name: 'Select Demo', diff --git a/packages/react-integration/demo-app-ts/src/components/demos/SearchInputDemo/SearchInputDemo.tsx b/packages/react-integration/demo-app-ts/src/components/demos/SearchInputDemo/SearchInputDemo.tsx new file mode 100644 index 00000000000..bec03547c96 --- /dev/null +++ b/packages/react-integration/demo-app-ts/src/components/demos/SearchInputDemo/SearchInputDemo.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { SearchInput, SearchInputProps } from '@patternfly/react-core'; + +interface SearchInputState { + value: string; + resultsCount: number; + currentResult: number; +} + +export class SearchInputDemo extends React.Component { + static displayName = 'SearchInputDemo'; + constructor(props: SearchInputProps) { + super(props); + this.state = { + value: '', + resultsCount: 0, + currentResult: 1 + }; + } + + onChange = (value: string) => { + this.setState({ + value, + resultsCount: 3 + }); + }; + + onClear = () => { + this.setState({ + value: '', + resultsCount: 0, + currentResult: 1 + }); + }; + + onNext = () => { + this.setState(prevState => { + const newCurrentResult = prevState.currentResult + 1; + return { + currentResult: newCurrentResult <= prevState.resultsCount ? newCurrentResult : prevState.resultsCount + }; + }); + }; + + onPrevious = () => { + this.setState(prevState => { + const newCurrentResult = prevState.currentResult - 1; + return { + currentResult: newCurrentResult > 0 ? newCurrentResult : 1 + }; + }); + }; + + render() { + return ( + + ); + } +} diff --git a/packages/react-integration/demo-app-ts/src/components/demos/index.ts b/packages/react-integration/demo-app-ts/src/components/demos/index.ts index 06d45546041..024b147bef9 100644 --- a/packages/react-integration/demo-app-ts/src/components/demos/index.ts +++ b/packages/react-integration/demo-app-ts/src/components/demos/index.ts @@ -93,6 +93,7 @@ export * from './PieChartDemo/PieOrangeDemo'; export * from './PopoverDemo/PopoverDemo'; export * from './ProgressDemo/ProgressDemo'; export * from './RadioDemo/RadioDemo'; +export * from './SearchInputDemo/SearchInputDemo'; export * from './SelectDemo/SelectDemo'; export * from './SelectDemo/FilteringSelectDemo'; export * from './SimpleList/SimpleListDemo';