diff --git a/src/components/Select/README.md b/src/components/Select/README.md index 25475fa2d7..7885a81ec0 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -29,6 +29,7 @@ | multiple | `boolean` | `false` | Indicates that multiple options can be selected in the list | | filterable | `boolean` | `false` | Indicates that select popup have filter section | | disabled | `boolean` | `false` | Indicates that the user cannot interact with the control | +| hasClear | `boolean` | `false` | Enable displaying icon for clear selected options | --- diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index b2011c7549..783aa8a974 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -72,6 +72,7 @@ export const Select = React.forwardRef(function disabled = false, filterable = false, disablePortal, + hasClear = false, onClose, } = props; const [mobile] = useMobile(); @@ -83,7 +84,7 @@ export const Select = React.forwardRef(function const filterRef = React.useRef(null); const listRef = React.useRef>(null); const handleControlRef = useForkRef(ref, controlRef); - const {value, open, toggleOpen, handleSelection} = useSelect({ + const {value, open, toggleOpen, handleSelection, handleClearValue} = useSelect({ onUpdate, value: propsValue, defaultValue, @@ -212,6 +213,8 @@ export const Select = React.forwardRef(function > { { + renderControl: ({onClick, onKeyDown, ref, renderClear}) => { return ( ); }, diff --git a/src/components/Select/__stories__/constants.ts b/src/components/Select/__stories__/constants.ts index 2c3d312db8..2421ae4ce4 100644 --- a/src/components/Select/__stories__/constants.ts +++ b/src/components/Select/__stories__/constants.ts @@ -134,7 +134,7 @@ export const EXAMPLE_USER_CONTROL = `const [value, setValue] = React.useState { + renderControl={({onClick, onKeyDown, ref, renderClear}) => { return ( ); }}, diff --git a/src/components/Select/__tests__/Select.base-actions.test.tsx b/src/components/Select/__tests__/Select.base-actions.test.tsx index d90a01a569..a039a3c551 100644 --- a/src/components/Select/__tests__/Select.base-actions.test.tsx +++ b/src/components/Select/__tests__/Select.base-actions.test.tsx @@ -13,7 +13,7 @@ import { GROUPED_OPTIONS, GROUPED_QUICK_SEARCH_OPTIONS, QUICK_SEARCH_OPTIONS, - SELECT_CONTROL_OPEN_CLASS, + SELECT_CONTROL_BUTTON_OPEN_CLASS, SELECT_LIST_VIRTUALIZED_CLASS, TEST_QA, generateOptions, @@ -60,7 +60,7 @@ describe('Select base actions', () => { setup(); }); const selectControl = screen.getByTestId(TEST_QA); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); expect(screen.queryByTestId(SelectQa.POPUP)).toBeNull(); }); test('should have [type="button"] attribute in root button', async () => { @@ -78,7 +78,7 @@ describe('Select base actions', () => { setup({defaultOpen: true}); }); const selectControl = screen.getByTestId(TEST_QA); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); screen.getByTestId(SelectQa.POPUP); }); @@ -88,29 +88,29 @@ describe('Select base actions', () => { }); const selectControl = screen.getByTestId(TEST_QA); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); test('shoult open/close by open prop', async () => { const {rerender, getByTestId} = render(); const selectControl = getByTestId(TEST_QA); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); rerender(); const rerenderedSelectControl = getByTestId(TEST_QA); - expect(rerenderedSelectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(rerenderedSelectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); test('should not close when open=true prop passed', async () => { setup({open: true}); const selectControl = screen.getByTestId(TEST_QA); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); await toggleSelectPopup(); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); test('should call onOpenChange while closing', async () => { diff --git a/src/components/Select/__tests__/Select.clear.test.tsx b/src/components/Select/__tests__/Select.clear.test.tsx new file mode 100644 index 0000000000..16e8dd9758 --- /dev/null +++ b/src/components/Select/__tests__/Select.clear.test.tsx @@ -0,0 +1,76 @@ +import {cleanup} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import {SelectQa} from '../constants'; +import type {SelectProps} from '../types'; + +import {DEFAULT_OPTIONS, setup} from './utils'; + +afterEach(() => { + cleanup(); + jest.clearAllMocks(); +}); + +const onUpdate = jest.fn(); + +describe('Select clear', () => { + test.each<[string, Partial]>([ + ['single', {hasClear: true, multiple: false}], + ['multiple', {hasClear: true, multiple: true}], + ])('hide clear icon with hasClear and no selected value', async () => { + const {queryByTestId} = setup({hasClear: true}); + expect(queryByTestId(SelectQa.CLEAR)).toBeNull(); + }); + + test.each<[string, Partial]>([ + ['single', {hasClear: true, multiple: false}], + ['multiple', {hasClear: true, multiple: true}], + ])('display clear icon with hasClear and with selected value', async () => { + const {getByTestId} = setup({hasClear: true, value: [DEFAULT_OPTIONS[0].value]}); + getByTestId(SelectQa.CLEAR); + }); + + test.each<[string, Partial]>([ + ['single', {hasClear: true, multiple: false}], + ['multiple', {hasClear: true, multiple: true}], + ])('hide clear icon for disabled select with hasClear and with selected value', async () => { + const {queryByTestId} = setup({ + hasClear: true, + disabled: true, + value: [DEFAULT_OPTIONS[0].value], + }); + expect(queryByTestId(SelectQa.CLEAR)).toBeNull(); + }); + + test.each<[string, Partial]>([ + ['single', {hasClear: true, multiple: false}], + ['multiple', {hasClear: true, multiple: true}], + ])('click on clear icon will remove selected value', async () => { + const {getByTestId} = setup({ + hasClear: true, + value: [DEFAULT_OPTIONS[0].value], + onUpdate, + }); + + const user = userEvent.setup(); + await user.click(getByTestId(SelectQa.CLEAR)); + expect(onUpdate).toHaveBeenCalledWith([]); + }); + + test.each<[string, Partial]>([ + ['single', {hasClear: true, multiple: false}], + ['multiple', {hasClear: true, multiple: true}], + ])('check for correct focus on tab with hasClear and with selected value', async () => { + setup({ + hasClear: true, + value: [DEFAULT_OPTIONS[0].value], + onUpdate, + }); + const user = userEvent.setup(); + await user.keyboard('[Tab]'); + await user.keyboard('[Tab]'); + // check that after double tab clear icon is focused and press enter will clear the value + await user.keyboard('[Enter]'); + expect(onUpdate).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/components/Select/__tests__/Select.muitiple.test.tsx b/src/components/Select/__tests__/Select.muitiple.test.tsx index c3aede5a5e..f70262ea09 100644 --- a/src/components/Select/__tests__/Select.muitiple.test.tsx +++ b/src/components/Select/__tests__/Select.muitiple.test.tsx @@ -5,7 +5,7 @@ import { DEFAULT_OPTIONS, GROUPED_OPTIONS, OptionsListType, - SELECT_CONTROL_OPEN_CLASS, + SELECT_CONTROL_BUTTON_OPEN_CLASS, TEST_QA, setup, } from './utils'; @@ -33,12 +33,12 @@ describe('Select multiple mode actions', () => { const selectControl = getByTestId(TEST_QA); // open select popup await user.click(selectControl); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); let option = getByText(selectedOptions[0].content as string); // select first option await user.click(option); expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[0].value]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); option = getByText(selectedOptions[1].content as string); // select second option await user.click(option); @@ -46,10 +46,10 @@ describe('Select multiple mode actions', () => { selectedOptions[0].value, selectedOptions[1].value, ]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // close select popup await user.keyboard('[Escape]'); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); @@ -70,15 +70,15 @@ describe('Select multiple mode actions', () => { const selectControl = getByTestId(TEST_QA); // open select popup await user.click(selectControl); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); const option = getByText(selectedOptions[0].content as string); // deselect first option await user.click(option); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[1].value]); // close select popup await user.keyboard('[Escape]'); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); @@ -96,11 +96,11 @@ describe('Select multiple mode actions', () => { await user.keyboard('[Tab]'); // open select popup await user.keyboard(`[${key}]`); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // select active option (first option by default) await user.keyboard(`[${key}]`); expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[0].value]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // focus next option await user.keyboard('[ArrowDown]'); // select next option @@ -109,10 +109,10 @@ describe('Select multiple mode actions', () => { selectedOptions[0].value, selectedOptions[1].value, ]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // close select popup await user.keyboard('[Escape]'); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); @@ -136,20 +136,20 @@ describe('Select multiple mode actions', () => { await user.keyboard('[Tab]'); // open select popup await user.keyboard(`[${key}]`); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // select active option (first option by default) await user.keyboard(`[${key}]`); expect(onUpdate).toHaveBeenLastCalledWith([selectedOptions[1].value]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // focus next option await user.keyboard('[ArrowDown]'); // select next option await user.keyboard(`[${key}]`); expect(onUpdate).toHaveBeenCalledWith([]); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // close select popup await user.keyboard('[Escape]'); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); }); diff --git a/src/components/Select/__tests__/Select.single.test.tsx b/src/components/Select/__tests__/Select.single.test.tsx index 0801289684..d43e0fdbe2 100644 --- a/src/components/Select/__tests__/Select.single.test.tsx +++ b/src/components/Select/__tests__/Select.single.test.tsx @@ -5,7 +5,7 @@ import { DEFAULT_OPTIONS, GROUPED_OPTIONS, OptionsListType, - SELECT_CONTROL_OPEN_CLASS, + SELECT_CONTROL_BUTTON_OPEN_CLASS, TEST_QA, setup, } from './utils'; @@ -31,12 +31,12 @@ describe('Select single mode actions', () => { const selectControl = getByTestId(TEST_QA); // open select popup await user.click(selectControl); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); const option = getByText(selectedOption.content as string); // select option await user.click(option); expect(onUpdate).toHaveBeenCalledWith([selectedOption.value]); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); @@ -54,11 +54,11 @@ describe('Select single mode actions', () => { await user.keyboard('[Tab]'); // open select popup await user.keyboard(`[${key}]`); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); // select active option (first option by default) await user.keyboard(`[${key}]`); expect(onUpdate).toHaveBeenCalledWith([selectedOption.value]); - expect(selectControl).not.toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).not.toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); @@ -71,12 +71,12 @@ describe('Select single mode actions', () => { const selectControl = getByTestId(TEST_QA); // open select popup await user.click(selectControl); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); const option = getByText(searchText); // select option await user.click(option); expect(onUpdate).not.toBeCalled(); - expect(selectControl).toHaveClass(SELECT_CONTROL_OPEN_CLASS); + expect(selectControl).toHaveClass(SELECT_CONTROL_BUTTON_OPEN_CLASS); }); }); }); diff --git a/src/components/Select/__tests__/utils.tsx b/src/components/Select/__tests__/utils.tsx index 20882a9f26..aeded7b9fd 100644 --- a/src/components/Select/__tests__/utils.tsx +++ b/src/components/Select/__tests__/utils.tsx @@ -6,7 +6,7 @@ import {range} from 'lodash'; import {Select} from '..'; import type {SelectOption, SelectOptionGroup, SelectProps} from '..'; import {MobileProvider} from '../../mobile'; -import {selectControlBlock, selectListBlock} from '../constants'; +import {selectControlBlock, selectControlButtonBlock, selectListBlock} from '../constants'; export const OptionsListType = { FLAT: 'flat', @@ -14,6 +14,7 @@ export const OptionsListType = { } as const; export const TEST_QA = 'select-test-qa'; export const SELECT_CONTROL_OPEN_CLASS = selectControlBlock({open: true}); +export const SELECT_CONTROL_BUTTON_OPEN_CLASS = selectControlButtonBlock({open: true}); export const SELECT_LIST_VIRTUALIZED_CLASS = selectListBlock({virtualized: true}); export const DEFAULT_OPTIONS = generateOptions([ ['js', 'JavaScript'], diff --git a/src/components/Select/components/SelectClear/SelectClear.scss b/src/components/Select/components/SelectClear/SelectClear.scss new file mode 100644 index 0000000000..2afa8b7a18 --- /dev/null +++ b/src/components/Select/components/SelectClear/SelectClear.scss @@ -0,0 +1,52 @@ +@use '../../../../../styles/mixins'; +@use '../../../variables'; + +$block: '.#{variables.$ns-new}select-clear'; + +#{$block} { + @include mixins.button-reset(); + display: inline-flex; + justify-content: center; + align-items: center; + margin-left: auto; + z-index: 1; + + &:focus-visible { + border: 1px solid var(--yc-color-line-generic-active); + } + + &_size_s { + height: #{variables.$s-height}; + width: #{variables.$s-height}; + border-radius: var(--yc-border-radius-s); + } + + &_size_m { + height: #{variables.$m-height}; + width: #{variables.$m-height}; + border-radius: var(--yc-border-radius-m); + } + + &_size_l { + height: #{variables.$l-height}; + width: #{variables.$l-height}; + border-radius: var(--yc-border-radius-l); + } + + &_size_xl { + height: #{variables.$xl-height}; + width: #{variables.$xl-height}; + border-radius: var(--yc-border-radius-xl); + } + + &__clear { + --yc-button-background-color: transparent; + --yc-button-background-color-hover: transparent; + + color: var(--yc-color-text-secondary); + + #{$block}:hover & { + color: var(--yc-color-text-primary); + } + } +} diff --git a/src/components/Select/components/SelectClear/SelectClear.tsx b/src/components/Select/components/SelectClear/SelectClear.tsx new file mode 100644 index 0000000000..e19661732a --- /dev/null +++ b/src/components/Select/components/SelectClear/SelectClear.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {Xmark} from '@gravity-ui/icons'; + +import {Icon} from '../../../Icon'; +import {SelectQa, selectClearBlock} from '../../constants'; +import i18n from '../../i18n'; +import type {SelectClearProps} from '../../types'; + +import './SelectClear.scss'; + +export const SelectClear = (props: SelectClearProps) => { + const {size, onClick, onMouseEnter, onMouseLeave, renderIcon} = props; + const icon = renderIcon ? ( + renderIcon() + ) : ( + + ); + return ( + + ); +}; + +SelectClear.displayName = 'SelectClear'; diff --git a/src/components/Select/components/SelectControl/SelectControl.scss b/src/components/Select/components/SelectControl/SelectControl.scss index 70c486a133..a78a351e2b 100644 --- a/src/components/Select/components/SelectControl/SelectControl.scss +++ b/src/components/Select/components/SelectControl/SelectControl.scss @@ -1,102 +1,187 @@ @use '../../../../../styles/mixins'; @use '../../../variables'; @use '../../variables.scss' as select-css-variables; +@use '../SelectClear/SelectClear'; $block: '.#{variables.$ns-new}select-control'; +$blockButton: '.#{variables.$ns-new}select-control__button'; -@mixin block_style($size: m) { +@mixin block_control($size: m) { @if $size == 's' { - @include mixins.text-body-short; - - height: #{variables.$s-height}; - padding: 4px #{select-css-variables.$s-hor-padding}; - --_--select-border-radius: var(--yc-border-radius-s); --_--select-options-text-right-padding: #{select-css-variables.$s-hor-padding}; + --_--select-border-radius: var(--yc-border-radius-s); + height: #{variables.$s-height}; + padding: 4px calc(var(--_--select-options-text-right-padding) + 1px); // plus border-width } @if $size == 'm' { - @include mixins.text-body-short; - - height: #{variables.$m-height}; - padding: 6px #{select-css-variables.$m-hor-padding}; - --_--select-border-radius: var(--yc-border-radius-m); --_--select-options-text-right-padding: #{select-css-variables.$m-hor-padding}; + --_--select-border-radius: var(--yc-border-radius-m); + height: #{variables.$m-height}; + padding: 6px calc(var(--_--select-options-text-right-padding) + 1px); // plus border-width } @if $size == 'l' { - @include mixins.text-body-short; - - height: #{variables.$l-height}; - padding: 10px #{select-css-variables.$l-hor-padding}; - --_--select-border-radius: var(--yc-border-radius-l); --_--select-options-text-right-padding: #{select-css-variables.$l-hor-padding}; + --_--select-border-radius: var(--yc-border-radius-l); + height: #{variables.$l-height}; + padding: 10px calc(var(--_--select-options-text-right-padding) + 1px); // plus border-width + } + @if $size == 'xl' { + --_--select-options-text-right-padding: #{select-css-variables.$xl-hor-padding}; + --_--select-border-radius: var(--yc-border-radius-xl); + height: #{variables.$xl-height}; + padding: 12px calc(var(--_--select-options-text-right-padding) + 1px); // plus border-width + } +} + +@mixin block_button_style($size: m) { + @if $size == 's' { + @include mixins.text-body-short; + } + @if $size == 'm' { + @include mixins.text-body-short; + } + @if $size == 'l' { + @include mixins.text-body-short; } @if $size == 'xl' { @include mixins.text-body-2; + } +} - height: #{variables.$xl-height}; - padding: 12px #{select-css-variables.$xl-hor-padding}; - --_--select-border-radius: var(--yc-border-radius-xl); - --_--select-options-text-right-padding: #{select-css-variables.$xl-hor-padding}; +@mixin block_clear_reserved_width() { + // reserving place for clear icon to fix width changing on displaying clear + #{$block}_has-clear:not(#{$block}_has-value)#{$block}_size_s & { + padding-right: calc(#{variables.$s-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear:not(#{$block}_has-value)#{$block}_size_m & { + padding-right: calc(#{variables.$m-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear:not(#{$block}_has-value)#{$block}_size_l & { + padding-right: calc(#{variables.$l-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear:not(#{$block}_has-value)#{$block}_size_xl & { + padding-right: calc(#{variables.$xl-height} + var(--_--select-options-text-right-padding)); + } +} + +@mixin block_clear_reserved_disabled_width() { + // reserving place for clear icon to fix width changing on displaying clear for disabled select + #{$block}_has-clear#{$block}_size_s #{$blockButton}_disabled & { + padding-right: calc(#{variables.$s-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear#{$block}_size_m #{$blockButton}_disabled & { + padding-right: calc(#{variables.$m-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear#{$block}_size_l #{$blockButton}_disabled & { + padding-right: calc(#{variables.$l-height} + var(--_--select-options-text-right-padding)); + } + #{$block}_has-clear#{$block}_size_xl #{$blockButton}_disabled & { + padding-right: calc(#{variables.$xl-height} + var(--_--select-options-text-right-padding)); } } #{$block} { @include mixins.button-reset(); - @include mixins.pin($block, $block, var(--_--select-border-radius)); - width: 100%; - box-sizing: border-box; + position: relative; display: inline-flex; align-items: center; - border: 1px solid var(--yc-color-line-generic); - overflow: hidden; + box-sizing: border-box; transition: transform 0.1s ease-out, color 0.15s linear, background-color 0.15s linear; - - &_view_clear { - border-color: transparent; - } + width: 100%; &_size_s { - @include block_style(s); + @include block_control(s); } &_size_m { - @include block_style(m); + @include block_control(m); } &_size_l { - @include block_style(l); + @include block_control(l); } &_size_xl { - @include block_style(xl); + @include block_control(xl); } - &_error { - border-color: var(--yc-color-line-danger); - } + &__button { + @include mixins.button-reset(); - &_disabled { - background-color: var(--yc-color-base-generic-accent-disabled); - color: var(--yc-color-text-hint); - border-color: transparent; - pointer-events: none; - } + overflow: hidden; - &:active { - transform: scale(0.96); - } + &::before { + @include mixins.pin($block, $block, var(--_--select-border-radius)); + + content: ''; + position: absolute; + inset: 0; + border: 1px solid var(--yc-color-line-generic); + border-radius: var(--_--select-border-radius); + } + + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: var(--_--select-border-radius); + } + + &_view_clear { + border-color: transparent; + } + + &_size_s { + @include block_button_style(s); + } - &:hover { - background-color: var(--yc-color-base-simple-hover); + &_size_m { + @include block_button_style(m); + } + + &_size_l { + @include block_button_style(l); + } + + &_size_xl { + @include block_button_style(xl); + } + + &_error::before { + border-color: var(--yc-color-line-danger); + } + + &:hover::after { + background-color: var(--yc-color-base-simple-hover); + } + + &_disabled { + color: var(--yc-color-text-hint); + pointer-events: none; + + &::after { + background-color: var(--yc-color-base-generic-accent-disabled); + } + + &::before { + border-color: transparent; + } + } - &:not(#{$block}_view_clear):not(#{$block}_error) { + &:not(&_error):not(&_disabled)::before { border-color: var(--yc-color-line-generic-hover); } + + &_open:not(&_error)::before, + &:not(&_error):focus-visible::before { + border-color: var(--yc-color-line-generic-active); + } } - &:focus-visible:not(#{$block}_error), - &_open:not(#{$block}_error) { - border-color: var(--yc-color-line-generic-active); + &:not(&_disabled):not(&_no-active):active { + transform: scale(0.96); } &__label { @@ -112,12 +197,17 @@ $block: '.#{variables.$ns-new}select-control'; &__placeholder, &__option-text { @include mixins.overflow-ellipsis(); + @include block_clear_reserved_disabled_width(); padding-right: var(--_--select-options-text-right-padding); } &__placeholder { color: var(--yc-color-text-hint); + + #{$blockButton}:not(#{$blockButton}_disabled) & { + @include block_clear_reserved_width(); + } } &__chevron-icon { @@ -130,6 +220,10 @@ $block: '.#{variables.$ns-new}select-control'; } } + #{SelectClear.$block} + &__chevron-icon { + margin-left: 0; + } + &__error { @include mixins.text-body-1(); diff --git a/src/components/Select/components/SelectControl/SelectControl.tsx b/src/components/Select/components/SelectControl/SelectControl.tsx index ea75085953..12a677cb6e 100644 --- a/src/components/Select/components/SelectControl/SelectControl.tsx +++ b/src/components/Select/components/SelectControl/SelectControl.tsx @@ -1,12 +1,19 @@ import React from 'react'; import {ChevronDown} from '@gravity-ui/icons'; +import isEmpty from 'lodash/isEmpty'; import {Icon} from '../../../Icon'; import type {CnMods} from '../../../utils/cn'; import {useForkRef} from '../../../utils/useForkRef'; -import {selectControlBlock} from '../../constants'; -import type {SelectProps, SelectRenderControl, SelectRenderControlProps} from '../../types'; +import {selectControlBlock, selectControlButtonBlock} from '../../constants'; +import type { + SelectProps, + SelectRenderClearArgs, + SelectRenderControl, + SelectRenderControlProps, +} from '../../types'; +import {SelectClear} from '../SelectClear/SelectClear'; import './SelectControl.scss'; @@ -25,11 +32,14 @@ type ControlProps = { error?: SelectProps['error']; disabled?: boolean; value: SelectProps['value']; + clearValue: () => void; + hasClear?: boolean; } & Omit; export const SelectControl = React.forwardRef((props, ref) => { const { toggleOpen, + clearValue, onKeyDown, renderControl, view, @@ -45,12 +55,63 @@ export const SelectControl = React.forwardRef((props, open, disabled, value, + hasClear, } = props; const controlRef = React.useRef(null); const handleControlRef = useForkRef(ref, controlRef); const showOptionsText = Boolean(selectedOptionsContent); const showPlaceholder = Boolean(placeholder && !showOptionsText); - const mods: CnMods = {open, size, view, pin, disabled, error: Boolean(error)}; + const hasValue = Array.isArray(value) && !isEmpty(value.filter(Boolean)); + + const [isDisabledButtonAnimation, setIsDisabledButtonAnimation] = React.useState(false); + + const controlMods: CnMods = { + open, + size, + pin, + disabled, + error: Boolean(error), + 'has-clear': hasClear, + 'no-active': isDisabledButtonAnimation, + 'has-value': hasValue, + }; + + const buttonMods: CnMods = { + open, + size, + view, + pin, + disabled, + error: Boolean(error), + }; + + const disableButtonAnimation = React.useCallback(() => { + setIsDisabledButtonAnimation(true); + }, []); + const enableButtonAnimation = React.useCallback(() => { + setIsDisabledButtonAnimation(false); + }, []); + const handleOnClearIconClick = React.useCallback(() => { + // return animation on clear click + setIsDisabledButtonAnimation(false); + clearValue(); + }, [clearValue]); + + const renderClearIcon = (args: SelectRenderClearArgs) => { + const hideOnEmpty = !value?.[0]; + if (!hasClear || !clearValue || hideOnEmpty || disabled) { + return null; + } + return ( + + ); + }; if (renderControl) { return renderControl( @@ -59,6 +120,7 @@ export const SelectControl = React.forwardRef((props, onClick: toggleOpen, ref: handleControlRef, open: Boolean(open), + renderClear: (arg) => renderClearIcon(arg), }, {value}, ); @@ -66,32 +128,35 @@ export const SelectControl = React.forwardRef((props, return ( - + {renderClearIcon({})}