diff --git a/packages/propel/package.json b/packages/propel/package.json index ad335f109..cd00ab38e 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -165,7 +165,7 @@ "./styles/react-day-picker": "./dist/styles/react-day-picker.css" }, "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.2", + "@base-ui-components/react": "1.0.0-beta.3", "@plane/constants": "workspace:*", "@plane/hooks": "workspace:*", "@plane/types": "workspace:*", diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx index 8f718ff81..5c2fb49dd 100644 --- a/packages/propel/src/combobox/combobox.tsx +++ b/packages/propel/src/combobox/combobox.tsx @@ -1,15 +1,10 @@ import * as React from "react"; -import { Command } from "../command/command"; -import { Popover } from "../popover/root"; +import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox"; +import { Search } from "lucide-react"; import { cn } from "../utils/classname"; -export interface ComboboxOption { - value: unknown; - query: string; - content: React.ReactNode; - disabled?: boolean; - tooltip?: string | React.ReactNode; -} +// Type definitions +type TMaxHeight = "lg" | "md" | "rg" | "sm"; export interface ComboboxProps { value?: string | string[]; @@ -27,19 +22,21 @@ export interface ComboboxButtonProps { disabled?: boolean; children?: React.ReactNode; className?: string; + ref?: React.Ref; } export interface ComboboxOptionsProps { searchPlaceholder?: string; emptyMessage?: string; showSearch?: boolean; - showCheckIcon?: boolean; className?: string; children?: React.ReactNode; - maxHeight?: "lg" | "md" | "rg" | "sm"; + maxHeight?: TMaxHeight; inputClassName?: string; optionsContainerClassName?: string; positionerClassName?: string; + searchQuery?: string; + onSearchQueryChange?: (query: string) => void; } export interface ComboboxOptionProps { @@ -49,249 +46,166 @@ export interface ComboboxOptionProps { className?: string; } -// Context for sharing state between components -interface ComboboxContextType { - value: string | string[]; - onValueChange?: (value: string | string[]) => void; - multiSelect: boolean; - maxSelections?: number; - disabled: boolean; - open: boolean; - setOpen: (open: boolean) => void; - handleValueChange: (newValue: string) => void; - handleRemoveSelection: (valueToRemove: string) => void; -} +// Constants +const MAX_HEIGHT_CLASSES: Record = { + lg: "max-h-60", + md: "max-h-48", + rg: "max-h-36", + sm: "max-h-28", +} as const; -const ComboboxContext = React.createContext(null); - -function useComboboxContext() { - const context = React.useContext(ComboboxContext); - if (!context) { - throw new Error("Combobox components must be used within a Combobox"); - } - return context; -} - -function ComboboxComponent({ +// Root component +function ComboboxRoot({ value, defaultValue, onValueChange, multiSelect = false, - maxSelections, disabled = false, - open: openProp, + open, onOpenChange, children, }: ComboboxProps) { - // Controlled/uncontrolled value - const isControlledValue = value !== undefined; - const [internalValue, setInternalValue] = React.useState( - (isControlledValue ? (value as string | string[]) : defaultValue) ?? (multiSelect ? [] : "") - ); - - // Controlled/uncontrolled open state - const isControlledOpen = openProp !== undefined; - const [internalOpen, setInternalOpen] = React.useState(false); - const open = isControlledOpen ? (openProp as boolean) : internalOpen; - - const setOpen = React.useCallback( - (nextOpen: boolean) => { - if (!isControlledOpen) { - setInternalOpen(nextOpen); - } - onOpenChange?.(nextOpen); - }, - [isControlledOpen, onOpenChange] - ); - - // Update internal value when prop changes - React.useEffect(() => { - if (isControlledValue) { - setInternalValue(value as string | string[]); - } - }, [isControlledValue, value]); - const handleValueChange = React.useCallback( - (newValue: string) => { - if (multiSelect) { - // Functional update to avoid stale closures - if (!isControlledValue) { - setInternalValue((prev) => { - const currentValues = Array.isArray(prev) ? (prev as string[]) : []; - const isSelected = currentValues.includes(newValue); - - if (!isSelected) { - if (maxSelections && currentValues.length >= maxSelections) { - return currentValues; // limit reached - } - const updated = [...currentValues, newValue]; - onValueChange?.(updated); - return updated; - } - - const updated = currentValues.filter((v) => v !== newValue); - onValueChange?.(updated); - return updated; - }); - } else { - // Controlled value: compute next and notify only - const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : []; - const isSelected = currentValues.includes(newValue); - let updated: string[]; - if (isSelected) { - updated = currentValues.filter((v) => v !== newValue); - } else { - if (maxSelections && currentValues.length >= maxSelections) { - return; - } - updated = [...currentValues, newValue]; - } - onValueChange?.(updated); - } - } else { - if (!isControlledValue) { - setInternalValue(newValue); - } - onValueChange?.(newValue); - setOpen(false); - } + (newValue: string | string[]) => { + onValueChange?.(newValue); }, - [multiSelect, isControlledValue, internalValue, maxSelections, onValueChange, setOpen] - ); - - const handleRemoveSelection = React.useCallback( - (valueToRemove: string) => { - if (!multiSelect) return; - - if (!isControlledValue) { - setInternalValue((prev) => { - const currentValues = Array.isArray(prev) ? (prev as string[]) : []; - const updated = currentValues.filter((v) => v !== valueToRemove); - onValueChange?.(updated); - return updated; - }); - } else { - const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : []; - const updated = currentValues.filter((v) => v !== valueToRemove); - onValueChange?.(updated); - } - }, - [multiSelect, isControlledValue, internalValue, onValueChange] - ); - - const contextValue = React.useMemo( - () => ({ - value: internalValue, - onValueChange, - multiSelect, - maxSelections, - disabled, - open, - setOpen, - handleValueChange, - handleRemoveSelection, - }), - [ - internalValue, - onValueChange, - multiSelect, - maxSelections, - disabled, - open, - setOpen, - handleValueChange, - handleRemoveSelection, - ] + [onValueChange] ); return ( - - - {children} - - - ); -} - -function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) { - const { disabled: ctxDisabled, open } = useComboboxContext(); - const isDisabled = disabled || ctxDisabled; - return ( - {children} - + ); } +// Trigger button component +const ComboboxButton = React.forwardRef( + ({ className, children, disabled = false }, ref) => ( + + {children} + + ) +); + +// Options popup component function ComboboxOptions({ children, showSearch = false, searchPlaceholder, - maxHeight, + maxHeight = "lg", className, inputClassName, optionsContainerClassName, emptyMessage, positionerClassName, + searchQuery: controlledSearchQuery, + onSearchQueryChange, }: ComboboxOptionsProps) { - const { multiSelect } = useComboboxContext(); + // 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) => { + if (!React.isValidElement(child)) 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(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 && } - + + - {children} - - {emptyMessage ?? "No options found."} - - +
+ {showSearch && ( +
+ + setSearchQuery(e.target.value)} + className={cn( + "w-full rounded border border-custom-border-100 bg-custom-background-90 py-1.5 pl-8 pr-2 text-sm outline-none placeholder:text-custom-text-400", + inputClassName + )} + /> +
+ )} + + {filteredChildren} + {showSearch && React.Children.count(filteredChildren) === 0 && ( +
{emptyMessage || "No results found"}
+ )} +
+
+ + + ); } +// Individual option component function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) { - const { handleValueChange, multiSelect, maxSelections, value: selectedValue } = useComboboxContext(); - - const stringValue = value; - const isSelected = React.useMemo(() => { - if (!multiSelect) return false; - return Array.isArray(selectedValue) ? (selectedValue as string[]).includes(stringValue) : false; - }, [multiSelect, selectedValue, stringValue]); - - const reachedMax = React.useMemo(() => { - if (!multiSelect || !maxSelections) return false; - const currentLength = Array.isArray(selectedValue) ? (selectedValue as string[]).length : 0; - return currentLength >= maxSelections && !isSelected; - }, [multiSelect, maxSelections, selectedValue, isSelected]); - - const isDisabled = disabled || reachedMax; - return ( - + {children} - + ); } -// compound component -const Combobox = Object.assign(ComboboxComponent, { +// Compound component export +const Combobox = Object.assign(ComboboxRoot, { Button: ComboboxButton, Options: ComboboxOptions, Option: ComboboxOption, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3176fb325..e83605bd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -995,8 +995,8 @@ importers: packages/propel: dependencies: '@base-ui-components/react': - specifier: ^1.0.0-beta.2 - version: 1.0.0-beta.2(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 1.0.0-beta.3 + version: 1.0.0-beta.3(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@plane/constants': specifier: workspace:* version: link:../constants @@ -1504,8 +1504,8 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} - '@base-ui-components/react@1.0.0-beta.2': - resolution: {integrity: sha512-jfAUfSgXvsfr8mQi7r/6gLG8U1Ybr77NN8WK5IXXM0c/hBvFDBtvUfwDJACV0gXiYbSKpA+dRzZz01V1tULobA==} + '@base-ui-components/react@1.0.0-beta.3': + resolution: {integrity: sha512-4sAq6zmDA9ixV2HRjjeM1+tSEw5R6nvGjXUQmFoQnC3DZLEUdwO94gWDmUDdpoDuChn27jdbaJs9F0Ih4w2UAA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17 || ^18 || ^19 @@ -1515,8 +1515,8 @@ packages: '@types/react': optional: true - '@base-ui-components/utils@0.1.0': - resolution: {integrity: sha512-9+uaWyF1o/PgXqHLJnC81IIG0HlV3o9eFCQ5hWZDMx5NHrFk0rrwqEFGQOB8lti/rnbxNPi+kYYw1D4e8xSn/Q==} + '@base-ui-components/utils@0.1.1': + resolution: {integrity: sha512-HWXZA8upEKgrdL1rQqxWu1H+2tB2cXzY2jCxvgnpUv3eoWN2jldhXxMZnXIjZF7jahGxSWXfSIM/qskiTWFFxA==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -1627,14 +1627,14 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -3634,8 +3634,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.0': + resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -8582,10 +8582,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@base-ui-components/react@1.0.0-beta.2(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@base-ui-components/react@1.0.0-beta.3(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 - '@base-ui-components/utils': 0.1.0(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@base-ui-components/utils': 0.1.1(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/utils': 0.2.10 react: 18.3.1 @@ -8596,7 +8596,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 - '@base-ui-components/utils@0.1.0(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@base-ui-components/utils@0.1.1(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 '@floating-ui/utils': 0.2.10 @@ -8730,9 +8730,9 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.5.0': + '@emnapi/core@1.4.5': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.0.4 tslib: 2.8.1 optional: true @@ -8741,7 +8741,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.0.4': dependencies: tslib: 2.8.1 optional: true @@ -9311,16 +9311,16 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.5': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 - '@tybys/wasm-util': 0.10.1 + '@tybys/wasm-util': 0.10.0 optional: true '@next/env@14.2.32': {} @@ -10782,7 +10782,7 @@ snapshots: '@tsconfig/node16@1.0.4': optional: true - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.0': dependencies: tslib: 2.8.1 optional: true