Skip to content

Commit

Permalink
feat(Select): added errorMessage, errorPlacement and validationType p…
Browse files Browse the repository at this point in the history
…rops (#1291)
  • Loading branch information
kkirik authored Jan 31, 2024
1 parent de63cef commit 8f2f07b
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 15 deletions.
22 changes: 22 additions & 0 deletions src/components/Select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,25 @@ const MyComponent = () => {

<!--/GITHUB_BLOCK-->

### Error

The state of the `Select` in which you want to indicate incorrect user input. To change `Select` appearance, use the `validationState` property with the `"invalid"` value. An optional message text can be added via the `errorMessage` property. By default, message text is rendered outside the component.
This behaviour can be changed with the `errorPlacement` property.

<!--LANDING_BLOCK
<ExampleBlock
code={`
<Select placeholder="Placeholder" errorMessage="Error message" validationState="invalid" />
<Select placeholder="Placeholder" errorPlacement="inside" errorMessage="Error message" validationState="invalid" />
`}
>
<UIKit.Select placeholder="Placeholder" errorMessage="Error message" validationState="invalid" />
<UIKit.Select placeholder="Placeholder" errorPlacement="inside" errorMessage="Error message" validationState="invalid" />
</ExampleBlock>
LANDING_BLOCK-->

<!--GITHUB_BLOCK-->

## Properties

| Name | Description | Type | Default |
Expand Down Expand Up @@ -1016,3 +1035,6 @@ const MyComponent = () => {
| view | Control view | `string` | `'normal'` |
| [virtualizationThreshold](#virtualized-list) | The threshold of the options count after which virtualization is enabled | `number` | `50` |
| [width](#control-width) | Control width | `string \| number` | `undefined` |
| errorMessage | Error text | `string` | |
| errorPlacement | Error placement | `outside` `inside` | `outside` |
| validationState | Validation state | `"invalid"` | |
25 changes: 24 additions & 1 deletion src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React from 'react';
import {KeyCode} from '../../constants';
import {useFocusWithin, useForkRef, useSelect, useUniqId} from '../../hooks';
import type {List} from '../List';
import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent';
import {errorPropsMapper} from '../controls/utils';
import {useMobile} from '../mobile';
import type {CnMods} from '../utils/cn';

Expand Down Expand Up @@ -122,6 +124,20 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
);
const virtualized = filteredFlattenOptions.length >= virtualizationThreshold;

const {errorMessage, errorPlacement, validationState} = errorPropsMapper({
error,
errorMessage: props.errorMessage,
errorPlacement: props.errorPlacement || 'outside',
validationState: props.validationState,
});
const errorMessageId = useUniqId();

const isErrorMsgVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'outside';
const isErrorIconVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'inside';
const isErrorStateVisible = isErrorMsgVisible || isErrorIconVisible;

const handleOptionClick = React.useCallback(
(option?: FlattenOption) => {
if (!option || option?.disabled || 'label' in option) {
Expand Down Expand Up @@ -250,7 +266,8 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
label={label}
placeholder={placeholder}
selectedOptionsContent={selectedOptionsContent}
error={error}
isErrorVisible={isErrorStateVisible}
errorMessage={isErrorIconVisible ? errorMessage : undefined}
open={open}
disabled={disabled}
onKeyDown={handleControlKeyDown}
Expand All @@ -260,6 +277,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
selectId={`select-${selectId}`}
activeIndex={activeIndex}
/>

<SelectPopup
ref={controlWrapRef}
className={popupClassName}
Expand Down Expand Up @@ -307,6 +325,11 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
<EmptyOptions filter={filter} renderEmptyOptions={renderEmptyOptions} />
)}
</SelectPopup>

<OuterAdditionalContent
errorMessage={isErrorMsgVisible ? errorMessage : null}
errorMessageId={errorMessageId}
/>
</div>
);
}) as unknown as SelectComponent;
Expand Down
34 changes: 34 additions & 0 deletions src/components/Select/__stories__/SelectShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,40 @@ export const SelectShowcase = (props: SelectProps) => {
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>

<div>
<h2>Select (with text error)</h2>

<ExampleItem
title="Select with outside error"
selectProps={{
...props,
errorPlacement: 'outside',
errorMessage: 'A validation error has occurred',
validationState: 'invalid',
}}
>
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>

<ExampleItem
title="Select with inside error"
selectProps={{
...props,
errorPlacement: 'inside',
errorMessage: 'A validation error has occurred',
validationState: 'invalid',
}}
>
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>
</div>
</div>
);
};
66 changes: 66 additions & 0 deletions src/components/Select/__tests__/Select.error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';

import {render, screen} from '@testing-library/react';

import {CONTROL_ERROR_MESSAGE_QA} from '../../controls/utils';
import {Select} from '../Select';

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

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

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

expect(screen.queryByText('Some Error with errorMessage prop')).not.toBeInTheDocument();
});

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

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

test('render error icon if tooltip option is selected for errorPlacement prop', () => {
render(
<Select errorMessage="Some Error" validationState="invalid" errorPlacement="inside" />,
);

expect(screen.getByLabelText('Show popup with error info')).toBeInTheDocument();
});

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

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

test('do not show error message if error prop value is an empty string', () => {
render(<Select 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(<Select errorMessage={''} />);

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

test('do not show error icon if error prop is an empty string', () => {
render(<Select error={''} errorPlacement="inside" />);

expect(screen.queryByLabelText('Show popup with error info')).not.toBeInTheDocument();
});

test('do not show error icon if errorMessage prop is an empty string', () => {
render(<Select errorMessage={''} errorPlacement="inside" />);

expect(screen.queryByLabelText('Show popup with error info')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,9 @@ $blockButton: '.#{variables.$ns-new}select-control__button';
margin-left: 0;
}

&__error {
@include mixins.text-body-1();

&__error-icon {
box-sizing: content-box;
color: var(--g-color-text-danger);
margin-top: 2px;
padding: var(--_--text-input-error-icon-padding);
}
}
30 changes: 22 additions & 8 deletions src/components/Select/components/SelectControl/SelectControl.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';

import {ChevronDown} from '@gravity-ui/icons';
import {ChevronDown, TriangleExclamation} from '@gravity-ui/icons';
import isEmpty from 'lodash/isEmpty';

import {Icon} from '../../../Icon';
import {Popover} from '../../../Popover';
import type {CnMods} from '../../../utils/cn';
import {selectControlBlock, selectControlButtonBlock} from '../../constants';
import i18n from '../../i18n';
import type {
SelectProps,
SelectRenderClearArgs,
Expand All @@ -28,7 +30,8 @@ type ControlProps = {
qa?: string;
label?: string;
placeholder?: SelectProps['placeholder'];
error?: SelectProps['error'];
isErrorVisible?: boolean;
errorMessage?: SelectProps['errorMessage'];
disabled?: boolean;
value: SelectProps['value'];
clearValue: () => void;
Expand All @@ -50,7 +53,8 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
name,
label,
placeholder,
error,
isErrorVisible,
errorMessage,
open,
disabled,
value,
Expand All @@ -70,7 +74,7 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
size,
pin,
disabled,
error: Boolean(error),
error: isErrorVisible,
'has-clear': hasClear,
'no-active': isDisabledButtonAnimation,
'has-value': hasValue,
Expand All @@ -82,7 +86,7 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
view,
pin,
disabled,
error: Boolean(error),
error: isErrorVisible,
};

const disableButtonAnimation = React.useCallback(() => {
Expand Down Expand Up @@ -163,15 +167,25 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
)}
</button>
{renderClearIcon({})}

{errorMessage && (
<Popover content={errorMessage}>
<span aria-label={i18n('label_show-error-info')}>
<Icon
data={TriangleExclamation}
className={selectControlBlock('error-icon')}
size={size === 's' ? 12 : 16}
/>
</span>
</Popover>
)}

<Icon
className={selectControlBlock('chevron-icon', {disabled})}
data={ChevronDown}
aria-hidden="true"
/>
</div>
{typeof error === 'string' && (
<div className={selectControlBlock('error')}>{error}</div>
)}
</React.Fragment>
);
});
Expand Down
3 changes: 2 additions & 1 deletion src/components/Select/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"label_clear": "Clear"
"label_clear": "Clear",
"label_show-error-info": "Show popup with error info"
}
3 changes: 2 additions & 1 deletion src/components/Select/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"label_clear": "Очистить"
"label_clear": "Очистить",
"label_show-error-info": "Показать попап с информацей об ошибке"
}
9 changes: 9 additions & 0 deletions src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,16 @@ export type SelectProps<T = any> = QAProps &
value?: string[];
defaultValue?: string[];
options?: (SelectOption<T> | SelectOptionGroup<T>)[];
/**
* @deprecated Prop `error` has a lower priority than `errorMessage`. Use `errorMessage` instead
*/
error?: string | boolean;
/** Determines content of the error message */
errorMessage?: React.ReactNode;
/** Determines whether the error message will be placed under the input field as text or in the tooltip */
errorPlacement?: 'outside' | 'inside';
/** Describes the validation state */
validationState?: 'invalid';
multiple?: boolean;
filterable?: boolean;
disablePortal?: boolean;
Expand Down

0 comments on commit 8f2f07b

Please sign in to comment.