Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Textarea): new design #1344

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ exports[`<PillButtonEnhanced> component in active, collapsed state (with content
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down Expand Up @@ -111,7 +110,6 @@ exports[`<PillButtonEnhanced> component in active, open state (content and isOpe
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down Expand Up @@ -276,7 +274,6 @@ exports[`<PillButtonEnhanced> component with additional props should render corr
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down Expand Up @@ -341,7 +338,6 @@ exports[`<PillButtonEnhanced> component with additional props should set style a
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,6 @@ exports[`<Pill> component with enhanced button variant should open dropdown when
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down Expand Up @@ -295,7 +294,6 @@ exports[`<Pill> component with enhanced button variant should render correctly 1
</div>
<button
class="Button Button--variant_ghost Button--context_secondary IconButton--size_medium PillButtonEnhanced__clear"
style="width: auto;"
tabindex="0"
type="button"
>
Expand Down
127 changes: 106 additions & 21 deletions src/components/TextArea/TextArea.scss
Original file line number Diff line number Diff line change
@@ -1,31 +1,116 @@
@use '../../themes/oneui/placeholders/input';

.TextArea {
@extend %input;
display: flex;
flex-direction: column;
gap: var(--space-50);
}

&::placeholder {
color: var(--color-neutral-30);
opacity: 1;
.TextArea__row {
display: flex;
}

.TextArea__column {
display: flex;
flex-direction: column;
}

.TextArea__labelRow {
display: flex;
align-items: center;
justify-content: space-between;
}

.TextArea__label {
composes: OneUI-caption-text-bold from global;
}

.TextArea__labelStatus {
margin-left: var(--space-50);
composes: OneUI-caption-text from global;
color: var(--color-text-subtlest, #808080);
}

.TextArea__letterCount {
composes: OneUI-caption-text from global;
color: var(--color-text-subtlest, #808080);
}

.TextArea__container {
display: flex;
flex-direction: column;
gap: var(--space-100);
border: 1px solid var(--color-border-input, #808080);
border-radius: var(--space-100);
padding: var(--space-125) var(--space-150);

&:hover {
background: var(--color-background-input-hover, #f2f2f2);
}

&:has(.TextArea__textarea:invalid) {
border: 2px solid var(--color-border-critical-bold-default, #ff3b2f);
padding: calc(var(--space-125) - 1px) calc(var(--space-150) - 1px);
}

&:focus {
border-color: var(--color-brand-50);
&:has(.TextArea__textarea:read-only) {
background: var(--color-background-input-read-only, #f2f2f2);
}

&--isBlock {
width: 100%;
&:has(.TextArea__textarea:disabled) {
border: 1px solid var(--color-border-disabled, #cccccc);
color: var(--color-text-disabled, #b3b3b3);
}

&--size {
&_small {
font-size: var(--font-size-small);
line-height: var(--line-height-small);
padding: var(--space-50);
}
&_large {
font-size: var(--font-size-large);
line-height: var(--line-height-large);
padding: var(--space-100);
}
&:has(.TextArea__textarea[aria-invalid='true']) {
border: 2px solid var(--color-text-critical-default);
padding: calc(var(--space-125) - 1px) calc(var(--space-150) - 1px);
}

&:has(.TextArea__textarea:focus):not(
:has(.TextArea__textarea:disabled),
:has(.TextArea__textarea:read-only),
:has(.TextArea__textarea[aria-invalid='true'])
) {
border: 2px solid var(--color-border-selected, #007aff);
padding: calc(var(--space-125) - 1px) calc(var(--space-150) - 1px);
}
}

.TextArea__textarea {
font-family: var(--font-family-primary);
outline: none;
composes: OneUI-label-text from global;
resize: none;
padding: 0;
border: none;
background: transparent;

&::placeholder {
color: var(--color-text-subtlest, #808080);
}
}

.TextArea__iconContainer {
display: flex;
justify-content: flex-end;
}

.TextArea__icon {
height: 20px;
width: 20px;
}

.TextArea__helperText {
composes: OneUI-caption-text from global;
color: var(--color-text-subtle, #4d4d4d);
}

.TextArea__errorContainer {
display: flex;
gap: var(--space-25);
align-items: center;
color: var(--color-text-critical-default);
}

.TextArea__errorText {
composes: OneUI-caption-text from global;
}
111 changes: 102 additions & 9 deletions src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,116 @@
import React, { forwardRef } from 'react';
import CopyAll from '@material-design-icons/svg/round/copy_all.svg';
import Error from '@material-design-icons/svg/round/error.svg';
import { bem } from '../../utils';
import { Text } from '../Text/Text';
import { IconButton } from '../Buttons';
import styles from './TextArea.scss';
import { OldSize as Size } from '../../constants';

export interface Props extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
/** Should the input field be disabled or not */
disabled?: boolean;
/** Whether or not to show block-level textarea (full width) */
isBlock?: boolean;
/** The size of the textarea */
size?: Size;
/** Should the input field be readOnly or not */
readOnly?: boolean;
/** Label text, display above the textarea */
label: string;
/** Label status, eg. required or optional, rendered between parenthesis */
labelStatus?: string;
/** Textarea maximum number of characters allowed */
maxLength?: number;
/** Helper text, displayed below the textarea */
helperText?: string;
/** Error text, displayed below the textarea */
errorText?: string;
/** Callback executed after clicking on Copy button */
copyCallback?: (text: string) => void;
}

const { block } = bem('TextArea', styles);
const { block, elem } = bem('TextArea', styles);

export const TextArea = forwardRef<HTMLTextAreaElement, Props>(
({ disabled = false, isBlock = false, size = 'normal', ...rest }, ref) => (
<textarea {...rest} {...block({ ...rest, size, isBlock })} ref={ref} disabled={disabled} />
)
(
{
disabled = false,
readOnly = false,
maxLength = 240,
label,
labelStatus,
helperText,
errorText,
defaultValue,
value,
copyCallback,
onChange,
...rest
},
ref
) => {
const [text, setText] = React.useState((defaultValue ?? value ?? '').toString());

const handleOnChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
onChange?.(e);
};

const handleOnCopy = () => {
navigator.clipboard.writeText(text);
copyCallback?.(text);
};

return (
<div {...block({ ...rest })}>
<div {...elem('labelRow')}>
<div>
<Text inline {...elem('label')}>
{label}
</Text>
{labelStatus && (
<Text inline {...elem('labelStatus')}>
({labelStatus})
</Text>
)}
</div>
<Text inline {...elem('letterCount')}>
{text.length} / {maxLength}
</Text>
</div>
<div {...elem('container')}>
<textarea
{...rest}
{...elem('textarea', { ...rest })}
ref={ref}
value={value}
defaultValue={defaultValue}
maxLength={maxLength}
disabled={disabled}
readOnly={readOnly}
aria-invalid={!!errorText}
onChange={handleOnChange}
/>
{copyCallback && (
<div {...elem('iconContainer')}>
<IconButton onClick={handleOnCopy} variant="ghost" size="large">
<CopyAll viewBox="0 0 24 24" {...elem('icon')} />
</IconButton>
</div>
)}
</div>
{errorText && !helperText && (
<div {...elem('errorContainer')}>
<Error viewBox="0 0 24 24" fill="currentColor" {...elem('icon')} />
<Text inline {...elem('errorText')}>
{errorText}
</Text>
</div>
)}
{helperText && !errorText && (
<Text inline {...elem('helperText')}>
{helperText}
</Text>
)}
</div>
);
}
);

TextArea.displayName = 'TextArea';
41 changes: 30 additions & 11 deletions src/components/TextArea/__tests__/TextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,47 @@ describe('<TextArea> that renders a textarea', () => {

let view: RenderResult;

it('should render default textarea correctly', () => {
view = render(<TextArea defaultValue="Some value" />);
it('should render textarea correctly', () => {
view = render(
<TextArea
defaultValue="Some value"
label="label"
labelStatus="required"
helperText="helper"
/>
);

expect(view.container).toMatchSnapshot();
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByText('label')).toBeInTheDocument();
expect(screen.getByText('(required)')).toBeInTheDocument();
expect(screen.getByText('helper')).toBeInTheDocument();
});

it('should add classes when props are changed', () => {
view = render(<TextArea size="large" isBlock disabled />);
it('should render error message', () => {
view = render(
<TextArea
defaultValue="Some value"
label="label"
labelStatus="required"
errorText="It should contain a number"
/>
);

const textarea = screen.getByRole('textbox');
expect(screen.getByTestId('default-icon')).toBeInTheDocument();
expect(screen.getByText('It should contain a number')).toBeInTheDocument();
});

expect(view.container).toMatchSnapshot();
expect(textarea).toBeInTheDocument();
expect(textarea).toBeDisabled();
expect(textarea).toHaveClass('TextArea TextArea--size_large TextArea--isBlock');
it('should render letter count correctly', () => {
view = render(<TextArea label="label" defaultValue="12345" maxLength={250} />);

expect(screen.getByText('5 / 250')).toBeInTheDocument();
});

it('should call change callback correctly', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
view = render(<TextArea onChange={onChange} />);
view = render(<TextArea label="label" onChange={onChange} />);

const textarea = screen.getByRole('textbox');

Expand All @@ -42,7 +61,7 @@ describe('<TextArea> that renders a textarea', () => {
});

it('should add string html attributes correctly', () => {
view = render(<TextArea data-test="something" />);
view = render(<TextArea label="label" data-test="something" />);

const textarea = screen.getByRole('textbox');

Expand Down
Loading
Loading