import { Combobox as PrimitiveCombobox, ComboboxDisclosure as PrimitiveComboboxDisclosure, ComboboxItem as PrimitiveComboboxItem, ComboboxItemCheck as PrimitiveComboboxItemCheck, ComboboxItemValue as PrimitiveComboboxItemValue, ComboboxPopover as PrimitiveComboboxPopover, ComboboxProvider as PrimitiveComboboxProvider, Separator as PrimitiveSeparator, } from "@ariakit/react" import { CheckMini, EllipseMiniSolid, PlusMini, TrianglesMini, XMarkMini, } from "@medusajs/icons" import { clx, Text } from "@medusajs/ui" import { matchSorter } from "match-sorter" import { ComponentPropsWithoutRef, CSSProperties, ForwardedRef, Fragment, ReactNode, useCallback, useDeferredValue, useImperativeHandle, useMemo, useRef, useState, useTransition, } from "react" import { useTranslation } from "react-i18next" import { genericForwardRef } from "../../utilities/generic-forward-ref" type ComboboxOption = { value: string label: string disabled?: boolean } type Value = string[] | string const TABLUAR_NUM_WIDTH = 8 const TAG_BASE_WIDTH = 28 interface ComboboxProps extends Omit, "onChange" | "value"> { value?: T onChange?: (value?: T) => void searchValue?: string onSearchValueChange?: (value: string) => void options: ComboboxOption[] fetchNextPage?: () => void isFetchingNextPage?: boolean onCreateOption?: (value: string) => void noResultsPlaceholder?: ReactNode allowClear?: boolean forceHideInput?: boolean // always hide input -> used for singe value select that don't have query/filter } const ComboboxImpl = ( { value: controlledValue, onChange, searchValue: controlledSearchValue, onSearchValueChange, options, className, placeholder, fetchNextPage, isFetchingNextPage, onCreateOption, noResultsPlaceholder, allowClear, forceHideInput, ...inputProps }: ComboboxProps, ref: ForwardedRef ) => { const [open, setOpen] = useState(false) const [isPending, startTransition] = useTransition() const { t } = useTranslation() const comboboxRef = useRef(null) const listboxRef = useRef(null) useImperativeHandle(ref, () => comboboxRef.current!) const isValueControlled = controlledValue !== undefined const isSearchControlled = controlledSearchValue !== undefined const isArrayValue = Array.isArray(controlledValue) const emptyState = (isArrayValue ? [] : "") as T const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState( controlledSearchValue || "" ) const defferedSearchValue = useDeferredValue(uncontrolledSearchValue) const [uncontrolledValue, setUncontrolledValue] = useState(emptyState) const searchValue = isSearchControlled ? controlledSearchValue : uncontrolledSearchValue const selectedValues = isValueControlled ? controlledValue : uncontrolledValue const handleValueChange = (newValues?: T) => { // check if the value already exists in options const exists = options .filter((o) => !o.disabled) .find((o) => { if (isArrayValue) { return newValues?.includes(o.value) } return o.value === newValues }) // If the value does not exist in the options, and the component has a handler // for creating new options, call it. if (!exists && onCreateOption && newValues) { onCreateOption(newValues as string) } if (!isValueControlled) { setUncontrolledValue(newValues || emptyState) } if (onChange) { onChange(newValues) } setUncontrolledSearchValue("") } const handleSearchChange = (query: string) => { setUncontrolledSearchValue(query) if (onSearchValueChange) { onSearchValueChange(query) } } /** * Filter and sort the options based on the search value, * and whether the value is already selected. * * This is only used when the search value is uncontrolled. */ const matches = useMemo(() => { if (isSearchControlled) { return [] } // do not use `matcher` if the input is hidden if (forceHideInput) { return options } return matchSorter(options, defferedSearchValue, { keys: ["label"], }) }, [options, defferedSearchValue, isSearchControlled, forceHideInput]) const observer = useRef( new IntersectionObserver( (entries) => { const first = entries[0] if (first.isIntersecting) { fetchNextPage?.() } }, { threshold: 1 } ) ) const lastOptionRef = useCallback( (node: HTMLDivElement) => { if (isFetchingNextPage) { return } if (observer.current) { observer.current.disconnect() } if (node) { observer.current.observe(node) } }, [isFetchingNextPage] ) const handleOpenChange = (open: boolean) => { if (!open) { setUncontrolledSearchValue("") } setOpen(open) } const hasValue = isArrayValue ? selectedValues?.length > 0 : !!selectedValues const showTag = hasValue && isArrayValue const showSelected = showTag && !searchValue && !open const hideInput = forceHideInput || (!isArrayValue && hasValue && !open) const selectedLabel = options.find((o) => o.value === selectedValues)?.label const hidePlaceholder = showSelected || open const tagWidth = useMemo(() => { if (!Array.isArray(selectedValues)) { return TAG_BASE_WIDTH + TABLUAR_NUM_WIDTH // There can only be a single digit } const count = selectedValues.length const digits = count.toString().length return TAG_BASE_WIDTH + digits * TABLUAR_NUM_WIDTH }, [selectedValues]) const results = useMemo(() => { return isSearchControlled ? options : matches }, [matches, options, isSearchControlled]) return ( handleValueChange(value as T)} value={uncontrolledSearchValue} setValue={(query) => { startTransition(() => handleSearchChange(query)) }} >
{showTag && ( )}
{showSelected && (
{t("general.selected")}
)} {hideInput && (
{selectedLabel}
)} setOpen(true)} className={clx( "txt-compact-small text-ui-fg-base !placeholder:text-ui-fg-muted transition-fg size-full cursor-pointer bg-transparent pe-8 ps-2 outline-none focus:cursor-text", "hover:bg-ui-bg-field-hover", { "opacity-0": hideInput, "ps-2": !showTag, "ps-[calc(var(--tag-width)+8px)]": showTag, } )} placeholder={hidePlaceholder ? undefined : placeholder} {...inputProps} />
{allowClear && controlledValue && ( )} { return ( ) }} />
{results.map(({ value, label, disabled }) => ( {isArrayValue ? : } {label} ))} {!!fetchNextPage &&
} {isFetchingNextPage && (
)} {!results.length && (noResultsPlaceholder && !searchValue?.length ? ( noResultsPlaceholder ) : (
{t("general.noResultsTitle")}
))} {!results.length && onCreateOption && ( {t("actions.create")} "{searchValue}" )} ) } export const Combobox = genericForwardRef(ComboboxImpl)