Adding `TextField` component (#16105)

* Adding TextField component

* Fixing lint issues

* More linting fixes

* Adding more tests

* Adding reference to TextFieldBase props

* Adding reminder todo comment to styles

* Using short hand syntax for conditionally firing event props and removing some css and unused classsNames in favor of box props

* Fixing up my sloppy code

* Removing text base docs update

* More clean up

* Adding more stories and docs

* Adding new stories to mdx docs
feature/default_network_editable
George Marshall 2 years ago committed by GitHub
parent c88efadf1e
commit da4e6d3e37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      test/env.js
  2. 1
      ui/components/component-library/component-library-components.scss
  3. 73
      ui/components/component-library/text-field/README.mdx
  4. 2
      ui/components/component-library/text-field/index.js
  5. 7
      ui/components/component-library/text-field/text-field.constants.js
  6. 108
      ui/components/component-library/text-field/text-field.js
  7. 10
      ui/components/component-library/text-field/text-field.scss
  8. 247
      ui/components/component-library/text-field/text-field.stories.js
  9. 161
      ui/components/component-library/text-field/text-field.test.js

@ -6,4 +6,5 @@ process.env.METAMASK_ENV = 'test';
*/
process.env.ICON_NAMES = {
LOADING_FILLED: 'loading-filled',
CLOSE_OUTLINE: 'close-outline',
};

@ -12,4 +12,5 @@
@import 'icon/icon';
@import 'tag/tag';
@import 'text/text';
@import 'text-field/text-field';
@import 'text-field-base/text-field-base';

@ -0,0 +1,73 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import { TextField } from './text-field';
# TextField
The `TextField` component lets users enter and edit text as well as adding a show clear button option. It wraps `TextFieldBase` and functions only as a controlled input.
<Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--default-story" />
</Canvas>
## Props
The `TextField` accepts all props below as well as all [Box](/docs/ui-components-ui-box-box-stories-js--default-story#props) and [TextFieldBase](/docs/ui-components-component-library-text-field-base-text-field-base-stories-js--default-story#props) component props
<ArgsTable of={TextField} />
### Show Clear
Use the `showClear` prop to display a clear button when `TextField` has a value. Clicking the button will clear the value.
You can also attach an `onClear` handler to the `TextField` to perform additional actions when the clear button is clicked.
<Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--show-clear" />
</Canvas>
```jsx
import { TextField } from '../../ui/component-library/text-field';
<TextField showClear />;
```
### On Clear
Use the `onClear` prop to perform additional actions when the clear button is clicked.
<Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--on-clear" />
</Canvas>
```jsx
import { TextField } from '../../ui/component-library/text-field';
<TextField showClear onClear={() => console.log('cleared input')} />;
```
### Clear Button Props and Clear Button Icon Props
Use the `clearButtonProps` and `clearButtonIconProps` props to pass props to the clear button and clear button icon respectively.
<Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--clear-button-props-clear-button-icon-props" />
</Canvas>
```jsx
import {
SIZES,
COLORS,
BORDER_RADIUS,
} from '../../../helpers/constants/design-system';
import { TextField } from '../../ui/component-library/text-field';
<TextField
showClear
clearButtonProps={{
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
borderRadius: BORDER_RADIUS.XS,
'data-testid': 'clear-button',
}}
clearButtonIconProps={{ size: SIZES.MD }}
/>;
```

@ -0,0 +1,2 @@
export { TextField } from './text-field';
export { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants';

@ -0,0 +1,7 @@
import {
TEXT_FIELD_BASE_SIZES,
TEXT_FIELD_BASE_TYPES,
} from '../text-field-base/text-field-base.constants';
export const TEXT_FIELD_SIZES = TEXT_FIELD_BASE_SIZES;
export const TEXT_FIELD_TYPES = TEXT_FIELD_BASE_TYPES;

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import {
SIZES,
DISPLAY,
JUSTIFY_CONTENT,
ALIGN_ITEMS,
COLORS,
} from '../../../helpers/constants/design-system';
import Box from '../../ui/box';
import { Icon, ICON_NAMES } from '../icon';
import { TextFieldBase } from '../text-field-base';
export const TextField = ({
className,
showClear,
clearButtonIconProps,
clearButtonProps,
rightAccessory,
value: valueProp,
onChange,
onClear,
inputProps,
...props
}) => {
const [value, setValue] = useState(valueProp || '');
const handleOnChange = (e) => {
setValue(e.target.value);
onChange?.(e);
};
const handleClear = (e) => {
setValue('');
clearButtonProps?.onClick?.(e);
onClear?.(e);
};
return (
<TextFieldBase
className={classnames('mm-text-field', className)}
value={value}
onChange={handleOnChange}
rightAccessory={
value && showClear ? (
<>
{/* replace with ButtonIcon */}
<Box
className="mm-text-field__button-clear"
as="button"
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
justifyContent={JUSTIFY_CONTENT.CENTER}
backgroundColor={COLORS.TRANSPARENT}
padding={0}
{...clearButtonProps} // don't override onClick
onClick={handleClear}
>
<Icon
name={ICON_NAMES.CLOSE_OUTLINE}
size={SIZES.SM}
aria-label="Clear" // TODO: i18n
{...clearButtonIconProps}
/>
</Box>
{rightAccessory}
</>
) : (
rightAccessory
)
}
inputProps={{
marginRight: showClear ? 6 : 0,
...inputProps,
}}
{...props}
/>
);
};
TextField.propTypes = {
/**
* An additional className to apply to the text-field
*/
className: PropTypes.string,
/**
* Show a clear button to clear the input
*/
showClear: PropTypes.bool,
/**
* The event handler for when the clear button is clicked
*/
onClear: PropTypes.func,
/**
* The props to pass to the clear button
*/
clearButtonProps: PropTypes.shape(Box.PropTypes),
/**
* The props to pass to the icon inside of the close button
*/
clearButtonIconProps: PropTypes.shape(Icon.PropTypes),
/**
* TextField accepts all the props from TextFieldBase and Box
*/
...TextFieldBase.propTypes,
};

@ -0,0 +1,10 @@
.mm-text-field {
// TOD: remove most of these styles when replaced by ButtonIcon
&__button-clear {
height: 24px;
width: 24px;
max-width: 24px;
flex: 0 0 24px;
margin-left: -24px;
}
}

@ -0,0 +1,247 @@
import React, { useState } from 'react';
import {
SIZES,
COLORS,
BORDER_RADIUS,
} from '../../../helpers/constants/design-system';
import { Text } from '../text';
import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants';
import { TextField } from './text-field';
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/TextField',
id: __filename,
component: TextField,
parameters: {
docs: {
page: README,
},
},
argTypes: {
showClear: {
control: 'boolean',
},
value: {
control: 'text',
},
onChange: {
action: 'onChange',
table: { category: 'text field base props' },
},
onClear: {
action: 'onClear',
},
clearButtonIconProps: {
control: 'object',
},
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: {
showClear: false,
placeholder: 'Placeholder...',
autoFocus: false,
disabled: false,
error: false,
id: '',
readOnly: false,
required: false,
size: SIZES.MD,
type: 'text',
truncate: false,
},
};
const Template = (args) => <TextField {...args} />;
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
export const ShowClear = (args) => {
const [value, setValue] = useState('show clear');
const handleOnChange = (e) => {
setValue(e.target.value);
};
return (
<TextField
{...args}
placeholder="Enter text to show clear"
value={value}
onChange={handleOnChange}
showClear
/>
);
};
export const OnClear = (args) => {
const [value, setValue] = useState('onClear example');
const [showOnClearMessage, setShowOnClearMessage] = useState(false);
const handleOnChange = (e) => {
setValue(e.target.value);
showOnClearMessage && setShowOnClearMessage(false);
};
const handleOnClear = () => {
setShowOnClearMessage(true);
};
return (
<>
<TextField
{...args}
placeholder="Clear text to show onClear message"
value={value}
onChange={handleOnChange}
onClear={handleOnClear}
showClear
/>
{showOnClearMessage && <Text marginTop={4}>onClear called</Text>}
</>
);
};
export const ClearButtonPropsClearButtonIconProps = Template.bind({});
ClearButtonPropsClearButtonIconProps.args = {
value: 'clear button props',
size: SIZES.LG,
showClear: true,
clearButtonProps: {
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
borderRadius: BORDER_RADIUS.XS,
},
clearButtonIconProps: {
size: SIZES.MD,
},
};

@ -0,0 +1,161 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { TextField } from './text-field';
describe('TextField', () => {
it('should render correctly', () => {
const { getByRole } = render(<TextField />);
expect(getByRole('textbox')).toBeDefined();
});
it('should render and be able to input text', () => {
const { getByTestId } = render(
<TextField inputProps={{ 'data-testid': 'text-field' }} />,
);
const textField = getByTestId('text-field');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.change(textField, { target: { value: '' } }); // reset value
expect(textField.value).toBe(''); // value is empty string after reset
});
it('should render and fire onFocus and onBlur events', () => {
const onFocus = jest.fn();
const onBlur = jest.fn();
const { getByTestId } = render(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onFocus={onFocus}
onBlur={onBlur}
/>,
);
const textField = getByTestId('text-field');
fireEvent.focus(textField);
expect(onFocus).toHaveBeenCalledTimes(1);
fireEvent.blur(textField);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('should render and fire onChange event', () => {
const onChange = jest.fn();
const { getByTestId } = render(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onChange={onChange}
/>,
);
const textField = getByTestId('text-field');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(onChange).toHaveBeenCalledTimes(1);
});
it('should render and fire onClick event', () => {
const onClick = jest.fn();
const { getByTestId } = render(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onClick={onClick}
/>,
);
const textField = getByTestId('text-field');
fireEvent.click(textField);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should render showClear button when showClear is true and value exists', () => {
const { getByRole, getByTestId } = render(
<TextField
clearButtonProps={{ 'data-testid': 'clear-button' }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
showClear
/>,
);
const textField = getByRole('textbox');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
expect(getByTestId('clear-button')).toBeDefined();
expect(getByTestId('clear-button-icon')).toBeDefined();
});
it('should render with the rightAccessory', () => {
const { getByText } = render(
<TextField rightAccessory={<div>right-accessory</div>} />,
);
expect(getByText('right-accessory')).toBeDefined();
});
it('should still render with the rightAccessory when showClear is true', () => {
const { getByRole, getByTestId, getByText } = render(
<TextField
clearButtonProps={{ 'data-testid': 'clear-button' }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
rightAccessory={<div>right-accessory</div>}
showClear
/>,
);
const textField = getByRole('textbox');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
expect(getByTestId('clear-button')).toBeDefined();
expect(getByTestId('clear-button-icon')).toBeDefined();
expect(getByText('right-accessory')).toBeDefined();
});
it('should clear text when clear button is clicked', () => {
const { getByRole, getByTestId } = render(
<TextField
clearButtonProps={{ 'data-testid': 'clear-button' }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
rightAccessory={<div>right-accessory</div>}
showClear
/>,
);
const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(textField.value).toBe('');
});
it('should fire onClear event when passed to onClear prop', () => {
const onClear = jest.fn();
const { getByRole, getByTestId } = render(
<TextField
onClear={onClear}
clearButtonProps={{ 'data-testid': 'clear-button' }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
showClear
/>,
);
const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('should fire clearButtonProps.onClick event when passed to clearButtonProps.onClick prop', () => {
const onClear = jest.fn();
const onClick = jest.fn();
const { getByRole, getByTestId } = render(
<TextField
onClear={onClear}
clearButtonProps={{ 'data-testid': 'clear-button', onClick }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
showClear
/>,
);
const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should be able to accept inputProps', () => {
const { getByRole } = render(
<TextField inputProps={{ 'data-testid': 'text-field' }} />,
);
const textField = getByRole('textbox');
expect(textField).toBeDefined();
});
});
Loading…
Cancel
Save