diff --git a/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022.png b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022.png new file mode 100644 index 00000000000..6c8800dcfb9 --- /dev/null +++ b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e9b1d6fe36bdcc20ba38328696bf2c4a13a78e34e8759e20aa464e49770cf12 +size 1737 diff --git a/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022Dark.png b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022Dark.png new file mode 100644 index 00000000000..bb15e90cd4d --- /dev/null +++ b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/chrome2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de49085c8925952a36e1b4f979260cdddda62ea7ec51da85ba18a270de0cfed0 +size 1786 diff --git a/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022.png b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022.png new file mode 100644 index 00000000000..e3ba2bb3c0e --- /dev/null +++ b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:171b1d4524ce3f4cb9ee08694d731c4b75220c2f250806864d974681aa649cca +size 3363 diff --git a/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022Dark.png b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022Dark.png new file mode 100644 index 00000000000..9457b96cb68 --- /dev/null +++ b/packages/react-ui/.creevey/images/Autocomplete/Clear Cross/firefox2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45cd74402ab5fad9bae129aadaf79861a503296161cbe8cd868413b5c9bac96c +size 3325 diff --git a/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022.png b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022.png new file mode 100644 index 00000000000..69c49299994 --- /dev/null +++ b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7ec45988f3e06047ea22582bb111938cedfc7df782ddc534625b403b1e2b583 +size 1323 diff --git a/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022Dark.png b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022Dark.png new file mode 100644 index 00000000000..3a155039eab --- /dev/null +++ b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/chrome2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cf9f8efaafbf38828ee7a7faa28b6041e215663b98896872a2154310fd8915f +size 1456 diff --git a/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022.png b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022.png new file mode 100644 index 00000000000..496ed76edb8 --- /dev/null +++ b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94dfd8c2cf7629fb691b49943389fcd5a2e8c443d9c90bb1644b1b2f9c81c9e3 +size 1608 diff --git a/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022Dark.png b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022Dark.png new file mode 100644 index 00000000000..f7384279d63 --- /dev/null +++ b/packages/react-ui/.creevey/images/ComboBox/Combobox With Clear Cross/firefox2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:162f2d29684ecc748b35fc4b964f9027dc99a48a70a3d7a2b503ae2502c01077 +size 1584 diff --git a/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022.png b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022.png new file mode 100644 index 00000000000..3ec0b5ef19d --- /dev/null +++ b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca0f02ccae6de676e28d2a0d786e6eaa53018d753b98787c39c6245b044013ef +size 3346 diff --git a/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022Dark.png b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022Dark.png new file mode 100644 index 00000000000..5bdfe0182b3 --- /dev/null +++ b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/chrome2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:303ae571327d840dbd469e0d13b80b2f3a5284dde657643ba1909c3a311e6e7e +size 3402 diff --git a/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022.png b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022.png new file mode 100644 index 00000000000..ca335164974 --- /dev/null +++ b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d469d8109dfa980fea6721dbd7404a6e162e8aad377c40b226777b803bdbb329 +size 4312 diff --git a/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022Dark.png b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022Dark.png new file mode 100644 index 00000000000..54ef16de5d3 --- /dev/null +++ b/packages/react-ui/.creevey/images/Input/Clear Cross Sizes/firefox2022Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddc4ff9f6522babde4daaeed57d83012c1b67518af0d65be571308d32b041a1f +size 4194 diff --git a/packages/react-ui/components/Autocomplete/__docs__/Autocomplete.docs.stories.tsx b/packages/react-ui/components/Autocomplete/__docs__/Autocomplete.docs.stories.tsx index 40c11ce19ec..2ac29f43738 100644 --- a/packages/react-ui/components/Autocomplete/__docs__/Autocomplete.docs.stories.tsx +++ b/packages/react-ui/components/Autocomplete/__docs__/Autocomplete.docs.stories.tsx @@ -110,3 +110,32 @@ export const Example9: Story = () => { return ; }; Example9.storyName = 'Режима прозрачной рамки'; + +/** При showClearIcon="always" крестик отображается всегда, если в автокомплит что-либо введено. + * + * При showClearIcon="onFocus" крестик отображается при фокусировке на автокомплите, в который что-либо введено. */ +export const Example10: Story = () => { + const items = ['Отображаю крестик всегда', 'Отображаю крестик по фокусу', 'Никогда не отображаю крестик']; + const [valueAlways, setValueAlways] = React.useState(items[0]); + const [valueOnFocus, setValueOnFocus] = React.useState(items[1]); + + return ( + + + + + ); +}; +Example10.storyName = 'Крестик для очистки'; diff --git a/packages/react-ui/components/Autocomplete/__stories__/Autocomplete.stories.tsx b/packages/react-ui/components/Autocomplete/__stories__/Autocomplete.stories.tsx index 41c7443413b..2b77a942a47 100644 --- a/packages/react-ui/components/Autocomplete/__stories__/Autocomplete.stories.tsx +++ b/packages/react-ui/components/Autocomplete/__stories__/Autocomplete.stories.tsx @@ -261,3 +261,7 @@ export const MenuPos = () => { ); }; MenuPos.storyName = 'menuPos'; + +export const ClearCross = () => { + return {}} />; +}; diff --git a/packages/react-ui/components/Autocomplete/__tests__/Autocomplete-test.tsx b/packages/react-ui/components/Autocomplete/__tests__/Autocomplete-test.tsx index e7e44167424..89ef7ea0fd6 100644 --- a/packages/react-ui/components/Autocomplete/__tests__/Autocomplete-test.tsx +++ b/packages/react-ui/components/Autocomplete/__tests__/Autocomplete-test.tsx @@ -4,7 +4,7 @@ import OkIcon from '@skbkontur/react-icons/Ok'; import userEvent from '@testing-library/user-event'; import { mount } from 'enzyme'; -import { InputDataTids } from '../../../components/Input'; +import { InputDataTids, ShowClearIcon } from '../../../components/Input'; import { Autocomplete, AutocompleteProps, AutocompleteIds, AutocompleteDataTids } from '../Autocomplete'; import { delay, clickOutside } from '../../../lib/utils'; @@ -164,6 +164,45 @@ describe('', () => { expect(screen.getByTestId('my-testy-icon')).toBeInTheDocument(); }); + it('passes showClearIcon prop to input', async () => { + const ControlledAutocomplete = ({ clear }: { clear?: ShowClearIcon }) => { + const [value, setValue] = useState(''); + return ; + }; + const { rerender } = render(); + const autocomplete = screen.getByRole('textbox'); + + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + await userEvent.type(autocomplete, 'hello'); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + + rerender(); + await userEvent.clear(autocomplete); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + await userEvent.type(autocomplete, 'hello'); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + + rerender(); + await userEvent.clear(autocomplete); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + await userEvent.type(autocomplete, 'hello'); + await userEvent.tab(); + expect(autocomplete).not.toHaveFocus(); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + await userEvent.click(autocomplete); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeInTheDocument(); + + rerender(); + await userEvent.clear(autocomplete); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeNull(); + await userEvent.type(autocomplete, 'hello'); + await userEvent.tab(); + expect(autocomplete).not.toHaveFocus(); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeInTheDocument(); + await userEvent.click(autocomplete); + expect(screen.queryByTestId(InputDataTids.clearCross)).toBeInTheDocument(); + }); + it('passes id prop to input', () => { const onValueChange = jest.fn(); const source: any[] = []; diff --git a/packages/react-ui/components/ComboBox/ComboBox.tsx b/packages/react-ui/components/ComboBox/ComboBox.tsx index 13820f4def1..639a5d7af28 100644 --- a/packages/react-ui/components/ComboBox/ComboBox.tsx +++ b/packages/react-ui/components/ComboBox/ComboBox.tsx @@ -3,7 +3,7 @@ import React, { AriaAttributes, HTMLAttributes } from 'react'; import { CustomComboBox } from '../../internal/CustomComboBox'; import { Nullable } from '../../typings/utility-types'; import { MenuItemState } from '../MenuItem'; -import { InputIconType } from '../Input'; +import { InputIconType, ShowClearIcon } from '../Input'; import { CommonProps } from '../../internal/CommonWrapper'; import { rootNode, TSetRootNode } from '../../lib/rootNode'; import { createPropsGetter } from '../../lib/createPropsGetter'; @@ -13,6 +13,12 @@ export interface ComboBoxProps extends Pick, Pick, 'id'>, CommonProps { + /** Устанавливает иконку крестика, при нажатии на который комбобокс очищается. + * При значении "always" крестик отображается всегда, если поле непустое. + * При значении "onFocus" крестик отображается при фокусировке на непустом поле. + * @default never */ + showClearIcon?: ShowClearIcon; + /** Задает выравнивание контента. */ align?: 'left' | 'center' | 'right'; @@ -170,7 +176,14 @@ export type ComboBoxExtendedItem = T | (() => React.ReactElement) | React. type DefaultProps = Required< Pick< ComboBoxProps, - 'itemToValue' | 'valueToString' | 'renderValue' | 'renderItem' | 'menuAlign' | 'searchOnFocus' | 'drawArrow' + | 'itemToValue' + | 'valueToString' + | 'renderValue' + | 'renderItem' + | 'menuAlign' + | 'searchOnFocus' + | 'drawArrow' + | 'showClearIcon' > >; @@ -197,6 +210,7 @@ export class ComboBox extends React.Component menuAlign: 'left', searchOnFocus: true, drawArrow: true, + showClearIcon: 'never', }; private getProps = createPropsGetter(ComboBox.defaultProps); diff --git a/packages/react-ui/components/ComboBox/__docs__/ComboBox.docs.stories.tsx b/packages/react-ui/components/ComboBox/__docs__/ComboBox.docs.stories.tsx index 4e7f294bc3b..e99ae5e2d61 100644 --- a/packages/react-ui/components/ComboBox/__docs__/ComboBox.docs.stories.tsx +++ b/packages/react-ui/components/ComboBox/__docs__/ComboBox.docs.stories.tsx @@ -537,3 +537,54 @@ export const Example9: Story = () => { ); }; Example9.storyName = 'Размер'; + +/** При showClearIcon="always" крестик отображается всегда, если в комбобокс что-либо введено. + * + * При showClearIcon="onFocus" крестик отображается при фокусировке на комбобоксе, в который что-либо введено. */ +export const Example10: Story = () => { + const [valueAlways, setValueAlways] = React.useState({ + value: 1, + label: 'Отображаю крестик всегда', + }); + const [valueOnFocus, setValueOnFocus] = React.useState({ + value: 2, + label: 'Отображаю крестик по фокусу', + }); + const getItems = (q: string) => { + return Promise.resolve( + [ + { + value: 1, + label: 'Отображаю крестик всегда', + }, + { + value: 2, + label: 'Отображаю крестик по фокусу', + }, + { + value: 3, + label: 'Никогда не отображаю крестик', + }, + ].filter((x) => x.label.toLowerCase().includes(q.toLowerCase()) || x.value.toString(10) === q), + ); + }; + return ( + + + + + ); +}; +Example10.storyName = 'Крестик для очистки'; diff --git a/packages/react-ui/components/ComboBox/__stories__/Combobox.stories.tsx b/packages/react-ui/components/ComboBox/__stories__/Combobox.stories.tsx index c3bef9eca2a..86ff13962e8 100644 --- a/packages/react-ui/components/ComboBox/__stories__/Combobox.stories.tsx +++ b/packages/react-ui/components/ComboBox/__stories__/Combobox.stories.tsx @@ -954,3 +954,29 @@ export const WithMenuAlignAndMenuPos: Story = () => { WithMenuAlignAndMenuPos.parameters = { creevey: { skip: { 'no themes': { in: /^(?!\b(chrome2022)\b)/ } } }, }; + +export const ComboboxWithClearCross: Story = () => { + const [value, setValue] = React.useState({ + value: 2, + label: 'Second', + }); + const getItems = (q: string) => { + return Promise.resolve( + [ + { + value: 1, + label: 'First', + }, + { + value: 2, + label: 'Second', + }, + ].filter((x) => x.label.toLowerCase().includes(q.toLowerCase()) || x.value.toString(10) === q), + ); + }; + return ( + + + + ); +}; diff --git a/packages/react-ui/components/ComboBox/__tests__/ComboBox-test.tsx b/packages/react-ui/components/ComboBox/__tests__/ComboBox-test.tsx index 3b7e40853ac..3a1868c1bb7 100644 --- a/packages/react-ui/components/ComboBox/__tests__/ComboBox-test.tsx +++ b/packages/react-ui/components/ComboBox/__tests__/ComboBox-test.tsx @@ -1404,6 +1404,61 @@ describe('ComboBox', () => { expect(await screen.findByTestId(ComboBoxMenuDataTids.item)).toHaveTextContent(testValues[1].label); }); }); + + describe('with clear cross', () => { + it('clears controlled combobox', async () => { + const ControlledCombobox = () => { + const [value, setValue] = React.useState({ + value: 2, + label: 'Second', + }); + const getItems = (q: string) => { + return Promise.resolve( + [ + { + value: 1, + label: 'First', + }, + { + value: 2, + label: 'Second', + }, + ].filter((x) => x.label.toLowerCase().includes(q.toLowerCase()) || x.value.toString(10) === q), + ); + }; + return ; + }; + render(); + + expect(screen.getByText('Second')).toBeInTheDocument(); + const cross = screen.getByTestId(InputDataTids.clearCross); + await userEvent.click(cross); + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + }); + + it('clears uncontrolled combobox', async () => { + const testValues = [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + { value: '3', label: 'Three' }, + { value: '4', label: 'Four' }, + ]; + const getItems = jest.fn((searchQuery) => + Promise.resolve(testValues.filter((x) => x.label.includes(searchQuery))), + ); + render(); + + comboboxRef.current?.focus(); + await userEvent.type(screen.getByRole('textbox'), 'z'); + expect(screen.getByRole('textbox')).toHaveValue('z'); + const cross = screen.getByTestId(InputDataTids.clearCross); + expect(cross).toBeInTheDocument(); + + await userEvent.click(cross); + expect(cross).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); }); describe('mobile comboBox', () => { diff --git a/packages/react-ui/components/CurrencyInput/CurrencyInput.tsx b/packages/react-ui/components/CurrencyInput/CurrencyInput.tsx index b9029887752..b4d7fa55de3 100644 --- a/packages/react-ui/components/CurrencyInput/CurrencyInput.tsx +++ b/packages/react-ui/components/CurrencyInput/CurrencyInput.tsx @@ -24,7 +24,7 @@ export interface CurrencyInputProps extends Pick, CommonProps, Override< - InputProps, + Omit, { /** Задает значение инпута. */ value?: Nullable; diff --git a/packages/react-ui/components/Input/Input.tsx b/packages/react-ui/components/Input/Input.tsx index 928536d1d28..ff4a6e7c8da 100644 --- a/packages/react-ui/components/Input/Input.tsx +++ b/packages/react-ui/components/Input/Input.tsx @@ -13,10 +13,12 @@ import { ThemeContext } from '../../lib/theming/ThemeContext'; import { Theme } from '../../lib/theming/Theme'; import { CommonProps, CommonWrapper, CommonWrapperRestProps } from '../../internal/CommonWrapper'; import { cx } from '../../lib/theming/Emotion'; -import { rootNode, TSetRootNode } from '../../lib/rootNode'; +import { getRootNode, rootNode, TSetRootNode } from '../../lib/rootNode'; import { createPropsGetter } from '../../lib/createPropsGetter'; import { SizeProp } from '../../lib/types/props'; import { FocusControlWrapper } from '../../internal/FocusControlWrapper'; +import { ClearCrossIcon } from '../../internal/ClearCrossIcon/ClearCrossIcon'; +import { UnreachableError } from '../../lib/utils'; import { InputElement, InputElementProps } from './Input.typings'; import { styles } from './Input.styles'; @@ -26,6 +28,7 @@ import { PolyfillPlaceholder } from './InputLayout/PolyfillPlaceholder'; export const inputTypes = ['password', 'text', 'number', 'tel', 'search', 'time', 'date', 'url', 'email'] as const; export type InputAlign = 'left' | 'center' | 'right'; +export type ShowClearIcon = 'always' | 'onFocus' | 'never'; export type InputType = (typeof inputTypes)[number]; export type InputIconType = React.ReactNode | (() => React.ReactNode); @@ -53,6 +56,12 @@ export interface InputProps Override< React.InputHTMLAttributes, { + /** Устанавливает иконку крестика, при нажатии на который инпут очищается. + * При значении "always" крестик отображается всегда, если поле непустое. + * При значении "onFocus" крестик отображается при фокусировке на непустом поле. + * @default never */ + showClearIcon?: ShowClearIcon; + /** Задает иконку слева. * При использовании `ReactNode` применяются дефолтные стили для иконки. * При использовании `() => ReactNode` применяются только стили для позиционирования. */ @@ -149,13 +158,15 @@ export interface InputState { blinking: boolean; focused: boolean; needsPolyfillPlaceholder: boolean; + clearCrossShowed: boolean; } export const InputDataTids = { root: 'Input__root', + clearCross: 'Input__clearCross', } as const; -type DefaultProps = Required>; +type DefaultProps = Required>; /** * Поле ввода `Input` дает возможность указать значение с помощью клавиатуры. @@ -178,22 +189,48 @@ export class Input extends React.Component { public static defaultProps: DefaultProps = { size: 'small', type: 'text', + showClearIcon: 'never', }; private getProps = createPropsGetter(Input.defaultProps); - public state: InputState = { - needsPolyfillPlaceholder, - blinking: false, - focused: false, - }; - private selectAllId: number | null = null; private theme!: Theme; private blinkTimeout: SafeTimer; public input: HTMLInputElement | null = null; private setRootNode!: TSetRootNode; + private getClearCrossShowed = ({ + focused, + hasInitialValue, + }: { + focused?: boolean; + hasInitialValue?: boolean; + }): boolean => { + const showClearIcon = this.getProps().showClearIcon; + const notEmptyValue = Boolean(this.input?.value || hasInitialValue); + switch (showClearIcon) { + case 'always': + return notEmptyValue; + case 'onFocus': + return Boolean(focused && notEmptyValue); + case 'never': + return false; + default: + throw new UnreachableError(showClearIcon); + } + }; + + public state: InputState = { + needsPolyfillPlaceholder, + blinking: false, + focused: false, + clearCrossShowed: this.getClearCrossShowed({ + focused: false, + hasInitialValue: Boolean(this.props.value || this.props.defaultValue), + }), + }; + private outputMaskError() { warning(!(this.props.mask && this.canBeUsedWithMask), maskErrorMessage(this.getProps().type)); } @@ -393,9 +430,12 @@ export class Input extends React.Component { 'aria-controls': ariaControls, 'aria-label': ariaLabel, element, + showClearIcon: initialShowClearIcon, ...rest } = props; + const { showClearIcon } = this.getProps(); + const { blinking, focused } = this.state; const labelProps = { @@ -444,10 +484,27 @@ export class Input extends React.Component { {this.getInput(inputProps)} ); + const getRightIcon = () => { + return this.state.clearCrossShowed ? ( + { + this.setState({ + clearCrossShowed: showClearIcon === 'always' && !!this.input?.value.toString(), + }); + }} + /> + ) : ( + rightIcon + ); + }; + return ( { } }; + private handleClearInput = () => { + if (this.props.onValueChange) { + this.props.onValueChange(''); + } + if (this.input) { + this.input.value = ''; + } + this.setState({ clearCrossShowed: false }); + }; + private handleChange = (event: React.ChangeEvent) => { + this.setState({ + clearCrossShowed: this.getClearCrossShowed({ focused: this.state.focused }), + }); + if (needsPolyfillPlaceholder) { const fieldIsEmpty = event.target.value === ''; if (this.state.needsPolyfillPlaceholder !== fieldIsEmpty) { @@ -532,6 +603,7 @@ export class Input extends React.Component { private handleFocus = (event: React.FocusEvent) => { this.setState({ focused: true, + clearCrossShowed: this.getClearCrossShowed({ focused: true }), }); if (this.props.selectAllOnFocus) { @@ -582,7 +654,15 @@ export class Input extends React.Component { private resetFocus = () => this.setState({ focused: false }); private handleBlur = (event: React.FocusEvent) => { - this.resetFocus(); - this.props.onBlur?.(event); + const showClearIcon = this.props.showClearIcon; + if (showClearIcon && getRootNode(this)?.contains(event.relatedTarget)) { + this.setState({ focused: false }); + } else { + this.setState({ + focused: false, + clearCrossShowed: this.getClearCrossShowed({ focused: false }), + }); + this.props.onBlur?.(event); + } }; } diff --git a/packages/react-ui/components/Input/__docs__/Input.docs.stories.tsx b/packages/react-ui/components/Input/__docs__/Input.docs.stories.tsx index 777e9fb9fc0..b6303d4d797 100644 --- a/packages/react-ui/components/Input/__docs__/Input.docs.stories.tsx +++ b/packages/react-ui/components/Input/__docs__/Input.docs.stories.tsx @@ -34,7 +34,7 @@ export const Example3: Story = () => { Example3.storyName = 'Очистка значения'; export const Example4: Story = () => { - return } />; + return } />; }; Example4.storyName = 'Префикс'; @@ -84,3 +84,18 @@ export const Example5: Story = () => { ); }; Example5.storyName = 'type'; + +/** При showClearIcon="always" крестик отображается всегда, если в инпут что-либо введено. + * + * При showClearIcon="onFocus" крестик отображается при фокусировке на инпуте, в который что-либо введено. */ +export const Example6: Story = () => { + const [valueAlways, setValueAlways] = React.useState('Отображаю крестик всегда'); + const [valueOnFocus, setValueOnFocus] = React.useState('Отображаю крестик по фокусу'); + return ( + + + + + ); +}; +Example6.storyName = 'Крестик для очистки'; diff --git a/packages/react-ui/components/Input/__stories__/Input.stories.tsx b/packages/react-ui/components/Input/__stories__/Input.stories.tsx index 02d75e14265..b84167be803 100644 --- a/packages/react-ui/components/Input/__stories__/Input.stories.tsx +++ b/packages/react-ui/components/Input/__stories__/Input.stories.tsx @@ -458,3 +458,16 @@ export const WithMaskAndSelectAllProp: Story = () => { export const SearchTypeApi: Story = () => ; export const InputTypeApi: Story = () => ; + +export const ClearCrossSizes: Story = () => { + const [valueSmall, setValueSmall] = React.useState('Small'); + const [valueMedium, setValueMedium] = React.useState('Medium'); + const [valueLarge, setValueLarge] = React.useState('Large'); + return ( + + + + + + ); +}; diff --git a/packages/react-ui/components/Input/__tests__/Input-test.tsx b/packages/react-ui/components/Input/__tests__/Input-test.tsx index 38eeb31bee7..ef01a144fc5 100644 --- a/packages/react-ui/components/Input/__tests__/Input-test.tsx +++ b/packages/react-ui/components/Input/__tests__/Input-test.tsx @@ -526,6 +526,97 @@ describe('', () => { expect(screen.getByRole('textbox')).toHaveAttribute('aria-label', ariaLabel); }); }); + + describe('clear cross', () => { + it('clears uncontrolled input', async () => { + render(); + + await userEvent.type(screen.getByRole('textbox'), 'z'); + expect(screen.getByRole('textbox')).toHaveValue('z'); + + const cross = screen.getByTestId(InputDataTids.clearCross); + await userEvent.click(cross); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('clears uncontrolled input with default value', async () => { + render(); + + expect(screen.getByRole('textbox')).toHaveValue('z'); + + const cross = screen.getByTestId(InputDataTids.clearCross); + await userEvent.click(cross); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('clears controlled input', async () => { + const ControlledInput = () => { + const [value, setValue] = useState('z'); + return ; + }; + render(); + + const cross = screen.getByTestId(InputDataTids.clearCross); + await userEvent.click(cross); + + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('tests always clear cross', () => { + const ControlledInput = () => { + const [value, setValue] = useState('z'); + return ; + }; + render(); + + expect(screen.getByTestId(InputDataTids.clearCross)).toBeInTheDocument(); + }); + + it('tests onFocus clear cross', async () => { + const ControlledInput = () => { + const [value, setValue] = useState('z'); + return ; + }; + render(); + + expect(screen.queryByTestId(InputDataTids.clearCross)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('textbox')); + expect(screen.getByTestId(InputDataTids.clearCross)).toBeInTheDocument(); + }); + + it('tests never clear cross', async () => { + const ControlledInput = () => { + const [value, setValue] = useState('z'); + return ; + }; + render(); + + expect(screen.queryByTestId(InputDataTids.clearCross)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('textbox')); + expect(screen.queryByTestId(InputDataTids.clearCross)).not.toBeInTheDocument(); + }); + + it('tests clear cross when rightIcon', async () => { + const rightIcon = ; + const ControlledInput = () => { + const [value, setValue] = useState('z'); + return ; + }; + render(); + + const cross = screen.getByTestId(InputDataTids.clearCross); + expect(cross).toBeInTheDocument(); + expect(screen.queryByTestId('my-testy-icon')).not.toBeInTheDocument(); + + await userEvent.click(cross); + expect(cross).not.toBeInTheDocument(); + expect(screen.queryByTestId('my-testy-icon')).toBeInTheDocument(); + }); + }); }); const renderEnzyme = (props: InputProps) => diff --git a/packages/react-ui/components/MaskedInput/MaskedInput.tsx b/packages/react-ui/components/MaskedInput/MaskedInput.tsx index ae1f4081076..4587afb812b 100644 --- a/packages/react-ui/components/MaskedInput/MaskedInput.tsx +++ b/packages/react-ui/components/MaskedInput/MaskedInput.tsx @@ -55,7 +55,10 @@ export type MaskInputType = Exclude { + Omit< + InputProps, + 'showClearIcon' | 'mask' | 'maxLength' | 'type' | 'alwaysShowMask' | 'onUnexpectedInput' | 'maskChar' + > { type?: MaskInputType; } diff --git a/packages/react-ui/components/PasswordInput/PasswordInput.tsx b/packages/react-ui/components/PasswordInput/PasswordInput.tsx index 69d1287f018..ae3f946a065 100644 --- a/packages/react-ui/components/PasswordInput/PasswordInput.tsx +++ b/packages/react-ui/components/PasswordInput/PasswordInput.tsx @@ -21,7 +21,10 @@ import { styles } from './PasswordInput.styles'; import { PasswordInputIcon } from './PasswordInputIcon'; import { PasswordInputLocale, PasswordInputLocaleHelper } from './locale'; -export interface PasswordInputProps extends Pick, CommonProps, InputProps { +export interface PasswordInputProps + extends Pick, + CommonProps, + Omit { /** Включает CapsLock детектор. */ detectCapsLock?: boolean; } diff --git a/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.styles.tsx b/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.styles.tsx new file mode 100644 index 00000000000..c4f3706810c --- /dev/null +++ b/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.styles.tsx @@ -0,0 +1,58 @@ +import { css, memoizeStyle } from '../../lib/theming/Emotion'; +import { Theme } from '../../lib/theming/Theme'; +import { resetButton } from '../../lib/styles/Mixins'; + +export const styles = memoizeStyle({ + root(t: Theme) { + return css` + ${resetButton()} + color: ${t.cleanCrossIconColor}; + cursor: pointer; + transition: color ${t.transitionDuration} ${t.transitionTimingFunction}; + &:hover { + color: ${t.cleanCrossIconHoverColor}; + } + display: flex; + justify-content: center; + align-items: center; + `; + }, + + rootDisabled(t: Theme) { + return css` + color: ${t.clearCrossIconDisabledColor}; + `; + }, + + focus(t: Theme) { + return css` + color: ${t.clearCrossIconHoverColor}; + box-shadow: ${t.clearCrossIconFocusShadow}; + `; + }, + + clearCrossSmall(t: Theme) { + return css` + width: ${t.clearCrossIconWidthSmall}; + height: ${t.clearCrossIconHeightSmall}; + margin-right: ${t.clearCrossIconRightMarginSmall}; + border-radius: ${t.clearCrossIconBorderRadiusSmall}; + `; + }, + clearCrossMedium(t: Theme) { + return css` + width: ${t.clearCrossIconWidthMedium}; + height: ${t.clearCrossIconHeightMedium}; + margin-right: ${t.clearCrossIconRightMarginMedium}; + border-radius: ${t.clearCrossIconBorderRadiusMedium}; + `; + }, + clearCrossLarge(t: Theme) { + return css` + width: ${t.clearCrossIconWidthLarge}; + height: ${t.clearCrossIconHeightLarge}; + margin-right: ${t.clearCrossIconRightMarginLarge}; + border-radius: ${t.clearCrossIconBorderRadiusLarge}; + `; + }, +}); diff --git a/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.tsx b/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.tsx new file mode 100644 index 00000000000..bd4b2d3ec67 --- /dev/null +++ b/packages/react-ui/internal/ClearCrossIcon/ClearCrossIcon.tsx @@ -0,0 +1,81 @@ +import React, { AriaAttributes } from 'react'; +import { globalObject } from '@skbkontur/global-object'; + +import { cx } from '../../lib/theming/Emotion'; +import { keyListener } from '../../lib/events/keyListener'; +import { ThemeContext } from '../../lib/theming/ThemeContext'; +import { CommonWrapper, CommonProps } from '../CommonWrapper'; +import { SizeProp } from '../../lib/types/props'; +import { TokenSize } from '../../components/Token'; +import { ThemeFactory } from '../../lib/theming/ThemeFactory'; + +import { styles } from './ClearCrossIcon.styles'; +import { CrossIcon } from './CrossIcon'; + +export interface ClearCrossIconProps + extends Pick, + React.ButtonHTMLAttributes, + CommonProps { + /** Ширина и высота иконки крестика + * @default small */ + size?: SizeProp; +} + +export const ClearCrossIcon: React.FunctionComponent = ({ size = 'small', style, ...rest }) => { + const _theme = React.useContext(ThemeContext); + const theme = ThemeFactory.create( + { + clearCrossIconColor: _theme.clearCrossIconColor, + clearCrossIconHoverColor: _theme.clearCrossIconHoverColor, + }, + _theme, + ); + const getSizeClassName = (size: TokenSize) => { + switch (size) { + case 'large': + return styles.clearCrossLarge(theme); + case 'medium': + return styles.clearCrossMedium(theme); + case 'small': + default: + return styles.clearCrossSmall(theme); + } + }; + + const [focusedByTab, setFocusedByTab] = React.useState(false); + + const handleFocus = () => { + // focus event fires before keyDown eventlistener so we should check tabPressed in async way + globalObject.requestAnimationFrame?.(() => { + if (keyListener.isTabPressed) { + setFocusedByTab(true); + } + }); + }; + const handleBlur = () => setFocusedByTab(false); + + return ( + + + + ); +}; + +ClearCrossIcon.__KONTUR_REACT_UI__ = 'ClearCrossIcon'; +ClearCrossIcon.displayName = 'ClearCrossIcon'; diff --git a/packages/react-ui/internal/ClearCrossIcon/CrossIcon.tsx b/packages/react-ui/internal/ClearCrossIcon/CrossIcon.tsx new file mode 100644 index 00000000000..2dc711c7e1e --- /dev/null +++ b/packages/react-ui/internal/ClearCrossIcon/CrossIcon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { iconSizer } from '../icons2022/iconSizer'; +import { XIcon16Light } from '../icons2022/XIcon/XIcon16Light'; +import { XIcon20Light } from '../icons2022/XIcon/XIcon20Light'; +import { XIcon24Regular } from '../icons2022/XIcon/XIcon24Regular'; + +export const CrossIcon = iconSizer( + { + small: () => , + medium: () => , + large: () => , + }, + 'CrossIcon', +); diff --git a/packages/react-ui/internal/ClearCrossIcon/__tests__/ClearCrossIcon.test.tsx b/packages/react-ui/internal/ClearCrossIcon/__tests__/ClearCrossIcon.test.tsx new file mode 100644 index 00000000000..331b6f7ee24 --- /dev/null +++ b/packages/react-ui/internal/ClearCrossIcon/__tests__/ClearCrossIcon.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ClearCrossIcon } from '../ClearCrossIcon'; + +describe('ClearCrossIcon', () => { + it('focuses on click', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button')).toHaveFocus(); + }); + + it('blurs on click', async () => { + render( + <> + +
+ , + ); + const button = screen.getByRole('button'); + + await userEvent.click(button); + await userEvent.click(screen.getByTestId('next-focus')); + + expect(button).not.toHaveFocus(); + }); + + it('disabled clear cross dont get focus', async () => { + render(); + + await userEvent.tab(); + expect(screen.getByRole('button')).not.toHaveFocus(); + }); + + it('gets data-tid', async () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute('data-tid', 'tid'); + }); +}); diff --git a/packages/react-ui/internal/CustomComboBox/ComboBoxView.tsx b/packages/react-ui/internal/CustomComboBox/ComboBoxView.tsx index d8f4c32b0f3..1998c869650 100644 --- a/packages/react-ui/internal/CustomComboBox/ComboBoxView.tsx +++ b/packages/react-ui/internal/CustomComboBox/ComboBoxView.tsx @@ -1,7 +1,7 @@ import React, { AriaAttributes, HTMLAttributes } from 'react'; import { getRandomID, isNonNullable } from '../../lib/utils'; -import { Input, InputIconType, InputProps } from '../../components/Input'; +import { Input, InputDataTids, InputIconType, InputProps, ShowClearIcon } from '../../components/Input'; import { InputLikeText } from '../InputLikeText'; import { Menu } from '../Menu'; import { MenuItemState } from '../../components/MenuItem'; @@ -21,6 +21,7 @@ import { SizeProp } from '../../lib/types/props'; import { Popup } from '../Popup'; import { getMenuPositions } from '../../lib/getMenuPositions'; import { ZIndex } from '../ZIndex'; +import { ClearCrossIcon } from '../ClearCrossIcon/ClearCrossIcon'; import { ArrowDownIcon } from './ArrowDownIcon'; import { ComboBoxMenu } from './ComboBoxMenu'; @@ -57,6 +58,7 @@ interface ComboBoxViewProps textValue?: string; totalCount?: number; value?: Nullable; + showClearIcon?: ShowClearIcon; /** * Cостояние валидации при предупреждении. */ @@ -77,6 +79,7 @@ interface ComboBoxViewProps onInputValueChange?: (value: string) => void; onInputFocus?: () => void; onInputClick?: () => void; + onClearCrossClick?: () => void; onInputKeyDown?: (e: React.KeyboardEvent) => void; onMouseEnter?: (e: React.MouseEvent) => void; onMouseOver?: (e: React.MouseEvent) => void; @@ -106,6 +109,7 @@ type DefaultProps = Required< | 'onFocusOutside' | 'size' | 'width' + | 'showClearIcon' > >; @@ -115,6 +119,7 @@ export const ComboBoxViewIds = { interface ComboBoxViewState { anchorElement: Nullable; + clearCrossShowed: boolean; } @responsiveLayout @@ -137,6 +142,7 @@ export class ComboBoxView extends React.Component, Combo }, size: 'small', width: 250, + showClearIcon: 'never', }; private getProps = createPropsGetter(ComboBoxView.defaultProps); @@ -151,6 +157,7 @@ export class ComboBoxView extends React.Component, Combo public state = { anchorElement: null, + clearCrossShowed: this.props.showClearIcon === 'always' && !!this.props.value?.toString(), }; public componentDidMount() { @@ -340,6 +347,7 @@ export class ComboBoxView extends React.Component, Combo size, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, + showClearIcon, } = this.props; const { renderValue } = this.getProps(); @@ -373,6 +381,7 @@ export class ComboBoxView extends React.Component, Combo aria-describedby={ariaDescribedby} aria-controls={this.menuId} aria-label={ariaLabel} + showClearIcon={showClearIcon} /> ); } @@ -436,6 +445,17 @@ export class ComboBoxView extends React.Component, Combo return ; } + if (this.getProps().showClearIcon !== 'never' && this.state.clearCrossShowed) { + return ( + event.stopPropagation()} + onClick={this.props.onClearCrossClick} + /> + ); + } + if (rightIcon || drawArrow) { return rightIcon || ; } diff --git a/packages/react-ui/internal/CustomComboBox/CustomComboBox.tsx b/packages/react-ui/internal/CustomComboBox/CustomComboBox.tsx index 49ada5c8a3e..1404658b739 100644 --- a/packages/react-ui/internal/CustomComboBox/CustomComboBox.tsx +++ b/packages/react-ui/internal/CustomComboBox/CustomComboBox.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { globalObject } from '@skbkontur/global-object'; import { Nullable } from '../../typings/utility-types'; -import { Input, InputIconType } from '../../components/Input'; +import { Input, InputIconType, ShowClearIcon } from '../../components/Input'; import { Menu } from '../Menu'; import { InputLikeText } from '../InputLikeText'; import { MenuItemState } from '../../components/MenuItem'; @@ -62,6 +62,7 @@ export interface CustomComboBoxProps size?: SizeProp; totalCount?: number; value?: Nullable; + showClearIcon?: ShowClearIcon; /** * Cостояние валидации при предупреждении. */ @@ -271,6 +272,7 @@ export class CustomComboBox extends React.PureComponent extends React.PureComponent this.dispatch({ type: 'TextChange', value }), onInputFocus: this.handleFocus, onInputClick: this.handleInputClick, + onClearCrossClick: () => this.dispatch({ type: 'ClearCrossClick' }), onInputKeyDown: (event: React.KeyboardEvent) => { event.persist(); this.dispatch({ type: 'KeyPress', event }); diff --git a/packages/react-ui/internal/CustomComboBox/CustomComboBoxReducer.tsx b/packages/react-ui/internal/CustomComboBox/CustomComboBoxReducer.tsx index 59903ae0597..8811256b4b2 100644 --- a/packages/react-ui/internal/CustomComboBox/CustomComboBoxReducer.tsx +++ b/packages/react-ui/internal/CustomComboBox/CustomComboBoxReducer.tsx @@ -25,6 +25,7 @@ export type CustomComboBoxAction = | { type: 'Mount' } | { type: 'Focus' } | { type: 'InputClick' } + | { type: 'ClearCrossClick' } | { type: 'Blur' } | { type: 'Reset' } | { type: 'Open' } @@ -55,6 +56,7 @@ interface EffectFactory { focus: Effect; valueChange: (value: any) => Effect; + valueClear: Effect; unexpectedInput: (textValue: string, items: Nullable) => Effect; inputChange: Effect; inputFocus: Effect; @@ -103,6 +105,9 @@ export const Effect: EffectFactory = { onValueChange(value); } }, + valueClear: (dispatch) => { + dispatch({ type: 'TextChange', value: '' }); + }, unexpectedInput: (textValue, items) => (dispatch, getState, getProps) => { const { onUnexpectedInput, valueToString } = getProps(); if (Array.isArray(items) && items.length === 1) { @@ -349,6 +354,13 @@ export function reducer( } return state; } + case 'ClearCrossClick': { + const newState = { + editing: true, + loading: true, + }; + return [newState, [Effect.focus, Effect.search(''), Effect.valueClear]]; + } case 'Blur': { const { inputChanged, items } = state; if (!inputChanged) { diff --git a/packages/react-ui/internal/InputLikeText/InputLikeText.tsx b/packages/react-ui/internal/InputLikeText/InputLikeText.tsx index 2a1d7bc6f4b..24ac69bee91 100644 --- a/packages/react-ui/internal/InputLikeText/InputLikeText.tsx +++ b/packages/react-ui/internal/InputLikeText/InputLikeText.tsx @@ -25,7 +25,7 @@ import { FocusControlWrapper } from '../FocusControlWrapper'; import { HiddenInput } from './HiddenInput'; import { styles } from './InputLikeText.styles'; -export interface InputLikeTextProps extends CommonProps, InputProps { +export interface InputLikeTextProps extends CommonProps, Omit { children?: React.ReactNode; innerRef?: (el: HTMLElement | null) => void; onFocus?: React.FocusEventHandler; @@ -35,7 +35,7 @@ export interface InputLikeTextProps extends CommonProps, InputProps { takeContentWidth?: boolean; } -export type InputLikeTextState = Omit; +export type InputLikeTextState = Omit; export const InputLikeTextDataTids = { root: 'InputLikeText__root', diff --git a/packages/react-ui/internal/themes/BasicDarkTheme.ts b/packages/react-ui/internal/themes/BasicDarkTheme.ts index 8fa6ab89991..3ca6878e56f 100644 --- a/packages/react-ui/internal/themes/BasicDarkTheme.ts +++ b/packages/react-ui/internal/themes/BasicDarkTheme.ts @@ -43,6 +43,14 @@ export class BasicDarkThemeInternal extends (class {} as typeof BasicLightTheme) //#region CloseIcon, CloseButtonIcon public static closeBtnIconColor = 'rgba(255, 255, 255, 0.32)'; //#endregion CloseIcon, CloseButtonIcon + + //#region ClearCrossIcon + public static clearCrossIconColor = 'rgba(255, 255, 255, 0.32)'; + public static get clearCrossIconHoverColor() { + return this.inputBorderColorFocus; + } + //#endregion ClearCrossIcon + //#region Link public static linkColor = 'rgba(255, 255, 255, 0.87)'; public static linkHoverColor = '#ffffff'; diff --git a/packages/react-ui/internal/themes/BasicLightTheme.ts b/packages/react-ui/internal/themes/BasicLightTheme.ts index 2ae1a22315c..5f1f60c19ee 100644 --- a/packages/react-ui/internal/themes/BasicLightTheme.ts +++ b/packages/react-ui/internal/themes/BasicLightTheme.ts @@ -2435,6 +2435,61 @@ export class BasicLightThemeInternal { '0px 0px 0px 3px rgb(149, 149, 149), 0px 0px 0px 8px rgba(61, 61, 61, 0.2)'; //#endregion FileUploader + //#region ClearCrossIcon + public static get clearCrossIconWidthSmall() { + return this.inputHeightSmall; + } + public static get clearCrossIconWidthMedium() { + return this.inputHeightMedium; + } + public static get clearCrossIconWidthLarge() { + return this.inputHeightLarge; + } + public static get clearCrossIconHeightSmall() { + return this.inputHeightSmall; + } + public static get clearCrossIconHeightMedium() { + return this.inputHeightMedium; + } + public static get clearCrossIconHeightLarge() { + return this.inputHeightLarge; + } + + public static get clearCrossIconRightMarginSmall() { + const inputPaddingXSmall = parseInt(this.inputPaddingXSmall); + const inputBorderWidth = parseInt(this.inputBorderWidth); + return `-${inputPaddingXSmall + inputBorderWidth}px`; + } + public static get clearCrossIconRightMarginMedium() { + const inputPaddingXMedium = parseInt(this.inputPaddingXMedium); + const inputBorderWidth = parseInt(this.inputBorderWidth); + return `-${inputPaddingXMedium + inputBorderWidth}px`; + } + public static get clearCrossIconRightMarginLarge() { + const inputPaddingXLarge = parseInt(this.inputPaddingXLarge); + const inputBorderWidth = parseInt(this.inputBorderWidth); + return `-${inputPaddingXLarge + inputBorderWidth}px`; + } + + public static get clearCrossIconBorderRadiusSmall() { + return this.inputBorderRadiusSmall; + } + public static get clearCrossIconBorderRadiusMedium() { + return this.inputBorderRadiusMedium; + } + public static get clearCrossIconBorderRadiusLarge() { + return this.inputBorderRadiusLarge; + } + + public static clearCrossIconColor = '#757575'; + public static clearCrossIconDisabledColor = '#8b8b8b'; + public static clearCrossIconHoverColor = '#222222'; + public static get clearCrossIconFocusShadow() { + return `inset 0 0 0 2px ${this.borderColorFocus}`; + } + + //#endregion ClearCrossIcon + //#region CloseIcon public static closeBtnIconColor = 'rgba(0, 0, 0, 0.32)'; public static closeBtnIconDisabledColor = '#8b8b8b'; diff --git a/packages/react-ui/lib/utils.ts b/packages/react-ui/lib/utils.ts index 0279dee1e0f..2d6bc02d0d1 100644 --- a/packages/react-ui/lib/utils.ts +++ b/packages/react-ui/lib/utils.ts @@ -233,3 +233,9 @@ export function clickOutside() { document.body.dispatchEvent(event); } + +export class UnreachableError extends Error { + public constructor(guard: never) { + super(`Unsupported kind: ${JSON.stringify(guard)}`); + } +}