multi-select.tsx612 lines · main
1'use client'
2
3import { cva, VariantProps } from 'class-variance-authority'
4import { 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
6import type { Popover as PopoverPrimitive } from 'radix-ui'
7import React, { isValidElement, ReactElement, useEffect } from 'react'
8import {
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'
21import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants'
22
23interface 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
39const MultiSelectContext = React.createContext<MultiSelectContextProps | null>(null)
40
41const DROPDOWN_MAX_HEIGHT = 300
42const DROPDOWN_GAP = 8
43
44const 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
50function 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
58const MultiSelectorVariants = cva('', {
59 variants: {
60 size: {
61 ...SIZE_VARIANTS,
62 },
63 },
64 defaultVariants: {
65 size: SIZE_VARIANTS_DEFAULT,
66 },
67})
68
69type MultiSelectorMode = 'combobox' | 'inline-combobox'
70
71type 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
79function 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
204export 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
214const 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
378MultiSelectorTrigger.displayName = 'MultiSelectorTrigger'
379MultiSelector.Trigger = MultiSelectorTrigger
380
381const MultiSelectorInputVariants = cva('bg-control border', {
382 variants: {
383 size: {
384 ...SIZE_VARIANTS,
385 },
386 },
387 defaultVariants: {
388 size: SIZE_VARIANTS_DEFAULT,
389 },
390})
391
392const getInputId = (id: string) => `${id}-input`
393
394const 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
468MultiSelectorInput.displayName = 'MultiSelectorInput'
469MultiSelector.Input = MultiSelectorInput
470
471const 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
498MultiSelectorContent.displayName = 'MultiSelectorContent'
499MultiSelector.Content = MultiSelectorContent
500
501const 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
560MultiSelectorList.displayName = 'MultiSelectorList'
561MultiSelector.List = MultiSelectorList
562
563const 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
602MultiSelectorItem.displayName = 'MultiSelectorItem'
603MultiSelector.Item = MultiSelectorItem
604
605export {
606 MultiSelector,
607 MultiSelectorContent,
608 MultiSelectorInput,
609 MultiSelectorItem,
610 MultiSelectorList,
611 MultiSelectorTrigger,
612}