[WEB-4682 | WEB-4685] feat: propel comobobox and command component (#7615)
* feat: comobobox and command component added to propel package * fix: format error * chore: code refactor * chore: code refactor * fix: format error --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
parent
c209a713d8
commit
e86b40ac82
5 changed files with 343 additions and 0 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
298
packages/propel/src/combobox/combobox.tsx
Normal file
298
packages/propel/src/combobox/combobox.tsx
Normal file
|
|
@ -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
packages/propel/src/combobox/index.ts
Normal file
1
packages/propel/src/combobox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./combobox";
|
||||
41
packages/propel/src/command/command.tsx
Normal file
41
packages/propel/src/command/command.tsx
Normal file
|
|
@ -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
packages/propel/src/command/index.ts
Normal file
1
packages/propel/src/command/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./command";
|
||||
Loading…
Add table
Add a link
Reference in a new issue