diff --git a/test/env.js b/test/env.js index 0fb3e9378..b932b2a42 100644 --- a/test/env.js +++ b/test/env.js @@ -1,9 +1,10 @@ process.env.METAMASK_ENV = 'test'; /** - * Used for testing components that use the Icon component + * Used for testing components that use the Icon component * 'ui/components/component-library/icon/icon.js' */ process.env.ICON_NAMES = { LOADING_FILLED: 'loading-filled', + CLOSE_OUTLINE: 'close-outline', }; diff --git a/ui/components/component-library/component-library-components.scss b/ui/components/component-library/component-library-components.scss index a4d49cc93..af5003d1e 100644 --- a/ui/components/component-library/component-library-components.scss +++ b/ui/components/component-library/component-library-components.scss @@ -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'; diff --git a/ui/components/component-library/text-field/README.mdx b/ui/components/component-library/text-field/README.mdx new file mode 100644 index 000000000..7a9bc8cd6 --- /dev/null +++ b/ui/components/component-library/text-field/README.mdx @@ -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. + + + + + +## 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 + + + +### 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. + + + + + +```jsx +import { TextField } from '../../ui/component-library/text-field'; + +; +``` + +### On Clear + +Use the `onClear` prop to perform additional actions when the clear button is clicked. + + + + + +```jsx +import { TextField } from '../../ui/component-library/text-field'; + + 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. + + + + + +```jsx +import { + SIZES, + COLORS, + BORDER_RADIUS, +} from '../../../helpers/constants/design-system'; +import { TextField } from '../../ui/component-library/text-field'; + +; +``` diff --git a/ui/components/component-library/text-field/index.js b/ui/components/component-library/text-field/index.js new file mode 100644 index 000000000..a680c752d --- /dev/null +++ b/ui/components/component-library/text-field/index.js @@ -0,0 +1,2 @@ +export { TextField } from './text-field'; +export { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants'; diff --git a/ui/components/component-library/text-field/text-field.constants.js b/ui/components/component-library/text-field/text-field.constants.js new file mode 100644 index 000000000..8047344e5 --- /dev/null +++ b/ui/components/component-library/text-field/text-field.constants.js @@ -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; diff --git a/ui/components/component-library/text-field/text-field.js b/ui/components/component-library/text-field/text-field.js new file mode 100644 index 000000000..ddf351776 --- /dev/null +++ b/ui/components/component-library/text-field/text-field.js @@ -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 ( + + {/* replace with ButtonIcon */} + + + + {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, +}; diff --git a/ui/components/component-library/text-field/text-field.scss b/ui/components/component-library/text-field/text-field.scss new file mode 100644 index 000000000..b21024d81 --- /dev/null +++ b/ui/components/component-library/text-field/text-field.scss @@ -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; + } +} diff --git a/ui/components/component-library/text-field/text-field.stories.js b/ui/components/component-library/text-field/text-field.stories.js new file mode 100644 index 000000000..70832ed2b --- /dev/null +++ b/ui/components/component-library/text-field/text-field.stories.js @@ -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) => ; + +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 ( + + ); +}; + +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 ( + <> + + {showOnClearMessage && onClear called} + + ); +}; + +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, + }, +}; diff --git a/ui/components/component-library/text-field/text-field.test.js b/ui/components/component-library/text-field/text-field.test.js new file mode 100644 index 000000000..f2d7819be --- /dev/null +++ b/ui/components/component-library/text-field/text-field.test.js @@ -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(); + expect(getByRole('textbox')).toBeDefined(); + }); + it('should render and be able to input text', () => { + const { getByTestId } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + right-accessory} />, + ); + expect(getByText('right-accessory')).toBeDefined(); + }); + it('should still render with the rightAccessory when showClear is true', () => { + const { getByRole, getByTestId, getByText } = render( + right-accessory} + 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( + right-accessory} + 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( + , + ); + 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( + , + ); + 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( + , + ); + const textField = getByRole('textbox'); + expect(textField).toBeDefined(); + }); +});