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(TextInput): add an option to display error via tooltip #821

Merged
merged 14 commits into from
Sep 4, 2023
26 changes: 22 additions & 4 deletions src/components/controls/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import type {
InputControlSize,
InputControlView,
} from '../types';
import {getInputControlState, prepareAutoComplete} from '../utils';
import {
CONTROL_ERROR_MESSAGE_QA,
errorPropsMapper,
getInputControlState,
prepareAutoComplete,
} from '../utils';

import {TextAreaControl} from './TextAreaControl';

Expand Down Expand Up @@ -49,6 +54,8 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
disabled = false,
hasClear = false,
error,
errorMessage: errorMessageProp,
validationState: validationStateProp,
autoComplete,
id: originalId,
tabIndex,
Expand All @@ -60,16 +67,23 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
onUpdate,
onChange,
} = props;

const {errorMessage, validationState} = errorPropsMapper({
error,
errorMessage: errorMessageProp,
validationState: validationStateProp,
});

const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? '');
const innerControlRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const [hasVerticalScrollbar, setHasVerticalScrollbar] = React.useState(false);
const state = getInputControlState({error});
const state = getInputControlState(validationState);
const handleRef = useForkRef(props.controlRef, innerControlRef);
const innerId = useUniqId();

const isControlled = value !== undefined;
const inputValue = isControlled ? value : uncontrolledValue;
const isErrorMsgVisible = typeof error === 'string';
const isErrorMsgVisible = validationState === 'invalid' && Boolean(errorMessage);
const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
const id = originalId || innerId;

Expand Down Expand Up @@ -161,7 +175,11 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
</span>
{(isErrorMsgVisible || note) && (
<div className={b('outer-additional-content')}>
{isErrorMsgVisible && <div className={b('error')}>{error}</div>}
{isErrorMsgVisible && (
<div className={b('error')} data-qa={CONTROL_ERROR_MESSAGE_QA}>
{errorMessage}
</div>
)}
{note && <div className={b('note')}>{note}</div>}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function TextAreaShowcase() {
<TextArea
{...textAreaProps}
placeholder="error with message"
error={isErrorMessageVisible ? 'It happened a validation error' : true}
error={isErrorMessageVisible ? 'A validation error has occurred' : true}
/>
<Checkbox
onUpdate={setErrorMessageVisibility}
Expand Down
33 changes: 33 additions & 0 deletions src/components/controls/TextArea/__tests__/TextArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import {fireEvent, render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import {CONTROL_ERROR_MESSAGE_QA} from '../../utils';
import {TextArea} from '../TextArea';

describe('TextArea', () => {
Expand Down Expand Up @@ -88,6 +89,38 @@ describe('TextArea', () => {
});
});

describe('error', () => {
test('render error message with error prop (if it is not an empty string)', () => {
render(<TextArea error="Some Error" />);

expect(screen.getByText('Some Error')).toBeVisible();
});

test('render error message with errorMessage prop (if it is not an empty string)', () => {
render(<TextArea errorMessage="Some Error with errorMessage prop" />);

expect(screen.getByText('Some Error with errorMessage prop')).toBeVisible();
});

test('do not show error message without error/errorMessage prop', () => {
render(<TextArea />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});

test('do not show error message if error prop value is an empty string', () => {
render(<TextArea error={''} />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});

test('do not show error message if errorMessage prop value is an empty string', () => {
render(<TextArea errorMessage={''} />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});
});

describe('autocomplete', () => {
test('render no autocomplete attribute when no autoComplete, no id, no name props', () => {
render(<TextArea />);
Expand Down
13 changes: 13 additions & 0 deletions src/components/controls/TextInput/TextInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ $block: '.#{variables.$ns}text-input';
margin-top: 2px;
}

&__error-icon {
color: var(--g-color-text-danger);
padding: var(--_--text-input-error-icon-padding);
}

&__additional-content {
display: flex;
align-items: center;
Expand Down Expand Up @@ -147,6 +152,8 @@ $block: '.#{variables.$ns}text-input';
padding-right: 1px;
}

--_--text-input-error-icon-padding: 5px 5px 5px 0;

--_--text-input-border-radius: var(--g-border-radius-s);
}

Expand Down Expand Up @@ -175,6 +182,8 @@ $block: '.#{variables.$ns}text-input';
padding-right: 1px;
}

--_--text-input-error-icon-padding: 5px 5px 5px 0;

--_--text-input-border-radius: var(--g-border-radius-m);
}

Expand Down Expand Up @@ -203,6 +212,8 @@ $block: '.#{variables.$ns}text-input';
padding-right: 3px;
}

--_--text-input-error-icon-padding: 9px 9px 9px 0;

--_--text-input-border-radius: var(--g-border-radius-l);
}

Expand Down Expand Up @@ -231,6 +242,8 @@ $block: '.#{variables.$ns}text-input';
padding-right: 3px;
}

--_--text-input-error-icon-padding: 13px 13px 13px 0;

--_--text-input-border-radius: var(--g-border-radius-xl);
}
}
Expand Down
42 changes: 38 additions & 4 deletions src/components/controls/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React from 'react';

import {TriangleExclamation} from '@gravity-ui/icons';

import {Icon} from '../../Icon';
import {Popover} from '../../Popover';
import {block} from '../../utils/cn';
import {useElementSize} from '../../utils/useElementSize';
import {useForkRef} from '../../utils/useForkRef';
Expand All @@ -11,7 +15,12 @@ import type {
InputControlSize,
InputControlView,
} from '../types';
import {getInputControlState, prepareAutoComplete} from '../utils';
import {
CONTROL_ERROR_ICON_QA,
errorPropsMapper,
getInputControlState,
prepareAutoComplete,
} from '../utils';

import {AdditionalContent} from './AdditionalContent';
import {TextInputControl} from './TextInputControl';
Expand Down Expand Up @@ -54,6 +63,9 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
disabled = false,
hasClear = false,
error,
errorMessage: errorMessageProp,
errorPlacement: errorPlacementProp = 'outside',
validationState: validationStateProp,
autoComplete,
id: originalId,
tabIndex,
Expand All @@ -67,17 +79,28 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
onUpdate,
onChange,
} = props;

const {errorMessage, errorPlacement, validationState} = errorPropsMapper({
error,
errorMessage: errorMessageProp,
errorPlacement: errorPlacementProp,
validationState: validationStateProp,
});

const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? '');
const innerControlRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const handleRef = useForkRef(props.controlRef, innerControlRef);
const labelRef = React.useRef<HTMLLabelElement>(null);
const leftContentRef = React.useRef<HTMLDivElement>(null);
const state = getInputControlState({error});
const state = getInputControlState(validationState);

const isControlled = value !== undefined;
const inputValue = isControlled ? value : uncontrolledValue;
const isLabelVisible = Boolean(label);
const isErrorMsgVisible = typeof error === 'string';
const isErrorMsgVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'outside';
const isErrorIconVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'inside';
const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
const isLeftContentVisible = Boolean(leftContent);
const isRightContentVisible = Boolean(rightContent);
Expand Down Expand Up @@ -208,10 +231,21 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
{rightContent}
</AdditionalContent>
)}
{isErrorIconVisible && (
<Popover content={errorMessage}>
<span data-qa={CONTROL_ERROR_ICON_QA}>
<Icon
data={TriangleExclamation}
className={b('error-icon')}
size={size === 's' ? 12 : 16}
/>
</span>
</Popover>
)}
</span>
{(isErrorMsgVisible || note) && (
<div className={b('outer-additional-content')}>
{isErrorMsgVisible && <div className={b('error')}>{error}</div>}
{isErrorMsgVisible && <div className={b('error')}>{errorMessage}</div>}
{note && <div className={b('note')}>{note}</div>}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
padding: 20px;
}

&__text-input-error-examples {
grid-area: text-area;
display: grid;
grid-template:
'title title' auto
'sizes additional-content' auto / 1fr 1fr;
gap: 20px;
padding: 20px;
}

&__title {
grid-area: title;
margin: 0;
Expand All @@ -47,6 +57,10 @@
grid-area: states;
}

&__additional-content-examples {
grid-area: additional-content;
}

&__row {
display: flex;
gap: 10px;
Expand Down
Loading
Loading