Todos os componentes

Searchable Dropdown

Searchable Dropdown é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT forms

O que é

Searchable Dropdown é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion lucide-react

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Searchable Dropdown

index.tsx
"use client";

import { ChevronDown, Search, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";

const ROTATION_ANGLE_OPEN = 180;

export interface SearchableDropdownItem {
  description?: string;
  icon?: React.ReactNode;
  id: string | number;
  label: string;
}

export interface SearchableDropdownProps {
  className?: string;
  emptyMessage?: string;
  items: SearchableDropdownItem[];
  label: string;
  onChange?: (item: SearchableDropdownItem) => void;
  placeholder?: string;
}

export default function SearchableDropdown({
  label,
  items,
  onChange,
  placeholder = "Search...",
  emptyMessage = "No results found",
  className = "",
}: SearchableDropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] =
    useState<SearchableDropdownItem | null>(null);
  const [searchQuery, setSearchQuery] = useState("");
  const dropdownRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const portalRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });
  const shouldReduceMotion = useReducedMotion();

  const filteredItems = useMemo(() => {
    const trimmedQuery = searchQuery.trim();
    if (!trimmedQuery) {
      return items;
    }

    // Cache lowercase query to avoid repeated calls
    const query = trimmedQuery.toLowerCase();
    const itemsLength = items.length;
    const results: typeof items = [];

    // Early exit optimization: use for loop instead of filter for better performance
    for (let i = 0; i < itemsLength; i++) {
      const item = items[i];
      const itemLabel = item.label.toLowerCase();
      const description = item.description?.toLowerCase();

      if (itemLabel.includes(query) || description?.includes(query)) {
        results.push(item);
      }
    }

    return results;
  }, [items, searchQuery]);

  const handleItemSelect = (item: SearchableDropdownItem) => {
    setSelectedItem(item);
    setIsOpen(false);
    setSearchQuery("");
    onChange?.(item);
  };

  const handleClearSearch = () => {
    setSearchQuery("");
    inputRef.current?.focus();
  };

  const handleToggle = () => {
    if (!isOpen && buttonRef.current) {
      const rect = buttonRef.current.getBoundingClientRect();
      setPosition({
        left: rect.left,
        top: rect.bottom + 4,
        width: rect.width,
      });
    }
    setIsOpen(!isOpen);
    if (isOpen) {
      setSearchQuery("");
    } else {
      setTimeout(() => inputRef.current?.focus(), 100);
    }
  };

  // Update position on scroll/resize when open
  useEffect(() => {
    if (!(isOpen && buttonRef.current)) {
      return;
    }

    const updatePosition = () => {
      if (buttonRef.current) {
        const rect = buttonRef.current.getBoundingClientRect();
        setPosition({
          left: rect.left,
          top: rect.bottom + 4,
          width: rect.width,
        });
      }
    };

    window.addEventListener("scroll", updatePosition, true);
    window.addEventListener("resize", updatePosition);

    return () => {
      window.removeEventListener("scroll", updatePosition, true);
      window.removeEventListener("resize", updatePosition);
    };
  }, [isOpen]);

  // Close dropdown when clicking outside
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      const target = event.target as Node;
      if (
        isOpen &&
        dropdownRef.current &&
        !dropdownRef.current.contains(target) &&
        portalRef.current &&
        !portalRef.current.contains(target)
      ) {
        setIsOpen(false);
        setSearchQuery("");
      }
    };

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

  // Keyboard navigation with arrow keys, enter, and escape
  const [focusedIndex, setFocusedIndex] = useState(-1);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (!isOpen) {
        // Open dropdown on Enter or Space when button is focused
        if (
          (event.key === "Enter" || event.key === " ") &&
          document.activeElement === buttonRef.current
        ) {
          event.preventDefault();
          handleToggle();
        }
        return;
      }

      if (event.key === "Escape") {
        setIsOpen(false);
        setSearchQuery("");
        setFocusedIndex(-1);
        buttonRef.current?.focus();
      } else if (event.key === "ArrowDown") {
        event.preventDefault();
        setFocusedIndex((prev) =>
          prev < filteredItems.length - 1 ? prev + 1 : 0
        );
      } else if (event.key === "ArrowUp") {
        event.preventDefault();
        setFocusedIndex((prev) =>
          prev > 0 ? prev - 1 : filteredItems.length - 1
        );
      } else if (event.key === "Enter" && focusedIndex >= 0) {
        event.preventDefault();
        const item = filteredItems[focusedIndex];
        if (item) {
          handleItemSelect(item);
        }
      } else if (event.key === "Home") {
        event.preventDefault();
        setFocusedIndex(0);
      } else if (event.key === "End") {
        event.preventDefault();
        setFocusedIndex(filteredItems.length - 1);
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
    // biome-ignore lint/correctness/useExhaustiveDependencies: Handlers are stable via closure
  }, [isOpen, filteredItems, focusedIndex, handleItemSelect, handleToggle]);

  // Reset focused index when items change
  useEffect(() => {
    setFocusedIndex(-1);
  }, []);

  const dropdownContent = (
    <AnimatePresence>
      {isOpen ? (
        <div ref={portalRef}>
          <motion.div
            animate={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 1, scaleY: 1, y: 0 }
            }
            className="fixed z-50 origin-top overflow-hidden rounded-lg border bg-background/95 shadow-lg backdrop-blur-md"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : {
                    opacity: 0,
                    scaleY: 0.8,
                    transition: { duration: 0.15 },
                    y: -10,
                  }
            }
            initial={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 0, scaleY: 0.8, y: -10 }
            }
            style={{
              left: `${position.left}px`,
              top: `${position.top}px`,
              width: `${position.width}px`,
            }}
            transition={
              shouldReduceMotion
                ? { duration: 0 }
                : {
                    damping: 30,
                    duration: 0.25,
                    mass: 0.8,
                    stiffness: 400,
                    type: "spring" as const,
                  }
            }
          >
            {/* Search Input */}
            <div className="relative border-b p-2">
              <motion.div
                animate={
                  shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }
                }
                className="relative"
                initial={
                  shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -10 }
                }
                transition={
                  shouldReduceMotion
                    ? { duration: 0 }
                    : {
                        damping: 25,
                        delay: 0.05,
                        duration: 0.2,
                        stiffness: 400,
                        type: "spring" as const,
                      }
                }
              >
                <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
                <input
                  aria-autocomplete="list"
                  aria-controls="dropdown-items"
                  aria-expanded={isOpen}
                  aria-label="Search dropdown items"
                  className="w-full rounded-md border bg-transparent py-2 pr-8 pl-9 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                  onChange={(e) => {
                    setSearchQuery(e.target.value);
                    setFocusedIndex(-1);
                  }}
                  placeholder={placeholder}
                  ref={inputRef}
                  role="combobox"
                  type="text"
                  value={searchQuery}
                />
                <AnimatePresence>
                  {searchQuery ? (
                    <motion.button
                      animate={{ opacity: 1 }}
                      aria-label="Clear search"
                      className="absolute top-1/2 right-2 min-h-[44px] min-w-[44px] -translate-y-1/2 rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                      exit={{ opacity: 0 }}
                      initial={{ opacity: 0 }}
                      onClick={handleClearSearch}
                      transition={{
                        damping: 25,
                        stiffness: 400,
                        type: "spring" as const,
                      }}
                      type="button"
                    >
                      <X aria-hidden="true" className="h-4 w-4" />
                    </motion.button>
                  ) : null}
                </AnimatePresence>
              </motion.div>
            </div>

            {/* Items List */}
            <ul
              aria-label="Dropdown options"
              className="max-h-60 overflow-y-auto py-2"
              id="dropdown-items"
            >
              <AnimatePresence mode="popLayout">
                {filteredItems.length > 0 ? (
                  filteredItems.map((item, index) => (
                    <motion.li
                      animate={
                        shouldReduceMotion
                          ? { opacity: 1 }
                          : { filter: "blur(0px)", opacity: 1, x: 0 }
                      }
                      aria-selected={
                        selectedItem?.id === item.id || index === focusedIndex
                      }
                      className="block"
                      exit={
                        shouldReduceMotion
                          ? { opacity: 0, transition: { duration: 0 } }
                          : { filter: "blur(4px)", opacity: 0, x: -10 }
                      }
                      initial={
                        shouldReduceMotion
                          ? { opacity: 1 }
                          : { filter: "blur(4px)", opacity: 0, x: -10 }
                      }
                      key={item.id}
                      layout
                      role="option"
                      transition={
                        shouldReduceMotion
                          ? { duration: 0 }
                          : {
                              damping: 28,
                              delay: index * 0.02,
                              duration: 0.2,
                              mass: 0.6,
                              stiffness: 400,
                              type: "spring" as const,
                            }
                      }
                    >
                      <button
                        aria-label={`${item.label}${item.description ? `, ${item.description}` : ""}`}
                        className={`flex min-h-[44px] w-full items-center px-4 py-2 text-left text-sm transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
                          selectedItem?.id === item.id
                            ? "font-medium text-brand"
                            : ""
                        } ${index === focusedIndex ? "bg-muted" : ""}`}
                        onClick={() => handleItemSelect(item)}
                        onMouseEnter={() => setFocusedIndex(index)}
                        type="button"
                      >
                        {item.icon ? (
                          <span className="mr-3 shrink-0">{item.icon}</span>
                        ) : null}
                        <div className="min-w-0 flex-1">
                          <span className="block truncate">{item.label}</span>
                          {item.description ? (
                            <span className="block truncate text-muted-foreground text-xs">
                              {item.description}
                            </span>
                          ) : null}
                        </div>

                        {selectedItem?.id === item.id && (
                          <motion.span
                            animate={shouldReduceMotion ? {} : { scale: 1 }}
                            className="ml-2 shrink-0"
                            initial={shouldReduceMotion ? {} : { scale: 0 }}
                            transition={
                              shouldReduceMotion
                                ? { duration: 0 }
                                : {
                                    damping: 25,
                                    duration: 0.2,
                                    mass: 0.5,
                                    stiffness: 400,
                                    type: "spring" as const,
                                  }
                            }
                          >
                            <svg
                              className="h-4 w-4 text-brand"
                              fill="none"
                              stroke="currentColor"
                              viewBox="0 0 24 24"
                            >
                              <title>Selected</title>
                              <path
                                d="M5 13l4 4L19 7"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                strokeWidth={2}
                              />
                            </svg>
                          </motion.span>
                        )}
                      </button>
                    </motion.li>
                  ))
                ) : (
                  <motion.li
                    animate={{ opacity: 1 }}
                    className="px-4 py-8 text-center text-muted-foreground text-sm"
                    initial={
                      shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }
                    }
                    transition={
                      shouldReduceMotion
                        ? { duration: 0 }
                        : {
                            damping: 25,
                            duration: 0.2,
                            stiffness: 400,
                            type: "spring" as const,
                          }
                    }
                  >
                    {emptyMessage}
                  </motion.li>
                )}
              </AnimatePresence>
            </ul>
          </motion.div>
        </div>
      ) : null}
    </AnimatePresence>
  );

  return (
    <>
      <div className={`relative inline-block ${className}`} ref={dropdownRef}>
        <button
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-label={selectedItem ? `${label}: ${selectedItem.label}` : label}
          className="flex min-h-[44px] w-full cursor-pointer items-center justify-between gap-2 rounded-lg border bg-background px-4 py-2 text-left transition-colors hover:bg-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
          id="dropdown-button"
          onClick={handleToggle}
          ref={buttonRef}
          type="button"
        >
          <span className="block truncate">
            {String(selectedItem ? selectedItem.label : label)}
          </span>
          <motion.div
            animate={{ rotate: isOpen ? ROTATION_ANGLE_OPEN : 0 }}
            transition={
              shouldReduceMotion
                ? { duration: 0 }
                : {
                    damping: 25,
                    duration: 0.2,
                    stiffness: 400,
                    type: "spring" as const,
                  }
            }
          >
            <ChevronDown className="h-4 w-4" />
          </motion.div>
        </button>
      </div>
      {typeof window !== "undefined" &&
        createPortal(dropdownContent, document.body)}
    </>
  );
}

Componentes parecidos