'use client' import { Combobox as ComboboxPrimitive } from '@base-ui/react' import { createContext, useContext, useRef, useState } from 'react' import type * as React from 'react' import { SvgCheck, SvgDelete1, SvgTaillessLineArrowDown1, } from '@chainlink/blocks-icons' import { SvgSearch } from '../../icons' import { cn } from '../../utils' import { DROPDOWN_EMPTY_STATE_HEIGHT, DROPDOWN_ELEMENT_HEIGHT, DROPDOWN_LIST_MAX_HEIGHT, type DropdownSize, } from '../dropdownHeights' import { inputVariants } from '../Input' import { ClearIcon } from '../Input/icons' import { getDropdownItemBaseClassName, getDropdownItemIndicatorOffsetClassName, getInputControlContentTextClassName, getInputControlTextClassName, getInputControlValueSlotClassName, } from '../inputControlStyles' import { useScrollArrows, ScrollArrowUp, ScrollArrowDown, } from '../ScrollArrows' type ComboboxRootProps = Omit< React.ComponentProps, 'itemToStringValue' > & { itemToStringValue?: (itemValue: any) => string /** When false, the hover clear control is not shown for single-select (default true). */ clearable?: boolean } type ComboboxOnValueChange = NonNullable type ComboboxChangeEventDetails = Parameters[1] type BaseUiMouseEvent = React.MouseEvent & { preventBaseUIHandler: () => void } type ComboboxSetValue = ( value: unknown, eventDetails?: ComboboxChangeEventDetails, ) => void const COMBOBOX_SELECT_ALL_VALUE = Symbol('combobox-select-all') const createComboboxChangeEventDetails = (): ComboboxChangeEventDetails => { const eventDetails: ComboboxChangeEventDetails = { reason: 'none', event: new Event('change'), cancel: () => { eventDetails.isCanceled = true }, allowPropagation: () => { eventDetails.isPropagationAllowed = true }, isCanceled: false, isPropagationAllowed: false, trigger: undefined, } return eventDetails } const ComboboxContentRootRefContext = createContext | null>(null) const ComboboxSizeContext = createContext('default') const ComboboxMultipleContext = createContext(false) /** Chip/trigger size for multi-select badges (follows trigger size). */ const ComboboxTriggerSizeContext = createContext('default') /** Used internally so TagsValue can clear overflow without a callback (e.g. "+N more" chip). */ const ComboboxSetValueContext = createContext(null) /** Used by List to render default Select All when multiple (value, items, setValue). */ const ComboboxMultiListContext = createContext<{ value: unknown items: unknown[] setValue: ComboboxSetValue | null } | null>(null) const ComboboxDisabledContext = createContext(false) const ComboboxClearableContext = createContext(true) /** * Root component. Manages open state, search filtering, and the selected value. * Supports both single-select and multi-select (`multiple`) modes. * Provides `size`, `disabled`, and `clearable` context to all child components. */ function Combobox(props: ComboboxRootProps & { size?: DropdownSize }) { const { multiple = false, value: valueProp, items, onValueChange, defaultValue, disabled = false, clearable = true, size = 'default', ...rest } = props const [internalValue, setInternalValue] = useState(() => multiple ? (defaultValue ?? []) : (defaultValue ?? null), ) const isControlled = valueProp !== undefined const value = isControlled ? valueProp : internalValue const handleValueChange = ( nextValue: unknown, eventDetails: ComboboxChangeEventDetails, ) => { if (!isControlled) { if (multiple) { const next = Array.isArray(nextValue) ? nextValue : [] setInternalValue((prev: unknown) => { if ( Array.isArray(prev) && prev.length === next.length && prev.every((item, index) => item === next[index]) ) { return prev } return next }) } else { setInternalValue(nextValue) } } onValueChange?.(nextValue as never, eventDetails) } const setValue: ComboboxSetValue | null = isControlled && onValueChange == null ? null : (nextValue, eventDetails = createComboboxChangeEventDetails()) => handleValueChange(nextValue, eventDetails) const multiListContext = multiple && (value !== undefined || items !== undefined) ? { value: value ?? [], items: Array.isArray(items) ? [...items] : [], setValue, } : null return ( ) } /** Renders the selected value or placeholder inside `ComboboxTrigger`. Pass a render function as `children` to display custom content such as an icon + label pair. */ function ComboboxValue({ children, placeholder, className, ...props }: ComboboxPrimitive.Value.Props & { className?: string }) { return ( {(value: any) => { const isEmpty = value == null || (Array.isArray(value) && value.length === 0) const content = isEmpty ? placeholder : typeof children === 'function' ? children(value) : typeof value === 'string' ? value : (value?.label ?? String(value)) return ( {content} ) }} ) } /** * The button that opens the combobox. Styled as a Blocks input. * Includes the clear button and caret icon automatically. * Pass `hideIcon` to remove the caret, or `width` to control sizing behaviour. */ function ComboboxTrigger({ className, children, disabled, hideIcon = false, width, ...props }: ComboboxPrimitive.Trigger.Props & { /** Hides the dropdown caret arrow. */ hideIcon?: boolean /** Controls trigger width: `'full'` fills the container, `'hug'` shrinks to content, `'responsive'` adapts to the viewport. */ width?: 'responsive' | 'hug' | 'full' }) { const size = useContext(ComboboxSizeContext) || 'default' const multiple = useContext(ComboboxMultipleContext) const clearable = useContext(ComboboxClearableContext) return ( :first-child]:min-w-0 [&>:first-child]:flex-1', 'hover:border-input-border-hover data-[popup-open]:border-input-border-active [&.hover]:border-input-border-hover', 'data-[disabled]:hover:border-input-border-disabled', 'data-[disabled]:hover:bg-input-disabled', '[&[data-disabled].hover]:border-input-border-disabled', '[&[data-disabled].hover]:bg-input-disabled', '[&_[data-slot=combobox-value]]:text-foreground', '[&_[data-slot=combobox-value][data-placeholder=true]]:!text-input-muted-foreground', '[&[data-disabled]_[data-slot=combobox-value]]:!text-input-muted-more-foreground', '[&[data-disabled]_[data-slot=combobox-value][data-placeholder=true]]:!text-input-muted-more-foreground', '[&[data-disabled]_[data-slot=combobox-badge]]:!text-input-muted-more-foreground', '[&[data-disabled]_[data-slot=combobox-badge]_span]:!text-input-muted-more-foreground', '[&[data-disabled]_[data-slot=combobox-badge]_img]:opacity-60', '[&[data-disabled]_[data-slot=combobox-badge]]:border-input-border-disabled', '[&[data-disabled]_[data-slot=combobox-badge]]:bg-input-disabled', getInputControlValueSlotClassName('combobox-value', size), 'data-[disabled]:cursor-not-allowed data-[disabled]:border-input-border-disabled data-[disabled]:bg-input-disabled data-[disabled]:!text-input-muted-more-foreground', className, )} style={{ height: DROPDOWN_ELEMENT_HEIGHT[size], minHeight: DROPDOWN_ELEMENT_HEIGHT[size], }} {...props} > {children}
{!multiple && clearable && ( e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > )} {!hideIcon && (
)}
) } /** The ×-button that clears the current selection. Rendered automatically inside `ComboboxTrigger`; returns `null` when `disabled`. */ function ComboboxClear({ className, disabled, ...props }: ComboboxPrimitive.Clear.Props) { if (disabled) return null return ( } data-slot="combobox-clear" className={cn( 'pointer-events-none cursor-pointer opacity-0 transition-opacity', 'group-hover:pointer-events-auto group-hover:opacity-100', 'focus:pointer-events-auto focus:opacity-100', 'text-input-muted-foreground hover:text-muted-foreground', 'focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', 'size-4 [&_svg]:h-full [&_svg]:w-full', className, )} {...props} > ) } /** * The dropdown panel. Renders inside a portal and positions relative to the trigger. * Contains the search input, item list, and empty state. Use `side`, `align`, and * `sideOffset` to customise positioning. */ function ComboboxContent({ className, children, side = 'bottom', sideOffset = 4, align = 'start', alignOffset = 0, anchor, container, ...props }: ComboboxPrimitive.Popup.Props & Pick< ComboboxPrimitive.Positioner.Props, 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor' > & { container?: HTMLElement | null | React.RefObject }) { const size = useContext(ComboboxSizeContext) const contentRootRef = useRef(null) return (
{children}
) } /** The search text field inside the dropdown. Base UI filters the item list automatically as the user types. */ function ComboboxInput({ className, ...props }: Omit) { const size = useContext(ComboboxSizeContext) return (
) } const resolveComboboxSize = (size: unknown): DropdownSize => size === 'sm' || size === 'xs' ? size : 'default' /** * The scrollable list of items. Accepts a render function for item rendering. * When `multiple`, automatically prepends a Select All row unless `hideSelectAll` is set. * Supports scroll arrows and an optional `header` above the list. */ function ComboboxList({ className, children, header, items, hideSelectAll = false, ...props }: ComboboxPrimitive.List.Props & { header?: React.ReactNode /** Options list (same as Root's `items`). When not passed, uses Root's items from context. When `multiple` and list has ≥1 item, Select All is shown unless `hideSelectAll`. */ items?: unknown[] /** When `true`, hides the default Select All row in multi-select mode. */ hideSelectAll?: boolean }) { const size = useContext(ComboboxSizeContext) const contentRootRef = useContext(ComboboxContentRootRefContext) const multiple = useContext(ComboboxMultipleContext) const multiListContext = useContext(ComboboxMultiListContext) const { scrollRef, containerRef, showScrollUp, showScrollDown, handleScroll, startAutoScroll, stopAutoScroll, scrollToStart, scrollToEnd, } = useScrollArrows({ focusScopeRef: contentRootRef ?? undefined }) const listItems = (Array.isArray(items) ? items : multiListContext?.items) ?? [] const resolvedSize = resolveComboboxSize(size) const hasItems = listItems.length > 0 const selectAllItem = multiple && !hideSelectAll && hasItems && multiListContext?.setValue && ( { if (!multiListContext.setValue) return const isAllSelected = Array.isArray(multiListContext.value) && multiListContext.value.length === listItems.length multiListContext.setValue(isAllSelected ? [] : listItems) }} /> ) const resolvedChildren = typeof children === 'function' ? ( {children} ) : ( children ) return (
} className="relative" >
} onScroll={handleScroll} tabIndex={-1} className={cn( 'overflow-y-auto overscroll-contain outline-none', '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden', showScrollUp && 'pt-6', showScrollDown && 'pb-6', header && '[&:has([data-slot=combobox-list][data-empty])_.combobox-list-header]:hidden', )} style={{ maxHeight: DROPDOWN_LIST_MAX_HEIGHT[resolvedSize] + (header ? DROPDOWN_ELEMENT_HEIGHT[resolvedSize] : 0) + (selectAllItem ? DROPDOWN_ELEMENT_HEIGHT[resolvedSize] : 0), }} > {header ?
{header}
: null} {selectAllItem} {resolvedChildren}
{showScrollUp && ( startAutoScroll('up')} onHoverEnd={stopAutoScroll} onJump={scrollToStart} /> )} {showScrollDown && ( startAutoScroll('down')} onHoverEnd={stopAutoScroll} onJump={scrollToEnd} /> )}
) } /** * An individual option inside `ComboboxList`. Shows a checkmark for the * selected item in single-select mode, and a checkbox in multi-select mode. * Pass an `Icon` to display an icon to the left of the label. */ function ComboboxItem({ className, children, Icon, multiple: multipleProp, ...props }: ComboboxPrimitive.Item.Props & { /** Optional icon rendered to the left of the label. */ Icon?: React.ReactElement /** Override `multiple` mode for this item. Defaults to the value provided by `Combobox`. */ multiple?: boolean }) { const multipleFromContext = useContext(ComboboxMultipleContext) const multiple = multipleProp ?? multipleFromContext const size = useContext(ComboboxSizeContext) const resolvedSize = resolveComboboxSize(size) return ( span:last-child]:!text-brand' : 'data-[selected]:!text-brand [&[data-selected]_span]:!text-brand [&[data-selected]_svg]:!text-brand', className, )} style={{ boxSizing: 'border-box', height: DROPDOWN_ELEMENT_HEIGHT[resolvedSize], minHeight: DROPDOWN_ELEMENT_HEIGHT[resolvedSize], }} {...props} > {multiple ? ( ) : ( )} {Icon && Icon} {children} ) } /** Groups related items inside `ComboboxContent`. Pair with `ComboboxLabel` to add a visible heading above the group. */ function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { return ( ) } /** Non-interactive heading label for a `ComboboxGroup`. Renders as small muted text above the group's items. */ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) { const listSize = useContext(ComboboxSizeContext) return ( ) } /** Empty-state message shown when the filtered list has no matching items. Hidden when results are present. */ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { const size = useContext(ComboboxSizeContext) return ( ) } /** Horizontal rule for visually separating item groups inside `ComboboxContent`. */ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) { return ( ) } /** * A removable badge chip for multi-select values. Used automatically inside * `ComboboxTagsValue`. Can also be composed standalone in a custom trigger. * Inherits the trigger's size via context. */ function ComboboxBadge({ className, children, ...props }: Omit< React.ComponentPropsWithoutRef, 'render' >) { const chipSize = useContext(ComboboxTriggerSizeContext) return ( {children} ) => ( { e.stopPropagation() props.onMouseDown?.(e) }} onClick={(e) => { e.stopPropagation() props.onClick?.(e) }} /> )} > ) } /** * Multi-select value display with horizontally-scrollable badges and * `maxCount` truncation: when selected count > maxCount, shows first maxCount * chips plus a "+N more" chip. Clicking the chip clears the overflow (keeps * first maxCount) using the root's value setter — no callback needed. * Not a Base UI prop — Blocks addition. */ function ComboboxTagsValue({ placeholder, maxCount = 3, children, className, }: { placeholder?: string maxCount?: number children: (item: T) => React.ReactNode className?: string }) { const setValue = useContext(ComboboxSetValueContext) const chipSize = useContext(ComboboxTriggerSizeContext) const disabled = useContext(ComboboxDisabledContext) return ( {(rawValue: any) => { const values: T[] = Array.isArray(rawValue) ? rawValue : [] if (values.length === 0) { return ( {placeholder} ) } const visibleItems = values.slice(0, maxCount) const extraCount = Math.max(0, values.length - maxCount) const handleClearOverflow = () => { setValue?.(values.slice(0, maxCount)) } return ( ) => e.currentTarget.scrollTo({ left: e.currentTarget.scrollWidth, behavior: 'smooth', }), onMouseLeave: (e: React.MouseEvent) => e.currentTarget.scrollTo({ left: 0, behavior: 'smooth' }), })} > {visibleItems.map((item, index) => ( {children(item)} ))} {extraCount > 0 && ( { e.stopPropagation() e.preventDefault() handleClearOverflow() }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.stopPropagation() handleClearOverflow() } }} onMouseDown={(e) => e.stopPropagation()} aria-label={`Clear overflow, keep first ${maxCount} selected`} > {`+${extraCount} more`} )} ) }} ) } /** * A synthetic "Select All" item for multi-select mode. Rendered automatically by * `ComboboxList` when `multiple` — use directly only to customise `label`. * Checked when all available items are selected. Hidden when the list is empty. */ function ComboboxSelectAll({ checked, onToggle, label = 'Select All', className, }: { checked: boolean onToggle: () => void label?: string className?: string }) { const size = useContext(ComboboxSizeContext) const resolvedSize = resolveComboboxSize(size) return ( { event.preventBaseUIHandler() onToggle() }} > {label} ) } export { Combobox, ComboboxTrigger, ComboboxValue, ComboboxTagsValue, ComboboxClear, ComboboxContent, ComboboxInput, ComboboxList, ComboboxItem, ComboboxGroup, ComboboxLabel, ComboboxEmpty, ComboboxSelectAll, ComboboxSeparator, ComboboxBadge, }