1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Updating TextField component (#17732)

* Removing TextFieldBase and updating TextField and TextFieldSearch

* Removing autoFocus from MDX so it's not annoying
This commit is contained in:
George Marshall 2023-02-15 15:43:51 -08:00 committed by GitHub
parent cdc1bce688
commit f6ee35b6e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1211 additions and 1489 deletions

View File

@ -23,7 +23,7 @@
// Molecules
@import 'picker-network/picker-network';
@import 'tag-url/tag-url';
@import 'text-field-base/text-field-base';
@import 'text-field/text-field';
@import 'text-field-search/text-field-search';
@import 'form-text-field/form-text-field';
@import 'banner-alert/banner-alert';

View File

@ -1,6 +1,6 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import { TextField, TextFieldBase } from '../';
import { TextField } from '../';
import { FormTextField } from './form-text-field';
# FormTextField
@ -22,10 +22,10 @@ component props
<ArgsTable of={TextField} />
`FormTextField` accepts all [TextFieldBase](/docs/components-componentlibrary-textfield--default-story#props)
`FormTextField` accepts all [TextField](/docs/components-componentlibrary-textfield--default-story#props)
component props
<ArgsTable of={TextFieldBase} />
<ArgsTable of={TextField} />
### Id
@ -305,7 +305,7 @@ import {
<FormTextField
id="custom-spending-cap"
placeholder="Enter a number"
rightAccessory={<ButtonLink>Max</ButtonLink>}
endAccessory={<ButtonLink>Max</ButtonLink>}
marginBottom={4}
type={TEXT_FIELD_TYPES.NUMBER}
/>

View File

@ -6,11 +6,11 @@ exports[`FormTextField should render correctly 1`] = `
class="box mm-form-text-field box--display-flex box--flex-direction-column"
>
<div
class="box mm-text-field-base mm-text-field-base--size-md mm-text-field-base--truncate mm-text-field mm-form-text-field__text-field box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
class="box mm-text-field mm-text-field--size-md mm-text-field--truncate mm-form-text-field__text-field box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
>
<input
autocomplete="off"
class="box mm-text mm-text-field-base__input mm-text--body-md mm-text--color-text-default box--padding-right-4 box--padding-left-4 box--flex-direction-row box--background-color-transparent"
class="box mm-text mm-text-field__input mm-text--body-md mm-text--color-text-default box--padding-right-4 box--padding-left-4 box--flex-direction-row box--background-color-transparent"
focused="false"
type="text"
value=""

View File

@ -28,7 +28,7 @@ export const FormTextField = ({
inputRef,
label,
labelProps,
leftAccessory,
startAccessory,
maxLength,
name,
onBlur,
@ -37,13 +37,10 @@ export const FormTextField = ({
placeholder,
readOnly,
required,
rightAccessory,
endAccessory,
size = Size.MD,
textFieldProps,
truncate,
showClearButton,
clearButtonOnClick,
clearButtonProps,
type = 'text',
value,
...props
@ -86,7 +83,7 @@ export const FormTextField = ({
id,
inputProps,
inputRef,
leftAccessory,
startAccessory,
maxLength,
name,
onBlur,
@ -95,10 +92,7 @@ export const FormTextField = ({
placeholder,
readOnly,
required,
rightAccessory,
showClearButton,
clearButtonOnClick,
clearButtonProps,
endAccessory,
size,
truncate,
type,

View File

@ -73,18 +73,6 @@ export default {
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' },
@ -113,7 +101,7 @@ export default {
control: 'object',
table: { category: 'text field base props' },
},
leftAccessory: {
startAccessory: {
control: 'text',
table: { category: 'text field base props' },
},
@ -157,7 +145,7 @@ export default {
control: 'boolean',
table: { category: 'text field base props' },
},
rightAccessory: {
endAccessory: {
control: 'text',
table: { category: 'text field base props' },
},
@ -209,17 +197,7 @@ const Template = (args) => {
const handleOnChange = (e) => {
updateArgs({ value: e.target.value });
};
const handleOnClear = () => {
updateArgs({ value: '' });
};
return (
<FormTextField
{...args}
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
/>
);
return <FormTextField {...args} value={value} onChange={handleOnChange} />;
};
export const DefaultStory = Template.bind({});
@ -245,9 +223,6 @@ export const HelpTextStory = (args) => {
const handleOnChange = (e) => {
updateArgs({ value: e.target.value });
};
const handleOnClear = () => {
updateArgs({ value: '' });
};
return (
<>
<FormTextField
@ -255,7 +230,6 @@ export const HelpTextStory = (args) => {
id="input-with-help-text"
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
marginBottom={4}
/>
<FormTextField
@ -265,7 +239,6 @@ export const HelpTextStory = (args) => {
helpText="When error is true the help text will be rendered as an error message"
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
/>
</>
);
@ -447,7 +420,7 @@ export const CustomLabelOrHelpText = () => (
<FormTextField
id="custom-spending-cap"
placeholder="Enter a number"
rightAccessory={<ButtonLink>Max</ButtonLink>}
endAccessory={<ButtonLink>Max</ButtonLink>}
marginBottom={4}
type={TEXT_FIELD_TYPES.NUMBER}
/>

View File

@ -2,10 +2,7 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import {
renderControlledInput,
renderWithUserEvent,
} from '../../../../test/lib/render-helpers';
import { renderWithUserEvent } from '../../../../test/lib/render-helpers';
import { Size } from '../../../helpers/constants/design-system';
@ -81,7 +78,7 @@ describe('FormTextField', () => {
helpText="test help text"
/>,
);
expect(getByTestId('text-field')).toHaveClass('mm-text-field-base--error');
expect(getByTestId('text-field')).toHaveClass('mm-text-field--error');
expect(getByText('test help text')).toHaveClass(
'mm-text--color-error-default',
);
@ -154,17 +151,17 @@ describe('FormTextField', () => {
'mm-form-text-field__label test',
);
});
// leftAccessory, // rightAccessory
// startAccessory, // endAccessory
it('should render with right and left accessories', () => {
const { getByRole, getByText } = render(
<FormTextField
leftAccessory={<div>left accessory</div>}
rightAccessory={<div>right accessory</div>}
startAccessory={<div>start accessory</div>}
endAccessory={<div>end accessory</div>}
/>,
);
expect(getByRole('textbox')).toBeDefined();
expect(getByText('left accessory')).toBeDefined();
expect(getByText('right accessory')).toBeDefined();
expect(getByText('start accessory')).toBeDefined();
expect(getByText('end accessory')).toBeDefined();
});
// maxLength;
it('should render with maxLength and not allow more than the set characters', async () => {
@ -230,7 +227,7 @@ describe('FormTextField', () => {
readOnly
value="test value"
data-testid="read-only"
inputProps={{ 'data-testid': 'text-field-base-readonly' }}
inputProps={{ 'data-testid': 'text-field-readonly' }}
/>,
);
await user.type(getByRole('textbox'), 'test');
@ -266,9 +263,9 @@ describe('FormTextField', () => {
/>
</>,
);
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');
expect(getByTestId('sm')).toHaveClass('mm-text-field--size-sm');
expect(getByTestId('md')).toHaveClass('mm-text-field--size-md');
expect(getByTestId('lg')).toHaveClass('mm-text-field--size-lg');
});
// textFieldProps
it('should render with textFieldProps', () => {
@ -288,45 +285,11 @@ describe('FormTextField', () => {
/>
</>,
);
expect(getByTestId('truncate')).toHaveClass('mm-text-field-base--truncate');
expect(getByTestId('truncate')).toHaveClass('mm-text-field--truncate');
expect(getByTestId('no-truncate')).not.toHaveClass(
'mm-text-field-base--truncate',
'mm-text-field--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(

View File

@ -180,7 +180,7 @@ export const DefaultStory = (args) => {
textAlign: TEXT_ALIGN.CENTER,
}}
backgroundColor={BackgroundColor.backgroundAlternative}
rightAccessory={
endAccessory={
<ButtonIcon
iconName={ICON_NAMES.COPY}
size={Size.SM}

View File

@ -28,11 +28,6 @@ export { Tag } from './tag';
export { TagUrl } from './tag-url';
export { Text, TEXT_DIRECTIONS } from './text';
export { TextField, TEXT_FIELD_TYPES, TEXT_FIELD_SIZES } from './text-field';
export {
TextFieldBase,
TEXT_FIELD_BASE_SIZES,
TEXT_FIELD_BASE_TYPES,
} from './text-field-base';
export { TextFieldSearch } from './text-field-search';
// Molecules

View File

@ -62,7 +62,7 @@ Use the `htmlFor` prop to allow the `Label` to focus on an input with the same i
</Canvas>
```jsx
import { Label, TextFieldBase } from '../../component-library';
import { Label, TextField } from '../../component-library';
<Label htmlFor="add-network">Add network</Label>
<TextField id="add-network" placeholder="Enter network name" />

View File

@ -2,7 +2,7 @@
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { Icon, ICON_NAMES } from '../icon';
import { TextFieldBase } from '../text-field-base';
import { TextField } from '../text-field';
import { Label } from './label';
@ -31,7 +31,7 @@ describe('label', () => {
const { getByText, getByRole } = render(
<>
<Label htmlFor="input">label</Label>
<TextFieldBase id="input" />
<TextField id="input" />
</>,
);
const input = getByRole('textbox');
@ -46,7 +46,7 @@ describe('label', () => {
<>
<Label>
Label text
<TextFieldBase />
<TextField />
</Label>
</>,
);

View File

@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextFieldBase should render correctly 1`] = `
<div>
<div
class="box mm-text-field-base mm-text-field-base--size-md mm-text-field-base--truncate box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
>
<input
autocomplete="off"
class="box mm-text mm-text-field-base__input mm-text--body-md mm-text--color-text-default box--padding-right-4 box--padding-left-4 box--flex-direction-row box--background-color-transparent"
focused="false"
type="text"
value=""
/>
</div>
</div>
`;

View File

@ -1,5 +0,0 @@
export { TextFieldBase } from './text-field-base';
export {
TEXT_FIELD_BASE_SIZES,
TEXT_FIELD_BASE_TYPES,
} from './text-field-base.constants';

View File

@ -1,13 +0,0 @@
import { Size } from '../../../helpers/constants/design-system';
export const TEXT_FIELD_BASE_SIZES = {
SM: Size.SM,
MD: Size.MD,
LG: Size.LG,
};
export const TEXT_FIELD_BASE_TYPES = {
TEXT: 'text',
NUMBER: 'number',
PASSWORD: 'password',
SEARCH: 'search',
};

View File

@ -1,266 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import {
DISPLAY,
Size,
AlignItems,
TextVariant,
BorderRadius,
BackgroundColor,
} 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 = Size.MD,
type = 'text',
truncate = true,
value,
InputComponent = Text,
...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 && !disabled) {
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 (
<Box
className={classnames(
'mm-text-field-base',
`mm-text-field-base--size-${size}`,
{
'mm-text-field-base--focused': focused && !disabled,
'mm-text-field-base--error': error,
'mm-text-field-base--disabled': disabled,
'mm-text-field-base--truncate': truncate,
},
className,
)}
display={DISPLAY.INLINE_FLEX}
backgroundColor={BackgroundColor.backgroundDefault}
alignItems={AlignItems.center}
borderWidth={1}
borderRadius={BorderRadius.SM}
paddingLeft={leftAccessory ? 4 : 0}
paddingRight={rightAccessory ? 4 : 0}
onClick={handleClick}
{...props}
>
{leftAccessory}
<InputComponent
aria-invalid={error}
as="input"
autoComplete={autoComplete ? 'on' : 'off'}
autoFocus={autoFocus}
backgroundColor={BackgroundColor.transparent}
defaultValue={defaultValue}
disabled={disabled}
focused={focused.toString()}
id={id}
margin={0}
maxLength={maxLength}
name={name}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
padding={0}
paddingLeft={leftAccessory ? 2 : 4}
paddingRight={rightAccessory ? 2 : 4}
placeholder={placeholder}
readOnly={readOnly}
ref={handleInputRef}
required={required}
value={value}
variant={TextVariant.bodyMd}
type={type}
{...inputProps} // before className so input className isn't overridden
className={classnames(
'mm-text-field-base__input',
inputProps?.className,
)}
/>
{rightAccessory}
</Box>
);
};
TextFieldBase.propTypes = {
/**
* Autocomplete allows the browser to predict the value based on earlier typed values
*/
autoComplete: PropTypes.bool,
/**
* 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,
/**
* The the component that is rendered as the input
* Defaults to the Text component
*/
InputComponent: PropTypes.elementType,
/**
* 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 when the TextField is clicked on
*/
onClick: 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)),
/**
* If true will ellipse the text of the input
* Defaults to true
*/
truncate: PropTypes.bool,
/**
* 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';

View File

@ -1,504 +0,0 @@
import React, { useState, useRef } from 'react';
import { useArgs } from '@storybook/client-api';
import PropTypes from 'prop-types';
import {
DISPLAY,
FLEX_DIRECTION,
AlignItems,
TextVariant,
IconColor,
BackgroundColor,
TextColor,
Size,
} from '../../../helpers/constants/design-system';
import Box from '../../ui/box/box';
import {
AvatarAccount,
AvatarToken,
Button,
ButtonIcon,
ICON_NAMES,
Icon,
Text,
} from '..';
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',
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...',
},
};
const Template = (args) => {
const [{ value }, updateArgs] = useArgs();
const handleOnChange = (e) => {
updateArgs({ value: e.target.value });
};
return <TextFieldBase {...args} value={value} onChange={handleOnChange} />;
};
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
export const SizeStory = (args) => {
return (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextFieldBase
{...args}
placeholder="Size.SM (height: 32px)"
size={Size.SM}
/>
<TextFieldBase
{...args}
placeholder="Size.MD (height: 40px)"
size={Size.MD}
/>
<TextFieldBase
{...args}
placeholder="Size.LG (height: 48px)"
size={Size.LG}
/>
</Box>
);
};
SizeStory.storyName = 'Size';
export const Type = (args) => (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextFieldBase {...args} placeholder="Default" />
<TextFieldBase
{...args}
type={TEXT_FIELD_BASE_TYPES.PASSWORD}
placeholder="Password"
/>
<TextFieldBase
{...args}
type={TEXT_FIELD_BASE_TYPES.NUMBER}
placeholder="Number"
/>
</Box>
);
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: '',
address: '',
amount: 1,
accountAddress: '0x514910771af9ca656af840dff83e8264ecf986ca',
});
const handleOnChange = (e) => {
setValue({ ...value, [e.target.name]: e.target.value });
};
const handleTokenPrice = (tokenAmount, priceUSD) => {
return tokenAmount * priceUSD;
};
return (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextFieldBase
{...args}
placeholder="Search"
value={value.search}
name="search"
onChange={handleOnChange}
leftAccessory={
<Icon color={IconColor.iconAlternative} name={ICON_NAMES.SEARCH} />
}
/>
<TextFieldBase
{...args}
placeholder="Public address (0x), or ENS"
value={value.address}
name="address"
onChange={handleOnChange}
rightAccessory={
<ButtonIcon
iconName={ICON_NAMES.SCAN_BARCODE}
ariaLabel="Scan QR code"
iconProps={{ color: IconColor.primaryDefault }}
/>
}
/>
<TextFieldBase
{...args}
placeholder="Enter amount"
value={value.amount}
name="amount"
onChange={handleOnChange}
type="number"
truncate
leftAccessory={
<Box
as="button"
style={{ padding: 0 }}
backgroundColor={BackgroundColor.transparent}
gap={1}
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
>
<AvatarToken
tokenName="eth"
tokenImageUrl="./images/eth_logo.svg"
size={Size.SM}
/>
<Text>ETH</Text>
<Icon
name={ICON_NAMES.ARROW_DOWN}
color={IconColor.iconDefault}
size={Size.SM}
/>
</Box>
}
rightAccessory={
<Text
variant={TextVariant.bodySm}
color={TextColor.textAlternative}
style={{ whiteSpace: 'nowrap' }}
>
= ${handleTokenPrice(value.amount, 1173.58)}
</Text>
}
/>
<TextFieldBase
{...args}
placeholder="Public address (0x), or ENS"
value={value.accountAddress}
name="accountAddress"
onChange={handleOnChange}
truncate
leftAccessory={
value.accountAddress && (
<AvatarAccount size={Size.SM} address={value.accountAddress} />
)
}
rightAccessory={
value.accountAddress.length === 42 && (
<Icon name={ICON_NAMES.CHECK} color={IconColor.successDefault} />
)
}
/>
</Box>
);
};
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 (
<Box display={DISPLAY.FLEX}>
<TextFieldBase
{...args}
inputRef={inputRef}
value={value}
onChange={handleOnChange}
/>
<Button marginLeft={1} onClick={handleOnClick}>
Edit
</Button>
</Box>
);
};
const CustomInputComponent = React.forwardRef(
(
{
as,
autoComplete,
autoFocus,
defaultValue,
disabled,
focused,
id,
inputProps,
inputRef,
maxLength,
name,
onBlur,
onChange,
onFocus,
padding,
paddingLeft,
paddingRight,
placeholder,
readOnly,
required,
value,
variant,
type,
className,
'aria-invalid': ariaInvalid,
...props
},
ref,
) => (
<Box
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
ref={ref}
{...{ padding, paddingLeft, paddingRight, ...props }}
>
<Box display={DISPLAY.INLINE_FLEX}>
<Text
style={{ padding: 0 }}
aria-invalid={ariaInvalid}
ref={inputRef}
{...{
className,
as,
autoComplete,
autoFocus,
defaultValue,
disabled,
focused,
id,
maxLength,
name,
onBlur,
onChange,
onFocus,
placeholder,
readOnly,
required,
value,
variant,
type,
...inputProps,
}}
/>
<Text variant={TextVariant.bodyXs} color={TextColor.textAlternative}>
GoerliETH
</Text>
</Box>
<Text variant={TextVariant.bodyXs}>No conversion rate available</Text>
</Box>
),
);
CustomInputComponent.propTypes = {
/**
* The custom input component should accepts all props that the
* InputComponent accepts in ./text-field-base.js
*/
autoFocus: PropTypes.bool,
className: PropTypes.string,
defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
id: PropTypes.string,
inputProps: PropTypes.object,
inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
maxLength: PropTypes.number,
name: PropTypes.string,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
placeholder: PropTypes.string,
readOnly: PropTypes.bool,
required: PropTypes.bool,
type: PropTypes.oneOf(Object.values(TEXT_FIELD_BASE_TYPES)),
/**
* Because we manipulate the type in TextFieldBase so the html element
* receives the correct attribute we need to change the autoComplete
* propType to a string
*/
autoComplete: PropTypes.string,
/**
* The custom input component should also accept all the props from Box
*/
...Box.propTypes,
};
CustomInputComponent.displayName = 'CustomInputComponent';
export const InputComponent = (args) => (
<TextFieldBase
{...args}
placeholder="0"
type="number"
size={Size.LG}
InputComponent={CustomInputComponent}
leftAccessory={
<Icon color={IconColor.iconAlternative} name={ICON_NAMES.WALLET} />
}
/>
);
InputComponent.args = { autoComplete: true };
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' };

View File

@ -1,258 +0,0 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { renderWithUserEvent } from '../../../../test/lib/render-helpers';
import { Size } from '../../../helpers/constants/design-system';
import Box from '../../ui/box';
import { TextFieldBase } from './text-field-base';
describe('TextFieldBase', () => {
it('should render correctly', () => {
const { getByRole, container } = render(<TextFieldBase />);
expect(getByRole('textbox')).toBeDefined();
expect(container).toMatchSnapshot();
});
it('should render and be able to input text', () => {
const { getByTestId } = render(
<TextFieldBase inputProps={{ 'data-testid': 'text-field-base' }} />,
);
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 with focused state when clicked', async () => {
const { getByTestId, user } = renderWithUserEvent(
<TextFieldBase
data-testid="text-field-base"
inputProps={{ 'data-testid': 'input' }}
/>,
);
const textFieldBase = getByTestId('input');
await user.click(textFieldBase);
expect(getByTestId('input')).toHaveFocus();
expect(getByTestId('text-field-base')).toHaveClass(
'mm-text-field-base--focused ',
);
});
it('should render and fire onFocus and onBlur events', async () => {
const onFocus = jest.fn();
const onBlur = jest.fn();
const { getByTestId, user } = renderWithUserEvent(
<TextFieldBase
inputProps={{ 'data-testid': 'text-field-base' }}
onFocus={onFocus}
onBlur={onBlur}
/>,
);
const textFieldBase = getByTestId('text-field-base');
await user.click(textFieldBase);
expect(onFocus).toHaveBeenCalledTimes(1);
fireEvent.blur(textFieldBase);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('should render and fire onChange event', async () => {
const onChange = jest.fn();
const { getByTestId, user } = renderWithUserEvent(
<TextFieldBase
inputProps={{ 'data-testid': 'text-field-base' }}
onChange={onChange}
/>,
);
const textFieldBase = getByTestId('text-field-base');
await user.type(textFieldBase, '123');
expect(textFieldBase).toHaveValue('123');
expect(onChange).toHaveBeenCalledTimes(3);
});
it('should render and fire onClick event', async () => {
const onClick = jest.fn();
const { getByTestId, user } = renderWithUserEvent(
<TextFieldBase
inputProps={{ 'data-testid': 'text-field-base' }}
onClick={onClick}
/>,
);
const textFieldBase = getByTestId('text-field-base');
await user.click(textFieldBase);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should render with different size classes', () => {
const { getByTestId } = render(
<>
<TextFieldBase size={Size.SM} data-testid="sm" />
<TextFieldBase size={Size.MD} data-testid="md" />
<TextFieldBase size={Size.LG} data-testid="lg" />
</>,
);
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(
<>
<TextFieldBase inputProps={{ 'data-testid': 'text-field-base-text' }} />
<TextFieldBase
type="number"
inputProps={{ 'data-testid': 'text-field-base-number' }}
/>
<TextFieldBase
type="password"
inputProps={{ 'data-testid': 'text-field-base-password' }}
/>
</>,
);
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 as true by default and remove it when truncate is false', () => {
const { getByTestId } = render(
<>
<TextFieldBase data-testid="truncate" />
<TextFieldBase truncate={false} data-testid="no-truncate" />
</>,
);
expect(getByTestId('truncate')).toHaveClass('mm-text-field-base--truncate');
expect(getByTestId('no-truncate')).not.toHaveClass(
'mm-text-field-base--truncate',
);
});
it('should render with right and left accessories', () => {
const { getByRole, getByText } = render(
<TextFieldBase
leftAccessory={<div>left accessory</div>}
rightAccessory={<div>right accessory</div>}
/>,
);
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(<TextFieldBase inputRef={mockRef} />);
expect(getByRole('textbox')).toBeDefined();
expect(mockRef).toHaveBeenCalledTimes(1);
});
it('should render with autoComplete', () => {
const { getByTestId } = render(
<TextFieldBase
autoComplete
inputProps={{ 'data-testid': 'text-field-base-auto-complete' }}
/>,
);
expect(getByTestId('text-field-base-auto-complete')).toHaveAttribute(
'autocomplete',
'on',
);
});
it('should render with autoFocus', () => {
const { getByRole } = render(<TextFieldBase autoFocus />);
expect(getByRole('textbox')).toHaveFocus();
});
it('should render with a defaultValue', () => {
const { getByRole } = render(
<TextFieldBase
defaultValue="default value"
inputProps={{ 'data-testid': 'text-field-base-default-value' }}
/>,
);
expect(getByRole('textbox').value).toBe('default value');
});
it('should render in disabled state and not focus or be clickable', async () => {
const mockOnClick = jest.fn();
const mockOnFocus = jest.fn();
const { getByRole, getByTestId, user } = renderWithUserEvent(
<TextFieldBase
disabled
onFocus={mockOnFocus}
onClick={mockOnClick}
data-testid="text-field-base"
/>,
);
const textFieldBase = getByTestId('text-field-base');
await user.click(textFieldBase);
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(
<TextFieldBase error data-testid="text-field-base-error" />,
);
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, user } = renderWithUserEvent(
<TextFieldBase maxLength={5} />,
);
const textFieldBase = getByRole('textbox');
await user.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', async () => {
const { getByTestId, getByRole, user } = renderWithUserEvent(
<TextFieldBase readOnly data-testid="read-only" />,
);
const textFieldBase = getByTestId('read-only');
await user.type(textFieldBase, '1234567890');
expect(getByRole('textbox').value).toBe('');
expect(getByRole('textbox')).toHaveAttribute('readonly', '');
});
it('should render with required attr when required is true', () => {
const { getByTestId } = render(
<TextFieldBase
required
inputProps={{ 'data-testid': 'text-field-base-required' }}
/>,
);
expect(getByTestId('text-field-base-required')).toHaveAttribute(
'required',
'',
);
});
it('should render with a custom input and still work', async () => {
const CustomInputComponent = React.forwardRef((props, ref) => (
<Box ref={ref} as="input" {...props} />
));
CustomInputComponent.displayName = 'CustomInputComponent'; // fixes eslint error
const { getByTestId, user } = renderWithUserEvent(
<TextFieldBase
InputComponent={CustomInputComponent}
inputProps={{ 'data-testid': 'text-field-base', className: 'test' }}
/>,
);
const textFieldBase = getByTestId('text-field-base');
expect(textFieldBase.value).toBe(''); // initial value is empty string
await user.type(textFieldBase, '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
});
});

View File

@ -1,6 +1,6 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import { TextFieldBase, TextField } from '..';
import { TextField } from '..';
import { TextFieldSearch } from './text-field-search';
@ -23,11 +23,6 @@ component props
<ArgsTable of={TextField} />
`TextFieldSearch` accepts all [TextFieldBase](/docs/components-componentlibrary-textfieldbase--default-story#props)
component props
<ArgsTable of={TextFieldBase} />
### Clear Button On Click
`TextFieldSearch` displays a clear button when text is entered into the input. Use the `clearButtonOnClick` prop to pass an `onClick` event handler to clear the value of the input. To hide the clear button, pass `false` to the `showClearButton` prop.

View File

@ -3,7 +3,7 @@
exports[`TextFieldSearch should render correctly 1`] = `
<div>
<div
class="box mm-text-field-base mm-text-field-base--size-md mm-text-field-base--truncate mm-text-field mm-text-field-search box--padding-left-4 box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
class="box mm-text-field mm-text-field--size-md mm-text-field--truncate mm-text-field-search box--padding-left-4 box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
>
<div
class="box mm-icon mm-icon--size-sm box--flex-direction-row box--color-inherit"
@ -11,7 +11,7 @@ exports[`TextFieldSearch should render correctly 1`] = `
/>
<input
autocomplete="off"
class="box mm-text mm-text-field-base__input mm-text--body-md mm-text--color-text-default box--margin-right-6 box--padding-right-4 box--padding-left-2 box--flex-direction-row box--background-color-transparent"
class="box mm-text mm-text-field__input mm-text--body-md mm-text--color-text-default box--margin-right-6 box--padding-right-4 box--padding-left-2 box--flex-direction-row box--background-color-transparent"
focused="false"
type="search"
value=""

View File

@ -6,26 +6,46 @@ import { Size } from '../../../helpers/constants/design-system';
import { ButtonIcon } from '../button-icon';
import { Icon, ICON_NAMES } from '../icon';
import { TextFieldBase, TEXT_FIELD_BASE_TYPES } from '../text-field-base';
import { TextField } from '../text-field';
import { TextField, TEXT_FIELD_TYPES } from '../text-field';
export const TextFieldSearch = ({
value,
onChange,
className,
showClearButton = true, // only works with a controlled input
clearButtonOnClick,
clearButtonProps,
className,
endAccessory,
inputProps,
value,
onChange,
...props
}) => (
<TextField
className={classnames('mm-text-field-search', className)}
value={value}
onChange={onChange}
type={TEXT_FIELD_BASE_TYPES.SEARCH}
leftAccessory={<Icon name={ICON_NAMES.SEARCH} size={Size.SM} />}
showClearButton
clearButtonOnClick={clearButtonOnClick}
clearButtonProps={clearButtonProps}
type={TEXT_FIELD_TYPES.SEARCH}
endAccessory={
value && showClearButton ? (
<>
<ButtonIcon
className="mm-text-field__button-clear"
ariaLabel="Clear" // TODO: i18n
iconName={ICON_NAMES.CLOSE}
size={Size.SM}
onClick={clearButtonOnClick}
{...clearButtonProps}
/>
{endAccessory}
</>
) : (
endAccessory
)
}
startAccessory={<Icon name={ICON_NAMES.SEARCH} size={Size.SM} />}
inputProps={{
marginRight: showClearButton ? 6 : 0,
...inputProps,
}}
{...props}
/>
);
@ -34,11 +54,16 @@ TextFieldSearch.propTypes = {
/**
* The value of the TextFieldSearch
*/
value: TextFieldBase.propTypes.value,
value: TextField.propTypes.value,
/**
* The onChange handler of the TextFieldSearch
*/
onChange: TextFieldBase.propTypes.onChange,
onChange: TextField.propTypes.onChange,
/**
* The clear button for the TextFieldSearch.
* Defaults to true
*/
showClearButton: PropTypes.bool,
/**
* The onClick handler for the clear button
* Required unless showClearButton is false
@ -66,6 +91,18 @@ TextFieldSearch.propTypes = {
* An additional className to apply to the TextFieldSearch
*/
className: PropTypes.string,
/**
* Component to appear on the right side of the input
*/
endAccessory: PropTypes.node,
/**
* Attributes applied to the `input` element.
*/
inputProps: PropTypes.object,
/**
* FormTextField accepts all the props from TextField and Box
*/
...TextField.propTypes,
};
TextFieldSearch.displayName = 'TextFieldSearch';

View File

@ -80,7 +80,7 @@ export default {
control: 'object',
table: { category: 'text field base props' },
},
leftAccessory: {
startAccessory: {
control: 'text',
table: { category: 'text field base props' },
},
@ -124,7 +124,7 @@ export default {
control: 'boolean',
table: { category: 'text field base props' },
},
rightAccessory: {
endAccessory: {
control: 'text',
table: { category: 'text field base props' },
},

View File

@ -31,20 +31,20 @@ describe('TextFieldSearch', () => {
expect(getByRole('searchbox')).toHaveValue('test value');
expect(getByRole('button', { name: /Clear/u })).toBeDefined();
});
it('should still render with the rightAccessory if it exists', async () => {
it('should still render with the endAccessory if it exists', async () => {
const fn = jest.fn();
// As showClearButton is intended to be used with a controlled input we need to use renderControlledInput
const { user, getByRole, getByText } = renderControlledInput(
TextFieldSearch,
{
clearButtonOnClick: fn,
rightAccessory: <div>right-accessory</div>,
endAccessory: <div>end-accessory</div>,
},
);
await user.type(getByRole('searchbox'), 'test value');
expect(getByRole('searchbox')).toHaveValue('test value');
expect(getByRole('button', { name: /Clear/u })).toBeDefined();
expect(getByText('right-accessory')).toBeDefined();
expect(getByText('end-accessory')).toBeDefined();
});
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

View File

@ -1,12 +1,10 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';
import { TextFieldBase } from '../text-field-base';
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.
`TextField` lets user enter a text data into a boxed field. It can sometimes contain related information or controls.
<Canvas>
<Story id="components-componentlibrary-textfield--default-story" />
@ -18,79 +16,318 @@ The `TextField` accepts all props below as well as all [Box](/docs/components-ui
<ArgsTable of={TextField} />
`TextField` accepts all [TextFieldBase](/docs/components-componentlibrary-textfieldbase--default-story#props)
component props
### Size
<ArgsTable of={TextFieldBase} />
Use the `size` prop to set the height of the `TextField`.
### Show Clear Button
Possible sizes include:
Use the `showClearButton` prop to display a clear button when `TextField` has a value. Use the `clearButtonOnClick` prop to pass an `onClick` event handler to clear the value of the input. Otherwise the clear button will not do anything.
- `sm` 32px
- `md` 40px
- `lg` 48px
The clear button uses [ButtonIcon](/docs/components-componentlibrary-buttonicon--default-story) and accepts all `ButtonIcon` props.
**NOTE: The `showClearButton` only works with a controlled input.**
Defaults to `md`
<Canvas>
<Story id="components-componentlibrary-textfield--show-clear-button" />
<Story id="components-componentlibrary-textfield--size-story" />
</Canvas>
```jsx
import { Size } from '../../../helpers/constants/design-system';
import { TextField } from '../../component-library';
<TextField size={Size.SM} />
<TextField size={Size.MD} />
<TextField size={Size.LG} />
```
### Type
Use the `type` prop to change the type of input.
Possible types include:
- `text`
- `number`
- `password`
Defaults to `text`.
<Canvas>
<Story id="components-componentlibrary-textfield--type" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
const [value, setValue] = useState('show clear');
const handleOnChange = (e) => {
setValue(e.target.value);
};
const handleOnClear = () => {
setValue('');
};
<TextField
placeholder="Enter text to show clear"
value={value}
onChange={handleOnChange}
showClearButton
clearButtonOnClick={handleOnClear}
/>;
<TextField type="text" /> // (Default)
<TextField type="number" />
<TextField type="password" />
```
### Clear Button Props
### Truncate
Use the `clearButtonProps` to access other props of the clear button.
Use the `truncate` prop to truncate the text of the the `TextField`. Defaults to `true`.
<Canvas>
<Story id="components-componentlibrary-textfield--clear-button-props" />
<Story id="components-componentlibrary-textfield--truncate" />
</Canvas>
```jsx
import React, { useState } from 'react';
import { Color, BorderRadius } from '../../../helpers/constants/design-system';
import { TextField } from '../../component-library';
const [value, setValue] = useState('show clear');
<TextField truncate />; // truncate is set to `true` by default
<TextField truncate={false} />;
```
### Start accessory End Accessory
Use the `startAccessory` and `endAccessory` props to add components such as icons or buttons to either side of the `TextField`.
<Canvas>
<Story id="components-componentlibrary-textfield--start-accessory-end-accessory" />
</Canvas>
```jsx
import { COLORS, SIZES, DISPLAY } from '../../../helpers/constants/design-system';
import { ButtonIcon, Icon, ICON_NAMES, TextField } from '../../component-library';
<TextField
placeholder="Search"
startAccessory={
<Icon
color={COLORS.ICON_ALTERNATIVE}
name={ICON_NAMES.SEARCH}
/>
}
/>
<TextField
placeholder="Public address (0x), or ENS"
endAccessory={
<ButtonIcon
iconName={ICON_NAMES.SCAN_BARCODE}
ariaLabel="Scan QR code"
iconProps={{ color: COLORS.PRIMARY_DEFAULT }}
/>
}
/>
<TextField
placeholder="Enter amount"
type="number"
truncate
startAccessory={<SelectTokenComponent />}
endAccessory={<TokenValueInUSDComponent />}
/>
<TextField
placeholder="Public address (0x), or ENS"
truncate
startAccessory={<AvatarAccount />}
endAccessory={
isAddressValid && (
<Icon
name={ICON_NAMES.CHECK}
color={COLORS.SUCCESS_DEFAULT}
/>
)
}
/>
```
### Input Ref
Use the `inputRef` prop to access the ref of the `<input />` html element of `TextField`. This is useful for focusing the input from a button or other component.
<Canvas>
<Story id="components-componentlibrary-textfield--input-ref" />
</Canvas>
```jsx
import { Button, TextField } from '../../component-library';
const inputRef = useRef(null);
const [value, setValue] = useState('');
const handleOnClick = () => {
inputRef.current.focus();
};
const handleOnChange = (e) => {
setValue(e.target.value);
};
const handleOnClear = () => {
setValue('');
};
<TextField
placeholder="Enter text to show clear"
inputRef={inputRef}
value={value}
onChange={handleOnChange}
showClearButton
clearButtonOnClick={handleOnClear}
clearButtonProps={{
backgroundColor: Color.backgroundAlternative,
borderRadius: BorderRadius.XS,
'data-testid': 'clear-button',
}}
/>;
/>
<Button marginLeft={1} onClick={handleOnClick}>
Edit
</Button>
```
### Input Component
Use the `InputComponent` prop change the component used for the input element. This is useful for replacing the base input with a custom input while retaining the functionality of the `TextField`.
Defaults to the [Text](/docs/components-componentlibrary-text--default-story) component
To function fully the custom component should accept the following props:
- `aria-invalid`
- `as`
- `autoComplete`
- `autoFocus`
- `backgroundColor`
- `defaultValue`
- `disabled`
- `focused`
- `id`
- `margin`
- `maxLength`
- `name`
- `onBlur`
- `onChange`
- `onFocus`
- `padding`
- `paddingLeft`
- `paddingRight`
- `placeholder`
- `readOnly`
- `ref`
- `required`
- `value`
- `variant`
- `type`
<Canvas>
<Story id="components-componentlibrary-textfield--input-component" />
</Canvas>
```jsx
import { TextField, Icon, ICON_NAMES } from '../../component-library';
// should map the props to the custom input component
const CustomInputComponent = () => <div>{/* Custom input component */}</div>;
const TextFieldCustomInput = (args) => (
<TextField
size={SIZES.LG}
InputComponent={CustomInputComponent}
startAccessory={
<Icon color={COLORS.ICON_ALTERNATIVE} name={ICON_NAMES.WALLET} />
}
/>
);
```
### 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.
<Canvas>
<Story id="components-componentlibrary-textfield--auto-complete" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField type="password" autoComplete />;
```
### Auto Focus
Use the `autoFocus` prop to focus the `TextField` during the first mount
To view story see [Canvas tab](/story/components-componentlibrary-textfield--auto-complete). Removing it from docs because created annoying reading experience 😁
```jsx
import { TextField } from '../../component-library';
<TextField autoFocus />;
```
### Default Value
Use the `defaultValue` prop to set the default value of the `TextField`
<Canvas>
<Story id="components-componentlibrary-textfield--default-value" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField defaultValue="default value" />;
```
### Disabled
Use the `disabled` prop to set the disabled state of the `TextField`
<Canvas>
<Story id="components-componentlibrary-textfield--disabled" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField disabled />;
```
### Error
Use the `error` prop to set the error state of the `TextField`
<Canvas>
<Story id="components-componentlibrary-textfield--error-story" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField error />;
```
### Max Length
Use the `maxLength` prop to set the maximum allowed input characters for the `TextField`
<Canvas>
<Story id="components-componentlibrary-textfield--max-length" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField maxLength={10} />;
```
### Read Only
Use the `readOnly` prop to set the `TextField` to read only. When `readOnly` is true `TextField` will not have a focus state.
<Canvas>
<Story id="components-componentlibrary-textfield--read-only" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
<TextField readOnly />;
```
### Required
Use the `required` prop to set the `TextField` to required. Currently there is no visual difference to the `TextField` when required.
<Canvas>
<Story id="components-componentlibrary-textfield--required" />
</Canvas>
```jsx
import { TextField } from '../../component-library';
// Currently no visual difference
<TextField required />;
```

View File

@ -3,11 +3,11 @@
exports[`TextField should render correctly 1`] = `
<div>
<div
class="box mm-text-field-base mm-text-field-base--size-md mm-text-field-base--truncate mm-text-field box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
class="box mm-text-field mm-text-field--size-md mm-text-field--truncate box--display-inline-flex box--flex-direction-row box--align-items-center box--background-color-background-default box--rounded-sm box--border-width-1 box--border-style-solid"
>
<input
autocomplete="off"
class="box mm-text mm-text-field-base__input mm-text--body-md mm-text--color-text-default box--padding-right-4 box--padding-left-4 box--flex-direction-row box--background-color-transparent"
class="box mm-text mm-text-field__input mm-text--body-md mm-text--color-text-default box--padding-right-4 box--padding-left-4 box--flex-direction-row box--background-color-transparent"
focused="false"
type="text"
value=""

View File

@ -1,7 +1,13 @@
import {
TEXT_FIELD_BASE_SIZES,
TEXT_FIELD_BASE_TYPES,
} from '../text-field-base/text-field-base.constants';
import { Size } from '../../../helpers/constants/design-system';
export const TEXT_FIELD_SIZES = TEXT_FIELD_BASE_SIZES;
export const TEXT_FIELD_TYPES = TEXT_FIELD_BASE_TYPES;
export const TEXT_FIELD_SIZES = {
SM: Size.SM,
MD: Size.MD,
LG: Size.LG,
};
export const TEXT_FIELD_TYPES = {
TEXT: 'text',
NUMBER: 'number',
PASSWORD: 'password',
SEARCH: 'search',
};

View File

@ -1,81 +1,260 @@
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { Size } from '../../../helpers/constants/design-system';
import {
DISPLAY,
Size,
AlignItems,
TextVariant,
BorderRadius,
BackgroundColor,
} from '../../../helpers/constants/design-system';
import { ICON_NAMES } from '../icon';
import { ButtonIcon } from '../button-icon';
import Box from '../../ui/box';
import { TextFieldBase } from '../text-field-base';
import { Text } from '../text';
import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants';
export const TextField = ({
autoComplete,
autoFocus,
className,
showClearButton, // only works with a controlled input
clearButtonOnClick,
clearButtonProps,
rightAccessory,
defaultValue,
disabled,
error,
id,
inputProps,
value,
inputRef,
startAccessory,
endAccessory,
maxLength,
name,
onBlur,
onChange,
onClick,
onFocus,
placeholder,
readOnly,
required,
size = Size.MD,
type = 'text',
truncate = true,
value,
InputComponent = Text,
...props
}) => (
<TextFieldBase
className={classnames('mm-text-field', className)}
value={value}
onChange={onChange}
rightAccessory={
value && showClearButton ? (
<>
<ButtonIcon
className="mm-text-field__button-clear"
ariaLabel="Clear" // TODO: i18n
iconName={ICON_NAMES.CLOSE}
size={Size.SM}
onClick={clearButtonOnClick}
{...clearButtonProps}
/>
{rightAccessory}
</>
) : (
rightAccessory
)
}) => {
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);
}
inputProps={{
marginRight: showClearButton ? 6 : 0,
...inputProps,
}}
{...props}
/>
);
}, [disabled]);
const handleClick = (event) => {
const { current } = internalInputRef;
if (current) {
current.focus();
setFocused(true);
}
if (onClick && !disabled) {
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 (
<Box
className={classnames(
'mm-text-field',
`mm-text-field--size-${size}`,
{
'mm-text-field--focused': focused && !disabled,
'mm-text-field--error': error,
'mm-text-field--disabled': disabled,
'mm-text-field--truncate': truncate,
},
className,
)}
display={DISPLAY.INLINE_FLEX}
backgroundColor={BackgroundColor.backgroundDefault}
alignItems={AlignItems.center}
borderWidth={1}
borderRadius={BorderRadius.SM}
paddingLeft={startAccessory ? 4 : 0}
paddingRight={endAccessory ? 4 : 0}
onClick={handleClick}
{...props}
>
{startAccessory}
<InputComponent
aria-invalid={error}
as="input"
autoComplete={autoComplete ? 'on' : 'off'}
autoFocus={autoFocus}
backgroundColor={BackgroundColor.transparent}
defaultValue={defaultValue}
disabled={disabled}
focused={focused.toString()}
id={id}
margin={0}
maxLength={maxLength}
name={name}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
padding={0}
paddingLeft={startAccessory ? 2 : 4}
paddingRight={endAccessory ? 2 : 4}
placeholder={placeholder}
readOnly={readOnly}
ref={handleInputRef}
required={required}
value={value}
variant={TextVariant.bodyMd}
type={type}
{...inputProps} // before className so input className isn't overridden
className={classnames('mm-text-field__input', inputProps?.className)}
/>
{endAccessory}
</Box>
);
};
TextField.propTypes = {
/**
* The value af the TextField
* Autocomplete allows the browser to predict the value based on earlier typed values
*/
value: TextFieldBase.propTypes.value,
autoComplete: PropTypes.bool,
/**
* The onChange handler af the TextField
* If `true`, the input will be focused during the first mount.
*/
onChange: TextFieldBase.propTypes.onChange,
autoFocus: PropTypes.bool,
/**
* An additional className to apply to the text-field
*/
className: PropTypes.string,
/**
* Show a clear button to clear the input
* The default input value, useful when not controlling the component.
*/
showClearButton: PropTypes.bool,
defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/**
* The onClick handler for the clear button
* If `true`, the input will be disabled.
*/
clearButtonOnClick: PropTypes.func,
disabled: PropTypes.bool,
/**
* The props to pass to the clear button
* If `true`, the input will indicate an error
*/
clearButtonProps: PropTypes.shape(ButtonIcon.PropTypes),
error: PropTypes.bool,
/**
* TextField accepts all the props from TextFieldBase and Box
* The id of the `input` element.
*/
...TextFieldBase.propTypes,
id: PropTypes.string,
/**
* The the component that is rendered as the input
* Defaults to the Text component
*/
InputComponent: PropTypes.elementType,
/**
* Attributes applied to the `input` element.
*/
inputProps: PropTypes.object,
/**
* Component to appear on the left side of the input
*/
startAccessory: PropTypes.node,
/**
* Component to appear on the right side of the input
*/
endAccessory: 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 when the TextField is clicked on
*/
onClick: 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_SIZES)),
/**
* Type of the input element. Can be TEXT_FIELD_TYPES.TEXT, TEXT_FIELD_TYPES.PASSWORD, TEXT_FIELD_TYPES.NUMBER
* Defaults to TEXT_FIELD_TYPES.TEXT ('text')
*/
type: PropTypes.oneOf(Object.values(TEXT_FIELD_TYPES)),
/**
* If true will ellipse the text of the input
* Defaults to true
*/
truncate: PropTypes.bool,
/**
* The input value, required for a controlled component.
*/
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/**
* TextField accepts all the props from Box
*/
...Box.propTypes,
};
TextField.displayName = 'TextField';

View File

@ -1,5 +1,5 @@
.mm-text-field-base {
--text-field-base-height: var(--size, 40px);
.mm-text-field {
--text-field-height: var(--size, 40px);
&--size-sm {
--size: 32px;
@ -13,7 +13,7 @@
--size: 48px;
}
height: var(--text-field-base-height);
height: var(--text-field-height);
border-color: var(--color-border-default);
&--focused {
@ -31,7 +31,7 @@
}
// truncates text with ellipsis
&--truncate .mm-text-field-base__input {
&--truncate .mm-text-field__input {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@ -1,11 +1,28 @@
import React from 'react';
import React, { useState, useRef } from 'react';
import { useArgs } from '@storybook/client-api';
import PropTypes from 'prop-types';
import {
Size,
BorderRadius,
DISPLAY,
FLEX_DIRECTION,
AlignItems,
TextVariant,
IconColor,
BackgroundColor,
TextColor,
Size,
} from '../../../helpers/constants/design-system';
import Box from '../../ui/box/box';
import {
AvatarAccount,
AvatarToken,
Button,
ButtonIcon,
ICON_NAMES,
Icon,
Text,
} from '..';
import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants';
import { TextField } from './text-field';
@ -40,110 +57,73 @@ export default {
},
},
argTypes: {
value: {
autoComplete: {
control: 'boolean',
},
autoFocus: {
control: 'boolean',
},
className: {
control: 'text',
},
defaultValue: {
control: 'text',
},
disabled: {
control: 'boolean',
},
error: {
control: 'boolean',
},
id: {
control: 'text',
},
inputProps: {
control: 'object',
},
startAccessory: {
control: 'text',
},
maxLength: {
control: 'number',
},
name: {
control: 'text',
},
onBlur: {
action: 'onBlur',
},
onChange: {
action: 'onChange',
},
showClearButton: {
control: 'boolean',
},
clearButtonOnClick: {
action: 'clearButtonOnClick',
},
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: {
endAccessory: {
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' },
value: {
control: 'text',
},
marginTop: {
options: marginSizeControlOptions,
@ -167,18 +147,7 @@ export default {
},
},
args: {
showClearButton: false,
placeholder: 'Placeholder...',
autoFocus: false,
disabled: false,
error: false,
id: '',
readOnly: false,
required: false,
size: Size.MD,
type: 'text',
truncate: false,
value: '',
},
};
@ -187,36 +156,342 @@ const Template = (args) => {
const handleOnChange = (e) => {
updateArgs({ value: e.target.value });
};
const handleOnClear = () => {
updateArgs({ value: '' });
};
return (
<TextField
{...args}
value={value}
onChange={handleOnChange}
clearButtonOnClick={handleOnClear}
/>
);
return <TextField {...args} value={value} onChange={handleOnChange} />;
};
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
export const ShowClearButton = Template.bind({});
export const SizeStory = (args) => {
return (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextField
{...args}
placeholder="Size.SM (height: 32px)"
size={Size.SM}
/>
<TextField
{...args}
placeholder="Size.MD (height: 40px)"
size={Size.MD}
/>
<TextField
{...args}
placeholder="Size.LG (height: 48px)"
size={Size.LG}
/>
</Box>
);
};
SizeStory.storyName = 'Size';
ShowClearButton.args = {
placeholder: 'Enter text to show clear',
showClearButton: true,
export const Type = (args) => (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextField {...args} placeholder="Default" />
<TextField
{...args}
type={TEXT_FIELD_TYPES.PASSWORD}
placeholder="Password"
/>
<TextField {...args} type={TEXT_FIELD_TYPES.NUMBER} placeholder="Number" />
</Box>
);
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 ClearButtonProps = Template.bind({});
ClearButtonProps.args = {
value: 'clear button props',
size: Size.LG,
showClearButton: true,
clearButtonProps: {
backgroundColor: BackgroundColor.backgroundAlternative,
borderRadius: BorderRadius.XS,
},
export const StartAccessoryEndAccessory = (args) => {
const [value, setValue] = useState({
search: '',
address: '',
amount: 1,
accountAddress: '0x514910771af9ca656af840dff83e8264ecf986ca',
});
const handleOnChange = (e) => {
setValue({ ...value, [e.target.name]: e.target.value });
};
const handleTokenPrice = (tokenAmount, priceUSD) => {
return tokenAmount * priceUSD;
};
return (
<Box
display={DISPLAY.INLINE_FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
gap={4}
>
<TextField
{...args}
placeholder="Search"
value={value.search}
name="search"
onChange={handleOnChange}
startAccessory={
<Icon color={IconColor.iconAlternative} name={ICON_NAMES.SEARCH} />
}
/>
<TextField
{...args}
placeholder="Public address (0x), or ENS"
value={value.address}
name="address"
onChange={handleOnChange}
endAccessory={
<ButtonIcon
iconName={ICON_NAMES.SCAN_BARCODE}
ariaLabel="Scan QR code"
iconProps={{ color: IconColor.primaryDefault }}
/>
}
/>
<TextField
{...args}
placeholder="Enter amount"
value={value.amount}
name="amount"
onChange={handleOnChange}
type="number"
truncate
startAccessory={
<Box
as="button"
style={{ padding: 0 }}
backgroundColor={BackgroundColor.transparent}
gap={1}
display={DISPLAY.FLEX}
alignItems={AlignItems.center}
>
<AvatarToken
name="eth"
src="./images/eth_logo.svg"
size={Size.SM}
/>
<Text>ETH</Text>
<Icon
name={ICON_NAMES.ARROW_DOWN}
color={IconColor.iconDefault}
size={Size.SM}
/>
</Box>
}
endAccessory={
<Text
variant={TextVariant.bodySm}
color={TextColor.textAlternative}
style={{ whiteSpace: 'nowrap' }}
>
= ${handleTokenPrice(value.amount, 1173.58)}
</Text>
}
/>
<TextField
{...args}
placeholder="Public address (0x), or ENS"
value={value.accountAddress}
name="accountAddress"
onChange={handleOnChange}
truncate
startAccessory={
value.accountAddress && (
<AvatarAccount size={Size.SM} address={value.accountAddress} />
)
}
endAccessory={
value.accountAddress.length === 42 && (
<Icon name={ICON_NAMES.CHECK} color={IconColor.successDefault} />
)
}
/>
</Box>
);
};
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 (
<Box display={DISPLAY.FLEX}>
<TextField
{...args}
inputRef={inputRef}
value={value}
onChange={handleOnChange}
/>
<Button marginLeft={1} onClick={handleOnClick}>
Edit
</Button>
</Box>
);
};
const CustomInputComponent = React.forwardRef(
(
{
as,
autoComplete,
autoFocus,
defaultValue,
disabled,
focused,
id,
inputProps,
inputRef,
maxLength,
name,
onBlur,
onChange,
onFocus,
padding,
paddingLeft,
paddingRight,
placeholder,
readOnly,
required,
value,
variant,
type,
className,
'aria-invalid': ariaInvalid,
...props
},
ref,
) => (
<Box
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
ref={ref}
{...{ padding, paddingLeft, paddingRight, ...props }}
>
<Box display={DISPLAY.INLINE_FLEX}>
<Text
style={{ padding: 0 }}
aria-invalid={ariaInvalid}
ref={inputRef}
{...{
className,
as,
autoComplete,
autoFocus,
defaultValue,
disabled,
focused,
id,
maxLength,
name,
onBlur,
onChange,
onFocus,
placeholder,
readOnly,
required,
value,
variant,
type,
...inputProps,
}}
/>
<Text variant={TextVariant.bodyXs} color={TextColor.textAlternative}>
GoerliETH
</Text>
</Box>
<Text variant={TextVariant.bodyXs}>No conversion rate available</Text>
</Box>
),
);
CustomInputComponent.propTypes = {
/**
* The custom input component should accepts all props that the
* InputComponent accepts in ./text-field.js
*/
autoFocus: PropTypes.bool,
className: PropTypes.string,
defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
id: PropTypes.string,
inputProps: PropTypes.object,
inputRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
maxLength: PropTypes.number,
name: PropTypes.string,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
placeholder: PropTypes.string,
readOnly: PropTypes.bool,
required: PropTypes.bool,
type: PropTypes.oneOf(Object.values(TEXT_FIELD_TYPES)),
/**
* Because we manipulate the type in TextField so the html element
* receives the correct attribute we need to change the autoComplete
* propType to a string
*/
autoComplete: PropTypes.string,
/**
* The custom input component should also accept all the props from Box
*/
...Box.propTypes,
};
CustomInputComponent.displayName = 'CustomInputComponent';
export const InputComponent = (args) => (
<TextField
{...args}
placeholder="0"
type="number"
size={Size.LG}
InputComponent={CustomInputComponent}
startAccessory={
<Icon color={IconColor.iconAlternative} name={ICON_NAMES.WALLET} />
}
/>
);
InputComponent.args = { autoComplete: true };
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' };

View File

@ -1,11 +1,11 @@
/* eslint-disable jest/require-top-level-describe */
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { renderWithUserEvent } from '../../../../test/lib/render-helpers';
import {
renderControlledInput,
renderWithUserEvent,
} from '../../../../test/lib/render-helpers';
import { Size } from '../../../helpers/constants/design-system';
import Box from '../../ui/box';
import { TextField } from './text-field';
@ -15,103 +15,234 @@ describe('TextField', () => {
expect(getByRole('textbox')).toBeDefined();
expect(container).toMatchSnapshot();
});
it('should render and be able to input text', async () => {
const { user, getByRole } = renderWithUserEvent(<TextField />);
const textField = getByRole('textbox');
await user.type(textField, 'text value');
expect(textField).toHaveValue('text value');
it('should render and be able to input text', () => {
const { getByTestId } = render(
<TextField inputProps={{ 'data-testid': 'text-field' }} />,
);
const textField = getByTestId('text-field');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.change(textField, { target: { value: '' } }); // reset value
expect(textField.value).toBe(''); // value is empty string after reset
});
it('should render and fire onFocus and onBlur events', () => {
it('should render with focused state when clicked', async () => {
const { getByTestId, user } = renderWithUserEvent(
<TextField
data-testid="text-field"
inputProps={{ 'data-testid': 'input' }}
/>,
);
const textField = getByTestId('input');
await user.click(textField);
expect(getByTestId('input')).toHaveFocus();
expect(getByTestId('text-field')).toHaveClass('mm-text-field--focused ');
});
it('should render and fire onFocus and onBlur events', async () => {
const onFocus = jest.fn();
const onBlur = jest.fn();
const { getByTestId } = render(
const { getByTestId, user } = renderWithUserEvent(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onFocus={onFocus}
onBlur={onBlur}
/>,
);
const textField = getByTestId('text-field');
fireEvent.focus(textField);
const textField = getByTestId('text-field');
await user.click(textField);
expect(onFocus).toHaveBeenCalledTimes(1);
fireEvent.blur(textField);
expect(onBlur).toHaveBeenCalledTimes(1);
});
it('should render and fire onChange event', async () => {
const onChange = jest.fn();
const { user, getByRole } = renderWithUserEvent(
const { getByTestId, user } = renderWithUserEvent(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onChange={onChange}
/>,
);
const textField = getByRole('textbox');
const textField = getByTestId('text-field');
await user.type(textField, '123');
expect(textField).toHaveValue('123');
expect(onChange).toHaveBeenCalledTimes(3);
});
it('should render and fire onClick event', async () => {
const onClick = jest.fn();
const { user, getByTestId } = renderWithUserEvent(
<TextField data-testid="text-field" onClick={onClick} />,
const { getByTestId, user } = renderWithUserEvent(
<TextField
inputProps={{ 'data-testid': 'text-field' }}
onClick={onClick}
/>,
);
await user.click(getByTestId('text-field'));
const textField = getByTestId('text-field');
await user.click(textField);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('should render with the rightAccessory', () => {
const { getByText } = render(
<TextField rightAccessory={<div>right-accessory</div>} />,
);
expect(getByText('right-accessory')).toBeDefined();
});
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(TextField, {
showClearButton: true,
});
await user.type(getByRole('textbox'), 'test value');
expect(getByRole('textbox')).toHaveValue('test value');
expect(getByRole('button', { name: /Clear/u })).toBeDefined();
});
it('should still render with the rightAccessory when showClearButton is true', async () => {
// As showClearButton is intended to be used with a controlled input we need to use renderControlledInput
const { user, getByRole, getByText } = renderControlledInput(TextField, {
showClearButton: true,
rightAccessory: <div>right-accessory</div>,
});
await user.type(getByRole('textbox'), 'test value');
expect(getByRole('textbox')).toHaveValue('test value');
expect(getByRole('button', { name: /Clear/u })).toBeDefined();
expect(getByText('right-accessory')).toBeDefined();
});
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(TextField, {
showClearButton: true,
clearButtonOnClick: fn,
});
await user.type(getByRole('textbox'), 'test value');
await user.click(getByRole('button', { name: /Clear/u }));
expect(fn).toHaveBeenCalledTimes(1);
});
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(TextField, {
showClearButton: true,
clearButtonProps: { onClick: fn },
});
await user.type(getByRole('textbox'), 'test value');
await user.click(getByRole('button', { name: /Clear/u }));
expect(fn).toHaveBeenCalledTimes(1);
});
it('should be able to accept inputProps', () => {
it('should render with different size classes', () => {
const { getByTestId } = render(
<TextField inputProps={{ 'data-testid': 'text-field' }} />,
<>
<TextField size={Size.SM} data-testid="sm" />
<TextField size={Size.MD} data-testid="md" />
<TextField size={Size.LG} data-testid="lg" />
</>,
);
expect(getByTestId('text-field')).toBeDefined();
expect(getByTestId('sm')).toHaveClass('mm-text-field--size-sm');
expect(getByTestId('md')).toHaveClass('mm-text-field--size-md');
expect(getByTestId('lg')).toHaveClass('mm-text-field--size-lg');
});
it('should render with different types', () => {
const { getByTestId } = render(
<>
<TextField inputProps={{ 'data-testid': 'text-field-text' }} />
<TextField
type="number"
inputProps={{ 'data-testid': 'text-field-number' }}
/>
<TextField
type="password"
inputProps={{ 'data-testid': 'text-field-password' }}
/>
</>,
);
expect(getByTestId('text-field-text')).toHaveAttribute('type', 'text');
expect(getByTestId('text-field-number')).toHaveAttribute('type', 'number');
expect(getByTestId('text-field-password')).toHaveAttribute(
'type',
'password',
);
});
it('should render with truncate class as true by default and remove it when truncate is false', () => {
const { getByTestId } = render(
<>
<TextField data-testid="truncate" />
<TextField truncate={false} data-testid="no-truncate" />
</>,
);
expect(getByTestId('truncate')).toHaveClass('mm-text-field--truncate');
expect(getByTestId('no-truncate')).not.toHaveClass(
'mm-text-field--truncate',
);
});
it('should render with right and left accessories', () => {
const { getByRole, getByText } = render(
<TextField
startAccessory={<div>start accessory</div>}
endAccessory={<div>end accessory</div>}
/>,
);
expect(getByRole('textbox')).toBeDefined();
expect(getByText('start accessory')).toBeDefined();
expect(getByText('end 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(<TextField inputRef={mockRef} />);
expect(getByRole('textbox')).toBeDefined();
expect(mockRef).toHaveBeenCalledTimes(1);
});
it('should render with autoComplete', () => {
const { getByTestId } = render(
<TextField
autoComplete
inputProps={{ 'data-testid': 'text-field-auto-complete' }}
/>,
);
expect(getByTestId('text-field-auto-complete')).toHaveAttribute(
'autocomplete',
'on',
);
});
it('should render with autoFocus', () => {
const { getByRole } = render(<TextField autoFocus />);
expect(getByRole('textbox')).toHaveFocus();
});
it('should render with a defaultValue', () => {
const { getByRole } = render(
<TextField
defaultValue="default value"
inputProps={{ 'data-testid': 'text-field-default-value' }}
/>,
);
expect(getByRole('textbox').value).toBe('default value');
});
it('should render in disabled state and not focus or be clickable', async () => {
const mockOnClick = jest.fn();
const mockOnFocus = jest.fn();
const { getByRole, getByTestId, user } = renderWithUserEvent(
<TextField
disabled
onFocus={mockOnFocus}
onClick={mockOnClick}
data-testid="text-field"
/>,
);
const textField = getByTestId('text-field');
await user.click(textField);
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(
<TextField error data-testid="text-field-error" />,
);
expect(getByTestId('text-field-error')).toHaveClass('mm-text-field--error');
});
it('should render with maxLength and not allow more than the set characters', async () => {
const { getByRole, user } = renderWithUserEvent(
<TextField maxLength={5} />,
);
const textField = getByRole('textbox');
await user.type(textField, '1234567890');
expect(getByRole('textbox')).toBeDefined();
expect(textField.maxLength).toBe(5);
expect(textField.value).toBe('12345');
expect(textField.value).toHaveLength(5);
});
it('should render with readOnly attr when readOnly is true', async () => {
const { getByTestId, getByRole, user } = renderWithUserEvent(
<TextField readOnly data-testid="read-only" />,
);
const textField = getByTestId('read-only');
await user.type(textField, '1234567890');
expect(getByRole('textbox').value).toBe('');
expect(getByRole('textbox')).toHaveAttribute('readonly', '');
});
it('should render with required attr when required is true', () => {
const { getByTestId } = render(
<TextField
required
inputProps={{ 'data-testid': 'text-field-required' }}
/>,
);
expect(getByTestId('text-field-required')).toHaveAttribute('required', '');
});
it('should render with a custom input and still work', async () => {
const CustomInputComponent = React.forwardRef((props, ref) => (
<Box ref={ref} as="input" {...props} />
));
CustomInputComponent.displayName = 'CustomInputComponent'; // fixes eslint error
const { getByTestId, user } = renderWithUserEvent(
<TextField
InputComponent={CustomInputComponent}
inputProps={{ 'data-testid': 'text-field', className: 'test' }}
/>,
);
const textField = getByTestId('text-field');
expect(textField.value).toBe(''); // initial value is empty string
await user.type(textField, '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
});
});