multi-select.tsx612 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { cva, VariantProps } from 'class-variance-authority' |
| 4 | import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react' |
| 5 | // @ts-ignore Required to avoid TS error: The inferred type of MultiSelectorContent cannot be named without a reference to @radix-ui |
| 6 | import type { Popover as PopoverPrimitive } from 'radix-ui' |
| 7 | import React, { isValidElement, ReactElement, useEffect } from 'react' |
| 8 | import { |
| 9 | Badge, |
| 10 | cn, |
| 11 | Command as Command, |
| 12 | CommandEmpty as CommandEmpty, |
| 13 | CommandInput as CommandInput, |
| 14 | CommandItem as CommandItem, |
| 15 | CommandList as CommandList, |
| 16 | Popover, |
| 17 | PopoverAnchor, |
| 18 | PopoverContent, |
| 19 | PopoverContentProps, |
| 20 | } from 'ui' |
| 21 | import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants' |
| 22 | |
| 23 | interface MultiSelectContextProps { |
| 24 | id: string |
| 25 | values: string[] |
| 26 | onValuesChange: (value: string[]) => void |
| 27 | toggleValue: (values: string) => void |
| 28 | open: boolean |
| 29 | setOpen: React.Dispatch<React.SetStateAction<boolean>> |
| 30 | inputValue: string |
| 31 | setInputValue: React.Dispatch<React.SetStateAction<string>> |
| 32 | activeIndex: number |
| 33 | setActiveIndex: React.Dispatch<React.SetStateAction<number>> |
| 34 | size: MultiSelectorProps['size'] |
| 35 | disabled?: boolean |
| 36 | dropdownMaxHeight: number |
| 37 | } |
| 38 | |
| 39 | const MultiSelectContext = React.createContext<MultiSelectContextProps | null>(null) |
| 40 | |
| 41 | const DROPDOWN_MAX_HEIGHT = 300 |
| 42 | const DROPDOWN_GAP = 8 |
| 43 | |
| 44 | const commandItemClass = cn( |
| 45 | 'relative text-foreground-lighter text-left px-2 py-1.5 rounded-sm', |
| 46 | 'hover:text-foreground hover:!bg-overlay-hover w-full flex items-center space-x-2', |
| 47 | 'peer-data-[value=true]:bg-overlay-hover' |
| 48 | ) |
| 49 | |
| 50 | function useMultiSelect() { |
| 51 | const context = React.useContext(MultiSelectContext) |
| 52 | if (!context) { |
| 53 | throw new Error('useMultiSelect must be used within a MultiSelectProvider') |
| 54 | } |
| 55 | return context |
| 56 | } |
| 57 | |
| 58 | const MultiSelectorVariants = cva('', { |
| 59 | variants: { |
| 60 | size: { |
| 61 | ...SIZE_VARIANTS, |
| 62 | }, |
| 63 | }, |
| 64 | defaultVariants: { |
| 65 | size: SIZE_VARIANTS_DEFAULT, |
| 66 | }, |
| 67 | }) |
| 68 | |
| 69 | type MultiSelectorMode = 'combobox' | 'inline-combobox' |
| 70 | |
| 71 | type MultiSelectorProps = { |
| 72 | mode?: MultiSelectorMode |
| 73 | values: string[] |
| 74 | onValuesChange: (value: string[]) => void |
| 75 | disabled?: boolean |
| 76 | } & React.ComponentPropsWithoutRef<typeof Command> & |
| 77 | VariantProps<typeof MultiSelectorVariants> |
| 78 | |
| 79 | function MultiSelector({ |
| 80 | values = [], |
| 81 | onValuesChange, |
| 82 | disabled, |
| 83 | dir, |
| 84 | size, |
| 85 | className, |
| 86 | children, |
| 87 | id: idProp, |
| 88 | ...props |
| 89 | }: MultiSelectorProps) { |
| 90 | const ref = React.useRef(null) |
| 91 | const [open, setOpen] = React.useState<boolean>(false) |
| 92 | const [inputValue, setInputValue] = React.useState<string>('') |
| 93 | const [activeIndex, setActiveIndex] = React.useState<number>(-1) |
| 94 | const [dropdownMaxHeight, setDropdownMaxHeight] = React.useState<number>(DROPDOWN_MAX_HEIGHT) |
| 95 | const generatedId = React.useId() |
| 96 | const id = idProp ?? generatedId |
| 97 | |
| 98 | const toggleValue = React.useCallback( |
| 99 | (toggledValue: string) => { |
| 100 | if (values.includes(toggledValue)) { |
| 101 | onValuesChange(values.filter((value) => value !== toggledValue) || []) |
| 102 | } else { |
| 103 | onValuesChange([...values, toggledValue]) |
| 104 | } |
| 105 | }, |
| 106 | [values] |
| 107 | ) |
| 108 | |
| 109 | useEffect(() => { |
| 110 | if (!open) return |
| 111 | const controller = new AbortController() |
| 112 | const { signal } = controller |
| 113 | |
| 114 | const updateDropdownMetrics = () => { |
| 115 | if (typeof window === 'undefined') return |
| 116 | const triggerEl = ref.current as HTMLDivElement | null |
| 117 | if (!triggerEl) return |
| 118 | |
| 119 | const rect = triggerEl.getBoundingClientRect() |
| 120 | const viewportHeight = window.innerHeight |
| 121 | const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP |
| 122 | const spaceAbove = rect.top - DROPDOWN_GAP |
| 123 | const shouldDropUp = spaceBelow < DROPDOWN_MAX_HEIGHT && spaceAbove > spaceBelow |
| 124 | const placement = shouldDropUp ? 'top' : 'bottom' |
| 125 | const availableSpace = Math.max(placement === 'top' ? spaceAbove : spaceBelow, 0) |
| 126 | const nextHeight = |
| 127 | availableSpace > 0 ? Math.min(DROPDOWN_MAX_HEIGHT, availableSpace) : DROPDOWN_MAX_HEIGHT |
| 128 | |
| 129 | setDropdownMaxHeight(nextHeight) |
| 130 | } |
| 131 | |
| 132 | const handleUpdate = updateDropdownMetrics |
| 133 | handleUpdate() |
| 134 | window.addEventListener('resize', handleUpdate, { signal }) |
| 135 | window.addEventListener('scroll', handleUpdate, { capture: true, passive: true, signal }) |
| 136 | |
| 137 | return () => controller.abort() |
| 138 | }, [open]) |
| 139 | |
| 140 | const handleKeyDown = React.useCallback( |
| 141 | (e: React.KeyboardEvent<HTMLDivElement>) => { |
| 142 | switch (e.key) { |
| 143 | case 'Backspace': |
| 144 | case 'Delete': |
| 145 | if (values.length > 0 && inputValue.length === 0) { |
| 146 | if (activeIndex !== -1 && activeIndex < values.length) { |
| 147 | onValuesChange(values.filter((item) => item !== values[activeIndex])) |
| 148 | const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1 |
| 149 | setActiveIndex(newIndex) |
| 150 | } else { |
| 151 | onValuesChange(values.filter((item) => item !== values[values.length - 1])) |
| 152 | } |
| 153 | } |
| 154 | break |
| 155 | case 'Escape': |
| 156 | activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false) |
| 157 | if (ref.current) { |
| 158 | const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]') |
| 159 | button && (button as HTMLButtonElement).focus() |
| 160 | } |
| 161 | break |
| 162 | case 'Enter': |
| 163 | setOpen(true) |
| 164 | break |
| 165 | } |
| 166 | }, |
| 167 | [values, inputValue, activeIndex] |
| 168 | ) |
| 169 | |
| 170 | return ( |
| 171 | <MultiSelectContext.Provider |
| 172 | value={{ |
| 173 | id, |
| 174 | values, |
| 175 | toggleValue, |
| 176 | onValuesChange, |
| 177 | open, |
| 178 | setOpen, |
| 179 | inputValue, |
| 180 | setInputValue, |
| 181 | activeIndex, |
| 182 | setActiveIndex, |
| 183 | size: size || 'small', |
| 184 | disabled, |
| 185 | dropdownMaxHeight, |
| 186 | }} |
| 187 | > |
| 188 | <Popover open={open} onOpenChange={setOpen}> |
| 189 | <Command |
| 190 | id={id} |
| 191 | ref={ref} |
| 192 | onKeyDown={handleKeyDown} |
| 193 | className={cn('relative w-auto overflow-visible bg-transparent flex flex-col', className)} |
| 194 | dir={dir} |
| 195 | {...props} |
| 196 | > |
| 197 | {children} |
| 198 | </Command> |
| 199 | </Popover> |
| 200 | </MultiSelectContext.Provider> |
| 201 | ) |
| 202 | } |
| 203 | |
| 204 | export interface MultiSelectorTriggerProps extends React.HTMLAttributes<HTMLButtonElement> { |
| 205 | label?: string |
| 206 | persistLabel?: boolean |
| 207 | className?: string |
| 208 | badgeLimit?: number | 'wrap' |
| 209 | deletableBadge?: boolean |
| 210 | showIcon?: boolean |
| 211 | mode?: MultiSelectorMode |
| 212 | } |
| 213 | |
| 214 | const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTriggerProps>( |
| 215 | ( |
| 216 | { |
| 217 | label, |
| 218 | persistLabel = false, |
| 219 | className, |
| 220 | deletableBadge = true, |
| 221 | badgeLimit = 9999, |
| 222 | showIcon = true, |
| 223 | mode = 'combobox', |
| 224 | children, |
| 225 | ...props |
| 226 | }, |
| 227 | ref |
| 228 | ) => { |
| 229 | const { activeIndex, values, setInputValue, toggleValue, disabled, open, setOpen } = |
| 230 | useMultiSelect() |
| 231 | |
| 232 | const inputRef = React.useRef<HTMLButtonElement>(null) |
| 233 | |
| 234 | // Use the provided ref if available, otherwise use the local ref |
| 235 | React.useImperativeHandle(ref, () => inputRef.current as HTMLButtonElement) |
| 236 | const inlineInputRef = React.useRef<HTMLInputElement>(null) |
| 237 | const badgesRef = React.useRef<HTMLDivElement>(null) |
| 238 | |
| 239 | const [visibleBadges, setVisibleBadges] = React.useState<string[]>([]) |
| 240 | const [extraBadgesCount, setExtraBadgesCount] = React.useState(0) |
| 241 | const [isDeleteHovered, setIsDeleteHovered] = React.useState(false) |
| 242 | |
| 243 | const IS_BADGE_LIMIT_WRAP = badgeLimit === 'wrap' |
| 244 | const IS_NUMERIC_LIMIT = typeof badgeLimit === 'number' |
| 245 | const IS_INLINE_MODE = mode === 'inline-combobox' |
| 246 | |
| 247 | React.useEffect(() => { |
| 248 | if (!inputRef?.current || !badgesRef.current) return |
| 249 | |
| 250 | if (IS_BADGE_LIMIT_WRAP) { |
| 251 | setVisibleBadges(values) |
| 252 | setExtraBadgesCount(0) |
| 253 | } else { |
| 254 | setVisibleBadges(values.slice(0, badgeLimit)) |
| 255 | setExtraBadgesCount(Math.max(0, values.length - badgeLimit)) |
| 256 | } |
| 257 | }, [values, badgeLimit]) |
| 258 | |
| 259 | const badgeClasses = 'rounded-sm shrink-0 px-1.5' |
| 260 | |
| 261 | const handleTriggerClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback( |
| 262 | (event) => { |
| 263 | if (IS_INLINE_MODE) { |
| 264 | event.stopPropagation() |
| 265 | event.preventDefault() |
| 266 | |
| 267 | if (!open) { |
| 268 | setInputValue('') |
| 269 | } |
| 270 | |
| 271 | setTimeout(() => { |
| 272 | inlineInputRef.current?.focus() |
| 273 | }, 100) |
| 274 | |
| 275 | return |
| 276 | } |
| 277 | |
| 278 | const willOpen = !open |
| 279 | setOpen(willOpen) |
| 280 | if (willOpen) setInputValue('') |
| 281 | }, |
| 282 | [open, setOpen, setInputValue, IS_INLINE_MODE] |
| 283 | ) |
| 284 | |
| 285 | return ( |
| 286 | <PopoverAnchor asChild> |
| 287 | <button |
| 288 | ref={inputRef} |
| 289 | onClick={(e) => !isDeleteHovered && handleTriggerClick(e)} |
| 290 | disabled={disabled} |
| 291 | type="button" |
| 292 | role="combobox" |
| 293 | className={cn( |
| 294 | 'flex w-full min-w-[200px] min-h-[40px] items-center justify-between rounded-md border', |
| 295 | 'border-alternative bg-control px-3 py-2 text-sm', |
| 296 | 'ring-offset-background placeholder:text-muted-foreground', |
| 297 | 'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', |
| 298 | 'disabled:cursor-not-allowed disabled:opacity-50', |
| 299 | 'hover:border-primary transition-colors duration-200', |
| 300 | className |
| 301 | )} |
| 302 | {...props} |
| 303 | > |
| 304 | <div |
| 305 | ref={badgesRef} |
| 306 | className={cn( |
| 307 | 'flex gap-1 -ml-1 overflow-hidden flex-1', |
| 308 | IS_BADGE_LIMIT_WRAP && 'flex-wrap', |
| 309 | !IS_BADGE_LIMIT_WRAP && |
| 310 | 'overflow-x-auto scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg' |
| 311 | )} |
| 312 | > |
| 313 | {visibleBadges.map((value) => ( |
| 314 | <Badge key={value} className={badgeClasses}> |
| 315 | {value} |
| 316 | {deletableBadge && ( |
| 317 | <div |
| 318 | onMouseEnter={() => setIsDeleteHovered(true)} |
| 319 | onMouseLeave={() => setIsDeleteHovered(false)} |
| 320 | onClick={(e) => { |
| 321 | e.stopPropagation() |
| 322 | toggleValue(value) |
| 323 | setIsDeleteHovered(false) |
| 324 | }} |
| 325 | className="ml-1 text-foreground-lighter hover:text-foreground-light transition-colors pointer-events-auto" |
| 326 | > |
| 327 | <RemoveIcon size={12} /> |
| 328 | </div> |
| 329 | )} |
| 330 | </Badge> |
| 331 | ))} |
| 332 | {extraBadgesCount > 0 && ( |
| 333 | <Badge className={badgeClasses}> |
| 334 | {IS_NUMERIC_LIMIT && badgeLimit < 1 |
| 335 | ? `${extraBadgesCount} item${extraBadgesCount > 1 ? 's' : ''} selected` |
| 336 | : `+${extraBadgesCount}`} |
| 337 | </Badge> |
| 338 | )} |
| 339 | <span |
| 340 | className={cn( |
| 341 | 'text-foreground-muted whitespace-nowrap leading-5.5 ml-1 opacity-0 transition-opacity hidden', |
| 342 | !IS_INLINE_MODE && |
| 343 | (persistLabel || values.length === 0) && |
| 344 | 'opacity-100 visible inline' |
| 345 | )} |
| 346 | > |
| 347 | {label} |
| 348 | </span> |
| 349 | {IS_INLINE_MODE && ( |
| 350 | <MultiSelectorInput |
| 351 | ref={inlineInputRef} |
| 352 | showSearchIcon={false} |
| 353 | onValueChange={activeIndex === -1 ? setInputValue : undefined} |
| 354 | placeholder={label} |
| 355 | autoFocus={false} |
| 356 | wrapperClassName={cn( |
| 357 | 'px-0 flex-1 border-none truncate', |
| 358 | IS_BADGE_LIMIT_WRAP && 'min-w-[85px]' |
| 359 | )} |
| 360 | className="py-0 px-1 truncate" |
| 361 | /> |
| 362 | )} |
| 363 | </div> |
| 364 | |
| 365 | {showIcon && ( |
| 366 | <ChevronsUpDown |
| 367 | size={16} |
| 368 | strokeWidth={2} |
| 369 | className="text-foreground-lighter shrink-0 ml-1.5" |
| 370 | /> |
| 371 | )} |
| 372 | </button> |
| 373 | </PopoverAnchor> |
| 374 | ) |
| 375 | } |
| 376 | ) |
| 377 | |
| 378 | MultiSelectorTrigger.displayName = 'MultiSelectorTrigger' |
| 379 | MultiSelector.Trigger = MultiSelectorTrigger |
| 380 | |
| 381 | const MultiSelectorInputVariants = cva('bg-control border', { |
| 382 | variants: { |
| 383 | size: { |
| 384 | ...SIZE_VARIANTS, |
| 385 | }, |
| 386 | }, |
| 387 | defaultVariants: { |
| 388 | size: SIZE_VARIANTS_DEFAULT, |
| 389 | }, |
| 390 | }) |
| 391 | |
| 392 | const getInputId = (id: string) => `${id}-input` |
| 393 | |
| 394 | const MultiSelectorInput = React.forwardRef< |
| 395 | React.ElementRef<typeof CommandInput>, |
| 396 | React.ComponentPropsWithoutRef<typeof CommandInput> & { |
| 397 | showResetIcon?: boolean |
| 398 | showSearchIcon?: boolean |
| 399 | wrapperClassName?: string |
| 400 | } |
| 401 | >(({ className, wrapperClassName, showResetIcon, showSearchIcon, ...props }, ref) => { |
| 402 | const { |
| 403 | id, |
| 404 | open, |
| 405 | setOpen, |
| 406 | inputValue, |
| 407 | setInputValue, |
| 408 | activeIndex, |
| 409 | setActiveIndex, |
| 410 | size, |
| 411 | disabled, |
| 412 | } = useMultiSelect() |
| 413 | const inputRef = React.useRef<HTMLInputElement>(null) |
| 414 | |
| 415 | // Use the provided ref if available, otherwise use the local ref |
| 416 | React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement) |
| 417 | |
| 418 | const handleFocus = () => !open && setOpen(true) |
| 419 | const handleClick = () => setActiveIndex(-1) |
| 420 | const handleReset = () => { |
| 421 | setInputValue('') |
| 422 | setInputFocus() |
| 423 | } |
| 424 | |
| 425 | const setInputFocus = () => { |
| 426 | setTimeout(() => { |
| 427 | if (!inputRef?.current) return |
| 428 | if (open) { |
| 429 | inputRef.current.focus() |
| 430 | } |
| 431 | }, 100) |
| 432 | } |
| 433 | |
| 434 | useEffect(() => { |
| 435 | setInputFocus() |
| 436 | |
| 437 | if (!open) { |
| 438 | inputRef.current?.blur() |
| 439 | } |
| 440 | }, [open]) |
| 441 | |
| 442 | return ( |
| 443 | <CommandInput |
| 444 | ref={inputRef} |
| 445 | value={inputValue} |
| 446 | onValueChange={activeIndex === -1 ? setInputValue : undefined} |
| 447 | onFocus={handleFocus} |
| 448 | onClick={handleClick} |
| 449 | tabIndex={open ? 0 : -1} |
| 450 | disabled={disabled} |
| 451 | showSearchIcon={showSearchIcon} |
| 452 | showResetIcon={showResetIcon} |
| 453 | handleReset={handleReset} |
| 454 | wrapperClassName={wrapperClassName} |
| 455 | className={cn( |
| 456 | MultiSelectorInputVariants({ size }), |
| 457 | 'text-sm bg-transparent h-full grow border-none outline-hidden placeholder:text-foreground-muted flex-1', |
| 458 | activeIndex !== -1 && 'caret-transparent', |
| 459 | className |
| 460 | )} |
| 461 | // Can't use id as CommandInput overrides it |
| 462 | data-id={getInputId(id)} |
| 463 | {...props} |
| 464 | /> |
| 465 | ) |
| 466 | }) |
| 467 | |
| 468 | MultiSelectorInput.displayName = 'MultiSelectorInput' |
| 469 | MultiSelector.Input = MultiSelectorInput |
| 470 | |
| 471 | const MultiSelectorContent = React.forwardRef<HTMLDivElement, PopoverContentProps>( |
| 472 | ({ className, children, ...props }, ref) => { |
| 473 | const { id } = useMultiSelect() |
| 474 | return ( |
| 475 | <PopoverContent |
| 476 | align="start" |
| 477 | ref={ref} |
| 478 | className={cn( |
| 479 | 'bg-overlay shadow-md z-50 border border-overlay rounded-md p-0', |
| 480 | 'w-(--radix-popper-anchor-width)', |
| 481 | className |
| 482 | )} |
| 483 | onFocusOutside={(event) => { |
| 484 | if (event.target instanceof HTMLElement && event.target.dataset.id === getInputId(id)) { |
| 485 | event.preventDefault() |
| 486 | event.stopPropagation() |
| 487 | } |
| 488 | }} |
| 489 | sameWidthAsTrigger |
| 490 | {...props} |
| 491 | > |
| 492 | {children} |
| 493 | </PopoverContent> |
| 494 | ) |
| 495 | } |
| 496 | ) |
| 497 | |
| 498 | MultiSelectorContent.displayName = 'MultiSelectorContent' |
| 499 | MultiSelector.Content = MultiSelectorContent |
| 500 | |
| 501 | const MultiSelectorList = React.forwardRef< |
| 502 | React.ElementRef<typeof CommandList>, |
| 503 | React.ComponentPropsWithoutRef<typeof CommandList> & { |
| 504 | creatable?: boolean |
| 505 | } |
| 506 | >(({ className, children, creatable = false }, ref) => { |
| 507 | const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect() |
| 508 | |
| 509 | const options = !!children |
| 510 | ? Array.isArray(children) |
| 511 | ? (children as React.ReactNode[]) |
| 512 | : typeof children === 'object' && |
| 513 | 'props' in children && |
| 514 | isValidElement<{ children: ReactElement[] }>(children) |
| 515 | ? children.props.children |
| 516 | : [] |
| 517 | : [] |
| 518 | const availableOptions = options |
| 519 | .filter((x: any) => !!x.props.value) |
| 520 | .map((x: any) => x.props.value.toLowerCase()) |
| 521 | const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase()) |
| 522 | |
| 523 | return ( |
| 524 | <CommandList |
| 525 | ref={ref} |
| 526 | className={cn( |
| 527 | 'p-2 flex flex-col gap-2 scrollbar-thin scrollbar-track-transparent transition-colors', |
| 528 | 'scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted', |
| 529 | 'scrollbar-thumb-rounded-lg w-full overflow-y-auto', |
| 530 | className |
| 531 | )} |
| 532 | style={{ maxHeight: dropdownMaxHeight }} |
| 533 | onWheel={(e) => e.stopPropagation()} |
| 534 | > |
| 535 | {children} |
| 536 | {creatable && inputValue.length > 0 && !isOptionExists ? ( |
| 537 | <CommandItem |
| 538 | role="option" |
| 539 | onSelect={() => { |
| 540 | open && toggleValue(inputValue) |
| 541 | setInputValue('') |
| 542 | }} |
| 543 | className={commandItemClass} |
| 544 | > |
| 545 | Create "{inputValue}" |
| 546 | </CommandItem> |
| 547 | ) : creatable && options.length === 0 ? ( |
| 548 | <div className="p-2 py-1.5 text-xs text-foreground-lighter font-italic"> |
| 549 | Type to add a value |
| 550 | </div> |
| 551 | ) : ( |
| 552 | <CommandEmpty> |
| 553 | <span className="text-foreground-muted">No results found</span> |
| 554 | </CommandEmpty> |
| 555 | )} |
| 556 | </CommandList> |
| 557 | ) |
| 558 | }) |
| 559 | |
| 560 | MultiSelectorList.displayName = 'MultiSelectorList' |
| 561 | MultiSelector.List = MultiSelectorList |
| 562 | |
| 563 | const MultiSelectorItem = React.forwardRef< |
| 564 | HTMLDivElement, |
| 565 | { value: string } & React.ComponentPropsWithoutRef<typeof CommandItem> |
| 566 | >(({ className, value, children, ...props }, ref) => { |
| 567 | const { values: selectedValues, setInputValue, toggleValue, open } = useMultiSelect() |
| 568 | const isSelected = selectedValues.includes(value) |
| 569 | |
| 570 | return ( |
| 571 | <CommandItem |
| 572 | ref={ref} |
| 573 | tabIndex={open ? 0 : -1} |
| 574 | role="option" |
| 575 | onSelect={() => { |
| 576 | open && toggleValue(value) |
| 577 | setInputValue('') |
| 578 | }} |
| 579 | className={cn(commandItemClass, className)} |
| 580 | {...props} |
| 581 | > |
| 582 | <div |
| 583 | className={cn( |
| 584 | 'flex items-center justify-center', |
| 585 | 'peer h-4 w-4 shrink-0 rounded-sm border border-control bg-control/25 ring-offset-background', |
| 586 | 'transition-colors duration-150 ease-in-out', |
| 587 | 'hover:border-strong', |
| 588 | 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', |
| 589 | 'disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=checked]:text-background', |
| 590 | isSelected ? 'bg-foreground text-background' : '[&_svg]:invisible' |
| 591 | )} |
| 592 | > |
| 593 | <Check className="h-3 w-3" strokeWidth={4} /> |
| 594 | </div> |
| 595 | <div className="text-xs grow leading-none pointer-events-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:pointer-events-none peer-disabled:opacity-50"> |
| 596 | {children} |
| 597 | </div> |
| 598 | </CommandItem> |
| 599 | ) |
| 600 | }) |
| 601 | |
| 602 | MultiSelectorItem.displayName = 'MultiSelectorItem' |
| 603 | MultiSelector.Item = MultiSelectorItem |
| 604 | |
| 605 | export { |
| 606 | MultiSelector, |
| 607 | MultiSelectorContent, |
| 608 | MultiSelectorInput, |
| 609 | MultiSelectorItem, |
| 610 | MultiSelectorList, |
| 611 | MultiSelectorTrigger, |
| 612 | } |