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'));