From 37b100037a997b78e3a8c1dd48e8b2fdccf4efcb Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 6 Jul 2026 13:09:18 +0300 Subject: [PATCH] fix(SelectNext): reset search-input on dropdown close --- .../src/components/SelectNext/Select.tsx | 43 +++++- .../SelectNext/__tests__/Select.test.tsx | 122 +++++++++++++++++- 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/packages/components/src/components/SelectNext/Select.tsx b/packages/components/src/components/SelectNext/Select.tsx index 21aff5bd3..a49f9fa4a 100644 --- a/packages/components/src/components/SelectNext/Select.tsx +++ b/packages/components/src/components/SelectNext/Select.tsx @@ -1,9 +1,15 @@ 'use client'; import type { Ref, RefObject } from 'react'; -import { forwardRef, useCallback } from 'react'; +import { forwardRef, useCallback, useEffect } from 'react'; -import { useDOMRef, mergeProps, useElementSize } from '@koobiq/react-core'; +import { + useDOMRef, + usePrevious, + useControlledState, + mergeProps, + useElementSize, +} from '@koobiq/react-core'; import { IconChevronDownS16 } from '@koobiq/react-icons'; import { useSelect, @@ -81,7 +87,7 @@ function SelectInner({ loadingText, isClearable, noItemsText, - inputValue, + inputValue: inputValueProp, labelAlign, startAddon, isRequired, @@ -104,6 +110,35 @@ function SelectInner({ const validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'aria'; + const [inputValue, setInputValue] = useControlledState( + inputValueProp, + defaultInputValue ?? '', + onInputChange + ); + + const wasOpen = usePrevious(inState.isOpen); + + useEffect(() => { + if ( + wasOpen !== true || + !isSearchable || + inState.isOpen || + inputValueProp !== undefined || + inputValue === '' + ) { + return; + } + + setInputValue(''); + }, [ + wasOpen, + inState.isOpen, + inputValueProp, + inputValue, + isSearchable, + setInputValue, + ]); + const clearButtonIsHidden = isDisabled || !inState.selectedItems.length; const handleClear = useCallback(() => { @@ -174,7 +209,7 @@ function SelectInner({ isSearchable, defaultFilter, state: inState, - onInputChange, + onInputChange: setInputValue, className: s.list, defaultInputValue, }, diff --git a/packages/components/src/components/SelectNext/__tests__/Select.test.tsx b/packages/components/src/components/SelectNext/__tests__/Select.test.tsx index d1bc9df9d..3afbccf8b 100644 --- a/packages/components/src/components/SelectNext/__tests__/Select.test.tsx +++ b/packages/components/src/components/SelectNext/__tests__/Select.test.tsx @@ -1,6 +1,6 @@ import { createRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -550,6 +550,40 @@ describe('Select', () => { expect(onInputChange.mock.calls.at(-1)?.[0]).toBe('t'); }); + it('should support list slotProps for search, divider and footer', async () => { + render( + + ); + + expect( + screen.getByRole('searchbox', { name: 'List search' }) + ).toHaveAttribute('placeholder', 'Find'); + + expect(document.querySelector('.list-divider')).toBeInTheDocument(); + expect(screen.getByText('List footer')).toHaveClass('list-footer'); + }); + it('should show noItemsText when search matches nothing', async () => { render( + Apple + Banana + Apricot + + ); + + await userEvent.type(getSearchInput(), 'ap'); + + expect(getOptions()).toHaveLength(2); + + await userEvent.click(screen.getByRole('option', { name: 'Apple' })); + + await waitFor(() => { + expect(onInputChange).toHaveBeenLastCalledWith(''); + }); + + await userEvent.click(getControl()); + + expect(getSearchInput()).toHaveValue(''); + expect(getOptions()).toHaveLength(3); + }); + + it('should preserve defaultInputValue before the first open', async () => { + const onInputChange = vi.fn(); + + render( + + ); + + expect(onInputChange).not.toHaveBeenCalled(); + + await userEvent.click(getControl()); + + expect(getSearchInput()).toHaveValue('ap'); + expect(getOptions()).toHaveLength(2); + }); + it('should use defaultFilter when provided', async () => { render( + Apple + Banana + Apricot + + ); + + expect(getSearchInput()).toHaveValue('ap'); + expect(getOptions()).toHaveLength(2); + + await userEvent.click(screen.getByRole('option', { name: 'Apple' })); + + expect(onInputChange).not.toHaveBeenCalledWith(''); + + await userEvent.click(getControl()); + + expect(getSearchInput()).toHaveValue('ap'); + expect(getOptions()).toHaveLength(2); + }); + describe('defaultFilter', () => { it('should use defaultFilter (custom filterFn)', async () => { render(