Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions packages/components/src/components/SelectNext/Select.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -81,7 +87,7 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
loadingText,
isClearable,
noItemsText,
inputValue,
inputValue: inputValueProp,
labelAlign,
startAddon,
isRequired,
Expand All @@ -104,6 +110,35 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
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(() => {
Expand Down Expand Up @@ -174,7 +209,7 @@ function SelectInner<T extends object, M extends SelectionMode = 'single'>({
isSearchable,
defaultFilter,
state: inState,
onInputChange,
onInputChange: setInputValue,
className: s.list,
defaultInputValue,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<Select
{...baseProps}
defaultOpen
isSearchable
slotProps={{
...baseProps.slotProps,
list: {
dropdownFooter: 'List footer',
slotProps: {
divider: { className: 'list-divider' },
dropdownFooter: { className: 'list-footer' },
'search-input': {
'aria-label': 'List search',
placeholder: 'Find',
},
},
},
}}
>
<Select.Item id="1">One</Select.Item>
<Select.Item id="2">Two</Select.Item>
</Select>
);

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(
<Select {...baseProps} defaultOpen isSearchable noItemsText="empty">
Expand Down Expand Up @@ -583,6 +617,62 @@ describe('Select', () => {
expect(getOptions()).toHaveLength(3);
});

it('should reset search query and call onInputChange after dropdown closes', async () => {
const onInputChange = vi.fn((v) => v);

render(
<Select
{...baseProps}
defaultOpen
isSearchable
onInputChange={onInputChange}
>
<Select.Item id="apple">Apple</Select.Item>
<Select.Item id="banana">Banana</Select.Item>
<Select.Item id="apricot">Apricot</Select.Item>
</Select>
);

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(
<Select
{...baseProps}
isSearchable
defaultInputValue="ap"
onInputChange={onInputChange}
>
<Select.Item id="apple">Apple</Select.Item>
<Select.Item id="banana">Banana</Select.Item>
<Select.Item id="apricot">Apricot</Select.Item>
</Select>
);

expect(onInputChange).not.toHaveBeenCalled();

await userEvent.click(getControl());

expect(getSearchInput()).toHaveValue('ap');
expect(getOptions()).toHaveLength(2);
});

it('should use defaultFilter when provided', async () => {
render(
<Select
Expand Down Expand Up @@ -645,6 +735,36 @@ describe('Select', () => {
expect(options[1]).toHaveTextContent('Apricot');
});

it('should not reset controlled inputValue after dropdown closes', async () => {
const onInputChange = vi.fn();

render(
<Select
{...baseProps}
defaultOpen
isSearchable
inputValue="ap"
onInputChange={onInputChange}
>
<Select.Item id="apple">Apple</Select.Item>
<Select.Item id="banana">Banana</Select.Item>
<Select.Item id="apricot">Apricot</Select.Item>
</Select>
);

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(
Expand Down
Loading