From 65bd36054a36c95002d37ca1926b6f8fcd179fb5 Mon Sep 17 00:00:00 2001 From: thyhjwb6 <56078793+thyhjwb6@users.noreply.github.com> Date: Fri, 25 Jun 2021 14:52:33 +0100 Subject: [PATCH] feat(TextAreaE): Create component --- .../TextAreaE/TextAreaE.stories.tsx | 33 +++ .../components/TextAreaE/TextAreaE.test.tsx | 201 ++++++++++++++++++ .../src/components/TextAreaE/TextAreaE.tsx | 86 ++++++++ .../src/components/TextAreaE/index.ts | 1 + .../TextAreaE/partials/StyledInput.tsx | 24 +++ .../TextAreaE/partials/StyledLabel.tsx | 25 +++ .../TextAreaE/partials/StyledOuterWrapper.tsx | 14 ++ .../TextAreaE/partials/StyledTextArea.tsx | 11 + packages/react-component-library/src/index.ts | 1 + 9 files changed, 396 insertions(+) create mode 100644 packages/react-component-library/src/components/TextAreaE/TextAreaE.stories.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/TextAreaE.test.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/TextAreaE.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/index.ts create mode 100644 packages/react-component-library/src/components/TextAreaE/partials/StyledInput.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/partials/StyledLabel.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/partials/StyledOuterWrapper.tsx create mode 100644 packages/react-component-library/src/components/TextAreaE/partials/StyledTextArea.tsx diff --git a/packages/react-component-library/src/components/TextAreaE/TextAreaE.stories.tsx b/packages/react-component-library/src/components/TextAreaE/TextAreaE.stories.tsx new file mode 100644 index 0000000000..22cb2029dd --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/TextAreaE.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { Story, Meta } from '@storybook/react/types-6-0' + +import { TextAreaE, TextAreaEProps } from '.' + +export default { + component: TextAreaE, + title: 'Text Area (Experimental)', + parameters: { + actions: { argTypesRegex: '^on.*' }, + }, +} as Meta + +export const Default: Story = (props) => ( + +) + +Default.args = { + name: 'textarea-default', + label: 'Example label', +} + +export const Disabled: Story = (props) => ( + +) + +Disabled.storyName = 'Disabled' + +export const WithError: Story = (props) => ( + +) + +WithError.storyName = 'With error' diff --git a/packages/react-component-library/src/components/TextAreaE/TextAreaE.test.tsx b/packages/react-component-library/src/components/TextAreaE/TextAreaE.test.tsx new file mode 100644 index 0000000000..7e49f374bf --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/TextAreaE.test.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { render, RenderResult } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { FieldProps } from '../../common/FieldProps' +import { FormProps } from '../../common/FormProps' +import { TextAreaE } from '.' +import { withFormik } from '../../enhancers/withFormik' + +describe('TextArea', () => { + let onBlurSpy: (event: React.FormEvent) => void + let onChangeSpy: (event: React.ChangeEvent) => void + let wrapper: RenderResult + + describe('all props', () => { + beforeEach(() => { + onBlurSpy = jest.fn() + onChangeSpy = jest.fn() + + wrapper = render( + + ) + }) + + it('adds the CSS modifier', () => { + expect(wrapper.getByTestId('textarea-container').classList).toContain( + 'rn-textarea--modifier' + ) + }) + + it('sets the `id` on the input', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveAttribute('id', 'id') + }) + + it('renders the label', () => { + expect(wrapper.getByText('label')).toBeInTheDocument() + }) + + it('should associate the label to the field with the custom id', () => { + expect(wrapper.getByTestId('textarea-label')).toHaveAttribute('for', 'id') + }) + + it('sets the `name` on the input', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveAttribute( + 'name', + 'name' + ) + }) + + it('sets the `value` on the input', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveValue('ab') + }) + + it('drills arbitrary props to the input', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveAttribute( + 'data-arbitrary', + '123' + ) + }) + + describe('when the text area loses focus', () => { + beforeEach(() => { + wrapper.getByTestId('textarea-input').focus() + wrapper.getByTestId('textarea-input').blur() + }) + + it('should call the onBlur callback once', () => { + expect(onBlurSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('when the value is changed', () => { + beforeEach(async () => { + await userEvent.type( + wrapper.getByTestId('textarea-input'), + '{backspace}' + ) + }) + + it('should update the value', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveValue('a') + }) + }) + + describe('when the value is deleted', () => { + beforeEach(async () => { + await userEvent.type( + wrapper.getByTestId('textarea-input'), + '{backspace}{backspace}' + ) + }) + + it('should remove the `has-content` to the container', () => { + expect( + wrapper.getByTestId('textarea-container').classList + ).not.toContain('has-content') + }) + + it('should update the value', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveValue('') + }) + }) + }) + + describe('when a Formik enhanced field has an error', () => { + let field: FieldProps + let form: FormProps + + describe('and the form has not been touched', () => { + beforeEach(() => { + field = { + name: 'colour', + value: '', + onBlur: null, + onChange: jest.fn(), + } + form = { + errors: { + colour: 'Something bad', + }, + touched: {}, + } + + const FormikTextArea = withFormik(TextAreaE) + + wrapper = render() + }) + + it('should add the aria attributes', () => { + expect(wrapper.getByTestId('textarea-input')).not.toHaveAttribute( + 'aria-invalid' + ) + expect(wrapper.getByTestId('textarea-input')).not.toHaveAttribute( + 'aria-describedby' + ) + }) + + it('should not indicate the field has an error', () => { + expect(wrapper.queryByTestId('textarea-container')).not.toHaveClass( + 'is-invalid' + ) + }) + + it('should not show the error', () => { + expect(wrapper.queryAllByText('Something bad')).toHaveLength(0) + }) + }) + + describe('and the form has been touched', () => { + beforeEach(() => { + field = { + name: 'colour', + value: '', + onBlur: null, + onChange: jest.fn(), + } + form = { + errors: { + colour: 'Something bad', + }, + touched: { + colour: true, + }, + } + + const FormikTextArea = withFormik(TextAreaE) + + wrapper = render() + }) + + it('should add the aria attributes', () => { + expect(wrapper.getByTestId('textarea-input')).toHaveAttribute( + 'aria-invalid' + ) + expect(wrapper.getByTestId('textarea-input')).toHaveAttribute( + 'aria-describedby' + ) + }) + + it('should indicate the field has an error', () => { + expect(wrapper.queryByTestId('textarea-container')).toHaveClass( + 'is-invalid' + ) + }) + + it('should show the error', () => { + expect(wrapper.getByText('Something bad')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/packages/react-component-library/src/components/TextAreaE/TextAreaE.tsx b/packages/react-component-library/src/components/TextAreaE/TextAreaE.tsx new file mode 100644 index 0000000000..9970c7bbed --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/TextAreaE.tsx @@ -0,0 +1,86 @@ +import React, { TextareaHTMLAttributes } from 'react' + +import { ComponentWithClass } from '../../common/ComponentWithClass' +import { StyledTextArea } from './partials/StyledTextArea' +import { StyledOuterWrapper } from './partials/StyledOuterWrapper' +import { StyledLabel } from './partials/StyledLabel' +import { StyledInput } from './partials/StyledInput' +import { getId } from '../../helpers' +import { InputValidationProps } from '../../common/InputValidationProps' +import { useFocus } from '../../hooks/useFocus' +import { useInputValue } from '../../hooks/useInputValue' + +export interface TextAreaEProps + extends TextareaHTMLAttributes, + ComponentWithClass, + InputValidationProps { + /** + * Toggles whether the component is disabled or not (preventing user interaction). + */ + isDisabled?: boolean + /** + * Label to display within the component. + */ + label: string + /** + * Optional handler invoked when the `onBlur` event is emitted. + */ + onBlur?: (event: React.FormEvent) => void + /** + * Optional HTML `value` attribute to apply to the component. + */ + value?: string +} + +export const TextAreaE: React.FC = (props) => { + const { + className, + isDisabled, + isInvalid, + id = getId('text-area'), + label, + onBlur, + onChange, + value, + ...rest + } = props + + const { hasFocus, onLocalBlur, onLocalFocus } = useFocus(onBlur) + const { committedValue, hasValue, onValueChange } = useInputValue(value) + + return ( + + + + {label} + + { + onValueChange(e) + if (onChange) { + onChange(e) + } + }} + onFocus={onLocalFocus} + value={committedValue} + {...rest} + /> + + + ) +} + +TextAreaE.displayName = 'TextAreaE' diff --git a/packages/react-component-library/src/components/TextAreaE/index.ts b/packages/react-component-library/src/components/TextAreaE/index.ts new file mode 100644 index 0000000000..aae8a5a065 --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/index.ts @@ -0,0 +1 @@ +export * from './TextAreaE' diff --git a/packages/react-component-library/src/components/TextAreaE/partials/StyledInput.tsx b/packages/react-component-library/src/components/TextAreaE/partials/StyledInput.tsx new file mode 100644 index 0000000000..9ec6b08378 --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/partials/StyledInput.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components' +import { selectors } from '@royalnavy/design-tokens' + +const { color, fontSize } = selectors + +export const StyledInput = styled.textarea` + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + border: 0; + background: none; + color: ${color('neutral', '600')}; + font-size: ${fontSize('m')}; + resize: vertical; + overflow: auto; + min-height: 80px; + + -webkit-tap-highlight-color: transparent; + + &:focus { + outline: 0; + } +` diff --git a/packages/react-component-library/src/components/TextAreaE/partials/StyledLabel.tsx b/packages/react-component-library/src/components/TextAreaE/partials/StyledLabel.tsx new file mode 100644 index 0000000000..f1ef5e8dad --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/partials/StyledLabel.tsx @@ -0,0 +1,25 @@ +import styled, { css } from 'styled-components' +import { selectors } from '@royalnavy/design-tokens' + +import { isIE11 } from '../../../helpers' +import { + StyledLabel as StyledLabelBase, + StyledLabelProps, +} from '../../../styled-components/partials/StyledLabel' + +const { color, spacing } = selectors + +export const StyledLabel = styled(StyledLabelBase)` + padding-bottom: ${spacing('2')}; + pointer-events: none; + background-color: ${color('neutral', 'white')}; + border-radius: 3px 3px 0 0; + + transform: translate(10px, ${isIE11() ? '9px' : '11px'}) scale(1); + + ${({ $hasFocus, $hasContent }) => + ($hasFocus || $hasContent) && + css` + transform: translate(10px, ${isIE11() ? '6px' : '4px'}) scale(0.75); + `} +` diff --git a/packages/react-component-library/src/components/TextAreaE/partials/StyledOuterWrapper.tsx b/packages/react-component-library/src/components/TextAreaE/partials/StyledOuterWrapper.tsx new file mode 100644 index 0000000000..ed253d26f4 --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/partials/StyledOuterWrapper.tsx @@ -0,0 +1,14 @@ +import styled from 'styled-components' + +import { isIE11 } from '../../../helpers' +import { + StyledOuterWrapper as StyledOuterWrapperBase, + StyledOuterWrapperProps, +} from '../../../styled-components' + +export const StyledOuterWrapper = styled( + StyledOuterWrapperBase +)` + position: relative; + padding: ${isIE11() ? '21px' : '19px'} 8px 8px 8px; +` diff --git a/packages/react-component-library/src/components/TextAreaE/partials/StyledTextArea.tsx b/packages/react-component-library/src/components/TextAreaE/partials/StyledTextArea.tsx new file mode 100644 index 0000000000..0fb57702e4 --- /dev/null +++ b/packages/react-component-library/src/components/TextAreaE/partials/StyledTextArea.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' + +export const StyledTextArea = styled.div` + display: inline-flex; + flex-direction: column; + position: relative; + padding: 0; + border: 0; + vertical-align: top; + width: 100%; +` diff --git a/packages/react-component-library/src/index.ts b/packages/react-component-library/src/index.ts index 8a606cf0a8..13b83cb6f8 100755 --- a/packages/react-component-library/src/index.ts +++ b/packages/react-component-library/src/index.ts @@ -49,6 +49,7 @@ export * from './components/Table' export * from './components/TabNav' export * from './components/TabSet' export * from './components/TextArea' +export * from './components/TextAreaE' export * from './components/TextInput' export * from './components/TextInputE' export * from './components/Tooltip'