diff --git a/packages/propel/package.json b/packages/propel/package.json index 967ba3af2c9..11e6921eb6a 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -19,6 +19,8 @@ "./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", "./tooltip": "./src/tooltip/index.ts", "./styles/fonts": "./src/styles/fonts/index.css" }, diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx new file mode 100644 index 00000000000..91e56372638 --- /dev/null +++ b/packages/propel/src/combobox/combobox.tsx @@ -0,0 +1,298 @@ +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: string; + 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, + emptyMessage, +}: ComboboxOptionsProps) { + const { multiSelect } = useComboboxContext(); + return ( + + + {showSearch && } + + {children} + + {emptyMessage ?? "No options found."} + + + ); +} + +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, { + 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 }; diff --git a/packages/propel/src/command/index.ts b/packages/propel/src/command/index.ts new file mode 100644 index 00000000000..f4c8ea9e0fd --- /dev/null +++ b/packages/propel/src/command/index.ts @@ -0,0 +1 @@ +export * from "./command";