Skip to content

Commit dc39e06

Browse files
committed
feat(select-extended): added multiple select
1 parent 27b67a2 commit dc39e06

File tree

2 files changed

+351
-81
lines changed

2 files changed

+351
-81
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"use client";
2+
import List from "@mui/material/List";
3+
import ListItem from "@mui/material/ListItem";
4+
import ListItemButton from "@mui/material/ListItemButton";
5+
import Card from "@mui/material/Card";
6+
import CardContent from "@mui/material/CardContent";
7+
import TextField from "@mui/material/TextField";
8+
import ClickAwayListener from "@mui/material/ClickAwayListener";
9+
import React, {
10+
ForwardedRef,
11+
forwardRef,
12+
useState,
13+
useRef,
14+
useEffect,
15+
} from "react";
16+
import {
17+
Controller,
18+
ControllerProps,
19+
FieldPath,
20+
FieldValues,
21+
} from "react-hook-form";
22+
import { ItemProps, ListProps, Virtuoso } from "react-virtuoso";
23+
import ListItemText from "@mui/material/ListItemText";
24+
import Box from "@mui/material/Box";
25+
26+
type MultipleSelectExtendedInputProps<T extends object> = {
27+
label: string;
28+
error?: string;
29+
testId?: string;
30+
disabled?: boolean;
31+
options: T[];
32+
renderSelected: (option: T[]) => React.ReactNode;
33+
renderOption: (option: T) => React.ReactNode;
34+
keyExtractor: (option: T) => string;
35+
onEndReached?: () => void;
36+
} & (
37+
| {
38+
isSearchable: true;
39+
searchLabel: string;
40+
searchPlaceholder: string;
41+
search: string;
42+
onSearchChange: (search: string) => void;
43+
}
44+
| {
45+
isSearchable?: false;
46+
}
47+
);
48+
49+
const MUIComponents = {
50+
List: forwardRef<HTMLDivElement, ListProps>(function MuiList(
51+
{ style, children },
52+
listRef
53+
) {
54+
return (
55+
<List
56+
style={{ padding: 0, ...style, margin: 0 }}
57+
component="div"
58+
ref={listRef}
59+
>
60+
{children}
61+
</List>
62+
);
63+
}),
64+
65+
Item: ({ children, ...props }: ItemProps<unknown>) => {
66+
return (
67+
<ListItem component="div" {...props} style={{ margin: 0 }} disablePadding>
68+
{children}
69+
</ListItem>
70+
);
71+
},
72+
};
73+
74+
function MultipleSelectExtendedInputRaw<T extends object>(
75+
props: MultipleSelectExtendedInputProps<T> & {
76+
name: string;
77+
value: T[] | null;
78+
onChange: (value: T[]) => void;
79+
onBlur: () => void;
80+
},
81+
ref?: ForwardedRef<HTMLDivElement | null>
82+
) {
83+
const [isOpen, setIsOpen] = useState(false);
84+
const boxRef = useRef<HTMLInputElement | null>(null);
85+
86+
const valueKeys = props.value?.map(props.keyExtractor) ?? [];
87+
88+
useEffect(() => {
89+
if (isOpen) {
90+
boxRef.current?.scrollIntoView({ behavior: "smooth" });
91+
}
92+
}, [isOpen]);
93+
94+
return (
95+
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
96+
<div>
97+
<Box mb={0.5} ref={boxRef}>
98+
<TextField
99+
ref={ref}
100+
name={props.name}
101+
value={props.value ? props.renderSelected(props.value) : ""}
102+
onBlur={props.onBlur}
103+
label={props.label}
104+
variant="outlined"
105+
onClick={() => {
106+
if (props.disabled) return;
107+
108+
setIsOpen((prev) => !prev);
109+
}}
110+
fullWidth
111+
error={!!props.error}
112+
data-testid={props.testId}
113+
helperText={props.error}
114+
disabled={props.disabled}
115+
slotProps={{
116+
input: {
117+
readOnly: true,
118+
},
119+
formHelperText: {
120+
["data-testid" as string]: `${props.testId}-error`,
121+
},
122+
}}
123+
/>
124+
</Box>
125+
126+
{isOpen && (
127+
<Card>
128+
<CardContent
129+
sx={{
130+
p: 0,
131+
"&:last-child": {
132+
pb: 0,
133+
},
134+
}}
135+
>
136+
{props.isSearchable && (
137+
<Box p={2}>
138+
<TextField
139+
placeholder={props.searchPlaceholder}
140+
value={props.search}
141+
onChange={(e) => props.onSearchChange?.(e.target.value)}
142+
label={props.searchLabel}
143+
variant="outlined"
144+
autoFocus
145+
fullWidth
146+
data-testid={`${props.testId}-search`}
147+
/>
148+
</Box>
149+
)}
150+
151+
<Virtuoso
152+
style={{
153+
height:
154+
props.options.length <= 6 ? props.options.length * 48 : 320,
155+
}}
156+
data={props.options}
157+
endReached={props.onEndReached}
158+
components={MUIComponents}
159+
itemContent={(index, item) => (
160+
<ListItemButton
161+
selected={valueKeys.includes(props.keyExtractor(item))}
162+
onClick={() => {
163+
const newValue = props.value
164+
? valueKeys.includes(props.keyExtractor(item))
165+
? props.value.filter(
166+
(selectedItem) =>
167+
props.keyExtractor(selectedItem) !==
168+
props.keyExtractor(item)
169+
)
170+
: [...props.value, item]
171+
: [item];
172+
props.onChange(newValue);
173+
}}
174+
>
175+
{item ? (
176+
<ListItemText primary={props.renderOption(item)} />
177+
) : (
178+
<></>
179+
)}
180+
</ListItemButton>
181+
)}
182+
/>
183+
</CardContent>
184+
</Card>
185+
)}
186+
</div>
187+
</ClickAwayListener>
188+
);
189+
}
190+
191+
const MultipleSelectExtendedInput = forwardRef(
192+
MultipleSelectExtendedInputRaw
193+
) as never as <T extends object>(
194+
props: MultipleSelectExtendedInputProps<T> & {
195+
name: string;
196+
value: T[] | null;
197+
onChange: (value: T[]) => void;
198+
onBlur: () => void;
199+
} & { ref?: ForwardedRef<HTMLDivElement | null> }
200+
) => ReturnType<typeof MultipleSelectExtendedInputRaw>;
201+
202+
function FormMultipleSelectExtendedInput<
203+
TFieldValues extends FieldValues = FieldValues,
204+
T extends object = object,
205+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
206+
>(
207+
props: Pick<ControllerProps<TFieldValues, TName>, "name" | "defaultValue"> &
208+
MultipleSelectExtendedInputProps<T>
209+
) {
210+
return (
211+
<Controller
212+
name={props.name}
213+
defaultValue={props.defaultValue}
214+
render={({ field, fieldState }) => (
215+
<MultipleSelectExtendedInput<T>
216+
{...field}
217+
isSearchable={props.isSearchable}
218+
label={props.label}
219+
error={fieldState.error?.message}
220+
disabled={props.disabled}
221+
testId={props.testId}
222+
options={props.options}
223+
renderOption={props.renderOption}
224+
renderSelected={props.renderSelected}
225+
keyExtractor={props.keyExtractor}
226+
search={props.isSearchable ? props.search : ""}
227+
onSearchChange={
228+
props.isSearchable ? props.onSearchChange : () => undefined
229+
}
230+
onEndReached={props.isSearchable ? props.onEndReached : undefined}
231+
searchLabel={props.isSearchable ? props.searchLabel : ""}
232+
searchPlaceholder={props.isSearchable ? props.searchPlaceholder : ""}
233+
/>
234+
)}
235+
/>
236+
);
237+
}
238+
239+
export default FormMultipleSelectExtendedInput;

0 commit comments

Comments
 (0)