From fd90302226bf04a8986a0401261e484805bccb4f Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Mon, 28 Aug 2023 15:14:26 +0300 Subject: [PATCH] feat(Autocomplete): add a new component --- src/components/AutoComplete.tsx | 288 +++++++++++++++++++++++++++ src/stories/AutoComplete.stories.tsx | 253 +++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 src/components/AutoComplete.tsx create mode 100644 src/stories/AutoComplete.stories.tsx diff --git a/src/components/AutoComplete.tsx b/src/components/AutoComplete.tsx new file mode 100644 index 00000000..42f1e4a8 --- /dev/null +++ b/src/components/AutoComplete.tsx @@ -0,0 +1,288 @@ +import React, { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react'; +import styled from 'styled-components'; +import { gapS, gapXs, gray4, gray7 } from '@taskany/colors'; + +import { nullable } from '../utils'; + +import { Text } from './Text'; +import { Input } from './Input'; + +type InputProps = React.ComponentProps; +type AutoCompleteMode = 'single' | 'multiple'; +type AutoCompleteSelectedMap = Set; + +interface AutoCompleteRenderItemProps { + item: T; + index: number; + onItemClick: () => void; + checked: boolean; +} + +interface AutoCompleteRenderItem { + (props: AutoCompleteRenderItemProps): React.ReactNode; +} + +interface AutoCompleteContext { + items: T[]; + value: T[]; + renderItem: AutoCompleteRenderItem; + renderItems: (props: React.PropsWithChildren) => React.ReactNode; + renderState: 'combine' | 'split'; + switchType: () => void; + popItem: (item: T) => void; + pushItem: (item: T) => void; + map: React.MutableRefObject>; +} + +interface AutoCompleteProps { + mode: AutoCompleteMode; + items: T[]; + renderItem: AutoCompleteRenderItem; + renderItems?: (props: React.PropsWithChildren) => React.ReactNode; + value?: T[]; + onChange: (items: T[]) => void; +} + +interface AutoCompleteListProps { + title?: string; + selected?: boolean; +} + +interface AutoCompleteInputProps extends Omit { + onChange: (val: string) => void; +} + +interface AutoCompleteRadioGroupProps { + name: string; + title: string; + items: T[]; + value?: T['value']; + onChange: (value: T) => void; + className?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AutoCompleteContextProvider = createContext | null>(null); + +export function useAutoCompleteContext(): AutoCompleteContext { + const ctx = useContext(AutoCompleteContextProvider) as AutoCompleteContext | null; + + if (!ctx) { + throw new Error("Don't use before initialization or outse of `AutoComplete` component"); + } + + return useMemo(() => ctx, [ctx]); +} + +const StyledText = styled(Text).attrs({ + size: 's', + weight: 'regular', + color: gray7, +})` + width: 100%; + border-bottom: 1px solid ${gray4}; + margin: ${gapS} 0; +`; + +const StyledLabel = styled.label` + display: inline-flex; + flex-wrap: nowrap; + align-items: baseline; + + input[type='radio'] { + padding: 0; + margin: 0; + margin-right: ${gapXs}; + } +`; + +const StyledRadioGroup = styled.div` + display: flex; + align-items: center; + gap: ${gapS}; +`; + +export function AutoCompleteRadioGroup({ + items, + onChange, + name, + title, + value, + className, +}: AutoCompleteRadioGroupProps) { + return ( + <> + {nullable(title, (t) => ( + {t} + ))} + + {items.map((item) => ( + + onChange(item)} + defaultChecked={item.value === value} + /> + + {item.title} + + + ))} + + + ); +} + +function getItemCreator(onChange: (item: T) => void, map: React.MutableRefObject>) { + return function createRenderItem(item: T1, index: number): AutoCompleteRenderItemProps { + return { + item, + index, + checked: map.current.has(item), + onItemClick: () => onChange(item), + }; + }; +} + +export function AutoCompleteList({ title, selected }: AutoCompleteListProps) { + const { + items, + value, + renderItem, + renderItems: Component, + renderState, + switchType, + popItem, + pushItem, + map, + } = useAutoCompleteContext(); + + useEffect(() => { + if (selected) { + switchType(); + } + }, [selected, switchType]); + + const onChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => { + const itemInMap = map.current.has(item); + + if (itemInMap) { + popItem(item); + } else { + pushItem(item); + } + }, + [map, popItem, pushItem], + ); + + 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 { + target = items; + } + + return target.map(createRenderItem); + }, [renderState, items, value, selected, createRenderItem, map]); + + return nullable(itemsToRender, (toRender) => ( + <> + {nullable(title, (t) => ( + {t} + ))} + {toRender.map(renderItem)} + + )); +} + +export const AutoCompleteInput: React.FC = ({ onChange, ...props }) => { + const handleInputChange = useCallback>((event) => { + onChange(event.target.value); + }, []); + + return ; +}; + +const defaultRenderItems: React.FC = ({ children }) => <>{children}; + +export function AutoComplete({ + mode, + items, + value = [], + onChange, + children, + renderItem, + renderItems = 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'); + }, []); + + useEffect(() => { + onChange(selected); + }, [selected, onChange]); + + const pushItem = useCallback( + (item: T) => { + if (mode === 'multiple') { + currentMap.current.add(item); + setSelected((prev) => { + return prev.concat(item); + }); + + return; + } + + setSelected([item]); + }, + [mode, onChange], + ); + + const popItem = useCallback((item: T) => { + currentMap.current.delete(item); + setSelected(() => { + const next: T[] = []; + currentMap.current.forEach((val) => next.push(val)); + + return next; + }); + }, []); + + return ( + + {children} + + ); +} diff --git a/src/stories/AutoComplete.stories.tsx b/src/stories/AutoComplete.stories.tsx new file mode 100644 index 00000000..5694fd22 --- /dev/null +++ b/src/stories/AutoComplete.stories.tsx @@ -0,0 +1,253 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Meta, StoryFn } from '@storybook/react'; +import { IconSearchOutline } from '@taskany/icons'; + +import { AutoComplete, AutoCompleteList, AutoCompleteRadioGroup, AutoCompleteInput } from '../components/AutoComplete'; +import { Table, TableRow, TableCell } from '../components/Table'; +import { Checkbox } from '../components/Checkbox'; + +export default { + title: 'AutoComplete', + component: AutoComplete, + subcomponents: { AutoCompleteList, AutoCompleteRadioGroup, AutoCompleteInput }, +} as Meta; + +type Item = { + id: string; + title: string; +}; + +type Items = Array; + +const data: Items = Array.from({ length: 1000 }, (_, i) => { + const value = i + 1; + + return { + id: String(value), + title: `Title ${value}`, + }; +}); + +export const Single: StoryFn = () => { + const [value, setValue] = useState(''); + const [list, setList] = useState([]); + + useEffect(() => { + if (value.length >= 3) { + setList(data.filter(({ title }) => title.toLowerCase().includes(value.toLowerCase())).slice(0, 10)); + } else { + setList([]); + } + }, [value]); + + const handleChangeSelectedValues = useCallback((items: Items) => { + console.log(items); + }, []); + + return ( +
{`${props.item.id}: ${props.item.title}`}
} + > + } + placeholder="Search..." + /> + +
+ ); +}; + +export const Multiple: StoryFn = () => { + const [value, setValue] = useState(''); + const [list, setList] = useState([]); + + useEffect(() => { + if (value.length >= 3) { + setList(data.filter(({ title }) => title.toLowerCase().includes(value.toLowerCase())).slice(0, 10)); + } else { + setList([]); + } + }, [value]); + + const handleChangeSelectedValues = useCallback((items: Items) => { + console.log(items); + }, []); + + return ( + ( + + )} + > + } + placeholder="Search..." + /> + + + + ); +}; + +const radios = [ + { title: 'Simple', value: 'simple' }, + { title: 'Goal', value: 'goal' }, +]; + +export const WithRadio: StoryFn = () => { + const [value, setValue] = useState(''); + const [list, setList] = useState([]); + const [mode, setMode] = useState<(typeof radios)[number]['value']>('simple'); + + useEffect(() => { + if (mode === 'goal') { + if (value.length >= 3) { + setList(data.filter(({ title }) => title.toLowerCase().includes(value.toLowerCase())).slice(0, 10)); + } else { + setList([]); + } + } else { + setList([]); + } + }, [value, mode]); + + const handleChangeSelectedValues = useCallback((items: Items) => { + console.log(items); + }, []); + + return ( + ( +
+ +
+ )} + > + } + placeholder="Search..." + /> + setMode(val.value)} + value={mode} + /> + +
+ ); +}; + +export const MultipleWithTable: StoryFn = () => { + const [value, setValue] = useState(''); + const [list, setList] = useState([]); + + useEffect(() => { + if (value.length >= 3) { + setList(data.filter(({ title }) => title.toLowerCase().includes(value.toLowerCase())).slice(0, 10)); + } else { + setList([]); + } + }, [value]); + + const handleChangeSelectedValues = useCallback((items: Items) => { + console.log(items); + }, []); + + return ( + {children}
} + renderItem={(props) => ( + + + + + + {props.item.id} + + {props.item.title} + + )} + > + } + placeholder="Search..." + /> + +
+ ); +}; +const finiteList: Items = Array.from({ length: 5 }, (_, i) => { + const value = i + 1; + + return { + id: String(value), + title: `Title ${value}`, + }; +}); + +export const MultipleFiniteList = () => { + const handleChangeSelectedValues = useCallback((items: Items) => { + console.log(items); + }, []); + + return ( + ( + + )} + > + + + ); +};