From 1ceb4f0d04b557b2323eb31be0e0c59aeecd980d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Thu, 21 Aug 2025 07:46:37 +0530 Subject: [PATCH 1/5] feat: comobobox and command component added to propel package --- packages/propel/package.json | 5 +- packages/propel/src/combobox/combobox.tsx | 297 ++++++++++++++++++++++ packages/propel/src/combobox/index.ts | 1 + packages/propel/src/command/command.tsx | 41 +++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 packages/propel/src/combobox/combobox.tsx create mode 100644 packages/propel/src/combobox/index.ts create mode 100644 packages/propel/src/command/command.tsx diff --git a/packages/propel/package.json b/packages/propel/package.json index 490d086de9e..c70ac7fa893 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -18,6 +18,9 @@ "./menu": "./src/menu/index.ts", "./table": "./src/table/index.ts", "./tabs": "./src/tabs/index.ts", + "./popover": "./src/popover/index.ts", + "./command": "./src/command/index.ts", + "./combobox": "./src/combobox/index.ts", "./styles/fonts": "./src/styles/fonts/index.css" }, "dependencies": { @@ -42,4 +45,4 @@ "@types/react-dom": "18.3.0", "typescript": "5.8.3" } -} +} \ No newline at end of file diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx new file mode 100644 index 00000000000..dde06acb12e --- /dev/null +++ b/packages/propel/src/combobox/combobox.tsx @@ -0,0 +1,297 @@ +import * as React from "react"; +import { Command } from "../command/command"; +import { Popover } from "../popover/root"; +import { cn } from "@plane/utils"; + +export interface ComboboxOption { + value: unknown; + query: string; + content: React.ReactNode; + disabled?: boolean; + tooltip?: string | React.ReactNode; +} + +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; +} + +export interface ComboboxOptionsProps { + searchPlaceholder?: string; + emptyMessage?: string; + showSearch?: boolean; + showCheckIcon?: boolean; + className?: string; + children?: React.ReactNode; + maxHeight?: "lg" | "md" | "rg" | "sm"; + inputClassName?: string; + optionsContainerClassName?: string; +} + +export interface ComboboxOptionProps { + value: unknown; + disabled?: boolean; + children?: React.ReactNode; + 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; +} + +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({ + value, + defaultValue, + onValueChange, + multiSelect = false, + maxSelections, + disabled = false, + open: openProp, + 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); + } + }, + [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, + ] + ); + + return ( + + + {children} + + + ); +} + +function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) { + const { disabled: ctxDisabled, open } = useComboboxContext(); + const isDisabled = disabled || ctxDisabled; + return ( + + {children} + + ); +} + +function ComboboxOptions({ + children, + showSearch = false, + searchPlaceholder, + maxHeight, + className, + inputClassName, + optionsContainerClassName, +}: ComboboxOptionsProps) { + const { multiSelect } = useComboboxContext(); + return ( + + + {showSearch && } + + {children} + + No options found. + + + ); +} + +function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) { + const { handleValueChange, multiSelect, maxSelections, value: selectedValue } = useComboboxContext(); + + const stringValue = String(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, { + Button: ComboboxButton, + Options: ComboboxOptions, + Option: ComboboxOption, +}); + +export { Combobox }; diff --git a/packages/propel/src/combobox/index.ts b/packages/propel/src/combobox/index.ts new file mode 100644 index 00000000000..1be314de737 --- /dev/null +++ b/packages/propel/src/combobox/index.ts @@ -0,0 +1 @@ +export * from "./combobox"; diff --git a/packages/propel/src/command/command.tsx b/packages/propel/src/command/command.tsx new file mode 100644 index 00000000000..25969aada8d --- /dev/null +++ b/packages/propel/src/command/command.tsx @@ -0,0 +1,41 @@ +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import * as React from "react"; +import { cn } from "@plane/ui"; + +function CommandComponent({ className, ...props }: React.ComponentProps) { + return ; +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ; +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ; +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ; +} + +const Command = Object.assign(CommandComponent, { + Input: CommandInput, + List: CommandList, + Empty: CommandEmpty, + Item: CommandItem, +}); + +export { Command }; From 1d05e1802f823a9f3cb3ef76119b7ed3a4915508 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Thu, 21 Aug 2025 07:49:39 +0530 Subject: [PATCH 2/5] fix: format error --- packages/propel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/propel/package.json b/packages/propel/package.json index c70ac7fa893..5b100e313fc 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -45,4 +45,4 @@ "@types/react-dom": "18.3.0", "typescript": "5.8.3" } -} \ No newline at end of file +} From 205bc408b771b8e6e51d4dc2f0ebc4f6595a3f48 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Thu, 21 Aug 2025 13:29:39 +0530 Subject: [PATCH 3/5] chore: code refactor --- packages/propel/src/command/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/propel/src/command/index.ts diff --git a/packages/propel/src/command/index.ts b/packages/propel/src/command/index.ts new file mode 100644 index 00000000000..5c684da8731 --- /dev/null +++ b/packages/propel/src/command/index.ts @@ -0,0 +1 @@ +export * from "./command"; \ No newline at end of file From ecc5d2c9e488ae9faa8122aa0996fe191edf3410 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Thu, 21 Aug 2025 13:35:05 +0530 Subject: [PATCH 4/5] chore: code refactor --- packages/propel/src/combobox/combobox.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx index dde06acb12e..91e56372638 100644 --- a/packages/propel/src/combobox/combobox.tsx +++ b/packages/propel/src/combobox/combobox.tsx @@ -42,7 +42,7 @@ export interface ComboboxOptionsProps { } export interface ComboboxOptionProps { - value: unknown; + value: string; disabled?: boolean; children?: React.ReactNode; className?: string; @@ -236,6 +236,7 @@ function ComboboxOptions({ className, inputClassName, optionsContainerClassName, + emptyMessage, }: ComboboxOptionsProps) { const { multiSelect } = useComboboxContext(); return ( @@ -257,7 +258,7 @@ function ComboboxOptions({ > {children} - No options found. + {emptyMessage ?? "No options found."} ); @@ -266,7 +267,7 @@ function ComboboxOptions({ function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) { const { handleValueChange, multiSelect, maxSelections, value: selectedValue } = useComboboxContext(); - const stringValue = String(value); + const stringValue = value; const isSelected = React.useMemo(() => { if (!multiSelect) return false; return Array.isArray(selectedValue) ? (selectedValue as string[]).includes(stringValue) : false; From 0c22a3b0bb1b880839002a3f5186950276532c71 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Thu, 21 Aug 2025 14:01:03 +0530 Subject: [PATCH 5/5] fix: format error --- packages/propel/src/command/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/propel/src/command/index.ts b/packages/propel/src/command/index.ts index 5c684da8731..f4c8ea9e0fd 100644 --- a/packages/propel/src/command/index.ts +++ b/packages/propel/src/command/index.ts @@ -1 +1 @@ -export * from "./command"; \ No newline at end of file +export * from "./command";