@@ -25168,20 +28994,12 @@ exports[`Storyshots Tabs basic usage 1`] = `
exports[`Storyshots Textarea label as element 1`] = `
@@ -25219,29 +29037,21 @@ exports[`Storyshots Textarea label as element 1`] = `
exports[`Storyshots Textarea minimal usage 1`] = `
@@ -25266,29 +29076,21 @@ exports[`Storyshots Textarea minimal usage 1`] = `
exports[`Storyshots Textarea scrollable 1`] = `
@@ -25313,29 +29115,21 @@ exports[`Storyshots Textarea scrollable 1`] = `
exports[`Storyshots Textarea validation 1`] = `
The unique name that identifies you throughout the site.
diff --git a/src/InputText/index.jsx b/src/InputText/index.jsx
index 83d66b6b48..a4d7376947 100644
--- a/src/InputText/index.jsx
+++ b/src/InputText/index.jsx
@@ -16,6 +16,7 @@ function Text(props) {
placeholder={props.placeholder}
aria-describedby={props.describedBy}
onChange={props.onChange}
+ onKeyPress={props.onKeyPress}
onBlur={props.onBlur}
aria-invalid={!props.isValid}
autoComplete={props.autoComplete}
diff --git a/src/SearchField/README.md b/src/SearchField/README.md
new file mode 100644
index 0000000000..2cdf5771ab
--- /dev/null
+++ b/src/SearchField/README.md
@@ -0,0 +1,45 @@
+# SearchField
+
+Provides a basic search field component.
+
+## API
+
+### `onSubmit` (function; required)
+`onSubmit` specifies a callback function for when the `SearchField` is submitted by the user. For example:
+
+```jsx
+
{ console.log(value); } />
+```
+
+### `inputLabel` (string, optional)
+`inputLabel` specifies the label to use for the input field (e.g., for i18n translations). The default is "Search:".
+
+### `onChange` (function; optional)
+`onChange` specifies a callback function for when the value in `SearchField` is changed by the user. The default is an empty function. For example:
+
+```jsx
+ { console.log(value); } />
+```
+
+### `onFocus` (function; optional)
+`onFocus` specifies a callback function for when the user focuses in the `SearchField` component. The default is an empty function. For example:
+
+```jsx
+ { console.log(value); } />
+```
+
+### `onBlur` (function; optional)
+`onBlur` specifies a callback function for when the user loses focus in the `SearchField` component. The default is an empty function. For example:
+
+```jsx
+ { console.log(value); }} />
+```
+
+### `placeholder` (string; optional)
+`placeholder` specifies the placeholder text for the input. The default is an empty string.
+
+### `screenReaderText` (object, optional)
+`screenReaderText` specifies the screenreader text for both the clear and search buttons (e.g., for i18n translations). The default is `{ clearButton: 'Clear search', searchButton: 'Submit search' }`.
+
+### `value` (string; optional)
+`value` specifies the initial value for the input. The default is an empty string.
diff --git a/src/SearchField/SearchField.scss b/src/SearchField/SearchField.scss
new file mode 100644
index 0000000000..8b2d149fc2
--- /dev/null
+++ b/src/SearchField/SearchField.scss
@@ -0,0 +1,77 @@
+@import "~bootstrap/scss/_variables";
+@import "~bootstrap/scss/utilities/_borders";
+@import "~bootstrap/scss/utilities/_spacing";
+@import "~bootstrap/scss/_buttons";
+
+$search-field-clear-btn-width: 30px;
+$search-field-border-radius: 30px;
+
+.search-field {
+ transition: $input-transition;
+ border-radius: $search-field-border-radius;
+ box-shadow: $input-box-shadow;
+
+ .form-group {
+ margin-bottom: 0;
+ }
+
+ .input-group {
+ flex: 1 1 0;
+ }
+
+ label {
+ margin: 0;
+ padding-left: 20px;
+ padding-right: 10px;
+ }
+
+ button:last-child {
+ border-radius: $search-field-border-radius;
+ }
+}
+
+.search-field__focused {
+ border-color: $blue !important;
+ box-shadow: $input-focus-box-shadow;
+}
+
+.search-field__input {
+ background: none;
+ border: 0;
+ box-shadow: none !important;
+ padding-left: 0;
+ padding-right: 0;
+
+ &:focus {
+ background: none;
+ }
+}
+
+.search-field__no-clear-btn {
+ padding-right: ($search-field-clear-btn-width + 1px + $border-width * 2);
+}
+
+.search-field__search-btn {
+ border-top: 0;
+ border-right: 0;
+ border-bottom: 0;
+ transition: border-color 0.15s ease-in-out;
+ padding: 10px 15px 10px 12px;
+ color: $gray-600;
+ background: none;
+ margin-left: 0;
+
+ &:not(:disabled):hover,
+ &:not(:disabled):focus {
+ background: #F2F8FB;
+ color: $blue;
+ }
+}
+
+.search-field__clear-btn {
+ width: $search-field-clear-btn-width;
+ justify-content: center;
+ color: $gray-600;
+ border: 0;
+ background: none;
+}
diff --git a/src/SearchField/SearchField.stories.jsx b/src/SearchField/SearchField.stories.jsx
new file mode 100644
index 0000000000..b21375c714
--- /dev/null
+++ b/src/SearchField/SearchField.stories.jsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import centered from '@storybook/addon-centered';
+import { checkA11y } from '@storybook/addon-a11y';
+import { withInfo } from '@storybook/addon-info';
+
+import SearchField from './index';
+import README from './README.md';
+
+storiesOf('SearchField', module)
+ .addDecorator((story, context) => withInfo({}, README)(story)(context))
+ .addDecorator(centered)
+ .addDecorator(checkA11y)
+ .add('basic usage', () => (
+
+ ))
+ .add('with placeholder', () => (
+
+ ))
+ .add('with value', () => (
+
+ ))
+ .add('with callbacks', () => (
+
+ ))
+ .add('with custom label and screenreader text', () => (
+
+ ));
diff --git a/src/SearchField/SearchField.test.jsx b/src/SearchField/SearchField.test.jsx
new file mode 100644
index 0000000000..7c52cf4f40
--- /dev/null
+++ b/src/SearchField/SearchField.test.jsx
@@ -0,0 +1,193 @@
+/* eslint-disable react/prop-types */
+import React from 'react';
+import { mount } from 'enzyme';
+
+import SearchField from './index';
+
+const baseProps = {
+ onSubmit: () => {},
+};
+
+describe('', () => {
+ it('renders', () => {
+ const props = {
+ ...baseProps,
+ };
+ const wrapper = mount();
+ expect(wrapper.exists()).toEqual(true);
+ });
+
+ it('uses passed in value', () => {
+ const value = 'foofoo';
+ const props = {
+ ...baseProps,
+ value,
+ };
+ const wrapper = mount();
+ expect(wrapper.state('value')).toEqual(value);
+ expect(wrapper.find('input').prop('value')).toEqual(value);
+ });
+
+ it('overrides state value when props value changes', () => {
+ const initValue = 'foofoo';
+ const newValue = 'barbar';
+ const props = {
+ ...baseProps,
+ value: initValue,
+ };
+ const wrapper = mount();
+ expect(wrapper.state('value')).toEqual(initValue);
+ wrapper.setProps({
+ value: newValue,
+ });
+ expect(wrapper.state('value')).toEqual(newValue);
+ });
+
+ it('does not override state value when props value changes with existing value', () => {
+ const value = 'foofoo';
+ const props = {
+ ...baseProps,
+ value,
+ };
+ const wrapper = mount();
+ expect(wrapper.state('value')).toEqual(value);
+ wrapper.setProps({
+ value,
+ });
+ expect(wrapper.state('value')).toEqual(value);
+ });
+
+ it('uses passed in placeholder', () => {
+ const placeholder = 'foofoo';
+ const props = {
+ ...baseProps,
+ placeholder,
+ };
+ const wrapper = mount();
+ expect(wrapper.find('input').prop('placeholder')).toEqual(placeholder);
+ });
+
+ it('uses passed in inputLabel text', () => {
+ const inputLabel = 'Buscar:';
+ const props = {
+ ...baseProps,
+ inputLabel,
+ };
+ const wrapper = mount();
+ expect(wrapper.find('label').text()).toEqual(inputLabel);
+ });
+
+ it('uses passed in screenReaderText', () => {
+ const screenReaderText = {
+ clearButton: 'Borrar búsqueda',
+ searchButton: 'Enviar búsqueda',
+ };
+ const props = {
+ ...baseProps,
+ screenReaderText,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ const searchActionButtons = wrapper.find('Button');
+ expect(searchActionButtons.first().find('Icon').prop('screenReaderText')).toEqual(screenReaderText.clearButton);
+ expect(searchActionButtons.last().find('Icon').prop('screenReaderText')).toEqual(screenReaderText.searchButton);
+ });
+
+ describe('fires', () => {
+ it('focus handler', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onFocus: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('focus');
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('blur handler', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onBlur: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('blur');
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('change handler', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onChange: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change');
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('submit handler on click', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onSubmit: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ wrapper.find('.input-group-append button').last().simulate('click');
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('submit handler on enter keypress', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onSubmit: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ wrapper.find('input').simulate('keypress', { key: 'Enter' });
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('does not fire', () => {
+ it('submit handler on non-enter keypress', () => {
+ const spy = jest.fn();
+ const props = {
+ ...baseProps,
+ onSubmit: spy,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ wrapper.find('input').simulate('keypress', { key: 's' });
+ expect(spy).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('clear button', () => {
+ it('renders', () => {
+ const props = {
+ ...baseProps,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ expect(wrapper.find('.input-group-append button').length).toEqual(2);
+ });
+
+ it('clears input', () => {
+ const props = {
+ ...baseProps,
+ hasClearButton: true,
+ };
+ const wrapper = mount();
+ wrapper.find('input').simulate('change', { target: { value: 'foobar' } });
+ expect(wrapper.find('input').prop('value')).toEqual('foobar');
+ expect(wrapper.find('.input-group-append button').length).toEqual(2);
+ wrapper.find('.input-group-append button').first().simulate('click');
+ expect(wrapper.find('input').prop('value')).toEqual('');
+ expect(wrapper.find('.input-group-append button').length).toEqual(1);
+ });
+ });
+});
diff --git a/src/SearchField/index.jsx b/src/SearchField/index.jsx
new file mode 100644
index 0000000000..ce943c8dd7
--- /dev/null
+++ b/src/SearchField/index.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import FontAwesomeStyles from 'font-awesome/css/font-awesome.min.css';
+
+import newId from '../utils/newId';
+import Icon from '../Icon';
+import InputText from '../InputText';
+import Button from '../Button';
+
+import styles from './SearchField.scss';
+
+const defaultProps = {
+ inputLabel: 'Search:',
+ onBlur: () => {},
+ onChange: () => {},
+ onFocus: () => {},
+ placeholder: '',
+ screenReaderText: {
+ clearButton: 'Clear search',
+ searchButton: 'Submit search',
+ },
+ value: '',
+};
+
+const propTypes = {
+ onSubmit: PropTypes.func.isRequired,
+ inputLabel: PropTypes.string,
+ onBlur: PropTypes.func,
+ onChange: PropTypes.func,
+ onFocus: PropTypes.func,
+ placeholder: PropTypes.string,
+ screenReaderText: PropTypes.shape({
+ clearButton: PropTypes.string.isRequired,
+ searchButton: PropTypes.string.isRequired,
+ }),
+ value: PropTypes.string,
+};
+
+class SearchField extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isFocused: false,
+ value: this.props.value,
+ };
+
+ this.textInput = null;
+ this.searchButton = null;
+
+ this.handleFocus = this.handleFocus.bind(this);
+ this.handleBlur = this.handleBlur.bind(this);
+ this.handleChange = this.handleChange.bind(this);
+ this.handleClear = this.handleClear.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleKeyPress = this.handleKeyPress.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.value !== this.props.value) {
+ this.setState({ value: nextProps.value });
+ }
+ }
+
+ getSearchActionButtons() {
+ const { isFocused } = this.state;
+ const { screenReaderText } = this.props;
+ const inputTextHasValue = this.inputTextHasValue();
+ const buttons = [
+
+ }
+ disabled={!inputTextHasValue}
+ inputRef={(input) => { this.searchButton = input; }}
+ onClick={this.handleSubmit}
+ />,
+ ];
+
+ if (inputTextHasValue) {
+ buttons.unshift((
+