Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

multi-select.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. // https://github.com/sersavan/shadcn-multi-select-component
  2. // src/components/multi-select.tsx
  3. import { cva, type VariantProps } from 'class-variance-authority';
  4. import {
  5. CheckIcon,
  6. ChevronDown,
  7. WandSparkles,
  8. XCircle,
  9. XIcon,
  10. } from 'lucide-react';
  11. import * as React from 'react';
  12. import { Badge } from '@/components/ui/badge';
  13. import { Button } from '@/components/ui/button';
  14. import {
  15. Command,
  16. CommandEmpty,
  17. CommandGroup,
  18. CommandInput,
  19. CommandItem,
  20. CommandList,
  21. CommandSeparator,
  22. } from '@/components/ui/command';
  23. import {
  24. Popover,
  25. PopoverContent,
  26. PopoverTrigger,
  27. } from '@/components/ui/popover';
  28. import { Separator } from '@/components/ui/separator';
  29. import { cn } from '@/lib/utils';
  30. export type MultiSelectOptionType = {
  31. label: React.ReactNode;
  32. value: string;
  33. disabled?: boolean;
  34. suffix?: React.ReactNode;
  35. icon?: React.ComponentType<{ className?: string }>;
  36. };
  37. export type MultiSelectGroupOptionType = {
  38. label: React.ReactNode;
  39. options: MultiSelectOptionType[];
  40. };
  41. function MultiCommandItem({
  42. option,
  43. isSelected,
  44. toggleOption,
  45. }: {
  46. option: MultiSelectOptionType;
  47. isSelected: boolean;
  48. toggleOption(value: string): void;
  49. }) {
  50. return (
  51. <CommandItem
  52. key={option.value}
  53. onSelect={() => {
  54. if (option.disabled) return false;
  55. toggleOption(option.value);
  56. }}
  57. className={cn('cursor-pointer', {
  58. 'cursor-not-allowed text-text-disabled': option.disabled,
  59. })}
  60. >
  61. <div
  62. className={cn(
  63. 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
  64. isSelected ? 'bg-primary ' : 'opacity-50 [&_svg]:invisible',
  65. { 'text-primary-foreground': !option.disabled },
  66. { 'text-text-disabled': option.disabled },
  67. )}
  68. >
  69. <CheckIcon className="h-4 w-4" />
  70. </div>
  71. {option.icon && (
  72. <option.icon
  73. className={cn('mr-2 h-4 w-4 ', {
  74. 'text-text-disabled': option.disabled,
  75. 'text-muted-foreground': !option.disabled,
  76. })}
  77. />
  78. )}
  79. <span className={cn({ 'text-text-disabled': option.disabled })}>
  80. {option.label}
  81. </span>
  82. {option.suffix && (
  83. <span className={cn({ 'text-text-disabled': option.disabled })}>
  84. {option.suffix}
  85. </span>
  86. )}
  87. </CommandItem>
  88. );
  89. }
  90. /**
  91. * Variants for the multi-select component to handle different styles.
  92. * Uses class-variance-authority (cva) to define different styles based on "variant" prop.
  93. */
  94. const multiSelectVariants = cva(
  95. 'm-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300',
  96. {
  97. variants: {
  98. variant: {
  99. default:
  100. 'border-foreground/10 text-foreground bg-card hover:bg-card/80',
  101. secondary:
  102. 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80',
  103. destructive:
  104. 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
  105. inverted: 'inverted',
  106. },
  107. },
  108. defaultVariants: {
  109. variant: 'default',
  110. },
  111. },
  112. );
  113. /**
  114. * Props for MultiSelect component
  115. */
  116. interface MultiSelectProps
  117. extends React.ButtonHTMLAttributes<HTMLButtonElement>,
  118. VariantProps<typeof multiSelectVariants> {
  119. /**
  120. * An array of option objects to be displayed in the multi-select component.
  121. * Each option object has a label, value, and an optional icon.
  122. */
  123. options: (MultiSelectGroupOptionType | MultiSelectOptionType)[];
  124. /**
  125. * Callback function triggered when the selected values change.
  126. * Receives an array of the new selected values.
  127. */
  128. onValueChange: (value: string[]) => void;
  129. /** The default selected values when the component mounts. */
  130. defaultValue?: string[];
  131. /**
  132. * Placeholder text to be displayed when no values are selected.
  133. * Optional, defaults to "Select options".
  134. */
  135. placeholder?: string;
  136. /**
  137. * Animation duration in seconds for the visual effects (e.g., bouncing badges).
  138. * Optional, defaults to 0 (no animation).
  139. */
  140. animation?: number;
  141. /**
  142. * Maximum number of items to display. Extra selected items will be summarized.
  143. * Optional, defaults to 3.
  144. */
  145. maxCount?: number;
  146. /**
  147. * The modality of the popover. When set to true, interaction with outside elements
  148. * will be disabled and only popover content will be visible to screen readers.
  149. * Optional, defaults to false.
  150. */
  151. modalPopover?: boolean;
  152. /**
  153. * If true, renders the multi-select component as a child of another component.
  154. * Optional, defaults to false.
  155. */
  156. asChild?: boolean;
  157. /**
  158. * Additional class names to apply custom styles to the multi-select component.
  159. * Optional, can be used to add custom styles.
  160. */
  161. className?: string;
  162. /**
  163. * If true, renders the multi-select component with a select all option.
  164. */
  165. showSelectAll?: boolean;
  166. }
  167. export const MultiSelect = React.forwardRef<
  168. HTMLButtonElement,
  169. MultiSelectProps
  170. >(
  171. (
  172. {
  173. options,
  174. onValueChange,
  175. variant,
  176. defaultValue = [],
  177. placeholder = 'Select options',
  178. animation = 0,
  179. maxCount = 3,
  180. modalPopover = false,
  181. asChild = false,
  182. className,
  183. showSelectAll = true,
  184. ...props
  185. },
  186. ref,
  187. ) => {
  188. const [selectedValues, setSelectedValues] =
  189. React.useState<string[]>(defaultValue);
  190. const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
  191. const [isAnimating, setIsAnimating] = React.useState(false);
  192. const flatOptions = React.useMemo(() => {
  193. return options.flatMap((option) =>
  194. 'options' in option ? option.options : [option],
  195. );
  196. }, [options]);
  197. const handleInputKeyDown = (
  198. event: React.KeyboardEvent<HTMLInputElement>,
  199. ) => {
  200. if (event.key === 'Enter') {
  201. setIsPopoverOpen(true);
  202. } else if (event.key === 'Backspace' && !event.currentTarget.value) {
  203. const newSelectedValues = [...selectedValues];
  204. newSelectedValues.pop();
  205. setSelectedValues(newSelectedValues);
  206. onValueChange(newSelectedValues);
  207. }
  208. };
  209. const toggleOption = (option: string) => {
  210. const newSelectedValues = selectedValues.includes(option)
  211. ? selectedValues.filter((value) => value !== option)
  212. : [...selectedValues, option];
  213. setSelectedValues(newSelectedValues);
  214. onValueChange(newSelectedValues);
  215. };
  216. const handleClear = () => {
  217. setSelectedValues([]);
  218. onValueChange([]);
  219. };
  220. const handleTogglePopover = () => {
  221. setIsPopoverOpen((prev) => !prev);
  222. };
  223. const clearExtraOptions = () => {
  224. const newSelectedValues = selectedValues.slice(0, maxCount);
  225. setSelectedValues(newSelectedValues);
  226. onValueChange(newSelectedValues);
  227. };
  228. const toggleAll = () => {
  229. if (selectedValues.length === flatOptions.length) {
  230. handleClear();
  231. } else {
  232. const allValues = flatOptions.map((option) => option.value);
  233. setSelectedValues(allValues);
  234. onValueChange(allValues);
  235. }
  236. };
  237. return (
  238. <Popover
  239. open={isPopoverOpen}
  240. onOpenChange={setIsPopoverOpen}
  241. modal={modalPopover}
  242. >
  243. <PopoverTrigger asChild>
  244. <Button
  245. ref={ref}
  246. {...props}
  247. onClick={handleTogglePopover}
  248. className={cn(
  249. 'flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto',
  250. className,
  251. )}
  252. >
  253. {selectedValues.length > 0 ? (
  254. <div className="flex justify-between items-center w-full">
  255. <div className="flex flex-wrap items-center">
  256. {selectedValues?.slice(0, maxCount)?.map((value) => {
  257. const option = flatOptions.find((o) => o.value === value);
  258. const IconComponent = option?.icon;
  259. return (
  260. <Badge
  261. key={value}
  262. variant="secondary"
  263. className={cn(
  264. isAnimating ? 'animate-bounce' : '',
  265. multiSelectVariants({ variant }),
  266. )}
  267. style={{ animationDuration: `${animation}s` }}
  268. >
  269. <div className="flex items-center gap-1">
  270. {IconComponent && (
  271. <IconComponent className="h-4 w-4" />
  272. )}
  273. <div>{option?.label}</div>
  274. <XCircle
  275. className="h-4 w-4 cursor-pointer"
  276. onClick={(event) => {
  277. event.stopPropagation();
  278. toggleOption(value);
  279. }}
  280. />
  281. </div>
  282. </Badge>
  283. );
  284. })}
  285. {selectedValues.length > maxCount && (
  286. <Badge
  287. className={cn(
  288. 'bg-transparent text-foreground border-foreground/1 hover:bg-transparent',
  289. isAnimating ? 'animate-bounce' : '',
  290. multiSelectVariants({ variant }),
  291. )}
  292. style={{ animationDuration: `${animation}s` }}
  293. >
  294. {`+ ${selectedValues.length - maxCount} more`}
  295. <XCircle
  296. className="ml-2 h-4 w-4 cursor-pointer"
  297. onClick={(event) => {
  298. event.stopPropagation();
  299. clearExtraOptions();
  300. }}
  301. />
  302. </Badge>
  303. )}
  304. </div>
  305. <div className="flex items-center justify-between">
  306. <XIcon
  307. className="h-4 mx-2 cursor-pointer text-muted-foreground"
  308. onClick={(event) => {
  309. event.stopPropagation();
  310. handleClear();
  311. }}
  312. />
  313. <Separator
  314. orientation="vertical"
  315. className="flex min-h-6 h-full"
  316. />
  317. <ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
  318. </div>
  319. </div>
  320. ) : (
  321. <div className="flex items-center justify-between w-full mx-auto">
  322. <span className="text-sm text-muted-foreground mx-3">
  323. {placeholder}
  324. </span>
  325. <ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
  326. </div>
  327. )}
  328. </Button>
  329. </PopoverTrigger>
  330. <PopoverContent
  331. className="w-auto p-0"
  332. align="start"
  333. onEscapeKeyDown={() => setIsPopoverOpen(false)}
  334. >
  335. <Command>
  336. <CommandInput
  337. placeholder="Search..."
  338. onKeyDown={handleInputKeyDown}
  339. />
  340. <CommandList>
  341. <CommandEmpty>No results found.</CommandEmpty>
  342. <CommandGroup>
  343. {showSelectAll && (
  344. <CommandItem
  345. key="all"
  346. onSelect={toggleAll}
  347. className="cursor-pointer"
  348. >
  349. <div
  350. className={cn(
  351. 'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
  352. selectedValues.length === flatOptions.length
  353. ? 'bg-primary text-primary-foreground'
  354. : 'opacity-50 [&_svg]:invisible',
  355. )}
  356. >
  357. <CheckIcon className="h-4 w-4" />
  358. </div>
  359. <span>(Select All)</span>
  360. </CommandItem>
  361. )}
  362. {!options.some((x) => 'options' in x) &&
  363. (options as unknown as MultiSelectOptionType[]).map(
  364. (option) => {
  365. const isSelected = selectedValues.includes(option.value);
  366. return (
  367. <MultiCommandItem
  368. option={option}
  369. key={option.value}
  370. isSelected={isSelected}
  371. toggleOption={toggleOption}
  372. ></MultiCommandItem>
  373. );
  374. },
  375. )}
  376. </CommandGroup>
  377. {options.every((x) => 'options' in x) &&
  378. options.map((x, idx) => (
  379. <CommandGroup heading={x.label} key={idx}>
  380. {x.options.map((option) => {
  381. const isSelected = selectedValues.includes(option.value);
  382. return (
  383. <MultiCommandItem
  384. option={option}
  385. key={option.value}
  386. isSelected={isSelected}
  387. toggleOption={toggleOption}
  388. ></MultiCommandItem>
  389. );
  390. })}
  391. </CommandGroup>
  392. ))}
  393. <CommandSeparator />
  394. <CommandGroup>
  395. <div className="flex items-center justify-between">
  396. {selectedValues.length > 0 && (
  397. <>
  398. <CommandItem
  399. onSelect={handleClear}
  400. className="flex-1 justify-center cursor-pointer"
  401. >
  402. Clear
  403. </CommandItem>
  404. <Separator
  405. orientation="vertical"
  406. className="flex min-h-6 h-full"
  407. />
  408. </>
  409. )}
  410. <CommandItem
  411. onSelect={() => setIsPopoverOpen(false)}
  412. className="flex-1 justify-center cursor-pointer max-w-full"
  413. >
  414. Close
  415. </CommandItem>
  416. </div>
  417. </CommandGroup>
  418. </CommandList>
  419. </Command>
  420. </PopoverContent>
  421. {animation > 0 && selectedValues.length > 0 && (
  422. <WandSparkles
  423. className={cn(
  424. 'cursor-pointer my-2 text-foreground bg-background w-3 h-3',
  425. isAnimating ? '' : 'text-muted-foreground',
  426. )}
  427. onClick={() => setIsAnimating(!isAnimating)}
  428. />
  429. )}
  430. </Popover>
  431. );
  432. },
  433. );
  434. MultiSelect.displayName = 'MultiSelect';