1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

Adding TextFieldSearch component (#16296)

* Adding TextFieldSearch component

* Updating docs and stories

* Moving controlled test into testing utils

* Fixing spelling in prop types af => of
This commit is contained in:
George Marshall 2022-11-15 08:49:02 -08:00 committed by GitHub
parent 0a5c46b156
commit 6907c4a565
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 461 additions and 31 deletions

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mount, shallow } from 'enzyme'; import { mount, shallow } from 'enzyme';
import { Router, MemoryRouter } from 'react-router-dom'; import { Router, MemoryRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -122,3 +123,17 @@ export function renderWithLocalization(component) {
return render(component, { wrapper: Wrapper }); return render(component, { wrapper: Wrapper });
} }
export function renderControlledInput(InputComponent, props) {
const ControlledWrapper = () => {
const [value, setValue] = useState('');
return (
<InputComponent
value={value}
onChange={(e) => setValue(e.target.value)}
{...props}
/>
);
};
return { user: userEvent.setup(), ...render(<ControlledWrapper />) };
}

View File

@ -18,3 +18,4 @@
@import 'text/text'; @import 'text/text';
@import 'text-field/text-field'; @import 'text-field/text-field';
@import 'text-field-base/text-field-base'; @import 'text-field-base/text-field-base';
@import 'text-field-search/text-field-search';

View File

@ -9,4 +9,5 @@ export const TEXT_FIELD_BASE_TYPES = {
TEXT: 'text', TEXT: 'text',
NUMBER: 'number', NUMBER: 'number',
PASSWORD: 'password', PASSWORD: 'password',
SEARCH: 'search',
}; };

View File

@ -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.
<Canvas>
<Story id="ui-components-component-library-text-field-search-text-field-search-stories-js--default-story" />
</Canvas>
## 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
<ArgsTable of={TextFieldSearch} />
### 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.**
<Canvas>
<Story id="ui-components-component-library-text-field-search-text-field-search-stories-js--show-clear-button" />
</Canvas>
```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('');
};
<TextFieldSearch
placeholder="Enter text to show clear"
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
/>;
```
### Clear Button Props
Use the `clearButtonProps` to access other props of the clear button.
<Canvas>
<Story id="ui-components-component-library-text-field-search-text-field-search-stories-js--clear-button-props" />
</Canvas>
```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('');
};
<TextFieldSearch
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
clearButtonProps={{
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
borderRadius: BORDER_RADIUS.XS,
'data-testid': 'clear-button',
}}
/>;
```

View File

@ -0,0 +1 @@
export { TextFieldSearch } from './text-field-search';

View File

@ -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
}) => (
<TextField
className={classnames('mm-text-field-search', className)}
value={value}
onChange={onChange}
type={TEXT_FIELD_BASE_TYPES.SEARCH}
leftAccessory={<Icon name={ICON_NAMES.SEARCH_FILLED} size={SIZES.SM} />}
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';

View File

@ -0,0 +1,5 @@
.mm-text-field-search {
::-webkit-search-cancel-button {
display: none; // hides the default search cancel button
}
}

View File

@ -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 (
<TextFieldSearch
{...args}
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
/>
);
};
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,
},
};

View File

@ -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(<TextFieldSearch />);
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: <div>right-accessory</div>,
},
);
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);
});
});

View File

@ -4,8 +4,6 @@ import classnames from 'classnames';
import { SIZES } from '../../../helpers/constants/design-system'; import { SIZES } from '../../../helpers/constants/design-system';
import Box from '../../ui/box';
import { ICON_NAMES } from '../icon'; import { ICON_NAMES } from '../icon';
import { ButtonIcon } from '../button-icon'; import { ButtonIcon } from '../button-icon';
@ -55,11 +53,11 @@ TextField.propTypes = {
/** /**
* The value af the TextField * The value af the TextField
*/ */
value: TextFieldBase.propTypes.value.isRequired, value: TextFieldBase.propTypes.value,
/** /**
* The onChange handler af the TextField * The onChange handler af the TextField
*/ */
onChange: TextFieldBase.propTypes.onChange.isRequired, onChange: TextFieldBase.propTypes.onChange,
/** /**
* An additional className to apply to the text-field * An additional className to apply to the text-field
*/ */
@ -75,7 +73,7 @@ TextField.propTypes = {
/** /**
* The props to pass to the clear button * 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 * TextField accepts all the props from TextFieldBase and Box
*/ */

View File

@ -1,8 +1,10 @@
/* eslint-disable jest/require-top-level-describe */ /* eslint-disable jest/require-top-level-describe */
import React, { useState } from 'react'; import React from 'react';
import { fireEvent, render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { renderControlledInput } from '../../../../test/lib/render-helpers';
import { TextField } from './text-field'; import { TextField } from './text-field';
// userEvent setup function as per testing-library docs // 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 (
<FormComponent
value={value}
onChange={(e) => setValue(e.target.value)}
{...props}
/>
);
};
return { user: userEvent.setup(), ...render(<ControlledWrapper />) };
}
describe('TextField', () => { describe('TextField', () => {
it('should render correctly', () => { it('should render correctly', () => {
const { getByRole } = render(<TextField />); const { getByRole } = render(<TextField />);
@ -87,8 +73,8 @@ describe('TextField', () => {
expect(getByText('right-accessory')).toBeDefined(); expect(getByText('right-accessory')).toBeDefined();
}); });
it('should render showClearButton button when showClearButton is true and value exists', async () => { 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 // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput
const { user, getByRole } = setupControlled(TextField, { const { user, getByRole } = renderControlledInput(TextField, {
showClearButton: true, showClearButton: true,
}); });
await user.type(getByRole('textbox'), 'test value'); await user.type(getByRole('textbox'), 'test value');
@ -96,8 +82,8 @@ describe('TextField', () => {
expect(getByRole('button', { name: /Clear/u })).toBeDefined(); expect(getByRole('button', { name: /Clear/u })).toBeDefined();
}); });
it('should still render with the rightAccessory when showClearButton is true', async () => { 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 // As showClearButton is intended to be used with a controlled input we need to use renderControlledInput
const { user, getByRole, getByText } = setupControlled(TextField, { const { user, getByRole, getByText } = renderControlledInput(TextField, {
showClearButton: true, showClearButton: true,
rightAccessory: <div>right-accessory</div>, rightAccessory: <div>right-accessory</div>,
}); });
@ -107,9 +93,9 @@ describe('TextField', () => {
expect(getByText('right-accessory')).toBeDefined(); expect(getByText('right-accessory')).toBeDefined();
}); });
it('should fire onClick event when passed to clearButtonOnClick when clear button is clicked', async () => { 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 fn = jest.fn();
const { user, getByRole } = setupControlled(TextField, { const { user, getByRole } = renderControlledInput(TextField, {
showClearButton: true, showClearButton: true,
clearButtonOnClick: fn, clearButtonOnClick: fn,
}); });
@ -118,9 +104,9 @@ describe('TextField', () => {
expect(fn).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledTimes(1);
}); });
it('should fire onClick event when passed to clearButtonProps.onClick prop', async () => { 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 fn = jest.fn();
const { user, getByRole } = setupControlled(TextField, { const { user, getByRole } = renderControlledInput(TextField, {
showClearButton: true, showClearButton: true,
clearButtonProps: { onClick: fn }, clearButtonProps: { onClick: fn },
}); });