From 6907c4a565a58b09ad2c21961874f02d1005a235 Mon Sep 17 00:00:00 2001 From: George Marshall Date: Tue, 15 Nov 2022 08:49:02 -0800 Subject: [PATCH] Adding `TextFieldSearch` component (#16296) * Adding TextFieldSearch component * Updating docs and stories * Moving controlled test into testing utils * Fixing spelling in prop types af => of --- test/lib/render-helpers.js | 17 +- .../component-library-components.scss | 1 + .../text-field-base.constants.js | 1 + .../text-field-search/README.mdx | 92 ++++++++ .../text-field-search/index.js | 1 + .../text-field-search/text-field-search.js | 62 +++++ .../text-field-search/text-field-search.scss | 5 + .../text-field-search.stories.js | 212 ++++++++++++++++++ .../text-field-search.test.js | 57 +++++ .../text-field/text-field.js | 8 +- .../text-field/text-field.test.js | 36 +-- 11 files changed, 461 insertions(+), 31 deletions(-) create mode 100644 ui/components/component-library/text-field-search/README.mdx create mode 100644 ui/components/component-library/text-field-search/index.js create mode 100644 ui/components/component-library/text-field-search/text-field-search.js create mode 100644 ui/components/component-library/text-field-search/text-field-search.scss create mode 100644 ui/components/component-library/text-field-search/text-field-search.stories.js create mode 100644 ui/components/component-library/text-field-search/text-field-search.test.js diff --git a/test/lib/render-helpers.js b/test/lib/render-helpers.js index 0291df0f8..27ac3f80e 100644 --- a/test/lib/render-helpers.js +++ b/test/lib/render-helpers.js @@ -1,6 +1,7 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Provider } from 'react-redux'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { mount, shallow } from 'enzyme'; import { Router, MemoryRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -122,3 +123,17 @@ export function renderWithLocalization(component) { return render(component, { wrapper: Wrapper }); } + +export function renderControlledInput(InputComponent, props) { + const ControlledWrapper = () => { + const [value, setValue] = useState(''); + return ( + setValue(e.target.value)} + {...props} + /> + ); + }; + return { user: userEvent.setup(), ...render() }; +} diff --git a/ui/components/component-library/component-library-components.scss b/ui/components/component-library/component-library-components.scss index cbc90849f..d2f77190d 100644 --- a/ui/components/component-library/component-library-components.scss +++ b/ui/components/component-library/component-library-components.scss @@ -18,3 +18,4 @@ @import 'text/text'; @import 'text-field/text-field'; @import 'text-field-base/text-field-base'; +@import 'text-field-search/text-field-search'; diff --git a/ui/components/component-library/text-field-base/text-field-base.constants.js b/ui/components/component-library/text-field-base/text-field-base.constants.js index decdb364a..c78ca02ea 100644 --- a/ui/components/component-library/text-field-base/text-field-base.constants.js +++ b/ui/components/component-library/text-field-base/text-field-base.constants.js @@ -9,4 +9,5 @@ export const TEXT_FIELD_BASE_TYPES = { TEXT: 'text', NUMBER: 'number', PASSWORD: 'password', + SEARCH: 'search', }; diff --git a/ui/components/component-library/text-field-search/README.mdx b/ui/components/component-library/text-field-search/README.mdx new file mode 100644 index 000000000..71b6dca90 --- /dev/null +++ b/ui/components/component-library/text-field-search/README.mdx @@ -0,0 +1,92 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import { TextFieldSearch } from './text-field-search'; + +# TextFieldSearch + +The `TextFieldSearch` allows users to enter text to search. It wraps the `TextField` component that adds a search icon to the left of the input. + + + + + +## Props + +The `TextFieldSearch` accepts all props below as well as all [Box](/docs/ui-components-ui-box-box-stories-js--default-story#props), [TextField](/docs/ui-components-component-library-text-field-text-field-stories-js--default-story#props) component props + + + +### Show Clear Button + +Use the `showClearButton` prop to display a clear button when `TextFieldSearch` has a value. Use the `clearButtonOnClick` prop to pass an `onClick` event handler to clear the value of the input. + +Defaults to `true` + +The clear button uses [ButtonIcon](/docs/ui-components-component-library-button-icon-button-icon-stories-js--default-story) and accepts all props from that component. + +**NOTE: The `showClearButton` only works with a controlled input.** + + + + + +```jsx +import { TextFieldSearch } from '../../ui/component-library/text-field'; + +const [value, setValue] = useState('show clear'); + +const handleOnChange = (e) => { + setValue(e.target.value); +}; + +const handleOnClear = () => { + setValue(''); +}; + +; +``` + +### Clear Button Props + +Use the `clearButtonProps` to access other props of the clear button. + + + + + +```jsx +import React, { useState } from 'react'; +import { + SIZES, + COLORS, + BORDER_RADIUS, +} from '../../../helpers/constants/design-system'; + +import { TextFieldSearch } from '../../ui/component-library/text-field'; + +const [value, setValue] = useState('show clear'); + +const handleOnChange = (e) => { + setValue(e.target.value); +}; + +const handleOnClear = () => { + setValue(''); +}; + +; +``` diff --git a/ui/components/component-library/text-field-search/index.js b/ui/components/component-library/text-field-search/index.js new file mode 100644 index 000000000..847665ddf --- /dev/null +++ b/ui/components/component-library/text-field-search/index.js @@ -0,0 +1 @@ +export { TextFieldSearch } from './text-field-search'; diff --git a/ui/components/component-library/text-field-search/text-field-search.js b/ui/components/component-library/text-field-search/text-field-search.js new file mode 100644 index 000000000..deccb7086 --- /dev/null +++ b/ui/components/component-library/text-field-search/text-field-search.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { SIZES } from '../../../helpers/constants/design-system'; + +import { ButtonIcon } from '../button-icon'; +import { Icon, ICON_NAMES } from '../icon'; +import { TextFieldBase, TEXT_FIELD_BASE_TYPES } from '../text-field-base'; +import { TextField } from '../text-field'; + +export const TextFieldSearch = ({ + value, + onChange, + showClearButton = true, + clearButtonOnClick, + clearButtonProps, + className, + ...props +}) => ( + } + showClearButton={showClearButton} + clearButtonOnClick={clearButtonOnClick} + clearButtonProps={clearButtonProps} + {...props} + /> +); + +TextFieldSearch.propTypes = { + /** + * The value of the TextFieldSearch + */ + value: TextFieldBase.propTypes.value, + /** + * The onChange handler of the TextFieldSearch + */ + onChange: TextFieldBase.propTypes.onChange, + /** + * Show a clear button to clear the input + * Defaults to true + */ + showClearButton: PropTypes.bool, + /** + * The onClick handler for the clear button + */ + clearButtonOnClick: PropTypes.func, + /** + * The props to pass to the clear button + */ + clearButtonProps: PropTypes.shape(ButtonIcon.PropTypes), + /** + * An additional className to apply to the TextFieldSearch + */ + className: PropTypes.string, +}; + +TextFieldSearch.displayName = 'TextFieldSearch'; diff --git a/ui/components/component-library/text-field-search/text-field-search.scss b/ui/components/component-library/text-field-search/text-field-search.scss new file mode 100644 index 000000000..78d53ba4f --- /dev/null +++ b/ui/components/component-library/text-field-search/text-field-search.scss @@ -0,0 +1,5 @@ +.mm-text-field-search { + ::-webkit-search-cancel-button { + display: none; // hides the default search cancel button + } +} diff --git a/ui/components/component-library/text-field-search/text-field-search.stories.js b/ui/components/component-library/text-field-search/text-field-search.stories.js new file mode 100644 index 000000000..903b4c6da --- /dev/null +++ b/ui/components/component-library/text-field-search/text-field-search.stories.js @@ -0,0 +1,212 @@ +import React from 'react'; +import { useArgs } from '@storybook/client-api'; + +import { + SIZES, + COLORS, + BORDER_RADIUS, +} from '../../../helpers/constants/design-system'; + +import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from '../text-field'; + +import { TextFieldSearch } from './text-field-search'; +import README from './README.mdx'; + +const marginSizeControlOptions = [ + undefined, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 'auto', +]; + +export default { + title: 'Components/ComponentLibrary/TextFieldSearch', + id: __filename, + component: TextFieldSearch, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + value: { + control: 'text', + }, + onChange: { + action: 'onChange', + }, + showClearButton: { + control: 'boolean', + }, + clearButtonOnClick: { + action: 'clearButtonOnClick', + }, + clearButtonProps: { + control: 'object', + }, + autoComplete: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + autoFocus: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + className: { + control: 'text', + table: { category: 'text field base props' }, + }, + disabled: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + error: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + id: { + control: 'text', + table: { category: 'text field base props' }, + }, + inputProps: { + control: 'object', + table: { category: 'text field base props' }, + }, + leftAccessory: { + control: 'text', + table: { category: 'text field base props' }, + }, + maxLength: { + control: 'number', + table: { category: 'text field base props' }, + }, + name: { + control: 'text', + table: { category: 'text field base props' }, + }, + onBlur: { + action: 'onBlur', + table: { category: 'text field base props' }, + }, + onClick: { + action: 'onClick', + table: { category: 'text field base props' }, + }, + onFocus: { + action: 'onFocus', + table: { category: 'text field base props' }, + }, + onKeyDown: { + action: 'onKeyDown', + table: { category: 'text field base props' }, + }, + onKeyUp: { + action: 'onKeyUp', + table: { category: 'text field base props' }, + }, + placeholder: { + control: 'text', + table: { category: 'text field base props' }, + }, + readOnly: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + required: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + rightAccessory: { + control: 'text', + table: { category: 'text field base props' }, + }, + size: { + control: 'select', + options: Object.values(TEXT_FIELD_SIZES), + table: { category: 'text field base props' }, + }, + type: { + control: 'select', + options: Object.values(TEXT_FIELD_TYPES), + table: { category: 'text field base props' }, + }, + truncate: { + control: 'boolean', + table: { category: 'text field base props' }, + }, + marginTop: { + options: marginSizeControlOptions, + control: 'select', + table: { category: 'box props' }, + }, + marginRight: { + options: marginSizeControlOptions, + control: 'select', + table: { category: 'box props' }, + }, + marginBottom: { + options: marginSizeControlOptions, + control: 'select', + table: { category: 'box props' }, + }, + marginLeft: { + options: marginSizeControlOptions, + control: 'select', + table: { category: 'box props' }, + }, + }, + args: { + showClearButton: true, + placeholder: 'Search', + }, +}; + +const Template = (args) => { + const [{ value }, updateArgs] = useArgs(); + const handleOnChange = (e) => { + updateArgs({ value: e.target.value }); + }; + const handleOnClear = () => { + updateArgs({ value: '' }); + }; + return ( + + ); +}; + +export const DefaultStory = Template.bind({}); +DefaultStory.storyName = 'Default'; + +export const ShowClearButton = Template.bind({}); + +ShowClearButton.args = { + placeholder: 'Enter text to show clear', + showClearButton: true, +}; + +export const ClearButtonProps = Template.bind({}); +ClearButtonProps.args = { + value: 'clear button props', + size: SIZES.LG, + showClearButton: true, + clearButtonProps: { + backgroundColor: COLORS.BACKGROUND_ALTERNATIVE, + borderRadius: BORDER_RADIUS.XS, + }, +}; diff --git a/ui/components/component-library/text-field-search/text-field-search.test.js b/ui/components/component-library/text-field-search/text-field-search.test.js new file mode 100644 index 000000000..8a73ea8bf --- /dev/null +++ b/ui/components/component-library/text-field-search/text-field-search.test.js @@ -0,0 +1,57 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { renderControlledInput } from '../../../../test/lib/render-helpers'; +import { TextFieldSearch } from './text-field-search'; + +describe('TextFieldSearch', () => { + it('should render correctly', () => { + const { getByRole } = render(); + expect(getByRole('searchbox')).toBeDefined(); + }); + it('should render showClearButton button when showClearButton is true and value exists', async () => { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const { user, getByRole } = renderControlledInput(TextFieldSearch, { + showClearButton: true, + }); + await user.type(getByRole('searchbox'), 'test value'); + expect(getByRole('searchbox')).toHaveValue('test value'); + expect(getByRole('button', { name: /Clear/u })).toBeDefined(); + }); + it('should still render with the rightAccessory when showClearButton is true', async () => { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const { user, getByRole, getByText } = renderControlledInput( + TextFieldSearch, + { + showClearButton: true, + rightAccessory:
right-accessory
, + }, + ); + await user.type(getByRole('searchbox'), 'test value'); + expect(getByRole('searchbox')).toHaveValue('test value'); + expect(getByRole('button', { name: /Clear/u })).toBeDefined(); + expect(getByText('right-accessory')).toBeDefined(); + }); + it('should fire onClick event when passed to clearButtonOnClick when clear button is clicked', async () => { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const fn = jest.fn(); + const { user, getByRole } = renderControlledInput(TextFieldSearch, { + showClearButton: true, + clearButtonOnClick: fn, + }); + await user.type(getByRole('searchbox'), 'test value'); + await user.click(getByRole('button', { name: /Clear/u })); + expect(fn).toHaveBeenCalledTimes(1); + }); + it('should fire onClick event when passed to clearButtonProps.onClick prop', async () => { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const fn = jest.fn(); + const { user, getByRole } = renderControlledInput(TextFieldSearch, { + showClearButton: true, + clearButtonProps: { onClick: fn }, + }); + await user.type(getByRole('searchbox'), 'test value'); + await user.click(getByRole('button', { name: /Clear/u })); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/components/component-library/text-field/text-field.js b/ui/components/component-library/text-field/text-field.js index 7d26ff49f..09b0e8794 100644 --- a/ui/components/component-library/text-field/text-field.js +++ b/ui/components/component-library/text-field/text-field.js @@ -4,8 +4,6 @@ import classnames from 'classnames'; import { SIZES } from '../../../helpers/constants/design-system'; -import Box from '../../ui/box'; - import { ICON_NAMES } from '../icon'; import { ButtonIcon } from '../button-icon'; @@ -55,11 +53,11 @@ TextField.propTypes = { /** * The value af the TextField */ - value: TextFieldBase.propTypes.value.isRequired, + value: TextFieldBase.propTypes.value, /** * The onChange handler af the TextField */ - onChange: TextFieldBase.propTypes.onChange.isRequired, + onChange: TextFieldBase.propTypes.onChange, /** * An additional className to apply to the text-field */ @@ -75,7 +73,7 @@ TextField.propTypes = { /** * The props to pass to the clear button */ - clearButtonProps: PropTypes.shape(Box.PropTypes), + clearButtonProps: PropTypes.shape(ButtonIcon.PropTypes), /** * TextField accepts all the props from TextFieldBase and Box */ diff --git a/ui/components/component-library/text-field/text-field.test.js b/ui/components/component-library/text-field/text-field.test.js index 7dfa930f9..224ba3769 100644 --- a/ui/components/component-library/text-field/text-field.test.js +++ b/ui/components/component-library/text-field/text-field.test.js @@ -1,8 +1,10 @@ /* eslint-disable jest/require-top-level-describe */ -import React, { useState } from 'react'; +import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { renderControlledInput } from '../../../../test/lib/render-helpers'; + import { TextField } from './text-field'; // userEvent setup function as per testing-library docs @@ -14,22 +16,6 @@ function setup(jsx) { }; } -// Custom userEvent setup function that renders the component in a controlled environment. -// This is used for the showClearButton and related props as the clearButton will only show in a controlled environment. -function setupControlled(FormComponent, props) { - const ControlledWrapper = () => { - const [value, setValue] = useState(''); - return ( - setValue(e.target.value)} - {...props} - /> - ); - }; - return { user: userEvent.setup(), ...render() }; -} - describe('TextField', () => { it('should render correctly', () => { const { getByRole } = render(); @@ -87,8 +73,8 @@ describe('TextField', () => { expect(getByText('right-accessory')).toBeDefined(); }); it('should render showClearButton button when showClearButton is true and value exists', async () => { - // As showClearButton is intended to be used with a controlled input we need to use setupControlled - const { user, getByRole } = setupControlled(TextField, { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const { user, getByRole } = renderControlledInput(TextField, { showClearButton: true, }); await user.type(getByRole('textbox'), 'test value'); @@ -96,8 +82,8 @@ describe('TextField', () => { expect(getByRole('button', { name: /Clear/u })).toBeDefined(); }); it('should still render with the rightAccessory when showClearButton is true', async () => { - // As showClearButton is intended to be used with a controlled input we need to use setupControlled - const { user, getByRole, getByText } = setupControlled(TextField, { + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput + const { user, getByRole, getByText } = renderControlledInput(TextField, { showClearButton: true, rightAccessory:
right-accessory
, }); @@ -107,9 +93,9 @@ describe('TextField', () => { expect(getByText('right-accessory')).toBeDefined(); }); it('should fire onClick event when passed to clearButtonOnClick when clear button is clicked', async () => { - // As showClearButton is intended to be used with a controlled input we need to use setupControlled + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput const fn = jest.fn(); - const { user, getByRole } = setupControlled(TextField, { + const { user, getByRole } = renderControlledInput(TextField, { showClearButton: true, clearButtonOnClick: fn, }); @@ -118,9 +104,9 @@ describe('TextField', () => { expect(fn).toHaveBeenCalledTimes(1); }); it('should fire onClick event when passed to clearButtonProps.onClick prop', async () => { - // As showClearButton is intended to be used with a controlled input we need to use setupControlled + // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput const fn = jest.fn(); - const { user, getByRole } = setupControlled(TextField, { + const { user, getByRole } = renderControlledInput(TextField, { showClearButton: true, clearButtonProps: { onClick: fn }, });