From 456ffaacbf20e1e0b5150d86abee1201d9ac3dad Mon Sep 17 00:00:00 2001 From: "Mr.Dr.Professor Patrick" Date: Wed, 21 Feb 2024 11:39:49 +0100 Subject: [PATCH] feat(Select): add useSelectOptions hook (#1356) --- src/components/Select/Select.tsx | 37 +++--- .../Select/__stories__/Select.stories.tsx | 5 + .../__stories__/UseSelectOptionsShowcase.tsx | 115 ++++++++++++++++++ .../Select/__tests__/useSelectOptions.test.ts | 38 ++++++ src/components/Select/constants.ts | 2 + src/components/Select/hooks-public/index.ts | 1 + .../hooks-public/useSelectOptions/README.md | 14 +++ .../hooks-public/useSelectOptions/index.ts | 63 ++++++++++ src/components/Select/index.ts | 2 + src/components/Select/types.ts | 2 + src/components/Select/utils.tsx | 60 ++++++--- 11 files changed, 300 insertions(+), 39 deletions(-) create mode 100644 src/components/Select/__stories__/UseSelectOptionsShowcase.tsx create mode 100644 src/components/Select/__tests__/useSelectOptions.test.ts create mode 100644 src/components/Select/hooks-public/index.ts create mode 100644 src/components/Select/hooks-public/useSelectOptions/README.md create mode 100644 src/components/Select/hooks-public/useSelectOptions/index.ts diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index ec5ed97c51..96f0946c87 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -11,6 +11,7 @@ import type {CnMods} from '../utils/cn'; import {EmptyOptions, SelectControl, SelectFilter, SelectList, SelectPopup} from './components'; 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'; @@ -19,8 +20,6 @@ import { activateFirstClickableItem, findItemIndexByQuickSearch, getActiveItem, - getFilteredFlattenOptions, - getFlattenOptions, getListItems, getOptionsFromChildren, getSelectedOptionsContent, @@ -112,7 +111,10 @@ export const Select = React.forwardRef(function onOpenChange?.(open); if (!open && filterable) { - handleFilterChange(''); + // FIXME: rework after https://github.com/gravity-ui/uikit/issues/1354 + setTimeout(() => { + handleFilterChange(''); + }, 100); } }, [filterable, onOpenChange, handleFilterChange], @@ -138,21 +140,16 @@ export const Select = React.forwardRef(function }); const uniqId = useUniqId(); const selectId = id ?? uniqId; - const options = props.options || getOptionsFromChildren(props.children); - const flattenOptions = getFlattenOptions(options); - const filteredFlattenOptions = filterable - ? getFilteredFlattenOptions({ - options: flattenOptions, - filter, - filterOption, - }) - : flattenOptions; - const selectedOptionsContent = getSelectedOptionsContent( - flattenOptions, - value, - renderSelectedOption, - ); - const virtualized = filteredFlattenOptions.length >= virtualizationThreshold; + const propsOptions = props.options || getOptionsFromChildren(props.children); + const options = useSelectOptions({ + options: propsOptions, + filter, + filterable, + filterOption, + }); + const filteredOptions = getSelectFilteredOptions(options) as FlattenOption[]; + const selectedOptionsContent = getSelectedOptionsContent(options, value, renderSelectedOption); + const virtualized = filteredOptions.length >= virtualizationThreshold; const {errorMessage, errorPlacement, validationState} = errorPropsMapper({ error, @@ -284,14 +281,14 @@ export const Select = React.forwardRef(function }; const _renderList = () => { - if (filteredFlattenOptions.length || props.loading) { + if (filteredOptions.length || props.loading) { return ( = (args: SelectProps) => = (args) => ( ); +const UseSelectOptionsShowcaseTemplate = () => { + return ; +}; export const Default = DefaultTemplate.bind({}); export const Showcase = ShowcaseTemplate.bind({}); export const PopupWidth = SelectPopupWidthShowcaseTemplate.bind({}); +export const UseSelectOptions = UseSelectOptionsShowcaseTemplate.bind({}); Showcase.args = { view: 'normal', diff --git a/src/components/Select/__stories__/UseSelectOptionsShowcase.tsx b/src/components/Select/__stories__/UseSelectOptionsShowcase.tsx new file mode 100644 index 0000000000..cf24da537e --- /dev/null +++ b/src/components/Select/__stories__/UseSelectOptionsShowcase.tsx @@ -0,0 +1,115 @@ +import React from 'react'; + +import {Button} from '../../Button'; +import {TextInput} from '../../controls'; +import {Select, getSelectFilteredOptions, isSelectGroupTitle, useSelectOptions} from '../index'; +import type {SelectOption, SelectProps} from '../index'; + +export const UseSelectOptionsShowcase = () => { + const [value, setValue] = React.useState([]); + const [filter, setFilter] = React.useState(''); + const filterable = true; + const options = useSelectOptions({ + options: [ + { + label: 'Group 1', + options: [ + {value: 'val1', content: 'Value 1'}, + {value: 'val2', content: 'Value 2'}, + {value: 'val3', content: 'Value 3'}, + {value: 'val4', content: 'Value 4'}, + ], + }, + { + label: 'Group 2', + options: [ + {value: 'val5', content: 'Value 5'}, + {value: 'val6', content: 'Value 6'}, + {value: 'val7', content: 'Value 7'}, + {value: 'val8', content: 'Value 8'}, + ], + }, + ], + filter, + filterable, + }); + const filteredOptions = getSelectFilteredOptions(options); + + const renderFilter: SelectProps['renderFilter'] = ({ + value: filterValue, + ref, + onChange, + onKeyDown, + }) => { + const optionsWithoutGroupLabels = options.filter( + (option) => !isSelectGroupTitle(option), + ) as SelectOption[]; + const filteredOptionsWithoutGroupLabels = filteredOptions.filter( + (option) => !isSelectGroupTitle(option), + ) as SelectOption[]; + const allOptionsSelected = Boolean( + value.length && optionsWithoutGroupLabels.length === value.length, + ); + const allVisibleOptionsSelected = Boolean( + value.length && + filteredOptionsWithoutGroupLabels + .map((o) => o.value) + .every((o) => value.includes(o)), + ); + + const handleAllOptionsButtonClick = () => { + const nextValue = allOptionsSelected + ? [] + : optionsWithoutGroupLabels.map((option) => option.value); + setValue(nextValue); + }; + + const handleAllVisibleOptionsButtonClick = () => { + const filteredValue = filteredOptionsWithoutGroupLabels.map((o) => o.value); + const nextValue = allVisibleOptionsSelected + ? value.filter((v) => !filteredValue.includes(v)) + : filteredOptionsWithoutGroupLabels.map((o) => o.value); + setValue(nextValue); + }; + + return ( +
+ + + +
+ ); + }; + + return ( + + * } + */ +export function getSelectFilteredOptions(options: SelectOptions): SelectOptions { + if (!isFlattenOptions(options)) { + throw Error('You should use options generated by useSelectOptions hook'); + } + + return get(options, [FLATTEN_KEY, 'filteredOptions']); +} + +export function useSelectOptions(props: UseSelectOptionsProps): SelectOptions { + const {filter = '', filterable, filterOption} = props; + const options = React.useMemo(() => { + return isFlattenOptions(props.options) + ? props.options + : (getFlattenOptions(props.options) as FlattenOptions); + }, [props.options]); + const filteredOptions = React.useMemo(() => { + return filterable ? getFilteredFlattenOptions({options, filter, filterOption}) : options; + }, [filter, filterable, filterOption, options]); + options[FLATTEN_KEY]['filteredOptions'] = filteredOptions; + + return options; +} diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts index a60bf8f965..59757f3f25 100644 --- a/src/components/Select/index.ts +++ b/src/components/Select/index.ts @@ -1,3 +1,5 @@ export * from './Select'; export * from './types'; export {SelectQa} from './constants'; +export * from './hooks-public'; +export {isSelectGroupTitle} from './utils'; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index e568b030c9..964126921b 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -146,3 +146,5 @@ export type SelectClearProps = SelectClearIconProps & { onMouseEnter: (e: React.MouseEvent) => void; onMouseLeave: (e: React.MouseEvent) => void; }; + +export type SelectOptions = NonNullable['options']>; diff --git a/src/components/Select/utils.tsx b/src/components/Select/utils.tsx index 1c60ce5c89..b63d7afff1 100644 --- a/src/components/Select/utils.tsx +++ b/src/components/Select/utils.tsx @@ -4,19 +4,40 @@ import {KeyCode} from '../../constants'; import {List} from '../List'; import type {ListItemData} from '../List'; -import {GROUP_ITEM_MARGIN_TOP, MOBILE_ITEM_HEIGHT, SIZE_TO_ITEM_HEIGHT} from './constants'; +import { + FLATTEN_KEY, + GROUP_ITEM_MARGIN_TOP, + MOBILE_ITEM_HEIGHT, + SIZE_TO_ITEM_HEIGHT, +} from './constants'; import type {Option, OptionGroup} from './tech-components'; -import type {SelectOption, SelectOptionGroup, SelectProps, SelectSize} from './types'; +import type { + SelectOption, + SelectOptionGroup, + SelectOptions, + SelectProps, + SelectSize, +} from './types'; // "disable" property needs to deactivate group title item in List export type GroupTitleItem = {label: string; disabled: true}; export type FlattenOption = SelectOption | GroupTitleItem; -export const getFlattenOptions = ( - options: (SelectOption | SelectOptionGroup)[], -): FlattenOption[] => { - return options.reduce((acc, option) => { +export type FlattenOptions = FlattenOption[] & { + [FLATTEN_KEY]: { + filteredOptions: FlattenOption[]; + }; +}; + +export const isSelectGroupTitle = ( + option?: SelectOption | SelectOptionGroup, +): option is GroupTitleItem => { + return Boolean(option && 'label' in option); +}; + +export const getFlattenOptions = (options: SelectOptions): FlattenOptions => { + const flatten = options.reduce((acc, option) => { if ('label' in option) { acc.push({label: option.label, disabled: true}); acc.push(...(option.options || [])); @@ -25,7 +46,12 @@ export const getFlattenOptions = ( } return acc; - }, [] as FlattenOption[]); + }, []); + Object.defineProperty(flatten, FLATTEN_KEY, { + enumerable: false, + value: {}, + }); + return flatten as FlattenOptions; }; export const getPopupItemHeight = (args: { @@ -40,7 +66,7 @@ export const getPopupItemHeight = (args: { let itemHeight = mobile ? MOBILE_ITEM_HEIGHT : SIZE_TO_ITEM_HEIGHT[size]; - if ('label' in option) { + if (isSelectGroupTitle(option)) { const marginTop = index === 0 ? 0 : GROUP_ITEM_MARGIN_TOP; itemHeight = option.label === '' ? 0 : itemHeight; @@ -83,7 +109,7 @@ const getOptionText = (option: SelectOption): string => { }; export const getSelectedOptionsContent = ( - flattenOptions: FlattenOption[], + options: SelectOptions, value: string[], renderSelectedOption?: SelectProps['renderSelectedOption'], ): React.ReactNode => { @@ -91,8 +117,8 @@ export const getSelectedOptionsContent = ( return null; } - const flattenSimpleOptions = flattenOptions.filter( - (opt) => !('label' in opt), + const flattenSimpleOptions = options.filter( + (opt) => !isSelectGroupTitle(opt), ) as SelectOption[]; const selectedOptions = value.reduce((acc, val) => { @@ -187,7 +213,7 @@ export const findItemIndexByQuickSearch = ( } return items.findIndex((item) => { - if ('label' in item) { + if (isSelectGroupTitle(item)) { return false; } @@ -224,10 +250,6 @@ const isOptionMatchedByFilter = (option: SelectOption, filter: string) => { return lowerOptionText.indexOf(lowerFilter) !== -1; }; -const isGroupTitle = (option?: FlattenOption): option is GroupTitleItem => { - return Boolean(option && 'label' in option); -}; - export const getFilteredFlattenOptions = (args: { options: FlattenOption[]; filter: string; @@ -235,7 +257,7 @@ export const getFilteredFlattenOptions = (args: { }) => { const {options, filter, filterOption} = args; const filteredOptions = options.filter((option) => { - if (isGroupTitle(option)) { + if (isSelectGroupTitle(option)) { return true; } @@ -245,8 +267,8 @@ export const getFilteredFlattenOptions = (args: { }); return filteredOptions.reduce((acc, option, index) => { - const groupTitle = isGroupTitle(option); - const previousGroupTitle = isGroupTitle(acc[acc.length - 1]); + const groupTitle = isSelectGroupTitle(option); + const previousGroupTitle = isSelectGroupTitle(acc[acc.length - 1]); const isLastOption = index === filteredOptions.length - 1; if (groupTitle && previousGroupTitle) {