Skip to content

Commit

Permalink
feat(AutoComplete): include ListView components
Browse files Browse the repository at this point in the history
  • Loading branch information
LamaEats committed Sep 1, 2023
1 parent f3b3cb2 commit 5b8db75
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 98 deletions.
167 changes: 84 additions & 83 deletions src/components/AutoComplete.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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';

import { nullable } from '../utils';

import { Text } from './Text';
import { Input } from './Input';
import { ListView, ListViewItem } from './ListView';

type InputProps = React.ComponentProps<typeof Input>;
type AutoCompleteMode = 'single' | 'multiple';
type AutoCompleteSelectedMap<T> = Set<T>;
type ListItemProps = Parameters<React.ComponentProps<typeof ListViewItem>['renderItem']>[0];

interface AutoCompleteRenderItemProps<T> {
item: T;
Expand All @@ -19,33 +21,38 @@ interface AutoCompleteRenderItemProps<T> {
}

interface AutoCompleteRenderItem<T> {
(props: AutoCompleteRenderItemProps<T>): React.ReactNode;
(props: AutoCompleteRenderItemProps<T> & ListItemProps): React.ReactNode;
}

interface AutoCompleteContext<T> {
items: T[];
value: T[];
keyGetter: (item: T) => string;
renderItem: AutoCompleteRenderItem<T>;
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<AutoCompleteSelectedMap<T>>;
}

interface AutoCompleteProps<T> {
mode: AutoCompleteMode;
items: T[];
renderItem: AutoCompleteRenderItem<T>;
keyGetter: (item: T) => string;
renderItems?: (props: React.PropsWithChildren) => React.ReactNode;
value?: T[];
onChange: (items: T[]) => void;
}

interface AutoCompleteListProps {
title?: string;
/**
* Render only selected items
*/
selected?: boolean;
/**
* Render filtered items by value
*/
filterSelected?: boolean;
}

interface AutoCompleteInputProps extends Omit<InputProps, 'onChange'> {
Expand Down Expand Up @@ -146,68 +153,54 @@ function getItemCreator<T>(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<T>(
renderItem: AutoCompleteRenderItem<T>,
keyGetter: (item: T) => string,
): React.FC<AutoCompleteRenderItemProps<T>> {
return function AutoCompleteListItem(props) {
return (
<ListViewItem
key={keyGetter(props.item)}
value={props.item}
renderItem={(viewProps) => 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<AutoCompleteListProps> = 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<any>[] = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let target: Array<any>;

if (renderState === 'split') {
if (selected) {
target = value;
} else {
target = items.filter((item) => !map.current.has(item));
}
} else {
let target: Array<any>;

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) => (
<StyledText>{t}</StyledText>
))}
<Component>{toRender.map(renderItem)}</Component>
{toRender.map(renderer)}
</>
));
}
});

export const AutoCompleteInput: React.FC<AutoCompleteInputProps> = ({ onChange, ...props }) => {
const handleInputChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>((event) => {
Expand All @@ -225,63 +218,71 @@ export function AutoComplete<T>({
value = [],
onChange,
children,
keyGetter,
renderItem,
renderItems = defaultRenderItems,
renderItems: Component = defaultRenderItems,
}: React.PropsWithChildren<AutoCompleteProps<T>>) {
const [type, setType] = useState<AutoCompleteContext<T>['renderState']>('combine');
const [selected, setSelected] = useState<T[]>(() => value);

const currentMap = useRef<Set<T>>(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 (
<AutoCompleteContextProvider.Provider
value={{
value: selected,
renderState: type,
switchType,
map: currentMap,
onChange: handleChange,
items,
keyGetter,
renderItem,
renderItems,
popItem,
pushItem,
map: currentMap,
}}
>
{children}
<ListView onKeyboardClick={handleChange}>
<Component>{children}</Component>
</ListView>
</AutoCompleteContextProvider.Provider>
);
}
1 change: 0 additions & 1 deletion src/components/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export const Checkbox = forwardRef<HTMLInputElement, React.PropsWithChildren<Che
name={name}
value={value}
defaultChecked={checked}
checked={checked}
ref={ref}
onChange={handleOnChange}
{...attrs}
Expand Down
42 changes: 28 additions & 14 deletions src/stories/AutoComplete.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import { Meta, StoryFn } from '@storybook/react';
import { IconSearchOutline } from '@taskany/icons';

Expand All @@ -12,7 +12,6 @@ type Props = React.ComponentProps<Component>;
export default {
title: 'AutoComplete',
component: AutoComplete,
subcomponents: { AutoCompleteList, AutoCompleteRadioGroup, AutoCompleteInput },
argTypes: {
onChange: { action: 'onChange' },
},
Expand Down Expand Up @@ -51,6 +50,7 @@ export const Single: StoryFn<Props> = (args) => {
mode="single"
items={list}
onChange={args.onChange}
keyGetter={(item) => item.id}
renderItem={(props) => <div onClick={props.onItemClick}>{`${props.item.id}: ${props.item.title}`}</div>}
>
<AutoCompleteInput
Expand Down Expand Up @@ -82,15 +82,17 @@ export const Multiple: StoryFn<Props> = (args) => {
mode="multiple"
items={list}
onChange={args.onChange}
keyGetter={(item) => item.id}
renderItem={(props) => (
<Checkbox
key={props.item.id}
onClick={props.onItemClick}
label={`${props.item.id}: ${props.item.title}`}
name="item"
checked={props.checked}
value={props.item.id}
/>
<div style={{ backgroundColor: props.active || props.hovered ? 'lightgrey' : 'transparent' }}>
<Checkbox
onClick={props.onItemClick}
label={`${props.item.id}: ${props.item.title}`}
name="item"
checked={props.checked}
value={props.item.id}
/>
</div>
)}
>
<AutoCompleteInput
Expand All @@ -101,7 +103,7 @@ export const Multiple: StoryFn<Props> = (args) => {
placeholder="Search..."
/>
<AutoCompleteList title="Your choise" selected />
<AutoCompleteList title="Suggesstions" />
<AutoCompleteList title="Suggesstions" filterSelected />
</AutoComplete>
);
};
Expand Down Expand Up @@ -133,6 +135,7 @@ export const WithRadio: StoryFn<Props> = (args) => {
mode="single"
items={list}
onChange={args.onChange}
keyGetter={(item) => item.id}
renderItem={(props) => (
<div key={props.item.id}>
<label onClick={props.onItemClick}>
Expand Down Expand Up @@ -160,6 +163,8 @@ export const WithRadio: StoryFn<Props> = (args) => {
);
};

const Wrapper = ({ children }: React.PropsWithChildren) => <Table gap={10}>{children}</Table>;

export const MultipleWithTable: StoryFn<Props> = (args) => {
const [value, setValue] = useState('');
const [list, setList] = useState<Items>([]);
Expand All @@ -177,9 +182,16 @@ export const MultipleWithTable: StoryFn<Props> = (args) => {
mode="multiple"
items={list}
onChange={args.onChange}
renderItems={({ children }) => <Table gap={10}>{children}</Table>}
keyGetter={(item) => item.id}
renderItems={Wrapper}
renderItem={(props) => (
<TableRow gap={5} key={props.item.id}>
<TableRow
gap={5}
key={props.item.id}
interactive
focused={props.active || props.hovered}
style={{ borderRadius: '5px' }}
>
<TableCell min>
<Checkbox
value={props.item.id}
Expand All @@ -202,7 +214,8 @@ export const MultipleWithTable: StoryFn<Props> = (args) => {
iconLeft={<IconSearchOutline size="s" />}
placeholder="Search..."
/>
<AutoCompleteList title="Suggesstions" />
<AutoCompleteList selected />
<AutoCompleteList filterSelected title="Sugesstions" />
</AutoComplete>
);
};
Expand All @@ -219,6 +232,7 @@ export const MultipleFiniteList: StoryFn<Props> = (args) => (
<AutoComplete
mode="multiple"
onChange={args.onChange}
keyGetter={(item) => item.id}
items={finiteList}
value={finiteList.slice(2, 4)}
renderItem={(props) => (
Expand Down

0 comments on commit 5b8db75

Please sign in to comment.