diff --git a/test/lib/render-helpers.js b/test/lib/render-helpers.js index 27ac3f80e..f407392ba 100644 --- a/test/lib/render-helpers.js +++ b/test/lib/render-helpers.js @@ -137,3 +137,12 @@ export function renderControlledInput(InputComponent, props) { }; return { user: userEvent.setup(), ...render() }; } + +// userEvent setup function as per testing-library docs +// https://testing-library.com/docs/user-event/intr +export function renderWithUserEvent(jsx) { + return { + user: userEvent.setup(), + ...render(jsx), + }; +} diff --git a/ui/components/component-library/component-library-components.scss b/ui/components/component-library/component-library-components.scss index 898d3945d..07a1fe4f9 100644 --- a/ui/components/component-library/component-library-components.scss +++ b/ui/components/component-library/component-library-components.scss @@ -26,3 +26,4 @@ @import 'text-field/text-field'; @import 'text-field-base/text-field-base'; @import 'text-field-search/text-field-search'; +@import 'form-text-field/form-text-field'; diff --git a/ui/components/component-library/form-text-field/README.mdx b/ui/components/component-library/form-text-field/README.mdx new file mode 100644 index 000000000..25b001043 --- /dev/null +++ b/ui/components/component-library/form-text-field/README.mdx @@ -0,0 +1,336 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import { TextField, TextFieldBase } from '../'; +import { FormTextField } from './form-text-field'; + +# FormTextField + +The `FormTextField` is an input component to create forms. It bundles the [TextField](/docs/ui-components-component-library-text-field-text-field-stories-js--default-story), [Label](/docs/ui-components-component-library-label-label-stories-js--default-story) and [HelpText](/docs/ui-components-component-library-help-text-help-text-stories-js--default-story) components together. + + + + + +## Props + +The `FormTextField` accepts all props below as well as all [Box](/docs/ui-components-ui-box-box-stories-js--default-story#props) component props + + + +`FormTextField` accepts all [TextField](/docs/ui-components-component-library-text-field-text-field-stories-js--default-story#props) +component props + + + +`FormTextField` accepts all [TextFieldBase](/docs/ui-components-component-library-text-field-base-text-field-base-stories-js--default-story#props) +component props + + + +### Id + +Use the `id` prop to set the `id` of the `FormTextField` component. This is required for accessibility when the `label` prop is set. It is also used internally to link the `label` and `input` elements using `htmlFor`, so clicking on the `label` will focus the `input`. + + + + + +```jsx +import { FormTextField } from '../../component-library'; + +; +``` + +### Label + +Use the `label` prop to add a label to the `FormTextField` component. Uses the [Label](/docs/ui-components-component-library-label-label-stories-js--default-story) component. Use the `labelProps` prop to pass props to the `Label` component. To use a custom label component see the [Custom Label or HelpText](#custom-label-or-helptext) story example. + + + + + +```jsx +import { FormTextField } from '../../component-library'; + +; +``` + +### HelpText + +Use the `helpText` prop to add help text to the `FormTextField` component. Uses the [HelpText](/docs/ui-components-component-library-helpText-helpText-stories-js--default-story) component. Use the `helpTextProps` prop to pass props to the `HelpText` component. To use a custom help text component see the [Custom Label or HelpText](#custom-helpText-or-helptext) story example. When `error` is true the `helpText` will be rendered as an error message. + + + + + +```jsx +import { FormTextField } from '../../component-library'; + +; +; +``` + +### Form Example + +An example of a form using the `FormTextField` component. + + + + + +```jsx +import React, { useState, useEffect } from 'react'; +import { + DISPLAY, + COLORS, + ALIGN_ITEMS, + TEXT, +} from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box/box'; + +import { + ButtonPrimary, + ButtonSecondary, + FormTextField, + ICON_NAMES, + Text, +} from '../../component-library'; + +const FORM_STATE = { + DEFAULT: 'default', + SUCCESS: 'success', + ERROR: 'error', +}; + +const VALIDATED_VALUES = { + NETWORK_NAME: 'network name', + NEW_RPC_URL: 'new rpc url', + CHAIN_ID: 'chain id', +}; + +const ERROR_MESSAGES = { + NETWORK_NAME: `Please enter "${VALIDATED_VALUES.NETWORK_NAME}"`, + NEW_RPC_URL: `Please enter "${VALIDATED_VALUES.NEW_RPC_URL}"`, + CHAIN_ID: `Please enter "${VALIDATED_VALUES.CHAIN_ID}"`, +}; + +const [submitted, setSubmitted] = useState(FORM_STATE.DEFAULT); + +const [values, setValues] = useState({ + networkName: '', + newRpcUrl: '', + chainId: '', +}); + +const [errors, setErrors] = useState({ + networkName: '', + newRpcUrl: '', + chainId: '', +}); + +useEffect(() => { + setErrors({ + networkName: + values.networkName && + values.networkName.toLowerCase() !== VALIDATED_VALUES.NETWORK_NAME + ? ERROR_MESSAGES.NETWORK_NAME + : '', + newRpcUrl: + values.newRpcUrl && + values.newRpcUrl.toLowerCase() !== VALIDATED_VALUES.NEW_RPC_URL + ? ERROR_MESSAGES.NEW_RPC_URL + : '', + chainId: + values.chainId && + values.chainId.toLowerCase() !== VALIDATED_VALUES.CHAIN_ID + ? ERROR_MESSAGES.CHAIN_ID + : '', + }); +}, [values]); + +const handleClearForm = () => { + setValues({ networkName: '', newRpcUrl: '', chainId: '' }); + setErrors({ networkName: '', newRpcUrl: '', chainId: '' }); + setSubmitted(FORM_STATE.DEFAULT); +}; + +const handleOnChange = (e) => { + if (submitted === FORM_STATE.ERROR) { + setErrors({ networkName: '', newRpcUrl: '', chainId: '' }); + setSubmitted(FORM_STATE.DEFAULT); + } + setValues({ + ...values, + [e.target.name]: e.target.value, + }); +}; + +const handleOnSubmit = (e) => { + e.preventDefault(); + if (errors.networkName || errors.newRpcUrl || errors.chainId) { + setSubmitted(FORM_STATE.ERROR); + } else { + setSubmitted(FORM_STATE.SUCCESS); + } +}; + +return ( + <> + + + + + + Submit + + + + Clear form + + {submitted === FORM_STATE.SUCCESS && ( + + Form successfully submitted! + + )} + +); +``` + +### Custom Label or HelpText + +There will be times when you will want to use a custom `Label` or `HelpText`. This can be done by simply not providing `label` or `helpText` props to the `FormTextField` component. You can then use the `Label` and `HelpText` components to create your own custom label or help text. + + + + + +```jsx +import { + SIZES, + DISPLAY, + COLORS, + ALIGN_ITEMS, + JUSTIFY_CONTENT, +} from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box/box'; + +import { + ButtonLink, + FormTextField, + HelpText, + ICON_NAMES, + Icon, + Label, + TEXT_FIELD_TYPES, + Text, +} from '../../component-library'; + + + Examples of how one might customize the Label or HelpText within the + FormTextField component + + + + {/** + * If you need a custom label + * or require adding some form of customization + * import the Label component separately + */} + + + + Use default + +Max} + marginBottom={4} + type={TEXT_FIELD_TYPES.NUMBER} +/> + + + {/** + * If you need a custom help text + * or require adding some form of customization + * import the HelpText component separately and handle the error + * logic yourself + */} + + Only enter a number that you're comfortable with the contract accessing + now or in the future. You can always increase the token limit later. + + + Max + + +``` diff --git a/ui/components/component-library/form-text-field/__snapshots__/form-text-field.test.js.snap b/ui/components/component-library/form-text-field/__snapshots__/form-text-field.test.js.snap new file mode 100644 index 000000000..6332f658a --- /dev/null +++ b/ui/components/component-library/form-text-field/__snapshots__/form-text-field.test.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FormTextField should render correctly 1`] = ` +
+
+
+ +
+
+
+`; diff --git a/ui/components/component-library/form-text-field/form-text-field.js b/ui/components/component-library/form-text-field/form-text-field.js new file mode 100644 index 000000000..5c46f5bc3 --- /dev/null +++ b/ui/components/component-library/form-text-field/form-text-field.js @@ -0,0 +1,167 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { + DISPLAY, + FLEX_DIRECTION, + SIZES, +} from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box/box'; + +import { TextField } from '../text-field'; +import { HelpText } from '../help-text'; +import { Label } from '../label'; + +export const FormTextField = ({ + autoComplete, + autoFocus, + className, + defaultValue, + disabled, + error, + helpText, + helpTextProps, + id, + inputProps, + inputRef, + label, + labelProps, + leftAccessory, + maxLength, + name, + onBlur, + onChange, + onFocus, + placeholder, + readOnly, + required, + rightAccessory, + size = SIZES.MD, + textFieldProps, + truncate, + showClearButton, + clearButtonOnClick, + clearButtonProps, + type = 'text', + value, + ...props +}) => ( + + {label && ( + + )} + + {helpText && ( + + {helpText} + + )} + +); + +FormTextField.propTypes = { + /** + * An additional className to apply to the form-text-field + */ + className: PropTypes.string, + /** + * The id of the FormTextField + * Required if label prop exists to ensure accessibility + * + * @param {object} props - The props passed to the component. + * @param {string} propName - The prop name in this case 'id'. + * @param {string} componentName - The name of the component. + */ + id: (props, propName, componentName) => { + if (props.label && !props[propName]) { + return new Error( + `If a label prop exists you must provide an ${propName} prop for the label's htmlFor attribute for accessibility. Warning coming from ${componentName} ui/components/component-library/form-text-field/form-text-field.js`, + ); + } + return null; + }, + /** + * The content of the Label component + */ + label: PropTypes.string, + /** + * Props that are applied to the Label component + */ + labelProps: PropTypes.object, + /** + * The content of the HelpText component + */ + helpText: PropTypes.string, + /** + * Props that are applied to the HelpText component + */ + helpTextProps: PropTypes.object, + /** + * Props that are applied to the TextField component + */ + textFieldProps: PropTypes.object, + /** + * FormTextField accepts all the props from TextField and Box + */ + ...TextField.propTypes, +}; diff --git a/ui/components/component-library/form-text-field/form-text-field.scss b/ui/components/component-library/form-text-field/form-text-field.scss new file mode 100644 index 000000000..c4316f8c1 --- /dev/null +++ b/ui/components/component-library/form-text-field/form-text-field.scss @@ -0,0 +1,9 @@ +.mm-form-text-field { + --help-text-opacity-disabled: 0.5; + + &--disabled { + .mm-form-text-field__help-text { + opacity: var(--help-text-opacity-disabled); + } + } +} diff --git a/ui/components/component-library/form-text-field/form-text-field.stories.js b/ui/components/component-library/form-text-field/form-text-field.stories.js new file mode 100644 index 000000000..d38f27f7b --- /dev/null +++ b/ui/components/component-library/form-text-field/form-text-field.stories.js @@ -0,0 +1,481 @@ +import React, { useState, useEffect } from 'react'; +import { useArgs } from '@storybook/client-api'; + +import { + SIZES, + DISPLAY, + COLORS, + ALIGN_ITEMS, + TEXT, + JUSTIFY_CONTENT, +} from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box/box'; + +import { + ButtonLink, + ButtonPrimary, + ButtonSecondary, + HelpText, + Icon, + ICON_NAMES, + Label, + Text, + TEXT_FIELD_SIZES, + TEXT_FIELD_TYPES, +} from '..'; + +import { FormTextField } from './form-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/FormTextField', + id: __filename, + component: FormTextField, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + value: { + control: 'text', + }, + onChange: { + action: 'onChange', + }, + labelProps: { + control: 'object', + }, + textFieldProps: { + control: 'object', + }, + helpTextProps: { + control: 'object', + }, + showClearButton: { + control: 'boolean', + table: { category: 'text field props' }, + }, + clearButtonOnClick: { + action: 'clearButtonOnClick', + table: { category: 'text field props' }, + }, + clearButtonProps: { + control: 'object', + table: { category: 'text field props' }, + }, + 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: { + placeholder: 'Form text field', + label: 'Label', + id: 'form-text-field', + helpText: 'Help text', + }, +}; + +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 Id = Template.bind({}); +Id.args = { + id: 'accessible-input-id', + label: 'If label prop exists id prop is required for accessibility', + helpText: '', +}; + +export const LabelStory = Template.bind({}); +LabelStory.storyName = 'Label'; // Need to use LabelStory to avoid conflict with Label component +LabelStory.args = { + id: 'input-with-label', + label: 'Label content appears here', + helpText: '', +}; + +export const HelpTextStory = (args) => { + const [{ value }, updateArgs] = useArgs(); + const handleOnChange = (e) => { + updateArgs({ value: e.target.value }); + }; + const handleOnClear = () => { + updateArgs({ value: '' }); + }; + return ( + <> + + + + ); +}; +HelpTextStory.storyName = 'HelpText'; // Need to use HelpTextStory to avoid conflict with HelpTextStory component +HelpTextStory.args = { + label: '', + helpText: 'HelpText content appears here', +}; + +export const FormExample = () => { + const FORM_STATE = { + DEFAULT: 'default', + SUCCESS: 'success', + ERROR: 'error', + }; + const VALIDATED_VALUES = { + NETWORK_NAME: 'network name', + NEW_RPC_URL: 'new rpc url', + CHAIN_ID: 'chain id', + }; + const ERROR_MESSAGES = { + NETWORK_NAME: `Please enter "${VALIDATED_VALUES.NETWORK_NAME}"`, + NEW_RPC_URL: `Please enter "${VALIDATED_VALUES.NEW_RPC_URL}"`, + CHAIN_ID: `Please enter "${VALIDATED_VALUES.CHAIN_ID}"`, + }; + const [submitted, setSubmitted] = useState(FORM_STATE.DEFAULT); + const [values, setValues] = useState({ + networkName: '', + newRpcUrl: '', + chainId: '', + }); + const [errors, setErrors] = useState({ + networkName: '', + newRpcUrl: '', + chainId: '', + }); + useEffect(() => { + setErrors({ + networkName: + values.networkName && + values.networkName.toLowerCase() !== VALIDATED_VALUES.NETWORK_NAME + ? ERROR_MESSAGES.NETWORK_NAME + : '', + newRpcUrl: + values.newRpcUrl && + values.newRpcUrl.toLowerCase() !== VALIDATED_VALUES.NEW_RPC_URL + ? ERROR_MESSAGES.NEW_RPC_URL + : '', + chainId: + values.chainId && + values.chainId.toLowerCase() !== VALIDATED_VALUES.CHAIN_ID + ? ERROR_MESSAGES.CHAIN_ID + : '', + }); + }, [ + values, + ERROR_MESSAGES.CHAIN_ID, + ERROR_MESSAGES.NETWORK_NAME, + ERROR_MESSAGES.NEW_RPC_URL, + VALIDATED_VALUES.CHAIN_ID, + VALIDATED_VALUES.NETWORK_NAME, + VALIDATED_VALUES.NEW_RPC_URL, + ]); + const handleClearForm = () => { + setValues({ networkName: '', newRpcUrl: '', chainId: '' }); + setErrors({ networkName: '', newRpcUrl: '', chainId: '' }); + setSubmitted(FORM_STATE.DEFAULT); + }; + const handleOnChange = (e) => { + if (submitted === FORM_STATE.ERROR) { + setErrors({ networkName: '', newRpcUrl: '', chainId: '' }); + setSubmitted(FORM_STATE.DEFAULT); + } + setValues({ + ...values, + [e.target.name]: e.target.value, + }); + }; + const handleOnSubmit = (e) => { + e.preventDefault(); + if (errors.networkName || errors.newRpcUrl || errors.chainId) { + setSubmitted(FORM_STATE.ERROR); + } else { + setSubmitted(FORM_STATE.SUCCESS); + } + }; + return ( + <> + + + + + + Submit + + + + Clear form + + {submitted === FORM_STATE.SUCCESS && ( + + Form successfully submitted! + + )} + + ); +}; + +export const CustomLabelOrHelpText = () => ( + <> + + Examples of how one might customize the Label or HelpText within the + FormTextField component + + + + {/* If you need a custom label + or require adding some form of customization + import the Label component separately */} + + + + Use default + + Max} + marginBottom={4} + type={TEXT_FIELD_TYPES.NUMBER} + /> + + + {/* If you need a custom help text + or require adding some form of customization + import the HelpText component separately and handle the error + logic yourself */} + + Only enter a number that you're comfortable with the contract + accessing now or in the future. You can always increase the token limit + later. + + + Max + + + +); diff --git a/ui/components/component-library/form-text-field/form-text-field.test.js b/ui/components/component-library/form-text-field/form-text-field.test.js new file mode 100644 index 000000000..02edd5834 --- /dev/null +++ b/ui/components/component-library/form-text-field/form-text-field.test.js @@ -0,0 +1,349 @@ +/* eslint-disable jest/require-top-level-describe */ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { + renderControlledInput, + renderWithUserEvent, +} from '../../../../test/lib/render-helpers'; + +import { SIZES } from '../../../helpers/constants/design-system'; + +import { FormTextField } from './form-text-field'; + +describe('FormTextField', () => { + it('should render correctly', () => { + const { getByRole, container } = render(); + expect(getByRole('textbox')).toBeDefined(); + expect(container).toMatchSnapshot(); + }); + // autoComplete + it('should render with autoComplete', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('form-text-field-auto-complete')).toHaveAttribute( + 'autocomplete', + 'on', + ); + }); + // autoFocus + it('should render with autoFocus', () => { + const { getByRole } = render(); + expect(getByRole('textbox')).toHaveFocus(); + }); + // className + it('should render with custom className', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('form-text-field')).toHaveClass('test-class'); + }); + // defaultValue + it('should render with a defaultValue', () => { + const { getByRole } = render( + , + ); + expect(getByRole('textbox').value).toBe('default value'); + }); + // disabled + it('should render in disabled state and not focus or be clickable', async () => { + const mockOnClick = jest.fn(); + const mockOnFocus = jest.fn(); + const { getByRole, user, getByLabelText } = renderWithUserEvent( + , + ); + + await user.click(getByLabelText('test label')); + expect(mockOnFocus).toHaveBeenCalledTimes(0); + await user.type(getByRole('textbox'), 'test value'); + expect(getByRole('textbox')).not.toHaveValue('test value'); + + expect(getByRole('textbox')).toBeDisabled(); + expect(mockOnClick).toHaveBeenCalledTimes(0); + expect(mockOnFocus).toHaveBeenCalledTimes(0); + }); + // error + it('should render with error classNames on TextField and HelpText components when error is true', () => { + const { getByTestId, getByText } = render( + , + ); + expect(getByTestId('text-field')).toHaveClass('mm-text-field-base--error'); + expect(getByText('test help text')).toHaveClass( + 'text--color-error-default', + ); + }); + // helpText + it('should render with helpText', () => { + const { getByText } = render(); + expect(getByText('test help text')).toBeDefined(); + }); + // helpTextProps + it('should render with helpText and helpTextProps', () => { + const { getByText, getByTestId } = render( + , + ); + expect(getByText('test help text')).toBeDefined(); + expect(getByTestId('help-text-test')).toBeDefined(); + }); + // id + it('should render the FormTextField with an id and pass it to input and Label as htmlFor. When clicking on Label the input should have focus', async () => { + const onFocus = jest.fn(); + const { getByRole, getByLabelText, user } = renderWithUserEvent( + , + ); + expect(getByRole('textbox')).toHaveAttribute('id', 'test-id'); + await user.click(getByLabelText('test label')); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(getByRole('textbox')).toHaveFocus(); + }); + // inputProps + it('should render with inputProps', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('test-id')).toBeDefined(); + }); + // inputRef + it('should render with working ref using inputRef prop', () => { + // Because the 'ref' attribute wont flow down to the DOM + // I'm not exactly sure how to test this? + const mockRef = jest.fn(); + const { getByRole } = render(); + expect(getByRole('textbox')).toBeDefined(); + expect(mockRef).toHaveBeenCalledTimes(1); + }); + // label + it('should render with a label', () => { + const { getByLabelText } = render( + , + ); + expect(getByLabelText('test label')).toBeDefined(); + }); + // labelProps + it('should render with a labelProps', () => { + const { getByTestId, getByLabelText } = render( + , + ); + expect(getByLabelText('test label')).toBeDefined(); + expect(getByTestId('label-test-id')).toBeDefined(); + }); + // leftAccessory, // rightAccessory + it('should render with right and left accessories', () => { + const { getByRole, getByText } = render( + left accessory} + rightAccessory={
right accessory
} + />, + ); + expect(getByRole('textbox')).toBeDefined(); + expect(getByText('left accessory')).toBeDefined(); + expect(getByText('right accessory')).toBeDefined(); + }); + // maxLength; + it('should render with maxLength and not allow more than the set characters', async () => { + const { getByRole, user } = renderWithUserEvent( + , + ); + const formTextField = getByRole('textbox'); + await user.type(formTextField, '1234567890'); + expect(getByRole('textbox')).toBeDefined(); + expect(formTextField.maxLength).toBe(5); + expect(formTextField.value).toBe('12345'); + expect(formTextField.value).toHaveLength(5); + }); + // name + it('should render with name prop', () => { + const { getByRole } = render(); + expect(getByRole('textbox')).toHaveAttribute('name', 'test-name'); + }); + // onBlur, // onFocus + it('should render and fire onFocus and onBlur events', async () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { getByTestId, user } = renderWithUserEvent( + , + ); + const formTextField = getByTestId('form-text-field'); + + await user.click(formTextField); + expect(onFocus).toHaveBeenCalledTimes(1); + fireEvent.blur(formTextField); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + // onChange + it('should render and fire onChange event', async () => { + const onChange = jest.fn(); + const { user, getByRole } = renderWithUserEvent( + , + ); + await user.type(getByRole('textbox'), 'test'); + expect(onChange).toHaveBeenCalledTimes(4); + }); + // placeholder + it('should render with placeholder', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('form-text-field-auto-complete')).toHaveAttribute( + 'placeholder', + 'test placeholder', + ); + }); + // readOnly + it('should render with readOnly attr when readOnly is true', async () => { + const { getByRole, user } = renderWithUserEvent( + , + ); + await user.type(getByRole('textbox'), 'test'); + expect(getByRole('textbox')).toHaveValue('test value'); + expect(getByRole('textbox')).toHaveAttribute('readonly', ''); + }); + // required + it('should render with required asterisk after Label', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('label-test-id')).toHaveTextContent('test label*'); + }); + // size = SIZES.MD + it('should render with different size classes', () => { + const { getByTestId } = render( + <> + + + + , + ); + expect(getByTestId('sm')).toHaveClass('mm-text-field-base--size-sm'); + expect(getByTestId('md')).toHaveClass('mm-text-field-base--size-md'); + expect(getByTestId('lg')).toHaveClass('mm-text-field-base--size-lg'); + }); + // textFieldProps + it('should render with textFieldProps', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('test-text-field')).toBeDefined(); + }); + // truncate + it('should render with truncate class as true by default and remove it when truncate is false', () => { + const { getByTestId } = render( + <> + + + , + ); + expect(getByTestId('truncate')).toHaveClass('mm-text-field-base--truncate'); + expect(getByTestId('no-truncate')).not.toHaveClass( + 'mm-text-field-base--truncate', + ); + }); + // showClearButton + 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(FormTextField, { + showClearButton: true, + }); + await user.type(getByRole('textbox'), 'test value'); + expect(getByRole('textbox')).toHaveValue('test value'); + expect(getByRole('button', { name: /Clear/u })).toBeDefined(); + }); + // clearButtonOnClick + 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(FormTextField, { + showClearButton: true, + clearButtonOnClick: fn, + }); + await user.type(getByRole('textbox'), 'test value'); + await user.click(getByRole('button', { name: /Clear/u })); + expect(fn).toHaveBeenCalledTimes(1); + }); + // clearButtonProps, + 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(FormTextField, { + showClearButton: true, + clearButtonProps: { onClick: fn }, + }); + await user.type(getByRole('textbox'), 'test value'); + await user.click(getByRole('button', { name: /Clear/u })); + expect(fn).toHaveBeenCalledTimes(1); + }); + // type, + it('should render with different types', () => { + const { getByTestId } = render( + <> + + + + , + ); + expect(getByTestId('form-text-field-text')).toHaveAttribute('type', 'text'); + expect(getByTestId('form-text-field-number')).toHaveAttribute( + 'type', + 'number', + ); + expect(getByTestId('form-text-field-password')).toHaveAttribute( + 'type', + 'password', + ); + }); +}); diff --git a/ui/components/component-library/form-text-field/index.js b/ui/components/component-library/form-text-field/index.js new file mode 100644 index 000000000..ddcb200ba --- /dev/null +++ b/ui/components/component-library/form-text-field/index.js @@ -0,0 +1 @@ +export { FormTextField } from './form-text-field'; diff --git a/ui/components/component-library/index.js b/ui/components/component-library/index.js index 6927e2334..5093c6a29 100644 --- a/ui/components/component-library/index.js +++ b/ui/components/component-library/index.js @@ -10,6 +10,7 @@ export { ButtonIcon } from './button-icon'; export { ButtonLink } from './button-link'; export { ButtonPrimary } from './button-primary'; export { ButtonSecondary } from './button-secondary'; +export { FormTextField } from './form-text-field'; export { HelpText } from './help-text'; export { Icon, ICON_NAMES } from './icon'; export { Label } from './label'; @@ -17,7 +18,7 @@ export { PickerNetwork } from './picker-network'; export { Tag } from './tag'; export { TagUrl } from './tag-url'; export { Text } from './text'; -export { TextField } from './text-field'; +export { TextField, TEXT_FIELD_TYPES, TEXT_FIELD_SIZES } from './text-field'; export { TextFieldBase, TEXT_FIELD_BASE_SIZES, 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 224ba3769..fdf85988a 100644 --- a/ui/components/component-library/text-field/text-field.test.js +++ b/ui/components/component-library/text-field/text-field.test.js @@ -1,28 +1,21 @@ /* eslint-disable jest/require-top-level-describe */ 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 { + renderControlledInput, + renderWithUserEvent, +} from '../../../../test/lib/render-helpers'; import { TextField } from './text-field'; -// userEvent setup function as per testing-library docs -// https://testing-library.com/docs/user-event/intr -function setup(jsx) { - return { - user: userEvent.setup(), - ...render(jsx), - }; -} - describe('TextField', () => { it('should render correctly', () => { const { getByRole } = render(); expect(getByRole('textbox')).toBeDefined(); }); it('should render and be able to input text', async () => { - const { user, getByRole } = setup(); + const { user, getByRole } = renderWithUserEvent(); const textField = getByRole('textbox'); await user.type(textField, 'text value'); expect(textField).toHaveValue('text value'); @@ -46,7 +39,7 @@ describe('TextField', () => { }); it('should render and fire onChange event', async () => { const onChange = jest.fn(); - const { user, getByRole } = setup( + const { user, getByRole } = renderWithUserEvent( { }); it('should render and fire onClick event', async () => { const onClick = jest.fn(); - const { user, getByTestId } = setup( + const { user, getByTestId } = renderWithUserEvent( , ); await user.click(getByTestId('text-field'));