'use client' import * as React from 'react' import { Combobox as BaseCombobox } from '@base-ui/react/combobox' import { useReducedMotion } from '../../hooks/use-reduced-motion' import { cn } from '../../internal/utils' import { type FloatingContentProps, splitFloatingProps } from '../../internal/floating' import { inputVariants } from '../input/input-variants' import { buttonVariants } from '../button/button-variants' type ComboboxSize = 'sm' | 'md' | 'lg' type ComboboxVariant = 'outline' | 'soft' interface ComboboxContextValue { size: ComboboxSize variant: ComboboxVariant clearable: boolean icon: boolean grid: boolean reducedMotion: boolean } const ComboboxContext = React.createContext(null) function useComboboxContext() { const ctx = React.useContext(ComboboxContext) if (!ctx) { throw new Error('Combobox sub-components must be rendered inside ') } return ctx } type BaseComboboxRootProps = React.ComponentProps interface ComboboxProps extends BaseComboboxRootProps { /** * Input height, popup radius, and item sizing. * @default 'md' */ size?: ComboboxSize /** * Input appearance - bordered or filled. * @default 'outline' */ variant?: ComboboxVariant /** * Render a clear button inside the input when a value is present. * @default false */ clearable?: boolean /** * Render a chevron button that toggles the popup. * @default true */ icon?: boolean } function Combobox({ size = 'md', variant = 'outline', clearable = false, icon = true, grid = false, children, ...rest }: ComboboxProps) { const reducedMotion = useReducedMotion() const ctx = React.useMemo( () => ({ size, variant, clearable, icon, grid, reducedMotion }), [size, variant, clearable, icon, grid, reducedMotion], ) return ( {children} ) } const ICON_SIZE: Record = { sm: 'size-4', md: 'size-4.5', lg: 'size-5', } interface ComboboxInputProps extends Omit, 'size'> { /** Adornment rendered before the field, inside the input frame. */ startSlot?: React.ReactNode /** Adornment rendered after the field, before the controls. */ endSlot?: React.ReactNode } function ComboboxInput({ className, startSlot, endSlot, placeholder, ...props }: ComboboxInputProps) { const { size, variant, clearable, icon } = useComboboxContext() const ariaInvalid = props['aria-invalid'] const invalid = ariaInvalid === true || ariaInvalid === 'true' return ( {startSlot && (
{startSlot}
)} {clearable && } {endSlot && (
{endSlot}
)} {icon && }
) } interface ComboboxChipsProps extends React.ComponentProps { /** Placeholder for the inline text field. */ placeholder?: string /** Props forwarded to the inner text `input`. */ inputProps?: Omit, 'placeholder'> } function ComboboxChips({ className, children, placeholder, inputProps, ...props }: ComboboxChipsProps) { const { size, variant, clearable, icon } = useComboboxContext() const hasControls = clearable || icon return ( {children} {hasControls && (
{clearable && } {icon && }
)}
) } function ComboboxClearButton() { return ( ) } function ComboboxToggleButton() { const { size } = useComboboxContext() return ( ) } interface ComboboxTriggerProps extends React.ComponentProps { /** Adornment before the value, inside the trigger frame. */ startSlot?: React.ReactNode /** Adornment after the value, before the chevron. */ endSlot?: React.ReactNode } function ComboboxTrigger({ className, startSlot, endSlot, children, ...props }: ComboboxTriggerProps) { const { size, variant } = useComboboxContext() return ( {startSlot && ( {startSlot} )} {children} {endSlot && ( {endSlot} )} } /> ) } type ComboboxValueProps = React.ComponentProps function ComboboxValue(props: ComboboxValueProps) { return } const CHIP_SIZE: Record = { sm: 'h-6 px-2 text-xs rounded-xs gap-1', md: 'h-8 px-3 text-sm rounded-sm gap-1.5', lg: 'h-10 px-3.5 text-base rounded-md gap-1.5', } const CHIPS_FILLED_MIN_H: Record = { sm: 'has-data-[slot=combobox-chip]:min-h-8', md: 'has-data-[slot=combobox-chip]:min-h-10', lg: 'has-data-[slot=combobox-chip]:min-h-12', } const CONTROLS_FILLED_PAD: Record = { sm: cn( 'group-has-data-[slot=combobox-chip]/combobox-chips:pt-1', 'group-has-data-[slot=combobox-chip]/combobox-chips:pe-2', ), md: cn( 'group-has-data-[slot=combobox-chip]/combobox-chips:pt-1.5', 'group-has-data-[slot=combobox-chip]/combobox-chips:pe-2.5', ), lg: cn( 'group-has-data-[slot=combobox-chip]/combobox-chips:pt-2.5', 'group-has-data-[slot=combobox-chip]/combobox-chips:pe-3', ), } const CHIP_BUTTON_VARIANT: Record = { outline: 'soft', soft: 'outline', } interface ComboboxChipProps extends React.ComponentProps {} function ComboboxChip({ className, children, ...props }: ComboboxChipProps) { const { size, variant } = useComboboxContext() return ( {children} ) } const POPUP_RADIUS: Record = { sm: 'rounded-md', md: 'rounded-lg', lg: 'rounded-xl', } type ComboboxContentProps = React.ComponentProps & FloatingContentProps< React.ComponentProps, React.ComponentProps > function ComboboxContent({ className, children, ...props }: ComboboxContentProps) { const { size } = useComboboxContext() const { positioner, portal, popup } = splitFloatingProps(props) return ( {children} ) } interface ComboboxListProps extends Omit, 'children'> { /** Items per row in grid mode (defaults to 2 when `grid` is set). */ cols?: number /** A render function over the filtered items, or static `ComboboxItem`s. */ children?: React.ReactNode | ((item: T, index: number) => React.ReactNode) } const COMBOBOX_LIST_CLASSNAME = 'flex flex-col gap-0.5 px-2 min-h-0 flex-1 overflow-y-auto overscroll-contain' function ComboboxList({ className, cols, children, ...props }: ComboboxListProps) { const { grid } = useComboboxContext() const effectiveCols = cols ?? (grid ? 2 : undefined) if (effectiveCols && effectiveCols > 1 && typeof children === 'function') { return ( ) } return ( {children as React.ComponentProps['children']} ) } function cellKey(item: unknown, fallback: number): React.Key { if (item == null) return fallback if (typeof item === 'object') { const rec = item as Record const candidate = rec.value ?? rec.id ?? rec.key if (typeof candidate === 'string' || typeof candidate === 'number') return candidate return fallback } return item as React.Key } function ComboboxGridRows({ cols, render }: { cols: number; render: (item: any, index: number) => React.ReactNode }) { const filtered = BaseCombobox.useFilteredItems() const rows = React.useMemo(() => { const out: unknown[][] = [] for (let i = 0; i < filtered.length; i += cols) out.push(filtered.slice(i, i + cols)) return out }, [filtered, cols]) return ( <> {rows.map((row, rowIndex) => ( {row.map((item, colIndex) => { const globalIndex = rowIndex * cols + colIndex return {render(item, globalIndex)} })} ))} ) } interface ComboboxRowProps extends React.ComponentProps {} function ComboboxRow({ className, ...props }: ComboboxRowProps) { return ( ) } const ITEM_SIZE: Record = { sm: "gap-1 rounded-xs py-1.5 px-2.5 text-xs has-data-[icon=end]:pe-1.5 has-data-[icon=start]:ps-1.5 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='stroke-'])]:stroke-[1.75]", md: "gap-1.5 rounded-sm py-2 px-3 text-sm has-data-[icon=end]:pe-2 has-data-[icon=start]:ps-2 [&_svg:not([class*='size-'])]:size-4.5 [&_svg:not([class*='stroke-'])]:stroke-[1.65]", lg: "gap-1.5 rounded-md py-2.5 px-3.5 text-base has-data-[icon=end]:pe-2.5 has-data-[icon=start]:ps-2.5 [&_svg:not([class*='size-'])]:size-5 [&_svg:not([class*='stroke-'])]:stroke-[1.65]", } const ITEM_TEXT_SIZE: Record = { sm: 'gap-1', md: 'gap-1.5', lg: 'gap-1.5', } interface ComboboxItemProps extends React.ComponentProps {} function ComboboxItem({ className, children, ...props }: ComboboxItemProps) { const { size, reducedMotion } = useComboboxContext() return ( {children} ) } interface ComboboxEmptyProps extends React.ComponentProps {} const EMPTY_SIZE: Record = { sm: 'px-2.5 py-2 text-xs', md: 'px-3 py-2.5 text-sm', lg: 'px-3.5 py-3 text-base', } function ComboboxEmpty({ className, ...props }: ComboboxEmptyProps) { const { size } = useComboboxContext() return (