`TextField` component updates (#16424)

* Updating showClearButton prop name and making component dumber

* Docs update

* Adding missing proptype

* Fixing casing on tests

* Replacing clear button placeholder with ButtonIcon and docs update

* Fixing linting issues

* Adding note about controlled only for showClearButton to work and fixing some tests

* Updating test to include controlled testing setup function for clearButton tests
feature/default_network_editable
George Marshall 2 years ago committed by GitHub
parent 4b08d2ecf8
commit 9821c59e11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 62
      ui/components/component-library/text-field/README.mdx
  2. 121
      ui/components/component-library/text-field/text-field.js
  3. 83
      ui/components/component-library/text-field/text-field.stories.js
  4. 188
      ui/components/component-library/text-field/text-field.test.js

@ -16,58 +16,78 @@ The `TextField` accepts all props below as well as all [Box](/docs/ui-components
<ArgsTable of={TextField} /> <ArgsTable of={TextField} />
### Show Clear ### Show Clear Button
Use the `showClear` prop to display a clear button when `TextField` has a value. Clicking the button will clear the value. 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.
You can also attach an `onClear` handler to the `TextField` to perform additional actions when the clear button is clicked.
The clear button uses [ButtonIcon](/docs/ui-components-component-library-button-icon-button-icon-stories-js--default-story) and accepts all props from that component.
**NOTE: The `showClearButton` only works with a controlled input.**
<Canvas> <Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--show-clear" /> <Story id="ui-components-component-library-text-field-text-field-stories-js--show-clear-button" />
</Canvas> </Canvas>
```jsx ```jsx
import { TextField } from '../../ui/component-library/text-field'; import { TextField } from '../../ui/component-library/text-field';
<TextField showClear />; const [value, setValue] = useState('show clear');
```
### On Clear
Use the `onClear` prop to perform additional actions when the clear button is clicked. const handleOnChange = (e) => {
setValue(e.target.value);
};
<Canvas> const handleOnClear = () => {
<Story id="ui-components-component-library-text-field-text-field-stories-js--on-clear" /> setValue('');
</Canvas> };
```jsx <TextField
import { TextField } from '../../ui/component-library/text-field'; placeholder="Enter text to show clear"
value={value}
<TextField showClear onClear={() => console.log('cleared input')} />; onChange={handleOnChange}
showClearButton
clearButtonOnClick={handleOnClear}
/>;
``` ```
### Clear Button Props and Clear Button Icon Props ### Clear Button Props
Use the `clearButtonProps` and `clearButtonIconProps` props to pass props to the clear button and clear button icon respectively. Use the `clearButtonProps` to access other props of the clear button.
<Canvas> <Canvas>
<Story id="ui-components-component-library-text-field-text-field-stories-js--clear-button-props-clear-button-icon-props" /> <Story id="ui-components-component-library-text-field-text-field-stories-js--clear-button-props" />
</Canvas> </Canvas>
```jsx ```jsx
import React, { useState } from 'react';
import { import {
SIZES, SIZES,
COLORS, COLORS,
BORDER_RADIUS, BORDER_RADIUS,
} from '../../../helpers/constants/design-system'; } from '../../../helpers/constants/design-system';
import { TextField } from '../../ui/component-library/text-field'; import { TextField } from '../../ui/component-library/text-field';
const [value, setValue] = useState('show clear');
const handleOnChange = (e) => {
setValue(e.target.value);
};
const handleOnClear = () => {
setValue('');
};
<TextField <TextField
showClear placeholder="Enter text to show clear"
value={value}
onChange={handleOnChange}
showClearButton
clearButtonOnClick={handleOnClear}
clearButtonProps={{ clearButtonProps={{
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE, backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
borderRadius: BORDER_RADIUS.XS, borderRadius: BORDER_RADIUS.XS,
'data-testid': 'clear-button', 'data-testid': 'clear-button',
}} }}
clearButtonIconProps={{ size: SIZES.MD }}
/>; />;
``` ```

@ -1,86 +1,65 @@
import React, { useState } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { import { SIZES } from '../../../helpers/constants/design-system';
SIZES,
DISPLAY,
JUSTIFY_CONTENT,
ALIGN_ITEMS,
COLORS,
} from '../../../helpers/constants/design-system';
import Box from '../../ui/box'; import Box from '../../ui/box';
import { Icon, ICON_NAMES } from '../icon'; import { ICON_NAMES } from '../icon';
import { ButtonIcon } from '../button-icon';
import { TextFieldBase } from '../text-field-base'; import { TextFieldBase } from '../text-field-base';
export const TextField = ({ export const TextField = ({
className, className,
showClear, showClearButton, // only works with a controlled input
clearButtonIconProps, clearButtonOnClick,
clearButtonProps, clearButtonProps,
rightAccessory, rightAccessory,
value: valueProp,
onChange,
onClear,
inputProps, inputProps,
value,
onChange,
...props ...props
}) => { }) => (
const [value, setValue] = useState(valueProp || ''); <TextFieldBase
const handleOnChange = (e) => { className={classnames('mm-text-field', className)}
setValue(e.target.value); value={value}
onChange?.(e); onChange={onChange}
}; rightAccessory={
const handleClear = (e) => { value && showClearButton ? (
setValue(''); <>
clearButtonProps?.onClick?.(e); <ButtonIcon
onClear?.(e); className="mm-text-field__button-clear"
}; ariaLabel="Clear" // TODO: i18n
return ( icon={ICON_NAMES.CLOSE_OUTLINE}
<TextFieldBase size={SIZES.SM}
className={classnames('mm-text-field', className)} onClick={clearButtonOnClick}
value={value} {...clearButtonProps}
onChange={handleOnChange} />
rightAccessory={ {rightAccessory}
value && showClear ? ( </>
<> ) : (
{/* replace with ButtonIcon */} rightAccessory
<Box )
className="mm-text-field__button-clear" }
as="button" inputProps={{
display={DISPLAY.FLEX} marginRight: showClearButton ? 6 : 0,
alignItems={ALIGN_ITEMS.CENTER} ...inputProps,
justifyContent={JUSTIFY_CONTENT.CENTER} }}
backgroundColor={COLORS.TRANSPARENT} {...props}
padding={0} />
{...clearButtonProps} // don't override onClick );
onClick={handleClear}
>
<Icon
name={ICON_NAMES.CLOSE_OUTLINE}
size={SIZES.SM}
aria-label="Clear" // TODO: i18n
{...clearButtonIconProps}
/>
</Box>
{rightAccessory}
</>
) : (
rightAccessory
)
}
inputProps={{
marginRight: showClear ? 6 : 0,
...inputProps,
}}
{...props}
/>
);
};
TextField.propTypes = { TextField.propTypes = {
/**
* The value af the TextField
*/
value: TextFieldBase.propTypes.value.isRequired,
/**
* The onChange handler af the TextField
*/
onChange: TextFieldBase.propTypes.onChange.isRequired,
/** /**
* An additional className to apply to the text-field * An additional className to apply to the text-field
*/ */
@ -88,19 +67,15 @@ TextField.propTypes = {
/** /**
* Show a clear button to clear the input * Show a clear button to clear the input
*/ */
showClear: PropTypes.bool, showClearButton: PropTypes.bool,
/** /**
* The event handler for when the clear button is clicked * The onClick handler for the clear button
*/ */
onClear: PropTypes.func, clearButtonOnClick: PropTypes.func,
/** /**
* The props to pass to the clear button * The props to pass to the clear button
*/ */
clearButtonProps: PropTypes.shape(Box.PropTypes), clearButtonProps: PropTypes.shape(Box.PropTypes),
/**
* The props to pass to the icon inside of the close button
*/
clearButtonIconProps: PropTypes.shape(Icon.PropTypes),
/** /**
* TextField accepts all the props from TextFieldBase and Box * TextField accepts all the props from TextFieldBase and Box
*/ */

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React from 'react';
import { useArgs } from '@storybook/client-api';
import { import {
SIZES, SIZES,
@ -6,8 +7,6 @@ import {
BORDER_RADIUS, BORDER_RADIUS,
} from '../../../helpers/constants/design-system'; } from '../../../helpers/constants/design-system';
import { Text } from '../text';
import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants'; import { TEXT_FIELD_SIZES, TEXT_FIELD_TYPES } from './text-field.constants';
import { TextField } from './text-field'; import { TextField } from './text-field';
@ -41,21 +40,17 @@ export default {
}, },
}, },
argTypes: { argTypes: {
showClear: {
control: 'boolean',
},
value: { value: {
control: 'text', control: 'text',
}, },
onChange: { onChange: {
action: 'onChange', action: 'onChange',
table: { category: 'text field base props' },
}, },
onClear: { showClearButton: {
action: 'onClear', control: 'boolean',
}, },
clearButtonIconProps: { clearButtonOnClick: {
control: 'object', action: 'clearButtonOnClick',
}, },
clearButtonProps: { clearButtonProps: {
control: 'object', control: 'object',
@ -172,7 +167,7 @@ export default {
}, },
}, },
args: { args: {
showClear: false, showClearButton: false,
placeholder: 'Placeholder...', placeholder: 'Placeholder...',
autoFocus: false, autoFocus: false,
disabled: false, disabled: false,
@ -186,62 +181,48 @@ export default {
}, },
}; };
const Template = (args) => <TextField {...args} />; const Template = (args) => {
const [{ value }, updateArgs] = useArgs();
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
export const ShowClear = (args) => {
const [value, setValue] = useState('show clear');
const handleOnChange = (e) => { const handleOnChange = (e) => {
setValue(e.target.value); updateArgs({ value: e.target.value });
};
const handleOnClear = () => {
updateArgs({ value: '' });
}; };
return ( return (
<TextField <TextField
{...args} {...args}
placeholder="Enter text to show clear"
value={value} value={value}
onChange={handleOnChange} onChange={handleOnChange}
showClear clearButtonOnClick={handleOnClear}
/> />
); );
}; };
export const OnClear = (args) => { export const DefaultStory = Template.bind({});
const [value, setValue] = useState('onClear example'); DefaultStory.storyName = 'Default';
const [showOnClearMessage, setShowOnClearMessage] = useState(false);
const handleOnChange = (e) => { export const ShowClearButton = Template.bind({});
setValue(e.target.value);
showOnClearMessage && setShowOnClearMessage(false); ShowClearButton.args = {
}; placeholder: 'Enter text to show clear',
const handleOnClear = () => { showClearButton: true,
setShowOnClearMessage(true); };
};
return ( export const ClearButtonOnClick = Template.bind({});
<>
<TextField ShowClearButton.args = {
{...args} placeholder: 'Enter text to show clear',
placeholder="Clear text to show onClear message" showClearButton: true,
value={value}
onChange={handleOnChange}
onClear={handleOnClear}
showClear
/>
{showOnClearMessage && <Text marginTop={4}>onClear called</Text>}
</>
);
}; };
export const ClearButtonPropsClearButtonIconProps = Template.bind({}); export const ClearButtonProps = Template.bind({});
ClearButtonPropsClearButtonIconProps.args = { ClearButtonProps.args = {
value: 'clear button props', value: 'clear button props',
size: SIZES.LG, size: SIZES.LG,
showClear: true, showClearButton: true,
clearButtonProps: { clearButtonProps: {
backgroundColor: COLORS.BACKGROUND_ALTERNATIVE, backgroundColor: COLORS.BACKGROUND_ALTERNATIVE,
borderRadius: BORDER_RADIUS.XS, borderRadius: BORDER_RADIUS.XS,
}, },
clearButtonIconProps: {
size: SIZES.MD,
},
}; };

@ -1,25 +1,45 @@
/* eslint-disable jest/require-top-level-describe */ /* eslint-disable jest/require-top-level-describe */
import React from 'react'; import React, { useState } from 'react';
import { fireEvent, render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TextField } from './text-field'; import { TextField } from './text-field';
// userEvent setup function as per testing-library docs
// https://testing-library.com/docs/user-event/intr
function setup(jsx) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
// Custom userEvent setup function that renders the component in a controlled environment.
// This is used for the showClearButton and related props as the clearButton will only show in a controlled environment.
function setupControlled(FormComponent, props) {
const ControlledWrapper = () => {
const [value, setValue] = useState('');
return (
<FormComponent
value={value}
onChange={(e) => setValue(e.target.value)}
{...props}
/>
);
};
return { user: userEvent.setup(), ...render(<ControlledWrapper />) };
}
describe('TextField', () => { describe('TextField', () => {
it('should render correctly', () => { it('should render correctly', () => {
const { getByRole } = render(<TextField />); const { getByRole } = render(<TextField />);
expect(getByRole('textbox')).toBeDefined(); expect(getByRole('textbox')).toBeDefined();
}); });
it('should render and be able to input text', () => { it('should render and be able to input text', async () => {
const { getByTestId } = render( const { user, getByRole } = setup(<TextField />);
<TextField inputProps={{ 'data-testid': 'text-field' }} />, const textField = getByRole('textbox');
); await user.type(textField, 'text value');
const textField = getByTestId('text-field'); expect(textField).toHaveValue('text value');
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 and fire onFocus and onBlur events', () => {
const onFocus = jest.fn(); const onFocus = jest.fn();
@ -38,124 +58,80 @@ describe('TextField', () => {
fireEvent.blur(textField); fireEvent.blur(textField);
expect(onBlur).toHaveBeenCalledTimes(1); expect(onBlur).toHaveBeenCalledTimes(1);
}); });
it('should render and fire onChange event', () => { it('should render and fire onChange event', async () => {
const onChange = jest.fn(); const onChange = jest.fn();
const { getByTestId } = render( const { user, getByRole } = setup(
<TextField <TextField
inputProps={{ 'data-testid': 'text-field' }} inputProps={{ 'data-testid': 'text-field' }}
onChange={onChange} onChange={onChange}
/>, />,
); );
const textField = getByTestId('text-field'); const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } }); await user.type(textField, '123');
expect(onChange).toHaveBeenCalledTimes(1); expect(textField).toHaveValue('123');
expect(onChange).toHaveBeenCalledTimes(3);
}); });
it('should render and fire onClick event', () => { it('should render and fire onClick event', async () => {
const onClick = jest.fn(); const onClick = jest.fn();
const { getByTestId } = render( const { user, getByTestId } = setup(
<TextField <TextField data-testid="text-field" onClick={onClick} />,
inputProps={{ 'data-testid': 'text-field' }}
onClick={onClick}
/>,
); );
const textField = getByTestId('text-field'); await user.click(getByTestId('text-field'));
fireEvent.click(textField);
expect(onClick).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1);
}); });
it('should render showClear button when showClear is true and value exists', () => {
const { getByRole, getByTestId } = render(
<TextField
clearButtonProps={{ 'data-testid': 'clear-button' }}
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }}
showClear
/>,
);
const textField = getByRole('textbox');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
expect(getByTestId('clear-button')).toBeDefined();
expect(getByTestId('clear-button-icon')).toBeDefined();
});
it('should render with the rightAccessory', () => { it('should render with the rightAccessory', () => {
const { getByText } = render( const { getByText } = render(
<TextField rightAccessory={<div>right-accessory</div>} />, <TextField rightAccessory={<div>right-accessory</div>} />,
); );
expect(getByText('right-accessory')).toBeDefined(); expect(getByText('right-accessory')).toBeDefined();
}); });
it('should still render with the rightAccessory when showClear is true', () => { it('should render showClearButton button when showClearButton is true and value exists', async () => {
const { getByRole, getByTestId, getByText } = render( // As showClearButton is intended to be used with a controlled input we need to use setupControlled
<TextField const { user, getByRole } = setupControlled(TextField, {
clearButtonProps={{ 'data-testid': 'clear-button' }} showClearButton: true,
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }} });
rightAccessory={<div>right-accessory</div>} await user.type(getByRole('textbox'), 'test value');
showClear expect(getByRole('textbox')).toHaveValue('test value');
/>, expect(getByRole('button', { name: /Clear/u })).toBeDefined();
);
const textField = getByRole('textbox');
expect(textField.value).toBe(''); // initial value is empty string
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
expect(getByTestId('clear-button')).toBeDefined();
expect(getByTestId('clear-button-icon')).toBeDefined();
expect(getByText('right-accessory')).toBeDefined();
}); });
it('should clear text when clear button is clicked', () => { it('should still render with the rightAccessory when showClearButton is true', async () => {
const { getByRole, getByTestId } = render( // As showClearButton is intended to be used with a controlled input we need to use setupControlled
<TextField const { user, getByRole, getByText } = setupControlled(TextField, {
clearButtonProps={{ 'data-testid': 'clear-button' }} showClearButton: true,
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }} rightAccessory: <div>right-accessory</div>,
rightAccessory={<div>right-accessory</div>} });
showClear await user.type(getByRole('textbox'), 'test value');
/>, expect(getByRole('textbox')).toHaveValue('test value');
); expect(getByRole('button', { name: /Clear/u })).toBeDefined();
const textField = getByRole('textbox'); expect(getByText('right-accessory')).toBeDefined();
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(textField.value).toBe('');
}); });
it('should fire onClear event when passed to onClear prop', () => { it('should fire onClick event when passed to clearButtonOnClick when clear button is clicked', async () => {
const onClear = jest.fn(); // As showClearButton is intended to be used with a controlled input we need to use setupControlled
const { getByRole, getByTestId } = render( const fn = jest.fn();
<TextField const { user, getByRole } = setupControlled(TextField, {
onClear={onClear} showClearButton: true,
clearButtonProps={{ 'data-testid': 'clear-button' }} clearButtonOnClick: fn,
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }} });
showClear await user.type(getByRole('textbox'), 'test value');
/>, await user.click(getByRole('button', { name: /Clear/u }));
); expect(fn).toHaveBeenCalledTimes(1);
const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
}); });
it('should fire clearButtonProps.onClick event when passed to clearButtonProps.onClick prop', () => { it('should fire onClick event when passed to clearButtonProps.onClick prop', async () => {
const onClear = jest.fn(); // As showClearButton is intended to be used with a controlled input we need to use setupControlled
const onClick = jest.fn(); const fn = jest.fn();
const { getByRole, getByTestId } = render( const { user, getByRole } = setupControlled(TextField, {
<TextField showClearButton: true,
onClear={onClear} clearButtonProps: { onClick: fn },
clearButtonProps={{ 'data-testid': 'clear-button', onClick }} });
clearButtonIconProps={{ 'data-testid': 'clear-button-icon' }} await user.type(getByRole('textbox'), 'test value');
showClear await user.click(getByRole('button', { name: /Clear/u }));
/>, expect(fn).toHaveBeenCalledTimes(1);
);
const textField = getByRole('textbox');
fireEvent.change(textField, { target: { value: 'text value' } });
expect(textField.value).toBe('text value');
fireEvent.click(getByTestId('clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
}); });
it('should be able to accept inputProps', () => { it('should be able to accept inputProps', () => {
const { getByRole } = render( const { getByTestId } = render(
<TextField inputProps={{ 'data-testid': 'text-field' }} />, <TextField inputProps={{ 'data-testid': 'text-field' }} />,
); );
const textField = getByRole('textbox'); expect(getByTestId('text-field')).toBeDefined();
expect(textField).toBeDefined();
}); });
}); });

Loading…
Cancel
Save