shadcn/ui 实现类似 antd input select tag 组件

189 阅读1分钟
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MonitoredFolder } from "@/types/monitored-folder";
import { Check, Command, Loader2, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";

interface TagDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onSave: (tags: MonitoredFolder[]) => Promise<void>;
  tags: MonitoredFolder[];
  defaultTags: MonitoredFolder[];
}

export function TagDialog({ open, onOpenChange, onSave, tags, defaultTags }: TagDialogProps) {
  const [selectedTags, setSelectedTags] = useState<MonitoredFolder[]>(defaultTags);
  const [inputValue, setInputValue] = useState("");
  const [showDropdown, setShowDropdown] = useState(false);
  const [isSaving, setIsSaving] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const filteredTags = useMemo(() => {
    return tags.filter((tag) => tag.folder_name.toLowerCase().includes(inputValue.toLowerCase()));
  }, [tags, inputValue, selectedTags]);

  const handleSelectTag = (tag: MonitoredFolder) => {
    if (selectedTags.some((t) => t.id === tag.id)) {
      setSelectedTags(selectedTags.filter((t) => t.id !== tag.id));
    } else {
      setSelectedTags([...selectedTags, tag]);
    }
    setInputValue("");
    inputRef.current?.focus();
  };

  const handleRemoveTag = (tagToRemove: MonitoredFolder) => {
    setSelectedTags(selectedTags.filter((tag) => tag.id !== tagToRemove.id));
  };

  const handleSave = async () => {
    setIsSaving(true);
    try {
      await onSave(selectedTags); // 直接传递选中的标签对象数组
      onOpenChange(false);
    } finally {
      setIsSaving(false);
    }
  };

  // 点击外部关闭下拉框
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
        setShowDropdown(false);
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  useEffect(() => {
    setSelectedTags(defaultTags);
  }, [open]);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent
        className="max-w-md"
        onOpenAutoFocus={(e) => e.preventDefault()}
        onPointerDownOutside={(e) => e.preventDefault()}
      >
        <DialogHeader>
          <DialogTitle>Select Tags</DialogTitle>
        </DialogHeader>

        <div className="space-y-4">
          <div className="relative">
            <div
              className="min-h-[40px] w-full border border-slate-200 rounded-md px-3 py-2 focus-within:border-slate-300 transition-colors duration-200"
              onClick={() => inputRef.current?.focus()}
            >
              <div className="flex flex-wrap gap-1.5 items-center">
                {selectedTags.map((tag) => (
                  <Badge key={tag.id} variant="secondary" className="gap-1 h-6 px-2">
                    {tag.folder_name}
                    <X
                      className="h-3 w-3 cursor-pointer hover:text-destructive"
                      onClick={(e) => {
                        e.stopPropagation();
                        handleRemoveTag(tag);
                      }}
                    />
                  </Badge>
                ))}
                <input
                  ref={inputRef}
                  type="text"
                  value={inputValue}
                  onChange={(e) => {
                    setInputValue(e.target.value);
                    setShowDropdown(true);
                  }}
                  onFocus={() => setShowDropdown(true)}
                  className="flex-1 outline-none min-w-[120px] bg-transparent"
                  placeholder={selectedTags.length === 0 ? "Please select tags..." : ""}
                />
              </div>
            </div>
            {showDropdown && (
              <div
                ref={dropdownRef}
                className="absolute z-50 w-full mt-1 bg-white border rounded-md shadow-lg max-h-[200px] overflow-y-auto"
              >
                {filteredTags.length > 0 ? (
                  filteredTags.map((tag) => {
                    const isSelected = selectedTags.includes(tag);
                    return (
                      <div
                        key={tag.id}
                        className={`
                          flex items-center justify-between px-3 py-2 cursor-pointer
                          hover:bg-slate-50 text-sm
                          ${isSelected ? "text-blue-600" : ""}
                        `}
                        onClick={() => handleSelectTag(tag)}
                      >
                        <span>{tag.folder_name}</span>
                        {isSelected && <Check className="h-4 w-4 text-blue-600" />}
                      </div>
                    );
                  })
                ) : (
                  <div className="flex flex-col items-center justify-center py-6 text-sm text-slate-500">
                    <Command className="h-10 w-10 text-slate-300 mb-2" />
                    <p>No matching tags</p>
                    {inputValue && <p className="text-xs">Try other keywords</p>}
                  </div>
                )}
              </div>
            )}
          </div>

          <div className="flex justify-end gap-2">
            <Button variant="outline" onClick={() => onOpenChange(false)}>
              Cancel
            </Button>
            <Button onClick={handleSave} disabled={isSaving}>
              {isSaving ? (
                <div className="flex items-center gap-2">
                  <Loader2 className="w-4 h-4 animate-spin" />
                  Saving...
                </div>
              ) : (
                "Save"
              )}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}