From 98750c64674abdea031da2fb08761e38bb640505 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Wed, 19 Jun 2024 21:02:14 +0200 Subject: [PATCH] feat(Select): add filter prop (#1669) --- src/components/Select/Select.tsx | 48 ++++++------- .../Select/__tests__/Select.filter.test.tsx | 71 ++++++++++++++++++- .../components/SelectPopup/SelectPopup.tsx | 2 + .../Select/components/SelectPopup/types.ts | 1 + src/components/Select/store/index.ts | 2 - src/components/Select/store/reducer.ts | 15 ---- src/components/Select/store/types.ts | 10 --- src/components/Select/types.ts | 3 +- src/hooks/useSelect/useOpenState.ts | 24 ++++--- 9 files changed, 108 insertions(+), 68 deletions(-) delete mode 100644 src/components/Select/store/index.ts delete mode 100644 src/components/Select/store/reducer.ts delete mode 100644 src/components/Select/store/types.ts diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 401469487e..3517f5e0b1 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -3,7 +3,7 @@ import React from 'react'; import {KeyCode} from '../../constants'; -import {useFocusWithin, useForkRef, useSelect, useUniqId} from '../../hooks'; +import {useControlledState, useFocusWithin, useForkRef, useSelect, useUniqId} from '../../hooks'; import type {List} from '../List'; import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent'; import {errorPropsMapper} from '../controls/utils'; @@ -21,7 +21,6 @@ import { import {DEFAULT_VIRTUALIZATION_THRESHOLD, selectBlock} from './constants'; import {useQuickSearch} from './hooks'; import {getSelectFilteredOptions, useSelectOptions} from './hooks-public'; -import {initialState, reducer} from './store'; import {Option, OptionGroup} from './tech-components'; import type {SelectProps, SelectRenderPopup} from './types'; import type {SelectFilterRef} from './types-misc'; @@ -93,6 +92,7 @@ export const Select = React.forwardRef(function multiple = false, disabled = false, filterable = false, + filter: propsFilter, disablePortal, hasClear = false, onClose, @@ -102,7 +102,7 @@ export const Select = React.forwardRef(function title, } = props; const mobile = useMobile(); - const [{filter}, dispatch] = React.useReducer(reducer, initialState); + const [filter, setFilter] = useControlledState(propsFilter, '', onFilterChange); // to avoid problem with incorrect popper offset calculation // for example: https://github.com/radix-ui/primitives/issues/1567 const controlWrapRef = React.useRef(null); @@ -111,28 +111,6 @@ export const Select = React.forwardRef(function const listRef = React.useRef>(null); const handleControlRef = useForkRef(ref, controlRef); - const handleFilterChange = React.useCallback( - (nextFilter: string) => { - onFilterChange?.(nextFilter); - dispatch({type: 'SET_FILTER', payload: {filter: nextFilter}}); - }, - [onFilterChange], - ); - - const handleOpenChange = React.useCallback( - (open: boolean) => { - onOpenChange?.(open); - - if (!open && filterable) { - // FIXME: rework after https://github.com/gravity-ui/uikit/issues/1354 - setTimeout(() => { - handleFilterChange(''); - }, 100); - } - }, - [filterable, onOpenChange, handleFilterChange], - ); - const { value, open, @@ -150,9 +128,18 @@ export const Select = React.forwardRef(function multiple, open: propsOpen, onClose, - onOpenChange: handleOpenChange, + onOpenChange, }); + React.useEffect(() => { + if (!open && filterable && mobile) { + // FIXME: add handlers to Sheet like in https://github.com/gravity-ui/uikit/issues/1354 + setTimeout(() => { + setFilter(''); + }, 300); + } + }, [open, filterable, setFilter, mobile]); + const propsOptions = props.options || getOptionsFromChildren(props.children); const options = useSelectOptions({ options: propsOptions, @@ -283,7 +270,7 @@ export const Select = React.forwardRef(function size={size} value={filter} placeholder={filterPlaceholder} - onChange={handleFilterChange} + onChange={setFilter} onKeyDown={handleFilterKeyDown} renderFilter={renderFilter} /> @@ -369,6 +356,13 @@ export const Select = React.forwardRef(function virtualized={virtualized} mobile={mobile} placement={popupPlacement} + onAfterClose={ + filterable + ? () => { + setFilter(''); + } + : undefined + } > {renderPopup({renderFilter: _renderFilter, renderList: _renderList})} diff --git a/src/components/Select/__tests__/Select.filter.test.tsx b/src/components/Select/__tests__/Select.filter.test.tsx index eedba44e5b..8bbda16feb 100644 --- a/src/components/Select/__tests__/Select.filter.test.tsx +++ b/src/components/Select/__tests__/Select.filter.test.tsx @@ -2,14 +2,15 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import {cleanup} from '../../../../test-utils/utils'; +import {render, screen} from '../../../../test-utils/utils'; import {TextInput} from '../../controls'; +import {MobileProvider} from '../../mobile'; +import {Select} from '../Select'; import type {SelectOption, SelectProps, SelectRenderPopup} from '../types'; -import {TEST_QA, generateOptions, generateOptionsGroups, setup} from './utils'; +import {TEST_QA, generateOptions, generateOptionsGroups, setup, timeout} from './utils'; afterEach(() => { - cleanup(); jest.clearAllMocks(); }); @@ -127,4 +128,68 @@ describe('Select filter', () => { await user.keyboard('definitely not option'); expect(queryAllByRole('option').length).toBe(0); }); + + test('should not clear filter onClose if open is true', async () => { + const onClose = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByPlaceholderText('filter')); + await userEvent.keyboard('test'); + + expect(onFilterChange).toHaveBeenCalledTimes(4); + onFilterChange.mockClear(); + + await userEvent.click(document.body); + await timeout(400); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledTimes(0); + }); + + test('should not clear filter onClose', async () => { + const onClose = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByPlaceholderText('filter')); + await userEvent.keyboard('test'); + + expect(onFilterChange).toHaveBeenCalledTimes(4); + onFilterChange.mockClear(); + + await userEvent.click(document.body); + await timeout(400); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/Select/components/SelectPopup/SelectPopup.tsx b/src/components/Select/components/SelectPopup/SelectPopup.tsx index c66ef12b76..f212de4e63 100644 --- a/src/components/Select/components/SelectPopup/SelectPopup.tsx +++ b/src/components/Select/components/SelectPopup/SelectPopup.tsx @@ -21,6 +21,7 @@ export const SelectPopup = React.forwardRef( ( { handleClose, + onAfterClose, width, open, placement = DEFAULT_PLACEMENT, @@ -57,6 +58,7 @@ export const SelectPopup = React.forwardRef( restoreFocusRef={controlRef} modifiers={getModifiers({width, disablePortal, virtualized})} id={id} + onTransitionExited={onAfterClose} > {children} diff --git a/src/components/Select/components/SelectPopup/types.ts b/src/components/Select/components/SelectPopup/types.ts index ee4901407e..597d754261 100644 --- a/src/components/Select/components/SelectPopup/types.ts +++ b/src/components/Select/components/SelectPopup/types.ts @@ -15,4 +15,5 @@ export type SelectPopupProps = { disablePortal?: boolean; virtualized?: boolean; id?: string; + onAfterClose?: () => void; }; diff --git a/src/components/Select/store/index.ts b/src/components/Select/store/index.ts deleted file mode 100644 index dc6ef46f40..0000000000 --- a/src/components/Select/store/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './reducer'; -export * from './types'; diff --git a/src/components/Select/store/reducer.ts b/src/components/Select/store/reducer.ts deleted file mode 100644 index a34f3907c5..0000000000 --- a/src/components/Select/store/reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type {Action, State} from './types'; - -export const initialState: State = {filter: ''}; - -export const reducer = (state: State = initialState, action: Action) => { - switch (action.type) { - case 'SET_FILTER': { - const {filter} = action.payload; - return {...state, filter}; - } - default: { - return state; - } - } -}; diff --git a/src/components/Select/store/types.ts b/src/components/Select/store/types.ts deleted file mode 100644 index 2b4a6f7cb5..0000000000 --- a/src/components/Select/store/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type State = { - filter: string; - controlRect?: DOMRect; -}; - -type SetFilter = {type: 'SET_FILTER'; payload: {filter: string}}; - -export type Action = SetFilter; - -export type Dispatch = React.Dispatch; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index 5b84a7dc89..0cb6553d2f 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -60,7 +60,6 @@ export type SelectRenderCounter = ( export type SelectProps = QAProps & UseOpenProps & { onUpdate?: (value: string[]) => void; - onFilterChange?: (filter: string) => void; renderControl?: SelectRenderControl; renderFilter?: (props: { onChange: (filter: string) => void; @@ -106,6 +105,8 @@ export type SelectProps = QAProps & validationState?: 'invalid'; multiple?: boolean; filterable?: boolean; + filter?: string; + onFilterChange?: (filter: string) => void; disablePortal?: boolean; hasClear?: boolean; onFocus?: (e: React.FocusEvent) => void; diff --git a/src/hooks/useSelect/useOpenState.ts b/src/hooks/useSelect/useOpenState.ts index 8379b60ed0..78fb7398c3 100644 --- a/src/hooks/useSelect/useOpenState.ts +++ b/src/hooks/useSelect/useOpenState.ts @@ -5,25 +5,29 @@ import {useControlledState} from '../useControlledState/useControlledState'; import type {UseOpenProps} from './types'; export const useOpenState = (props: UseOpenProps) => { + const {onOpenChange, onClose} = props; + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + onOpenChange?.(newOpen); + if (newOpen === false && onClose) { + onClose(); + } + }, + [onOpenChange, onClose], + ); + const [open, setOpenState] = useControlledState( props.open, props.defaultOpen ?? false, - props.onOpenChange, + handleOpenChange, ); - const {onClose} = props; const toggleOpen = React.useCallback( (val?: boolean) => { const newOpen = typeof val === 'boolean' ? val : !open; - if (newOpen !== open) { - setOpenState(newOpen); - } - - if (newOpen === false && onClose) { - onClose(); - } + setOpenState(newOpen); }, - [open, setOpenState, onClose], + [open, setOpenState], ); return {