This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,267 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChevronsUpDown } from "lucide-react";
import { useArgs } from "storybook/preview-api";
import { CheckIcon } from "../icons";
import { Combobox } from "./combobox";
const frameworks = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "Solid" },
{ value: "next", label: "Next.js" },
{ value: "nuxt", label: "Nuxt" },
{ value: "remix", label: "Remix" },
];
const meta = {
title: "Components/Combobox",
component: Combobox,
subcomponents: {
ComboboxButton: Combobox.Button,
ComboboxOptions: Combobox.Options,
ComboboxOption: Combobox.Option,
},
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
children: null,
value: "",
onValueChange: () => {},
},
render(args) {
const [{ value }, updateArgs] = useArgs();
const setValue = (newValue: string | string[]) => updateArgs({ value: newValue });
return (
<Combobox {...args} value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
} satisfies Meta<typeof Combobox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithoutSearch: Story = {
render() {
const [value, setValue] = useState("");
return (
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
export const MultiSelect: Story = {
render() {
const [value, setValue] = useState<string[]>([]);
return (
<Combobox multiSelect value={value} onValueChange={(v) => setValue(v as string[])}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span className="truncate">{value.length > 0 ? `${value.length} selected` : "Select frameworks..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value.includes(framework.value) && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
export const MultiSelectWithLimit: Story = {
render() {
const [value, setValue] = useState<string[]>([]);
return (
<div className="space-y-2">
<Combobox multiSelect maxSelections={3} value={value} onValueChange={(v) => setValue(v as string[])}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span className="truncate">
{value.length > 0 ? `${value.length}/3 selected` : "Select up to 3 frameworks..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value.includes(framework.value) && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
<p className="text-gray-500 text-11">Maximum 3 selections allowed</p>
</div>
);
},
};
export const Disabled: Story = {
args: { disabled: true },
render() {
const [value, setValue] = useState("");
return (
<Combobox disabled value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 bg-gray-100 flex w-72 items-center justify-between rounded-md border px-4 py-2 opacity-50">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
export const DisabledOptions: Story = {
render() {
const [value, setValue] = useState("");
return (
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
disabled={framework.value === "angular" || framework.value === "svelte"}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
export const CustomMaxHeight: Story = {
render() {
const [value, setValue] = useState("");
return (
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options showSearch searchPlaceholder="Search framework..." maxHeight="sm" className="w-72">
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
export const CustomEmptyMessage: Story = {
render() {
const [value, setValue] = useState("");
return (
<Combobox value={value} onValueChange={(v) => setValue(v as string)}>
<Combobox.Button className="border-gray-300 hover:bg-gray-50 flex w-72 items-center justify-between rounded-md border bg-white px-4 py-2">
<span>{value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Combobox.Button>
<Combobox.Options
showSearch
searchPlaceholder="Search framework..."
emptyMessage="No frameworks found. Try a different search."
className="w-72"
>
{frameworks.map((framework) => (
<Combobox.Option
key={framework.value}
value={framework.value}
className="flex items-center gap-2 px-4 py-2"
>
{value === framework.value && <CheckIcon className="h-4 w-4" />}
<span>{framework.label}</span>
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
);
},
};
@@ -0,0 +1,236 @@
/**
* 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 { SearchIcon } from "../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) => {
if (!React.isValidElement(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(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">
<SearchIcon 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 };
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./combobox";