Skip to content

Commit 4f49577

Browse files
garaev-insafscotchdshtefan
authored
Feature/dropdown multiselect (#164)
* feat: init and dev dropdown component * feat: init and dev dropdown-muitiselect --------- Co-authored-by: scotch <[email protected]> Co-authored-by: Denis Shtefan <[email protected]>
1 parent 0812a00 commit 4f49577

File tree

12 files changed

+377
-62
lines changed

12 files changed

+377
-62
lines changed

src/hooks/use-debounce.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export const useDebounce = <T>(value: T, delay?: number): T => {
4+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
5+
6+
useEffect(() => {
7+
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
8+
9+
return () => {
10+
clearTimeout(timer);
11+
};
12+
}, [value, delay]);
13+
14+
return debouncedValue;
15+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { Button, DropdownMenu } from '@chirp/ui/lib';
3+
import { useState } from 'react';
4+
5+
const meta: Meta<typeof DropdownMenu> = {
6+
title: 'UI/DropdownMenu',
7+
component: DropdownMenu,
8+
parameters: {
9+
layout: 'centered',
10+
},
11+
tags: ['autodocs'],
12+
};
13+
14+
export default meta;
15+
16+
type Story = StoryObj<typeof DropdownMenu>;
17+
18+
const MOCK_DATA = [
19+
{
20+
id: 1,
21+
title: 'Send by Email',
22+
},
23+
{
24+
id: 2,
25+
title: 'Planned',
26+
},
27+
{
28+
id: 3,
29+
title: 'Export',
30+
},
31+
];
32+
33+
export const Default: Story = {
34+
render: () => {
35+
const [openState, setOpenState] = useState(false);
36+
return (
37+
<DropdownMenu<{ title: string; id: number }>
38+
items={MOCK_DATA}
39+
resolveTitle={(item) => item.title}
40+
onClose={() => setOpenState(false)}
41+
onOpen={() => setOpenState(true)}
42+
isOpened={openState}
43+
>
44+
<Button variant="tertiary" onClick={() => setOpenState(!openState)}>
45+
Show
46+
</Button>
47+
</DropdownMenu>
48+
);
49+
},
50+
};

src/lib/dropdown-menu/index.tsx

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { PropsWithChildren, useRef } from 'react';
2+
import MenuItem from '@mui/material/MenuItem';
3+
import { Menu } from '@mui/material';
4+
5+
export interface IDropdownMenuProps<T> {
6+
items: T[];
7+
isOpened?: boolean;
8+
gap?: string;
9+
10+
onOpen: () => void;
11+
onClose: () => void;
12+
onSelect?: (val: T) => void;
13+
resolveTitle: (val: T) => string;
14+
}
15+
16+
export const DropdownMenu = <T,>({
17+
children,
18+
isOpened = false,
19+
onClose,
20+
items,
21+
resolveTitle,
22+
gap = '5px',
23+
}: PropsWithChildren<IDropdownMenuProps<T>>) => {
24+
const controlWrapperRef = useRef(null);
25+
return (
26+
<>
27+
<div ref={controlWrapperRef}>{children}</div>
28+
<Menu
29+
sx={{
30+
mt: gap,
31+
}}
32+
open={isOpened}
33+
onClose={onClose}
34+
anchorEl={controlWrapperRef.current}
35+
MenuListProps={{
36+
'aria-labelledby': 'basic-button',
37+
}}
38+
>
39+
{items?.map((item, idx) => (
40+
<MenuItem key={idx} onClick={onClose}>
41+
{resolveTitle(item)}
42+
</MenuItem>
43+
))}
44+
</Menu>
45+
</>
46+
);
47+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { FC } from 'react';
2+
import * as S from './style';
3+
import { Stack } from '@mui/system';
4+
import { Typography } from '../../typogrpahy';
5+
import { SelectIndicator } from '../../select-indicator';
6+
7+
interface IMultiselectDropdownButtonProps {
8+
title: string;
9+
10+
onClick: () => void;
11+
}
12+
13+
export const MultiselectDropdownButton: FC<IMultiselectDropdownButtonProps> = ({ title, onClick }) => {
14+
return (
15+
<S.Wrapper onClick={onClick}>
16+
<Stack direction="row" justifyContent="space-between" alignItems="center">
17+
<Typography variant="caption12" color="text.text4">
18+
{title}
19+
</Typography>
20+
<SelectIndicator />
21+
</Stack>
22+
</S.Wrapper>
23+
);
24+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { styled } from '@mui/material';
2+
3+
export const Wrapper = styled('div')(({ theme }) => ({
4+
padding: '1px 16px',
5+
borderRadius: '8px',
6+
background: theme.palette.background.background1,
7+
}));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { FC } from 'react';
2+
import { Stack } from '@mui/material';
3+
4+
import { Button } from '../../button';
5+
import * as S from './style';
6+
7+
interface IDropdownFooterProps {
8+
selectedCount?: number;
9+
10+
onAccept: () => void;
11+
onClear: () => void;
12+
}
13+
14+
export const DropdownFooter: FC<IDropdownFooterProps> = ({ selectedCount, onAccept, onClear }) => {
15+
const applyText = selectedCount ? `Apply (${selectedCount})` : 'Apply';
16+
return (
17+
<S.Wrapper p={4} pt={3}>
18+
<Stack direction="row">
19+
<Button fullWidth onClick={onClear}>
20+
Clear all
21+
</Button>
22+
<Button fullWidth variant="primary" onClick={onAccept}>
23+
{applyText}
24+
</Button>
25+
</Stack>
26+
</S.Wrapper>
27+
);
28+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Box, styled } from '@mui/material';
2+
3+
export const Wrapper = styled(Box)(({ theme }) => ({
4+
borderTop: '1px solid',
5+
borderColor: theme.palette.border.input,
6+
}));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { DropdownMultiselect } from '@chirp/ui/lib';
3+
import { useState } from 'react';
4+
5+
const meta: Meta<typeof DropdownMultiselect> = {
6+
title: 'UI/DropdownMultiselect',
7+
component: DropdownMultiselect,
8+
parameters: {
9+
layout: 'centered',
10+
},
11+
tags: ['autodocs'],
12+
};
13+
14+
export default meta;
15+
16+
type Story = StoryObj<typeof DropdownMultiselect>;
17+
18+
export const Default: Story = {
19+
render: () => {
20+
const [selectedOptions, setSelectedOptions] = useState<{ label: string; id: string }[]>([]);
21+
22+
return (
23+
<DropdownMultiselect
24+
title="Type"
25+
options={[
26+
{ label: 'Option 1', id: '1' },
27+
{ label: 'Option 2', id: '2' },
28+
{ label: 'Option 3', id: '3' },
29+
{ label: 'Option 4', id: '4' },
30+
{ label: 'Option 5', id: '5' },
31+
]}
32+
idKey="id"
33+
nameKey="label"
34+
width="230px"
35+
selectedOptions={selectedOptions}
36+
onAccept={setSelectedOptions}
37+
onClear={() => setSelectedOptions([])}
38+
></DropdownMultiselect>
39+
);
40+
},
41+
};
+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
import { Box, Paper, Stack } from '@mui/material';
3+
import { Dropdown } from '../dropdown';
4+
import { MultiselectDropdownButton } from './dropdown-button/dropdown-button';
5+
import { Checkbox } from '../checkbox';
6+
import { DropdownFooter } from './dropdown-content/footer';
7+
import { SearchInput } from '../search-input';
8+
import { useDebounce } from '@chirp/ui/hooks/use-debounce';
9+
10+
type CheckedStateType<T> = {
11+
array: T[];
12+
map: { [key: number | string]: boolean };
13+
};
14+
15+
interface IDropdownMultiselectProps<T> {
16+
title: string;
17+
width: string;
18+
19+
options: T[];
20+
idKey: keyof T;
21+
nameKey: keyof T;
22+
selectedOptions: T[];
23+
24+
onAccept: (arr: T[]) => void;
25+
onClear: () => void;
26+
}
27+
28+
export const DropdownMultiselect = <T extends Record<keyof T, unknown>>({
29+
title = '',
30+
width = '230px',
31+
selectedOptions = [],
32+
33+
options = [],
34+
idKey,
35+
nameKey,
36+
37+
onAccept,
38+
onClear,
39+
}: IDropdownMultiselectProps<T>) => {
40+
const [openState, setOpenState] = useState(false);
41+
const [searchState, setSearchState] = useState('');
42+
const [checkedItemsState, setCheckedItemsState] = useState<CheckedStateType<T>>({
43+
array: [],
44+
map: {},
45+
});
46+
47+
const debouncedSearch = useDebounce(searchState);
48+
49+
useEffect(() => {
50+
if (!selectedOptions) return;
51+
const mappedSelectedItems = selectedOptions.reduce(
52+
(acc, item) => ({ ...acc, [item[idKey] as PropertyKey]: true }),
53+
{} as CheckedStateType<T>['map'],
54+
);
55+
setCheckedItemsState({ array: selectedOptions, map: mappedSelectedItems });
56+
}, [selectedOptions]);
57+
58+
const handleChangeCheckedITem = (elem: T, checked: boolean) => {
59+
console.log(checked);
60+
if (checked) {
61+
setCheckedItemsState((prev) => ({
62+
array: [...prev.array, elem],
63+
map: { ...prev.map, [elem[idKey] as PropertyKey]: true },
64+
}));
65+
} else {
66+
setCheckedItemsState((prev) => ({
67+
array: prev.array.filter((item) => item[idKey] !== elem[idKey]),
68+
map: { ...prev.map, [elem[idKey] as PropertyKey]: false },
69+
}));
70+
}
71+
};
72+
73+
const handleApplyClick = () => {
74+
onAccept(checkedItemsState.array);
75+
setOpenState(false);
76+
};
77+
78+
const handleClearClick = () => {
79+
onClear();
80+
setOpenState(false);
81+
};
82+
83+
const filteredOptions = useMemo(() => {
84+
return options.filter((item) =>
85+
String(item[nameKey]).toString().toLowerCase().includes(debouncedSearch.toLowerCase()),
86+
);
87+
}, [options, debouncedSearch]);
88+
89+
return (
90+
<Box width={width}>
91+
<Dropdown
92+
isOpened={openState}
93+
anchorEl={<MultiselectDropdownButton title={title} onClick={() => setOpenState(!openState)} />}
94+
>
95+
<Paper>
96+
<Stack width={width}>
97+
<Box p={4}>
98+
<Stack gap={4}>
99+
<SearchInput
100+
value={searchState}
101+
onChange={setSearchState}
102+
placeholder="Search by name"
103+
/>
104+
{filteredOptions.map((item, index) => (
105+
<Checkbox
106+
key={`${index}-${item[idKey]}`}
107+
variant="check"
108+
label={item[nameKey] as string}
109+
checked={!!checkedItemsState.map[item[idKey] as number | string]}
110+
onChange={(_, checked) => handleChangeCheckedITem(item, checked)}
111+
labelTypographyVariant="body1"
112+
formControlLabelProps={{
113+
sx: {
114+
color: 'text.text4',
115+
},
116+
}}
117+
sx={{
118+
height: '26px',
119+
}}
120+
/>
121+
))}
122+
</Stack>
123+
</Box>
124+
<DropdownFooter
125+
selectedCount={checkedItemsState.array?.length}
126+
onAccept={handleApplyClick}
127+
onClear={handleClearClick}
128+
/>
129+
</Stack>
130+
</Paper>
131+
</Dropdown>
132+
</Box>
133+
);
134+
};

0 commit comments

Comments
 (0)