feat(agents): Add identity and soul template fields to board creation
This commit is contained in:
215
frontend/src/components/ui/dropdown-select.tsx
Normal file
215
frontend/src/components/ui/dropdown-select.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
export type DropdownSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
icon?: React.ElementType<{ className?: string }>;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
type DropdownSelectProps = {
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: DropdownSelectOption[];
|
||||
placeholder?: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
triggerClassName?: string;
|
||||
contentClassName?: string;
|
||||
itemClassName?: string;
|
||||
searchEnabled?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
const resolvePlaceholder = (ariaLabel: string, placeholder?: string) => {
|
||||
if (placeholder) {
|
||||
return placeholder;
|
||||
}
|
||||
const trimmed = ariaLabel.trim();
|
||||
if (!trimmed) {
|
||||
return "Select an option";
|
||||
}
|
||||
return trimmed.endsWith("...") ? trimmed : `${trimmed}...`;
|
||||
};
|
||||
|
||||
const resolveSearchPlaceholder = (
|
||||
ariaLabel: string,
|
||||
searchPlaceholder?: string,
|
||||
) => {
|
||||
if (searchPlaceholder) {
|
||||
return searchPlaceholder;
|
||||
}
|
||||
const cleaned = ariaLabel.replace(/^select\\s+/i, "").trim();
|
||||
if (!cleaned) {
|
||||
return "Search...";
|
||||
}
|
||||
const value = `Search ${cleaned}`;
|
||||
return value.endsWith("...") ? value : `${value}...`;
|
||||
};
|
||||
|
||||
export default function DropdownSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
itemClassName,
|
||||
searchEnabled = true,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
}: DropdownSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
const resolvedPlaceholder = resolvePlaceholder(ariaLabel, placeholder);
|
||||
const triggerLabel = selectedOption?.label ?? value ?? "";
|
||||
const SelectedIcon = selectedOption?.icon;
|
||||
const selectedIconClassName = selectedOption?.iconClassName;
|
||||
const showPlaceholder = !triggerLabel;
|
||||
const resolvedSearchPlaceholder = searchEnabled
|
||||
? resolveSearchPlaceholder(ariaLabel, searchPlaceholder)
|
||||
: "";
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSearchValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (nextValue: string) => {
|
||||
if (nextValue !== value) {
|
||||
onValueChange(nextValue);
|
||||
}
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [open, searchValue]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex h-10 w-auto cursor-pointer items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
open && "bg-slate-50",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 truncate",
|
||||
showPlaceholder && "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
<SelectedIcon
|
||||
className={cn("h-4 w-4 text-slate-600", selectedIconClassName)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{showPlaceholder ? resolvedPlaceholder : triggerLabel}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-slate-400 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[12rem] overflow-hidden rounded-md border border-slate-200 bg-white p-0 text-slate-900 shadow-lg",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<Command label={ariaLabel} className="w-full">
|
||||
{searchEnabled ? (
|
||||
<CommandInput
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
placeholder={resolvedSearchPlaceholder}
|
||||
autoFocus
|
||||
className="text-sm"
|
||||
/>
|
||||
) : null}
|
||||
<CommandList ref={listRef} className="max-h-64 p-1">
|
||||
<CommandEmpty className="px-3 py-6 text-center text-sm text-slate-500">
|
||||
{emptyMessage ?? "No results found."}
|
||||
</CommandEmpty>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
const OptionIcon = option.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, option.value]}
|
||||
disabled={option.disabled}
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg px-4 py-3 text-sm text-gray-700 transition-colors data-[selected=true]:bg-gray-50 data-[selected=true]:text-gray-900",
|
||||
isSelected && "font-semibold",
|
||||
!isSelected && "hover:bg-gray-50",
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{OptionIcon ? (
|
||||
<OptionIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSelected ? "text-gray-700" : "text-gray-500",
|
||||
option.iconClassName,
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 text-gray-400" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user