From 5b8db753efce6b3172a4ed73445579879619108d Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Fri, 1 Sep 2023 11:45:16 +0300 Subject: [PATCH] feat(AutoComplete): include ListView components --- src/components/AutoComplete.tsx | 167 ++++++++++++++------------- src/components/Checkbox.tsx | 1 - src/stories/AutoComplete.stories.tsx | 42 ++++--- 3 files changed, 112 insertions(+), 98 deletions(-) diff --git a/src/components/AutoComplete.tsx b/src/components/AutoComplete.tsx index 315c632f..9feb0aec 100644 --- a/src/components/AutoComplete.tsx +++ b/src/components/AutoComplete.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react'; +import React, { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef, memo } from 'react'; import styled from 'styled-components'; import { gapS, gapXs, gray4, gray7 } from '@taskany/colors'; @@ -6,10 +6,12 @@ import { nullable } from '../utils'; import { Text } from './Text'; import { Input } from './Input'; +import { ListView, ListViewItem } from './ListView'; type InputProps = React.ComponentProps; type AutoCompleteMode = 'single' | 'multiple'; type AutoCompleteSelectedMap = Set; +type ListItemProps = Parameters['renderItem']>[0]; interface AutoCompleteRenderItemProps { item: T; @@ -19,18 +21,15 @@ interface AutoCompleteRenderItemProps { } interface AutoCompleteRenderItem { - (props: AutoCompleteRenderItemProps): React.ReactNode; + (props: AutoCompleteRenderItemProps & ListItemProps): React.ReactNode; } interface AutoCompleteContext { items: T[]; value: T[]; + keyGetter: (item: T) => string; renderItem: AutoCompleteRenderItem; - renderItems: (props: React.PropsWithChildren) => React.ReactNode; - renderState: 'combine' | 'split'; - switchType: () => void; - popItem: (item: T) => void; - pushItem: (item: T) => void; + onChange: (item: T) => void; map: React.MutableRefObject>; } @@ -38,6 +37,7 @@ interface AutoCompleteProps { mode: AutoCompleteMode; items: T[]; renderItem: AutoCompleteRenderItem; + keyGetter: (item: T) => string; renderItems?: (props: React.PropsWithChildren) => React.ReactNode; value?: T[]; onChange: (items: T[]) => void; @@ -45,7 +45,14 @@ interface AutoCompleteProps { interface AutoCompleteListProps { title?: string; + /** + * Render only selected items + */ selected?: boolean; + /** + * Render filtered items by value + */ + filterSelected?: boolean; } interface AutoCompleteInputProps extends Omit { @@ -146,68 +153,54 @@ function getItemCreator(onChange: (item: T) => void, map: React.MutableRefObj }; } -export function AutoCompleteList({ title, selected }: AutoCompleteListProps) { - const { - items, - value, - renderItem, - renderItems: Component, - renderState, - switchType, - popItem, - pushItem, - map, - } = useAutoCompleteContext(); - - useEffect(() => { - if (selected) { - switchType(); - } - }, [selected, switchType]); +function getRenderItemWithKey( + renderItem: AutoCompleteRenderItem, + keyGetter: (item: T) => string, +): React.FC> { + return function AutoCompleteListItem(props) { + return ( + renderItem({ ...props, ...viewProps })} + /> + ); + }; +} - const onChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (item: any) => { - const itemInMap = map.current.has(item); +export const AutoCompleteList: React.FC = memo(({ title, selected, filterSelected }) => { + const { items, value, renderItem, onChange, map, keyGetter } = useAutoCompleteContext(); - if (itemInMap) { - popItem(item); - } else { - pushItem(item); - } - }, - [map, popItem, pushItem], - ); + const renderer = getRenderItemWithKey(renderItem, keyGetter); const createRenderItem = getItemCreator(onChange, map); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const itemsToRender: AutoCompleteRenderItemProps[] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let target: Array; - - if (renderState === 'split') { - if (selected) { - target = value; - } else { - target = items.filter((item) => !map.current.has(item)); - } - } else { + let target: Array; + + switch (true) { + case selected: + target = value; + break; + case filterSelected: + target = items.filter((item) => !map.current.has(item)); + break; + default: target = items; - } + break; + } - return target.map(createRenderItem); - }, [renderState, items, value, selected, createRenderItem, map]); + const itemsToRender = target.map(createRenderItem); return nullable(itemsToRender, (toRender) => ( <> {nullable(title, (t) => ( {t} ))} - {toRender.map(renderItem)} + {toRender.map(renderer)} )); -} +}); export const AutoCompleteInput: React.FC = ({ onChange, ...props }) => { const handleInputChange = useCallback>((event) => { @@ -225,63 +218,71 @@ export function AutoComplete({ value = [], onChange, children, + keyGetter, renderItem, - renderItems = defaultRenderItems, + renderItems: Component = defaultRenderItems, }: React.PropsWithChildren>) { - const [type, setType] = useState['renderState']>('combine'); const [selected, setSelected] = useState(() => value); - const currentMap = useRef>(new Set(selected)); - - const switchType = useCallback(() => { - setType('split'); - }, []); + const selectedLengthRef = useRef(selected.length); useEffect(() => { - onChange(selected); + if (selectedLengthRef.current !== selected.length) { + onChange(selected); + + selectedLengthRef.current = selected.length; + } }, [selected, onChange]); const pushItem = useCallback( (item: T) => { - if (mode === 'multiple') { - currentMap.current.add(item); - setSelected((prev) => { - return prev.concat(item); - }); - + if (mode === 'single') { + setSelected([item]); return; } - setSelected([item]); + currentMap.current.add(item); + + setSelected((prev) => { + return prev.concat(item); + }); }, [mode, onChange], ); - const popItem = useCallback((item: T) => { - currentMap.current.delete(item); - setSelected(() => { - const next: T[] = []; - currentMap.current.forEach((val) => next.push(val)); + const popItem = useCallback( + (item: T) => { + currentMap.current.delete(item); - return next; - }); - }, []); + setSelected((prev) => { + const itemKey = keyGetter(item); + return prev.filter((val) => keyGetter(val) !== itemKey); + }); + }, + [keyGetter], + ); + + const handleChange = useCallback( + (item: T) => { + currentMap.current.has(item) ? popItem(item) : pushItem(item); + }, + [pushItem, popItem], + ); return ( - {children} + + {children} + ); } diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx index d148820c..0a343677 100644 --- a/src/components/Checkbox.tsx +++ b/src/components/Checkbox.tsx @@ -40,7 +40,6 @@ export const Checkbox = forwardRef; export default { title: 'AutoComplete', component: AutoComplete, - subcomponents: { AutoCompleteList, AutoCompleteRadioGroup, AutoCompleteInput }, argTypes: { onChange: { action: 'onChange' }, }, @@ -51,6 +50,7 @@ export const Single: StoryFn = (args) => { mode="single" items={list} onChange={args.onChange} + keyGetter={(item) => item.id} renderItem={(props) =>
{`${props.item.id}: ${props.item.title}`}
} > = (args) => { mode="multiple" items={list} onChange={args.onChange} + keyGetter={(item) => item.id} renderItem={(props) => ( - +
+ +
)} > = (args) => { placeholder="Search..." /> - + ); }; @@ -133,6 +135,7 @@ export const WithRadio: StoryFn = (args) => { mode="single" items={list} onChange={args.onChange} + keyGetter={(item) => item.id} renderItem={(props) => (