Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/propel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
298 changes: 298 additions & 0 deletions packages/propel/src/combobox/combobox.tsx
Original file line number Diff line number Diff line change
@@ -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<ComboboxContextType | null>(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<string | string[]>(
(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<ComboboxContextType>(
() => ({
value: internalValue,
onValueChange,
multiSelect,
maxSelections,
disabled,
open,
setOpen,
handleValueChange,
handleRemoveSelection,
}),
[
internalValue,
onValueChange,
multiSelect,
maxSelections,
disabled,
open,
setOpen,
handleValueChange,
handleRemoveSelection,
]
);

return (
<ComboboxContext.Provider value={contextValue}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</ComboboxContext.Provider>
);
}

function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) {
const { disabled: ctxDisabled, open } = useComboboxContext();
const isDisabled = disabled || ctxDisabled;
return (
<Popover.Button
disabled={isDisabled}
aria-disabled={isDisabled || undefined}
aria-haspopup="listbox"
aria-expanded={open}
className={className}
>
{children}
</Popover.Button>
);
}

function ComboboxOptions({
children,
showSearch = false,
searchPlaceholder,
maxHeight,
className,
inputClassName,
optionsContainerClassName,
emptyMessage,
}: ComboboxOptionsProps) {
const { multiSelect } = useComboboxContext();
return (
<Popover.Panel sideOffset={8} className={cn(className)}>
<Command>
{showSearch && <Command.Input placeholder={searchPlaceholder} className={cn(inputClassName)} />}
<Command.List
className={cn(
{
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
},
optionsContainerClassName
)}
role="listbox"
aria-multiselectable={multiSelect || undefined}
>
{children}
</Command.List>
<Command.Empty>{emptyMessage ?? "No options found."}</Command.Empty>
</Command>
</Popover.Panel>
);
}

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 (
<Command.Item value={stringValue} disabled={isDisabled} onSelect={handleValueChange} className={className}>
{children}
</Command.Item>
);
}

// compound component
const Combobox = Object.assign(ComboboxComponent, {
Button: ComboboxButton,
Options: ComboboxOptions,
Option: ComboboxOption,
});

export { Combobox };
1 change: 1 addition & 0 deletions packages/propel/src/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./combobox";
41 changes: 41 additions & 0 deletions packages/propel/src/command/command.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CommandPrimitive>) {
return <CommandPrimitive data-slot="command" className={cn("", className)} {...props} />;
}

function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2"
>
<SearchIcon className="size-3.5 flex-shrink-0 text-custom-text-400" strokeWidth={1.5} />
<CommandPrimitive.Input data-slot="command-input" className={cn(className)} {...props} />
</div>
);
}

function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List data-slot="command-list" {...props} />;
}

function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" {...props} />;
}

function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item data-slot="command-item" {...props} />;
}

const Command = Object.assign(CommandComponent, {
Input: CommandInput,
List: CommandList,
Empty: CommandEmpty,
Item: CommandItem,
});

export { Command };
1 change: 1 addition & 0 deletions packages/propel/src/command/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./command";
Loading