/** * Copyright (c) 2023-present Plane Software, Inc. and contributors * SPDX-License-Identifier: AGPL-3.0-only * See the LICENSE file for details. */ import * as React from "react"; import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox"; import { SearchOutline } from "@makeplane/propel/icons"; import { cn } from "../utils/classname"; // Type definitions type TMaxHeight = "lg" | "md" | "rg" | "sm"; export interface ComboboxProps { value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; multiSelect?: boolean; maxSelections?: number; disabled?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; children: React.ReactNode; } export interface ComboboxButtonProps { disabled?: boolean; children?: React.ReactNode; className?: string; ref?: React.Ref; } export interface ComboboxOptionsProps { searchPlaceholder?: string; emptyMessage?: string; showSearch?: boolean; className?: string; children?: React.ReactNode; maxHeight?: TMaxHeight; inputClassName?: string; optionsContainerClassName?: string; positionerClassName?: string; searchQuery?: string; onSearchQueryChange?: (query: string) => void; onSearchQueryKeyDown?: (e: React.KeyboardEvent) => void; dataPreventOutsideClick?: boolean; } export interface ComboboxOptionProps { value: string; disabled?: boolean; children?: React.ReactNode; className?: string; } // Constants const MAX_HEIGHT_CLASSES: Record = { lg: "max-h-60", md: "max-h-48", rg: "max-h-36", sm: "max-h-28", } as const; // Root component function ComboboxRoot({ value, defaultValue, onValueChange, multiSelect = false, disabled = false, open, onOpenChange, children, }: ComboboxProps) { const handleValueChange = React.useCallback( (newValue: string | string[]) => { onValueChange?.(newValue); }, [onValueChange] ); return ( {children} ); } // Trigger button component const ComboboxButton = React.forwardRef(function ComboboxButton( { className, children, disabled = false }: ComboboxButtonProps, ref: React.ForwardedRef ) { return ( {children} ); }); // Options popup component function ComboboxOptions({ children, showSearch = false, searchPlaceholder, maxHeight = "lg", className, inputClassName, optionsContainerClassName, emptyMessage = "No results found", positionerClassName, searchQuery: controlledSearchQuery, onSearchQueryChange, onSearchQueryKeyDown, dataPreventOutsideClick, }: ComboboxOptionsProps) { // const [searchQuery, setSearchQuery] = React.useState(""); const [internalSearchQuery, setInternalSearchQuery] = React.useState(""); const searchQuery = controlledSearchQuery !== undefined ? controlledSearchQuery : internalSearchQuery; const setSearchQuery = React.useCallback( (query: string) => { if (onSearchQueryChange) { onSearchQueryChange(query); } else { setInternalSearchQuery(query); } }, [onSearchQueryChange] ); // Filter children based on search query const filteredChildren = React.useMemo(() => { if (!showSearch || !searchQuery) return children; return React.Children.toArray(children).filter((child) => { // React 19 defaults ReactElement's props to `unknown`; name the shape read below. if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) return true; // Only filter ComboboxOption components, leave other elements (like additional content) unfiltered if (child.type !== ComboboxOption) return true; // Extract text content from child to search against const getTextContent = (node: React.ReactNode): string => { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.props.children) { return getTextContent(node.props.children); } if (Array.isArray(node)) { return node.map(getTextContent).join(" "); } return ""; }; const textContent = getTextContent(child.props.children); const value = child.props.value || ""; const searchLower = searchQuery.toLowerCase(); return textContent.toLowerCase().includes(searchLower) || String(value).toLowerCase().includes(searchLower); }); }, [children, searchQuery, showSearch]); return (
{showSearch && (
setSearchQuery(e.target.value)} onKeyDown={onSearchQueryKeyDown} className={cn( "w-full rounded-sm border border-subtle bg-surface-2 py-1.5 pr-2 pl-8 text-13 outline-none placeholder:text-placeholder", inputClassName )} />
)} {filteredChildren} {showSearch && emptyMessage && React.Children.count( React.Children.toArray(filteredChildren).filter( (child) => React.isValidElement(child) && child.type === ComboboxOption ) ) === 0 &&
{emptyMessage}
}
); } // Individual option component function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) { return ( {children} ); } // Compound component export const Combobox = Object.assign(ComboboxRoot, { Button: ComboboxButton, Options: ComboboxOptions, Option: ComboboxOption, }); export { Combobox };