From 055a7c52c00cb3cecda314d99d7dff39f8cb7072 Mon Sep 17 00:00:00 2001 From: George Marshall Date: Thu, 6 Oct 2022 12:41:22 -0700 Subject: [PATCH] Adding `TextFieldBase` component (#16043) * Adding TextInputBase component * Removing keyup and keydown props, tests and docs * removing showClear from stories * removing unneeded css * simplifying uncontrolled vs controlled to work * Fortifying maxLength test * Lint fix for test * Doc, style and prop updates * Updating constant names with 'base' * Adding a background color * Adding a background color to input --- .../component-library-components.scss | 1 + .../text-field-base/README.mdx | 298 +++++++++++++++ .../text-field-base/index.js | 5 + .../text-field-base.constants.js | 12 + .../text-field-base/text-field-base.js | 250 ++++++++++++ .../text-field-base/text-field-base.scss | 52 +++ .../text-field-base.stories.js | 361 ++++++++++++++++++ .../text-field-base/text-field-base.test.js | 213 +++++++++++ 8 files changed, 1192 insertions(+) create mode 100644 ui/components/component-library/text-field-base/README.mdx create mode 100644 ui/components/component-library/text-field-base/index.js create mode 100644 ui/components/component-library/text-field-base/text-field-base.constants.js create mode 100644 ui/components/component-library/text-field-base/text-field-base.js create mode 100644 ui/components/component-library/text-field-base/text-field-base.scss create mode 100644 ui/components/component-library/text-field-base/text-field-base.stories.js create mode 100644 ui/components/component-library/text-field-base/text-field-base.test.js diff --git a/ui/components/component-library/component-library-components.scss b/ui/components/component-library/component-library-components.scss index 97c7320a4..85c2f84c6 100644 --- a/ui/components/component-library/component-library-components.scss +++ b/ui/components/component-library/component-library-components.scss @@ -6,3 +6,4 @@ @import 'button-primary/button-primary'; @import 'icon/icon'; @import 'text/text'; +@import 'text-field-base/text-field-base'; diff --git a/ui/components/component-library/text-field-base/README.mdx b/ui/components/component-library/text-field-base/README.mdx new file mode 100644 index 000000000..a19fdbab1 --- /dev/null +++ b/ui/components/component-library/text-field-base/README.mdx @@ -0,0 +1,298 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import { TextFieldBase } from './text-field-base'; + +### This is a base component. It should not be used in your feature code directly but as a "base" for other UI components + +# TextFieldBase + +The `TextFieldBase` is the base component for all text fields. It should not be used directly. It functions as both a uncontrolled and controlled input. + + + + + +## Props + +The `TextFieldBase` accepts all props below as well as all [Box](/docs/ui-components-ui-box-box-stories-js--default-story#props) component props + + + +### Size + +Use the `size` prop to set the height of the `TextFieldBase`. + +Possible sizes include: + +- `sm` 32px +- `md` 40px +- `lg` 48px + +Defaults to `md` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; +import { SIZES } from '../../../helpers/constants/design-system'; + + + + +``` + +### Type + +Use the `type` prop to change the type of input. + +Possible types include: + +- `text` +- `number` +- `password` + +Defaults to `text`. + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + + // (Default) + + +``` + +### Truncate + +Use the `truncate` prop to truncate the text of the the `TextFieldBase` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Left Accessory Right Accessory + +Use the `leftAccessory` and `rightAccessory` props to add components such as icons or buttons to either side of the `TextFieldBase`. + + + + + +```jsx +import { COLORS, SIZES } from '../../../helpers/constants/design-system'; +import { Icon, ICON_NAMES } from '../../ui/component-library/icons'; + +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + + + } +/> + + + + + } +/> + +} + rightAccessory={ + // TODO: replace with ButtonIcon + + } +/> + + + } + rightAccessory={ + // TODO: replace with ButtonLink + + } +/> +``` + +### Input Ref + +Use the `inputRef` prop to access the ref of the `` html element of `TextFieldBase`. This is useful for focusing the input from a button or other component. + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +const inputRef = useRef(null); +const [value, setValue] = useState(''); +const handleOnClick = () => { + inputRef.current.focus(); +}; +const handleOnChange = (e) => { + setValue(e.target.value); +}; + + +// TODO: replace with Button component + + Edit + +``` + +### Auto Complete + +Use the `autoComplete` prop to set the autocomplete html attribute. It allows the browser to predict the value based on earlier typed values. + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Auto Focus + +Use the `autoFocus` prop to focus the `TextFieldBase` during the first mount + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Default Value + +Use the `defaultValue` prop to set the default value of the `TextFieldBase` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Disabled + +Use the `disabled` prop to set the disabled state of the `TextFieldBase` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Error + +Use the `error` prop to set the error state of the `TextFieldBase` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Max Length + +Use the `maxLength` prop to set the maximum allowed input characters for the `TextFieldBase` + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Read Only + +Use the `readOnly` prop to set the `TextFieldBase` to read only + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +; +``` + +### Required + +Use the `required` prop to set the `TextFieldBase` to required. Currently there is no visual difference to the `TextFieldBase` when required. + + + + + +```jsx +import { TextFieldBase } from '../../ui/component-library/text-field-base'; + +// Currently no visual difference +; +``` diff --git a/ui/components/component-library/text-field-base/index.js b/ui/components/component-library/text-field-base/index.js new file mode 100644 index 000000000..748a4de12 --- /dev/null +++ b/ui/components/component-library/text-field-base/index.js @@ -0,0 +1,5 @@ +export { TextFieldBase } from './text-field-base'; +export { + TEXT_FIELD_BASE_SIZES, + TEXT_FIELD_BASE_TYPES, +} from './text-field-base.constants'; diff --git a/ui/components/component-library/text-field-base/text-field-base.constants.js b/ui/components/component-library/text-field-base/text-field-base.constants.js new file mode 100644 index 000000000..decdb364a --- /dev/null +++ b/ui/components/component-library/text-field-base/text-field-base.constants.js @@ -0,0 +1,12 @@ +import { SIZES } from '../../../helpers/constants/design-system'; + +export const TEXT_FIELD_BASE_SIZES = { + SM: SIZES.SM, + MD: SIZES.MD, + LG: SIZES.LG, +}; +export const TEXT_FIELD_BASE_TYPES = { + TEXT: 'text', + NUMBER: 'number', + PASSWORD: 'password', +}; diff --git a/ui/components/component-library/text-field-base/text-field-base.js b/ui/components/component-library/text-field-base/text-field-base.js new file mode 100644 index 000000000..909dae659 --- /dev/null +++ b/ui/components/component-library/text-field-base/text-field-base.js @@ -0,0 +1,250 @@ +import React, { useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { + DISPLAY, + SIZES, + ALIGN_ITEMS, + TEXT, + COLORS, +} from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box'; + +import { Text } from '../text'; + +import { + TEXT_FIELD_BASE_SIZES, + TEXT_FIELD_BASE_TYPES, +} from './text-field-base.constants'; + +export const TextFieldBase = ({ + autoComplete, + autoFocus, + className, + defaultValue, + disabled, + error, + id, + inputProps, + inputRef, + leftAccessory, + rightAccessory, + maxLength, + name, + onBlur, + onChange, + onClick, + onFocus, + placeholder, + readOnly, + required, + size = SIZES.MD, + type = 'text', + truncate, + value, + ...props +}) => { + const internalInputRef = useRef(null); + const [focused, setFocused] = useState(false); + + useEffect(() => { + // The blur won't fire when the disabled state is set on a focused input. + // We need to set the focused state manually. + if (disabled) { + setFocused(false); + } + }, [disabled]); + + const handleClick = (event) => { + const { current } = internalInputRef; + + if (current) { + current.focus(); + setFocused(true); + } + + if (onClick) { + onClick(event); + } + }; + + const handleFocus = (event) => { + setFocused(true); + onFocus && onFocus(event); + }; + + const handleBlur = (event) => { + setFocused(false); + onBlur && onBlur(event); + }; + + const handleInputRef = (ref) => { + internalInputRef.current = ref; + if (inputRef && inputRef.current !== undefined) { + inputRef.current = ref; + } else if (typeof inputRef === 'function') { + inputRef(ref); + } + }; + + return ( + + {leftAccessory} + + {rightAccessory} + + ); +}; + +TextFieldBase.propTypes = { + /** + * Autocomplete allows the browser to predict the value based on earlier typed values + */ + autoComplete: PropTypes.string, + /** + * If `true`, the input will be focused during the first mount. + */ + autoFocus: PropTypes.bool, + /** + * An additional className to apply to the text-field-base + */ + className: PropTypes.string, + /** + * The default input value, useful when not controlling the component. + */ + defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** + * If `true`, the input will be disabled. + */ + disabled: PropTypes.bool, + /** + * If `true`, the input will indicate an error + */ + error: PropTypes.bool, + /** + * The id of the `input` element. + */ + id: PropTypes.string, + /** + * Attributes applied to the `input` element. + */ + inputProps: PropTypes.object, + /** + * Component to appear on the left side of the input + */ + leftAccessory: PropTypes.node, + /** + * Component to appear on the right side of the input + */ + rightAccessory: PropTypes.node, + /** + * Use inputRef to pass a ref to the html input element. + */ + inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + /** + * Max number of characters to allow + */ + maxLength: PropTypes.number, + /** + * Name attribute of the `input` element. + */ + name: PropTypes.string, + /** + * Callback fired on blur + */ + onBlur: PropTypes.func, + /** + * Callback fired when the value is changed. + */ + onChange: PropTypes.func, + /** + * Callback fired on focus + */ + onFocus: PropTypes.func, + /** + * The short hint displayed in the input before the user enters a value. + */ + placeholder: PropTypes.string, + /** + * It prevents the user from changing the value of the field (not from interacting with the field). + */ + readOnly: PropTypes.bool, + /** + * If `true`, the input will be required. Currently no visual difference is shown. + */ + required: PropTypes.bool, + /** + * The size of the text field. Changes the height of the component + * Accepts SM(32px), MD(40px), LG(48px) + */ + size: PropTypes.oneOf(Object.values(TEXT_FIELD_BASE_SIZES)), + /** + * Type of the input element. Can be TEXT_FIELD_BASE_TYPES.TEXT, TEXT_FIELD_BASE_TYPES.PASSWORD, TEXT_FIELD_BASE_TYPES.NUMBER + * Defaults to TEXT_FIELD_BASE_TYPES.TEXT ('text') + */ + type: PropTypes.oneOf(Object.values(TEXT_FIELD_BASE_TYPES)), + /** + * The input value, required for a controlled component. + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** + * TextFieldBase accepts all the props from Box + */ + ...Box.propTypes, +}; + +TextFieldBase.displayName = 'TextFieldBase'; diff --git a/ui/components/component-library/text-field-base/text-field-base.scss b/ui/components/component-library/text-field-base/text-field-base.scss new file mode 100644 index 000000000..6672506f5 --- /dev/null +++ b/ui/components/component-library/text-field-base/text-field-base.scss @@ -0,0 +1,52 @@ +.mm-text-field-base { + --text-field-base-height: var(--size, 40px); + + &--size-sm { + --size: 32px; + } + + &--size-md { + --size: 40px; + } + + &--size-lg { + --size: 48px; + } + + height: var(--text-field-base-height); + border-color: var(--color-border-default); + + &--focused { + border-color: var(--color-primary-default); + } + + &--error { + border-color: var(--color-error-default); + } + + &--disabled { + opacity: 0.5; + border-color: var(--color-border-default); + } + + // truncates text with ellipsis + &--truncate .mm-text-field-base__input { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__input { + border: none; + height: 100%; + flex-grow: 1; + box-sizing: content-box; + margin: 0; + padding: 0; + + &:focus, + &:focus-visible { + outline: none; + } + } +} diff --git a/ui/components/component-library/text-field-base/text-field-base.stories.js b/ui/components/component-library/text-field-base/text-field-base.stories.js new file mode 100644 index 000000000..f30e5cef5 --- /dev/null +++ b/ui/components/component-library/text-field-base/text-field-base.stories.js @@ -0,0 +1,361 @@ +import React, { useState, useRef } from 'react'; + +import { + SIZES, + DISPLAY, + COLORS, + FLEX_DIRECTION, +} from '../../../helpers/constants/design-system'; +import Box from '../../ui/box/box'; + +import { Icon, ICON_NAMES } from '../icon'; +import { AvatarToken } from '../avatar-token'; + +import { + TEXT_FIELD_BASE_SIZES, + TEXT_FIELD_BASE_TYPES, +} from './text-field-base.constants'; +import { TextFieldBase } from './text-field-base'; +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/TextFieldBase', + id: __filename, + component: TextFieldBase, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + autoComplete: { + control: 'boolean', + }, + autoFocus: { + control: 'boolean', + }, + className: { + control: 'text', + }, + defaultValue: { + control: 'text', + }, + disabled: { + control: 'boolean', + }, + error: { + control: 'boolean', + }, + id: { + control: 'text', + }, + inputProps: { + control: 'object', + }, + leftAccessory: { + control: 'text', + }, + maxLength: { + control: 'number', + }, + name: { + control: 'text', + }, + onBlur: { + action: 'onBlur', + }, + onChange: { + action: 'onChange', + }, + onClick: { + action: 'onClick', + }, + onFocus: { + action: 'onFocus', + }, + placeholder: { + control: 'text', + }, + readOnly: { + control: 'boolean', + }, + required: { + control: 'boolean', + }, + rightAccessory: { + control: 'text', + }, + size: { + control: 'select', + options: Object.values(TEXT_FIELD_BASE_SIZES), + }, + type: { + control: 'select', + options: Object.values(TEXT_FIELD_BASE_TYPES), + }, + value: { + control: 'text', + }, + 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: 'Placeholder...', + autoFocus: false, + defaultValue: '', + 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 Size = (args) => { + return ( + + + + + + ); +}; + +export const Type = (args) => ( + + + + + +); + +export const Truncate = Template.bind({}); +Truncate.args = { + placeholder: 'Truncate', + value: 'Truncated text when truncate and width is set', + truncate: true, + style: { width: 240 }, +}; + +export const LeftAccessoryRightAccessory = (args) => { + const [value, setValue] = useState({ + search: '', + metaMask: '', + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + amount: 1, + }); + return ( + + setValue({ ...value, search: e.target.value })} + leftAccessory={ + + } + /> + setValue({ ...value, metaMask: e.target.value })} + placeholder="MetaMask" + rightAccessory={ + + } + /> + setValue({ ...value, address: e.target.value })} + truncate + leftAccessory={} + rightAccessory={ + + } + /> + setValue({ ...value, amount: e.target.value })} + type="number" + leftAccessory={ + + } + rightAccessory={ + + } + /> + + ); +}; + +export const InputRef = (args) => { + const inputRef = useRef(null); + const [value, setValue] = useState(''); + const handleOnClick = () => { + inputRef.current.focus(); + }; + const handleOnChange = (e) => { + setValue(e.target.value); + }; + return ( + <> + + + Edit + + + ); +}; + +export const AutoComplete = Template.bind({}); +AutoComplete.args = { + autoComplete: true, + type: 'password', + placeholder: 'Enter password', +}; + +export const AutoFocus = Template.bind({}); +AutoFocus.args = { autoFocus: true }; + +export const DefaultValue = Template.bind({}); +DefaultValue.args = { defaultValue: 'Default value' }; + +export const Disabled = Template.bind({}); +Disabled.args = { disabled: true }; + +export const ErrorStory = Template.bind({}); +ErrorStory.args = { error: true }; +ErrorStory.storyName = 'Error'; + +export const MaxLength = Template.bind({}); +MaxLength.args = { maxLength: 10, placeholder: 'Max length 10' }; + +export const ReadOnly = Template.bind({}); +ReadOnly.args = { readOnly: true, value: 'Read only' }; + +export const Required = Template.bind({}); +Required.args = { required: true, placeholder: 'Required' }; diff --git a/ui/components/component-library/text-field-base/text-field-base.test.js b/ui/components/component-library/text-field-base/text-field-base.test.js new file mode 100644 index 000000000..37f190cfd --- /dev/null +++ b/ui/components/component-library/text-field-base/text-field-base.test.js @@ -0,0 +1,213 @@ +/* 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 { SIZES } from '../../../helpers/constants/design-system'; + +import { TextFieldBase } from './text-field-base'; + +describe('TextFieldBase', () => { + it('should render correctly', () => { + const { getByRole } = render(); + expect(getByRole('textbox')).toBeDefined(); + }); + it('should render and be able to input text', () => { + const { getByTestId } = render( + , + ); + const textFieldBase = getByTestId('text-field-base'); + + expect(textFieldBase.value).toBe(''); // initial value is empty string + fireEvent.change(textFieldBase, { target: { value: 'text value' } }); + expect(textFieldBase.value).toBe('text value'); + fireEvent.change(textFieldBase, { target: { value: '' } }); // reset value + expect(textFieldBase.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 textFieldBase = getByTestId('text-field-base'); + + fireEvent.focus(textFieldBase); + expect(onFocus).toHaveBeenCalledTimes(1); + fireEvent.blur(textFieldBase); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + it('should render and fire onChange event', () => { + const onChange = jest.fn(); + const { getByTestId } = render( + , + ); + const textFieldBase = getByTestId('text-field-base'); + + fireEvent.change(textFieldBase, { target: { value: 'text value' } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + it('should render and fire onClick event', () => { + const onClick = jest.fn(); + const { getByTestId } = render( + , + ); + const textFieldBase = getByTestId('text-field-base'); + + fireEvent.click(textFieldBase); + expect(onClick).toHaveBeenCalledTimes(1); + }); + 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'); + }); + it('should render with different types', () => { + const { getByTestId } = render( + <> + + + + , + ); + expect(getByTestId('text-field-base-text')).toHaveAttribute('type', 'text'); + expect(getByTestId('text-field-base-number')).toHaveAttribute( + 'type', + 'number', + ); + expect(getByTestId('text-field-base-password')).toHaveAttribute( + 'type', + 'password', + ); + }); + it('should render with truncate class', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('truncate')).toHaveClass('mm-text-field-base--truncate'); + }); + 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(); + }); + 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); + }); + it('should render with autoComplete', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('text-field-base-auto-complete')).toHaveAttribute( + 'autocomplete', + 'on', + ); + }); + it('should render with autoFocus', () => { + const { getByRole } = render(); + expect(getByRole('textbox')).toHaveFocus(); + }); + it('should render with a defaultValue', () => { + const { getByRole } = render( + , + ); + expect(getByRole('textbox').value).toBe('default value'); + }); + it('should render in disabled state and not focus or be clickable', () => { + const mockOnClick = jest.fn(); + const mockOnFocus = jest.fn(); + const { getByRole } = render( + , + ); + + getByRole('textbox').focus(); + expect(getByRole('textbox')).toBeDisabled(); + expect(mockOnClick).toHaveBeenCalledTimes(0); + expect(mockOnFocus).toHaveBeenCalledTimes(0); + }); + it('should render with error className when error is true', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('text-field-base-error')).toHaveClass( + 'mm-text-field-base--error', + ); + }); + it('should render with maxLength and not allow more than the set characters', async () => { + const { getByRole } = render(); + const textFieldBase = getByRole('textbox'); + await userEvent.type(textFieldBase, '1234567890'); + expect(getByRole('textbox')).toBeDefined(); + expect(textFieldBase.maxLength).toBe(5); + expect(textFieldBase.value).toBe('12345'); + expect(textFieldBase.value).toHaveLength(5); + }); + it('should render with readOnly attr when readOnly is true', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('text-field-base-readonly')).toHaveAttribute( + 'readonly', + '', + ); + }); + it('should render with required attr when required is true', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('text-field-base-required')).toHaveAttribute( + 'required', + '', + ); + }); +});