Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
/**
|
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
* See the LICENSE file for details.
|
|
*/
|
|
|
|
import * as React from "react";
|
|
import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox";
|
|
import { SearchOutline } from "@makeplane/propel/icons";
|
|
import { cn } from "../utils/classname";
|
|
|
|
// Type definitions
|
|
type TMaxHeight = "lg" | "md" | "rg" | "sm";
|
|
|
|
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;
|
|
ref?: React.Ref<HTMLButtonElement>;
|
|
}
|
|
|
|
export interface ComboboxOptionsProps {
|
|
searchPlaceholder?: string;
|
|
emptyMessage?: string;
|
|
showSearch?: boolean;
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
maxHeight?: TMaxHeight;
|
|
inputClassName?: string;
|
|
optionsContainerClassName?: string;
|
|
positionerClassName?: string;
|
|
searchQuery?: string;
|
|
onSearchQueryChange?: (query: string) => void;
|
|
onSearchQueryKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
dataPreventOutsideClick?: boolean;
|
|
}
|
|
|
|
export interface ComboboxOptionProps {
|
|
value: string;
|
|
disabled?: boolean;
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
// Constants
|
|
const MAX_HEIGHT_CLASSES: Record<TMaxHeight, string> = {
|
|
lg: "max-h-60",
|
|
md: "max-h-48",
|
|
rg: "max-h-36",
|
|
sm: "max-h-28",
|
|
} as const;
|
|
|
|
// Root component
|
|
function ComboboxRoot({
|
|
value,
|
|
defaultValue,
|
|
onValueChange,
|
|
multiSelect = false,
|
|
disabled = false,
|
|
open,
|
|
onOpenChange,
|
|
children,
|
|
}: ComboboxProps) {
|
|
const handleValueChange = React.useCallback(
|
|
(newValue: string | string[]) => {
|
|
onValueChange?.(newValue);
|
|
},
|
|
[onValueChange]
|
|
);
|
|
|
|
return (
|
|
<BaseCombobox.Root
|
|
value={value}
|
|
defaultValue={defaultValue}
|
|
onValueChange={handleValueChange}
|
|
multiple={multiSelect}
|
|
disabled={disabled}
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
>
|
|
{children}
|
|
</BaseCombobox.Root>
|
|
);
|
|
}
|
|
|
|
// Trigger button component
|
|
const ComboboxButton = React.forwardRef(function ComboboxButton(
|
|
{ className, children, disabled = false }: ComboboxButtonProps,
|
|
ref: React.ForwardedRef<HTMLButtonElement>
|
|
) {
|
|
return (
|
|
<BaseCombobox.Trigger ref={ref} disabled={disabled} className={className}>
|
|
{children}
|
|
</BaseCombobox.Trigger>
|
|
);
|
|
});
|
|
|
|
// Options popup component
|
|
function ComboboxOptions({
|
|
children,
|
|
showSearch = false,
|
|
searchPlaceholder,
|
|
maxHeight = "lg",
|
|
className,
|
|
inputClassName,
|
|
optionsContainerClassName,
|
|
emptyMessage = "No results found",
|
|
positionerClassName,
|
|
searchQuery: controlledSearchQuery,
|
|
onSearchQueryChange,
|
|
onSearchQueryKeyDown,
|
|
dataPreventOutsideClick,
|
|
}: ComboboxOptionsProps) {
|
|
// 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) => {
|
|
// React 19 defaults ReactElement's props to `unknown`; name the shape read below.
|
|
if (!React.isValidElement<{ children?: React.ReactNode; value?: string }>(child)) return true;
|
|
|
|
// Only filter ComboboxOption components, leave other elements (like additional content) unfiltered
|
|
if (child.type !== ComboboxOption) 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<{ children?: React.ReactNode }>(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 (
|
|
<BaseCombobox.Portal>
|
|
<BaseCombobox.Positioner sideOffset={8} className={positionerClassName}>
|
|
<BaseCombobox.Popup
|
|
className={cn("shadow-lg rounded-md border border-subtle bg-surface-1 p-1", className)}
|
|
data-prevent-outside-click={dataPreventOutsideClick}
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
{showSearch && (
|
|
<div className="relative">
|
|
<SearchOutline className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-placeholder" />
|
|
<input
|
|
type="text"
|
|
placeholder={searchPlaceholder}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={onSearchQueryKeyDown}
|
|
className={cn(
|
|
"w-full rounded-sm border border-subtle bg-surface-2 py-1.5 pr-2 pl-8 text-13 outline-none placeholder:text-placeholder",
|
|
inputClassName
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<BaseCombobox.List
|
|
className={cn("overflow-auto outline-none", MAX_HEIGHT_CLASSES[maxHeight], optionsContainerClassName)}
|
|
>
|
|
{filteredChildren}
|
|
{showSearch &&
|
|
emptyMessage &&
|
|
React.Children.count(
|
|
React.Children.toArray(filteredChildren).filter(
|
|
(child) => React.isValidElement(child) && child.type === ComboboxOption
|
|
)
|
|
) === 0 && <div className="px-2 py-1.5 text-13 text-placeholder">{emptyMessage}</div>}
|
|
</BaseCombobox.List>
|
|
</div>
|
|
</BaseCombobox.Popup>
|
|
</BaseCombobox.Positioner>
|
|
</BaseCombobox.Portal>
|
|
);
|
|
}
|
|
|
|
// Individual option component
|
|
function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) {
|
|
return (
|
|
<BaseCombobox.Item
|
|
value={value}
|
|
disabled={disabled}
|
|
className={cn("cursor-pointer rounded-sm px-2 py-1.5 text-13 transition-colors outline-none", className)}
|
|
>
|
|
{children}
|
|
</BaseCombobox.Item>
|
|
);
|
|
}
|
|
|
|
// Compound component export
|
|
const Combobox = Object.assign(ComboboxRoot, {
|
|
Button: ComboboxButton,
|
|
Options: ComboboxOptions,
|
|
Option: ComboboxOption,
|
|
});
|
|
|
|
export { Combobox };
|