|
| 1 | +import { |
| 2 | + Avatar, |
| 3 | + Box, |
| 4 | + Checkbox, |
| 5 | + Divider, |
| 6 | + Flex, |
| 7 | + HStack, |
| 8 | + Icon, |
| 9 | + Input, |
| 10 | + InputGroup, |
| 11 | + InputLeftElement, |
| 12 | + Text, |
| 13 | +} from "@chakra-ui/react" |
| 14 | +import { PiMagnifyingGlass } from "@react-icons/all-files/pi/PiMagnifyingGlass" |
| 15 | +import type * as React from "react" |
| 16 | +import { useMemo, useState } from "react" |
| 17 | + |
| 18 | +export type MultiSelectListOption = { |
| 19 | + label: string |
| 20 | + value: string |
| 21 | + avatar?: string |
| 22 | + email?: string |
| 23 | + isDisabled?: boolean |
| 24 | +} |
| 25 | + |
| 26 | +type MultiSelectListFieldProps = { |
| 27 | + id?: string |
| 28 | + value?: string[] |
| 29 | + options: MultiSelectListOption[] |
| 30 | + onChange: (values: string[]) => void |
| 31 | + maxHeight?: string |
| 32 | + isSearchable?: boolean |
| 33 | + searchPlaceholder?: string |
| 34 | + selectedFirst?: boolean |
| 35 | + showSelectedSeparator?: boolean |
| 36 | +} |
| 37 | + |
| 38 | +export const MultiSelectListField: React.FC<MultiSelectListFieldProps> = ({ |
| 39 | + id, |
| 40 | + value = [], |
| 41 | + options, |
| 42 | + onChange, |
| 43 | + maxHeight = "300px", |
| 44 | + isSearchable = false, |
| 45 | + searchPlaceholder = "Search...", |
| 46 | + selectedFirst = false, |
| 47 | + showSelectedSeparator = false, |
| 48 | +}) => { |
| 49 | + const safeValue = Array.isArray(value) ? value : [] |
| 50 | + const [query, setQuery] = useState("") |
| 51 | + |
| 52 | + const toggleValue = (v: string) => { |
| 53 | + const set = new Set(safeValue) |
| 54 | + if (set.has(v)) { |
| 55 | + set.delete(v) |
| 56 | + } else { |
| 57 | + set.add(v) |
| 58 | + } |
| 59 | + onChange(Array.from(set)) |
| 60 | + } |
| 61 | + |
| 62 | + const { selected, other } = useMemo(() => { |
| 63 | + const q = query.trim().toLowerCase() |
| 64 | + const filtered = |
| 65 | + q.length === 0 |
| 66 | + ? options |
| 67 | + : options.filter((o) => { |
| 68 | + const haystack = `${o.label} ${o.email ?? ""}`.toLowerCase() |
| 69 | + return haystack.includes(q) |
| 70 | + }) |
| 71 | + |
| 72 | + if (!selectedFirst) return { selected: filtered, other: [] } |
| 73 | + |
| 74 | + const selectedOptions: MultiSelectListOption[] = [] |
| 75 | + const otherOptions: MultiSelectListOption[] = [] |
| 76 | + const selectedSet = new Set(safeValue) |
| 77 | + for (const opt of filtered) { |
| 78 | + ;(selectedSet.has(opt.value) ? selectedOptions : otherOptions).push(opt) |
| 79 | + } |
| 80 | + return { selected: selectedOptions, other: otherOptions } |
| 81 | + }, [options, query, safeValue, selectedFirst]) |
| 82 | + |
| 83 | + const shouldRenderSeparator = |
| 84 | + selectedFirst && showSelectedSeparator && selected.length > 0 && other.length > 0 |
| 85 | + |
| 86 | + const renderOption = (opt: MultiSelectListOption, idx: number, arrLen: number) => { |
| 87 | + const isChecked = safeValue.includes(opt.value) |
| 88 | + const disabled = opt.isDisabled |
| 89 | + |
| 90 | + return ( |
| 91 | + <HStack |
| 92 | + key={opt.value} |
| 93 | + px="3" |
| 94 | + py="2.5" |
| 95 | + spacing="3" |
| 96 | + cursor={disabled ? "not-allowed" : "pointer"} |
| 97 | + opacity={disabled ? 0.5 : 1} |
| 98 | + onClick={() => { |
| 99 | + if (!disabled) toggleValue(opt.value) |
| 100 | + }} |
| 101 | + _hover={{ |
| 102 | + bg: disabled ? undefined : "gray.50", |
| 103 | + }} |
| 104 | + borderBottomWidth={idx < arrLen - 1 ? "1px" : "0"} |
| 105 | + borderBottomColor="gray.100" |
| 106 | + transition="background-color 0.12s ease-in-out" |
| 107 | + > |
| 108 | + <Checkbox |
| 109 | + isChecked={isChecked} |
| 110 | + isDisabled={disabled} |
| 111 | + onChange={() => { |
| 112 | + if (!disabled) toggleValue(opt.value) |
| 113 | + }} |
| 114 | + pointerEvents="none" |
| 115 | + flexShrink={0} |
| 116 | + /> |
| 117 | + |
| 118 | + <Avatar |
| 119 | + size="xs" |
| 120 | + name={opt.label} |
| 121 | + src={opt.avatar} |
| 122 | + flexShrink={0} |
| 123 | + /> |
| 124 | + |
| 125 | + <Flex direction="column" minW={0} flex="1"> |
| 126 | + <Text fontSize="sm" fontWeight="500" noOfLines={1}> |
| 127 | + {opt.label} |
| 128 | + </Text> |
| 129 | + {opt.email && ( |
| 130 | + <Text fontSize="xs" color="gray.500" noOfLines={1}> |
| 131 | + {opt.email} |
| 132 | + </Text> |
| 133 | + )} |
| 134 | + </Flex> |
| 135 | + </HStack> |
| 136 | + ) |
| 137 | + } |
| 138 | + |
| 139 | + const renderedCount = selectedFirst ? selected.length + other.length : selected.length |
| 140 | + |
| 141 | + return ( |
| 142 | + <Box |
| 143 | + id={id} |
| 144 | + role="group" |
| 145 | + borderWidth="1px" |
| 146 | + borderColor="gray.200" |
| 147 | + borderRadius="md" |
| 148 | + overflow="hidden" |
| 149 | + bg="white" |
| 150 | + > |
| 151 | + {isSearchable ? ( |
| 152 | + <Box px="3" py="2.5" borderBottomWidth="1px" borderBottomColor="gray.100"> |
| 153 | + <InputGroup size="sm"> |
| 154 | + <InputLeftElement pointerEvents="none"> |
| 155 | + <Icon as={PiMagnifyingGlass} color="gray.400" boxSize={4} /> |
| 156 | + </InputLeftElement> |
| 157 | + <Input |
| 158 | + placeholder={searchPlaceholder} |
| 159 | + value={query} |
| 160 | + onChange={(e) => setQuery(e.target.value)} |
| 161 | + bg="gray.50" |
| 162 | + borderColor="gray.200" |
| 163 | + _hover={{ borderColor: "gray.300" }} |
| 164 | + _focus={{ |
| 165 | + borderColor: "blue.500", |
| 166 | + boxShadow: "0 0 0 1px #3182ce", |
| 167 | + }} |
| 168 | + /> |
| 169 | + </InputGroup> |
| 170 | + </Box> |
| 171 | + ) : null} |
| 172 | + |
| 173 | + <Box overflowY="auto" maxH={maxHeight}> |
| 174 | + {selectedFirst ? ( |
| 175 | + <> |
| 176 | + {selected.map((opt, idx) => renderOption(opt, idx, selected.length))} |
| 177 | + {shouldRenderSeparator ? <Divider borderColor="gray.200" /> : null} |
| 178 | + {other.map((opt, idx) => renderOption(opt, idx, other.length))} |
| 179 | + </> |
| 180 | + ) : ( |
| 181 | + selected.map((opt, idx) => renderOption(opt, idx, selected.length)) |
| 182 | + )} |
| 183 | + |
| 184 | + {renderedCount === 0 && ( |
| 185 | + <Flex align="center" justify="center" px="3" py="8" color="gray.500"> |
| 186 | + <Text fontSize="sm">{query.trim() ? "No matches found" : "No items available"}</Text> |
| 187 | + </Flex> |
| 188 | + )} |
| 189 | + </Box> |
| 190 | + </Box> |
| 191 | + ) |
| 192 | +} |
| 193 | + |
| 194 | +export default MultiSelectListField |
0 commit comments