Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): add filter prop #1669

Merged
merged 1 commit into from
Jun 19, 2024
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
48 changes: 21 additions & 27 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -93,6 +92,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
multiple = false,
disabled = false,
filterable = false,
filter: propsFilter,
disablePortal,
hasClear = false,
onClose,
Expand All @@ -102,7 +102,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(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<HTMLDivElement>(null);
Expand All @@ -111,28 +111,6 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
const listRef = React.useRef<List<FlattenOption>>(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,
Expand All @@ -150,9 +128,18 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(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,
Expand Down Expand Up @@ -283,7 +270,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
size={size}
value={filter}
placeholder={filterPlaceholder}
onChange={handleFilterChange}
onChange={setFilter}
onKeyDown={handleFilterKeyDown}
renderFilter={renderFilter}
/>
Expand Down Expand Up @@ -369,6 +356,13 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
virtualized={virtualized}
mobile={mobile}
placement={popupPlacement}
onAfterClose={
filterable
? () => {
setFilter('');
}
: undefined
}
>
{renderPopup({renderFilter: _renderFilter, renderList: _renderList})}
</SelectPopup>
Expand Down
71 changes: 68 additions & 3 deletions src/components/Select/__tests__/Select.filter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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(
<MobileProvider mobile>
<Select
open
filterable
onFilterChange={onFilterChange}
onClose={onClose}
qa="select"
filterPlaceholder="filter"
>
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
</MobileProvider>,
);

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(
<MobileProvider mobile>
<Select
defaultOpen={true}
filterable
onFilterChange={onFilterChange}
onClose={onClose}
qa="select"
filterPlaceholder="filter"
>
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
</MobileProvider>,
);

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SelectPopup = React.forwardRef<HTMLDivElement, SelectPopupProps>(
(
{
handleClose,
onAfterClose,
width,
open,
placement = DEFAULT_PLACEMENT,
Expand Down Expand Up @@ -57,6 +58,7 @@ export const SelectPopup = React.forwardRef<HTMLDivElement, SelectPopupProps>(
restoreFocusRef={controlRef}
modifiers={getModifiers({width, disablePortal, virtualized})}
id={id}
onTransitionExited={onAfterClose}
>
{children}
</Popup>
Expand Down
1 change: 1 addition & 0 deletions src/components/Select/components/SelectPopup/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export type SelectPopupProps = {
disablePortal?: boolean;
virtualized?: boolean;
id?: string;
onAfterClose?: () => void;
};
2 changes: 0 additions & 2 deletions src/components/Select/store/index.ts

This file was deleted.

15 changes: 0 additions & 15 deletions src/components/Select/store/reducer.ts

This file was deleted.

10 changes: 0 additions & 10 deletions src/components/Select/store/types.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ export type SelectRenderCounter = (
export type SelectProps<T = any> = QAProps &
UseOpenProps & {
onUpdate?: (value: string[]) => void;
onFilterChange?: (filter: string) => void;
renderControl?: SelectRenderControl;
renderFilter?: (props: {
onChange: (filter: string) => void;
Expand Down Expand Up @@ -106,6 +105,8 @@ export type SelectProps<T = any> = QAProps &
validationState?: 'invalid';
multiple?: boolean;
filterable?: boolean;
filter?: string;
onFilterChange?: (filter: string) => void;
disablePortal?: boolean;
hasClear?: boolean;
onFocus?: (e: React.FocusEvent) => void;
Expand Down
24 changes: 14 additions & 10 deletions src/hooks/useSelect/useOpenState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading