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
17 changes: 13 additions & 4 deletions src/components/controls/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
InputControlSize,
InputControlView,
} from '../types';
import {getInputControlState, prepareAutoComplete} from '../utils';
import {errorPropsMapper, getInputControlState, prepareAutoComplete} from '../utils';

import {TextAreaControl} from './TextAreaControl';

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

const {errorMessageProp, validationStateProp} = errorPropsMapper({
saxumcordis marked this conversation as resolved.
Show resolved Hide resolved
error,
errorMessage,
validationState,
});

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(validationStateProp);
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 = Boolean(errorMessageProp);
saxumcordis marked this conversation as resolved.
Show resolved Hide resolved
const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
const id = originalId || innerId;

Expand Down Expand Up @@ -156,7 +165,7 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
/>
)}
</span>
{isErrorMsgVisible && <div className={b('error')}>{error}</div>}
{isErrorMsgVisible && <div className={b('error')}>{errorMessageProp}</div>}
</span>
);
});
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
74 changes: 59 additions & 15 deletions src/components/controls/TextArea/__tests__/TextArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,6 @@ describe('TextArea', () => {
expect(input.tagName.toLowerCase()).toBe('textarea');
});

test('render error message with error prop', () => {
const {container} = render(<TextArea error="Some Error" />);

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).toBeInTheDocument();
expect(screen.getByText('Some Error')).toBeVisible();
});

test('do not show error without error prop', () => {
const {container} = render(<TextArea />);

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).not.toBeInTheDocument();
});

test('check clear button visibility with hasClear prop', async () => {
render(<TextArea hasClear />);
const user = userEvent.setup();
Expand Down Expand Up @@ -84,6 +69,65 @@ describe('TextArea', () => {
});
});

describe('error', () => {
test('render error message with error prop', () => {
const {container} = render(<TextArea error="Some Error" />);

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
saxumcordis marked this conversation as resolved.
Show resolved Hide resolved
expect(container.querySelector('.g-text-area__error')).toBeInTheDocument();
expect(screen.getByText('Some Error')).toBeVisible();
});

test('render error message with errorMessage prop', () => {
const {container} = render(
<TextArea errorMessage="Some Error with errorMessage prop" />,
);

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).toBeInTheDocument();
expect(screen.getByText('Some Error with errorMessage prop')).toBeVisible();
});

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

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).not.toBeInTheDocument();
saxumcordis marked this conversation as resolved.
Show resolved Hide resolved
});

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

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).not.toBeInTheDocument();
});

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

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('.g-text-area__error')).not.toBeInTheDocument();
});

test('visually, area should be in error state (red border) if error prop is received', () => {
const {container} = render(<TextArea error={'Some error'} />);

expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
container.querySelector('.g-text-area_state_error'),
).toBeInTheDocument();
});

test('visually, area should be in error state (red border) even if received error prop is an empty string', () => {
const {container} = render(<TextArea error={''} />);

expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
container.querySelector('.g-text-area_state_error'),
).toBeInTheDocument();
});
});

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

&__error-icon {
color: var(--g-color-text-danger);
}

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

#{$block}__error-icon {
saxumcordis marked this conversation as resolved.
Show resolved Hide resolved
padding: 5px 5px 5px 0;
}

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

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

#{$block}__error-icon {
padding: 5px 5px 5px 0;
}

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

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

#{$block}__error-icon {
padding: 9px 9px 9px 0;
}

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

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

#{$block}__error-icon {
padding: 13px 13px 13px 0;
}

--_--text-input-border-radius: var(--g-border-radius-xl);
}
}
Expand Down
33 changes: 29 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,7 @@ import type {
InputControlSize,
InputControlView,
} from '../types';
import {getInputControlState, prepareAutoComplete} from '../utils';
import {errorPropsMapper, getInputControlState, prepareAutoComplete} from '../utils';

import {AdditionalContent} from './AdditionalContent';
import {TextInputControl} from './TextInputControl';
Expand Down Expand Up @@ -50,6 +54,9 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
disabled = false,
hasClear = false,
error,
errorMessage,
errorPlacement = 'text',
validationState,
autoComplete,
id: originalId,
tabIndex,
Expand All @@ -62,17 +69,26 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
onUpdate,
onChange,
} = props;

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

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(validationStateProp);

const isControlled = value !== undefined;
const inputValue = isControlled ? value : uncontrolledValue;
const isLabelVisible = Boolean(label);
const isErrorMsgVisible = typeof error === 'string';
const isErrorMsgVisible = Boolean(errorMessageProp) && errorPlacementProp === 'text';
const isErrorIconVisible = Boolean(errorMessageProp) && errorPlacementProp === 'tooltip';
const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
const isLeftContentVisible = Boolean(leftContent);
const isRightContentVisible = Boolean(rightContent);
Expand Down Expand Up @@ -203,8 +219,17 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
{rightContent}
</AdditionalContent>
)}
{isErrorIconVisible && (
<Popover content={errorMessageProp}>
<Icon
data={TriangleExclamation}
className={b('error-icon')}
size={size === 's' ? 12 : 16}
/>
</Popover>
)}
</span>
{isErrorMsgVisible && <div className={b('error')}>{error}</div>}
{isErrorMsgVisible && <div className={b('error')}>{errorMessageProp}</div>}
</span>
);
});
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