Files
supabase/packages/ui-patterns/src/multi-select/multi-select.tsx
Gildas Garcia 86a3f8b03d chore: upgrade to react-19 (#45886)
- Most changes are related to either types or `useRef` usages (it now
requires an initial value).
- also updated `vaul` to its latest version and haven't noticed any
change ([design-system
demo](https://design-system-git-react-19-supabase.vercel.app/design-system/docs/components/drawer))

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Upgraded workspace to React 19.

* **Bug Fixes**
* Improved null-safety and ref handling across editors, UI components,
shortcuts, and markdown/image rendering to reduce runtime errors.
* Safer event/timeout/interval cleanup and more robust command/context
handling.

* **Chores**
  * Bumped vaul dependency versions.

* **Documentation**
* Type and TypeScript accuracy improvements for clearer developer
feedback.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45886)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 16:04:41 +02:00

613 lines
19 KiB
TypeScript

'use client'
import { cva, VariantProps } from 'class-variance-authority'
import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react'
// @ts-ignore Required to avoid TS error: The inferred type of MultiSelectorContent cannot be named without a reference to @radix-ui
import type { Popover as PopoverPrimitive } from 'radix-ui'
import React, { isValidElement, ReactElement, useEffect } from 'react'
import {
Badge,
cn,
Command_Shadcn_ as Command,
CommandEmpty_Shadcn_ as CommandEmpty,
CommandInput_Shadcn_ as CommandInput,
CommandItem_Shadcn_ as CommandItem,
CommandList_Shadcn_ as CommandList,
Popover,
PopoverAnchor,
PopoverContent,
PopoverContentProps,
} from 'ui'
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from 'ui/src/lib/constants'
interface MultiSelectContextProps {
id: string
values: string[]
onValuesChange: (value: string[]) => void
toggleValue: (values: string) => void
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
inputValue: string
setInputValue: React.Dispatch<React.SetStateAction<string>>
activeIndex: number
setActiveIndex: React.Dispatch<React.SetStateAction<number>>
size: MultiSelectorProps['size']
disabled?: boolean
dropdownMaxHeight: number
}
const MultiSelectContext = React.createContext<MultiSelectContextProps | null>(null)
const DROPDOWN_MAX_HEIGHT = 300
const DROPDOWN_GAP = 8
const commandItemClass = cn(
'relative text-foreground-lighter text-left px-2 py-1.5 rounded-sm',
'hover:text-foreground hover:!bg-overlay-hover w-full flex items-center space-x-2',
'peer-data-[value=true]:bg-overlay-hover'
)
function useMultiSelect() {
const context = React.useContext(MultiSelectContext)
if (!context) {
throw new Error('useMultiSelect must be used within a MultiSelectProvider')
}
return context
}
const MultiSelectorVariants = cva('', {
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
})
type MultiSelectorMode = 'combobox' | 'inline-combobox'
type MultiSelectorProps = {
mode?: MultiSelectorMode
values: string[]
onValuesChange: (value: string[]) => void
disabled?: boolean
} & React.ComponentPropsWithoutRef<typeof Command> &
VariantProps<typeof MultiSelectorVariants>
function MultiSelector({
values = [],
onValuesChange,
disabled,
dir,
size,
className,
children,
id: idProp,
...props
}: MultiSelectorProps) {
const ref = React.useRef(null)
const [open, setOpen] = React.useState<boolean>(false)
const [inputValue, setInputValue] = React.useState<string>('')
const [activeIndex, setActiveIndex] = React.useState<number>(-1)
const [dropdownMaxHeight, setDropdownMaxHeight] = React.useState<number>(DROPDOWN_MAX_HEIGHT)
const generatedId = React.useId()
const id = idProp ?? generatedId
const toggleValue = React.useCallback(
(toggledValue: string) => {
if (values.includes(toggledValue)) {
onValuesChange(values.filter((value) => value !== toggledValue) || [])
} else {
onValuesChange([...values, toggledValue])
}
},
[values]
)
useEffect(() => {
if (!open) return
const controller = new AbortController()
const { signal } = controller
const updateDropdownMetrics = () => {
if (typeof window === 'undefined') return
const triggerEl = ref.current as HTMLDivElement | null
if (!triggerEl) return
const rect = triggerEl.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom - DROPDOWN_GAP
const spaceAbove = rect.top - DROPDOWN_GAP
const shouldDropUp = spaceBelow < DROPDOWN_MAX_HEIGHT && spaceAbove > spaceBelow
const placement = shouldDropUp ? 'top' : 'bottom'
const availableSpace = Math.max(placement === 'top' ? spaceAbove : spaceBelow, 0)
const nextHeight =
availableSpace > 0 ? Math.min(DROPDOWN_MAX_HEIGHT, availableSpace) : DROPDOWN_MAX_HEIGHT
setDropdownMaxHeight(nextHeight)
}
const handleUpdate = updateDropdownMetrics
handleUpdate()
window.addEventListener('resize', handleUpdate, { signal })
window.addEventListener('scroll', handleUpdate, { capture: true, passive: true, signal })
return () => controller.abort()
}, [open])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'Backspace':
case 'Delete':
if (values.length > 0 && inputValue.length === 0) {
if (activeIndex !== -1 && activeIndex < values.length) {
onValuesChange(values.filter((item) => item !== values[activeIndex]))
const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1
setActiveIndex(newIndex)
} else {
onValuesChange(values.filter((item) => item !== values[values.length - 1]))
}
}
break
case 'Escape':
activeIndex !== -1 ? setActiveIndex(-1) : setOpen(false)
if (ref.current) {
const button = (ref.current as HTMLDivElement).querySelector('button[role="combobox"]')
button && (button as HTMLButtonElement).focus()
}
break
case 'Enter':
setOpen(true)
break
}
},
[values, inputValue, activeIndex]
)
return (
<MultiSelectContext.Provider
value={{
id,
values,
toggleValue,
onValuesChange,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
size: size || 'small',
disabled,
dropdownMaxHeight,
}}
>
<Popover open={open} onOpenChange={setOpen}>
<Command
id={id}
ref={ref}
onKeyDown={handleKeyDown}
className={cn('relative w-auto overflow-visible bg-transparent flex flex-col', className)}
dir={dir}
{...props}
>
{children}
</Command>
</Popover>
</MultiSelectContext.Provider>
)
}
export interface MultiSelectorTriggerProps extends React.HTMLAttributes<HTMLButtonElement> {
label?: string
persistLabel?: boolean
className?: string
badgeLimit?: number | 'wrap'
deletableBadge?: boolean
showIcon?: boolean
mode?: MultiSelectorMode
}
const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTriggerProps>(
(
{
label,
persistLabel = false,
className,
deletableBadge = true,
badgeLimit = 9999,
showIcon = true,
mode = 'combobox',
children,
...props
},
ref
) => {
const { activeIndex, values, setInputValue, toggleValue, disabled, open, setOpen } =
useMultiSelect()
const inputRef = React.useRef<HTMLButtonElement>(null)
// Use the provided ref if available, otherwise use the local ref
React.useImperativeHandle(ref, () => inputRef.current as HTMLButtonElement)
const inlineInputRef = React.useRef<HTMLInputElement>(null)
const badgesRef = React.useRef<HTMLDivElement>(null)
const [visibleBadges, setVisibleBadges] = React.useState<string[]>([])
const [extraBadgesCount, setExtraBadgesCount] = React.useState(0)
const [isDeleteHovered, setIsDeleteHovered] = React.useState(false)
const IS_BADGE_LIMIT_WRAP = badgeLimit === 'wrap'
const IS_NUMERIC_LIMIT = typeof badgeLimit === 'number'
const IS_INLINE_MODE = mode === 'inline-combobox'
React.useEffect(() => {
if (!inputRef?.current || !badgesRef.current) return
if (IS_BADGE_LIMIT_WRAP) {
setVisibleBadges(values)
setExtraBadgesCount(0)
} else {
setVisibleBadges(values.slice(0, badgeLimit))
setExtraBadgesCount(Math.max(0, values.length - badgeLimit))
}
}, [values, badgeLimit])
const badgeClasses = 'rounded-sm shrink-0 px-1.5'
const handleTriggerClick: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(
(event) => {
if (IS_INLINE_MODE) {
event.stopPropagation()
event.preventDefault()
if (!open) {
setInputValue('')
}
setTimeout(() => {
inlineInputRef.current?.focus()
}, 100)
return
}
const willOpen = !open
setOpen(willOpen)
if (willOpen) setInputValue('')
},
[open, setOpen, setInputValue, IS_INLINE_MODE]
)
return (
<PopoverAnchor asChild>
<button
ref={inputRef}
onClick={(e) => !isDeleteHovered && handleTriggerClick(e)}
disabled={disabled}
type="button"
role="combobox"
className={cn(
'flex w-full min-w-[200px] min-h-[40px] items-center justify-between rounded-md border',
'border-alternative bg-control px-3 py-2 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'hover:border-primary transition-colors duration-200',
className
)}
{...props}
>
<div
ref={badgesRef}
className={cn(
'flex gap-1 -ml-1 overflow-hidden flex-1',
IS_BADGE_LIMIT_WRAP && 'flex-wrap',
!IS_BADGE_LIMIT_WRAP &&
'overflow-x-auto scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg'
)}
>
{visibleBadges.map((value) => (
<Badge key={value} className={badgeClasses}>
{value}
{deletableBadge && (
<div
onMouseEnter={() => setIsDeleteHovered(true)}
onMouseLeave={() => setIsDeleteHovered(false)}
onClick={(e) => {
e.stopPropagation()
toggleValue(value)
setIsDeleteHovered(false)
}}
className="ml-1 text-foreground-lighter hover:text-foreground-light transition-colors pointer-events-auto"
>
<RemoveIcon size={12} />
</div>
)}
</Badge>
))}
{extraBadgesCount > 0 && (
<Badge className={badgeClasses}>
{IS_NUMERIC_LIMIT && badgeLimit < 1
? `${extraBadgesCount} item${extraBadgesCount > 1 ? 's' : ''} selected`
: `+${extraBadgesCount}`}
</Badge>
)}
<span
className={cn(
'text-foreground-muted whitespace-nowrap leading-5.5 ml-1 opacity-0 transition-opacity hidden',
!IS_INLINE_MODE &&
(persistLabel || values.length === 0) &&
'opacity-100 visible inline'
)}
>
{label}
</span>
{IS_INLINE_MODE && (
<MultiSelectorInput
ref={inlineInputRef}
showSearchIcon={false}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
placeholder={label}
autoFocus={false}
wrapperClassName={cn(
'px-0 flex-1 border-none truncate',
IS_BADGE_LIMIT_WRAP && 'min-w-[85px]'
)}
className="py-0 px-1 truncate"
/>
)}
</div>
{showIcon && (
<ChevronsUpDown
size={16}
strokeWidth={2}
className="text-foreground-lighter shrink-0 ml-1.5"
/>
)}
</button>
</PopoverAnchor>
)
}
)
MultiSelectorTrigger.displayName = 'MultiSelectorTrigger'
MultiSelector.Trigger = MultiSelectorTrigger
const MultiSelectorInputVariants = cva('bg-control border', {
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
})
const getInputId = (id: string) => `${id}-input`
const MultiSelectorInput = React.forwardRef<
React.ElementRef<typeof CommandInput>,
React.ComponentPropsWithoutRef<typeof CommandInput> & {
showResetIcon?: boolean
showSearchIcon?: boolean
wrapperClassName?: string
}
>(({ className, wrapperClassName, showResetIcon, showSearchIcon, ...props }, ref) => {
const {
id,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
size,
disabled,
} = useMultiSelect()
const inputRef = React.useRef<HTMLInputElement>(null)
// Use the provided ref if available, otherwise use the local ref
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const handleFocus = () => !open && setOpen(true)
const handleClick = () => setActiveIndex(-1)
const handleReset = () => {
setInputValue('')
setInputFocus()
}
const setInputFocus = () => {
setTimeout(() => {
if (!inputRef?.current) return
if (open) {
inputRef.current.focus()
}
}, 100)
}
useEffect(() => {
setInputFocus()
if (!open) {
inputRef.current?.blur()
}
}, [open])
return (
<CommandInput
ref={inputRef}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onFocus={handleFocus}
onClick={handleClick}
tabIndex={open ? 0 : -1}
disabled={disabled}
showSearchIcon={showSearchIcon}
showResetIcon={showResetIcon}
handleReset={handleReset}
wrapperClassName={wrapperClassName}
className={cn(
MultiSelectorInputVariants({ size }),
'text-sm bg-transparent h-full grow border-none outline-hidden placeholder:text-foreground-muted flex-1',
activeIndex !== -1 && 'caret-transparent',
className
)}
// Can't use id as CommandInput overrides it
data-id={getInputId(id)}
{...props}
/>
)
})
MultiSelectorInput.displayName = 'MultiSelectorInput'
MultiSelector.Input = MultiSelectorInput
const MultiSelectorContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
({ className, children, ...props }, ref) => {
const { id } = useMultiSelect()
return (
<PopoverContent
align="start"
ref={ref}
className={cn(
'bg-overlay shadow-md z-50 border border-overlay rounded-md p-0',
'w-(--radix-popper-anchor-width)',
className
)}
onFocusOutside={(event) => {
if (event.target instanceof HTMLElement && event.target.dataset.id === getInputId(id)) {
event.preventDefault()
event.stopPropagation()
}
}}
sameWidthAsTrigger
{...props}
>
{children}
</PopoverContent>
)
}
)
MultiSelectorContent.displayName = 'MultiSelectorContent'
MultiSelector.Content = MultiSelectorContent
const MultiSelectorList = React.forwardRef<
React.ElementRef<typeof CommandList>,
React.ComponentPropsWithoutRef<typeof CommandList> & {
creatable?: boolean
}
>(({ className, children, creatable = false }, ref) => {
const { open, inputValue, setInputValue, toggleValue, dropdownMaxHeight } = useMultiSelect()
const options = !!children
? Array.isArray(children)
? (children as React.ReactNode[])
: typeof children === 'object' &&
'props' in children &&
isValidElement<{ children: ReactElement[] }>(children)
? children.props.children
: []
: []
const availableOptions = options
.filter((x: any) => !!x.props.value)
.map((x: any) => x.props.value.toLowerCase())
const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase())
return (
<CommandList
ref={ref}
className={cn(
'p-2 flex flex-col gap-2 scrollbar-thin scrollbar-track-transparent transition-colors',
'scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted',
'scrollbar-thumb-rounded-lg w-full overflow-y-auto',
className
)}
style={{ maxHeight: dropdownMaxHeight }}
onWheel={(e) => e.stopPropagation()}
>
{children}
{creatable && inputValue.length > 0 && !isOptionExists ? (
<CommandItem
role="option"
onSelect={() => {
open && toggleValue(inputValue)
setInputValue('')
}}
className={commandItemClass}
>
Create "{inputValue}"
</CommandItem>
) : creatable && options.length === 0 ? (
<div className="p-2 py-1.5 text-xs text-foreground-lighter font-italic">
Type to add a value
</div>
) : (
<CommandEmpty>
<span className="text-foreground-muted">No results found</span>
</CommandEmpty>
)}
</CommandList>
)
})
MultiSelectorList.displayName = 'MultiSelectorList'
MultiSelector.List = MultiSelectorList
const MultiSelectorItem = React.forwardRef<
HTMLDivElement,
{ value: string } & React.ComponentPropsWithoutRef<typeof CommandItem>
>(({ className, value, children, ...props }, ref) => {
const { values: selectedValues, setInputValue, toggleValue, open } = useMultiSelect()
const isSelected = selectedValues.includes(value)
return (
<CommandItem
ref={ref}
tabIndex={open ? 0 : -1}
role="option"
onSelect={() => {
open && toggleValue(value)
setInputValue('')
}}
className={cn(commandItemClass, className)}
{...props}
>
<div
className={cn(
'flex items-center justify-center',
'peer h-4 w-4 shrink-0 rounded-sm border border-control bg-control/25 ring-offset-background',
'transition-colors duration-150 ease-in-out',
'hover:border-strong',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=checked]:text-background',
isSelected ? 'bg-foreground text-background' : '[&_svg]:invisible'
)}
>
<Check className="h-3 w-3" strokeWidth={4} />
</div>
<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">
{children}
</div>
</CommandItem>
)
})
MultiSelectorItem.displayName = 'MultiSelectorItem'
MultiSelector.Item = MultiSelectorItem
export {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
}