确保您已设置 Next.js 项目。如果没有,请创建一个:
npx create-next-app my-app --typescriptcd my-app
安装所需的 shadcn 组件:
npx shadcn@最新 init npx shadcn@latest 添加命令弹出按钮分隔符徽章
在components
目录中创建multi-select.tsx
:
// src/components/multi-select.tsximport * as React from "react";import { cva, type VariantProps } from "class-variance-authority";import { 检查图标, X圆, 雪佛龙向下, X图标, WandSparkles,} 来自 "lucide-react";从 "@/lib/utils" 导入 { cn };从 "@/components/ui/separator" 导入 { Separator };从 "@/components/ui/ 导入 { Button }按钮”;从“@/components/ui/badge”导入{徽章};导入{ 弹出窗口, 弹出内容, PopoverTrigger,}来自“@/components/ui/popover”;导入{ 命令, 命令为空, 指挥组, 命令输入, 命令项, 命令列表, CommandSeparator,} from "@/components/ui/command";/** * 多选组件的变体以处理不同的样式。 * 使用 class-variance-authority (cva) 根据“variant”属性定义不同的样式。 */const multiSelectVariants = cva( "m-1 过渡缓入缓出延迟-150 悬停:-translate-y-1 悬停:scale-110 持续时间-300", {变体:{ 变体:{默认值:“border-foreground/10 text-foreground bg-card 悬停:bg-card/80”,次要:“border-foreground/10 bg-secondary text-secondary-foreground 悬停:bg- secondary/80”,破坏性:“边框透明背景破坏性文本破坏性前景悬停:bg-破坏性/80”,反向:“反向”, },},defaultVariants: { 变体: "默认",}, });/** * MultiSelect 组件的 Props */interface MultiSelectProps 扩展 React.ButtonHTMLAttributes<HTMLButtonElement>,VariantProps<typeof multiSelectVariants> { /** * 要在多选组件中显示的选项对象数组。 * 每个选项对象都有一个标签、值和一个可选图标。 */ options: {/** 选项显示的文本。 */label: string;/** 与选项关联的唯一值。 */value: string;/** 与选项一起显示的可选图标组件。 */icon?: React.ComponentType<{ className?: string }>; }[]; /** * 当所选值改变时触发回调函数。 * 接收新选定值的数组。 */ onValueChange: (值: string[]) => void; /** 组件挂载时默认选择的值。 */ 默认值?:字符串[]; /** * 未选择任何值时显示的占位符文本。 * 可选,默认为“选择选项”。 */ 占位符?:字符串; /** * 视觉效果的动画持续时间(以秒为单位)(例如,弹跳徽章)。 * 可选,默认为 0(无动画)。 */ 动画?:数量; /** * 显示的最大项目数。额外选定的项目将被总结。 * 可选,默认为3。 */ 最大计数?:数量; /** * 弹出框的形式。当设置为 true 时,与外部元素的交互 * 将被禁用,并且只有弹出内容对屏幕阅读器可见。 * 可选,默认为 false。 */ modalPopover?:布尔值; /** * 如果为 true,则将多选组件呈现为另一个组件的子组件。 * 可选,默认为 false。 */ asChild?:布尔值; /** * 用于将自定义样式应用于多选组件的附加类名称。 * 可选,可用于添加自定义样式。 */ className?: string;}export const MultiSelect = React.forwardRef< HTML 按钮元素, 多选属性>( ({ options, onValueChange,variant,defaultValue = [], placeholder =“选择选项”,animation = 0, maxCount = 3, modalPopover = false, asChild = false, className, ...props},ref ) => {const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue);const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);const [isAnimating, setIsAnimating] = React.useState(false) ;const handleInputKeyDown = ( event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") {setIsPopoverOpen(true); } else if (event.key === "Backspace" && !event.currentTarget.value) {const newSelectedValues = [...selectedValues];newSelectedValues.pop();setSelectedValues(newSelectedValues);onValueChange(newSelectedValues); }};consttoggleOption = (option: string) => { const newSelectedValues = selectedValues.includes(option)? selectedValues.filter((value) => value !== option): [...selectedValues, option]; setSelectedValues(newSelectedValues); onValueChange(newSelectedValues);};const handleClear = () => { setSelectedValues([]); onValueChange([]);};const handleTogglePopover = () => { setIsPopoverOpen((prev) => !prev);};const clearExtraOptions = () => { const newSelectedValues = selectedValues.slice(0, maxCount); setSelectedValues(newSelectedValues); onValueChange(newSelectedValues);};consttoggleAll = () => { if (selectedValues.length === options.length) {handleClear(); } } else {const allValues = options.map((option) => option.value);setSelectedValues(allValues);onValueChange(allValues); }};return ( <Popoveropen={isPopoverOpen}onOpenChange={setIsPopoverOpen}modal={modalPopover} ><PopoverTrigger asChild> <Buttonref={ref}{...props}onClick={handleTogglePopover}className={cn( "flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify- Between bg-inherit 悬停:bg-inherit", className)} >{selectedValues.length > 0 ? ( <div className="flex justify- Between items-center w-full"><div className="flex flex-wrap items- center"> {selectedValues.slice(0, maxCount).map((value) => {const option = options.find((o) => o.value === value);const IconComponent = option?.icon;return ( <Badgekey={value}className={cn( isAnimating ? "animate-bounce" : "", multiSelectVariants({variant }))}style={{animationDuration: `${animation}s` }} >{IconComponent && ( <IconComponent className="h-4 w-4 mr-2" />)}{option?.label}<XCircle className="ml-2 h-4 w-4 光标指针" onClick={(event) => {event.stopPropagation();toggleOption(value); }}/> </Badge>); })} {selectedValues.length > maxCount && (<Badge className={cn("bg-透明文本-前景边框-前景/1 悬停:bg-透明",isAnimating ? "animate-bounce" : "",multiSelectVariants({variant }) )} style={{animationDuration: `${animation}s` }}> {`+ ${selectedValues.length - maxCount} more`} <XCircleclassName= "ml-2 h-4 w-4 光标指针"onClick={(event) => { event.stopPropagation(); clearExtraOptions();}} /></Badge> )}</div><div className="flex items-center justify- Between"> <XIconclassName="h-4 mx-2 光标指针 text-muted-foreground “onClick={(事件) => { event.stopPropagation(); handleClear();}} /> <Separatororientation="vertical"className="flex min-h-6 h-full" /> <ChevronDown className="h-4 mx-2 光标指针 text-muted-foreground" / ></div> </div>) : ( <div className="flex items-center justify- Between w-full mx-auto"><span className="text-sm text-muted-foreground mx-3"> {占位符}</span><ChevronDown className="h-4 光标指针 text-muted-foreground mx-2" /> </div>)} </Button></PopoverTrigger><PopoverContent className =“w-auto p-0”align =“start”onEscapeKeyDown = {()=> setIsPopoverOpen(false)}> <Command> <CommandInput placeholder =“搜索...” onKeyDown={handleInputKeyDown}/><CommandList> <CommandEmpty>未找到结果。</CommandEmpty> <CommandGroup><CommandItem key="all" onSelect={toggleAll} className="cursor-pointer"> <divclassName={cn( “mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary”,selectedValues.length === options.length? "bg-primary text-primary-foreground": "opacity-50 [&_svg]:invisible")} ><CheckIcon className="h-4 w-4" /> </div> <span>(全选)< /span></CommandItem>{options.map((option) => { const isSelected = selectedValues.includes(option.value); return (<CommandItem key={option.value} onSelect={() =>toggleOption(option.value)} className="cursor-pointer"> <divclassName={cn( "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", isSelected? "bg-primary text-primary-foreground": "opacity-50 [&_svg]:invisible")} ><CheckIcon className="h-4 w-4" /> </div> {option.icon && (<option.icon className="mr-2 h-4 w-4 text-muted-foreground" /> ) } <span>{option.label}</span></CommandItem> );})} </CommandGroup> <CommandSeparator /> <CommandGroup><div className="flex items-center justify- Between"> {selectedValues.length > 0 && (<> <CommandItemonSelect={handleClear}className="flex-1 justify-center 光标指针" >清除 </CommandItem> <Separatororientation="vertical"className="flex min-h-6 h-full" /></> )} <CommandItemonSelect={() => setIsPopoverOpen(false)}className="flex-1 justify-center 光标指针max-w-full" >关闭 </CommandItem></div> </CommandGroup></CommandList> </Command></PopoverContent>{animation > 0 && selectedValues.length > 0 && ( <WandSparklesclassName={cn( “光标指针 my-2 文本前景 bg-背景 w-3 h-3”, isAnimating ? "" : "text-muted-foreground")}onClick={() => setIsAnimating(!isAnimating)} />)} </Popover>); });MultiSelect.displayName =</sp