๐Ÿ” Action Search Bar Component

Next
Docs
react-marketcomponent

Preview

Loading previewโ€ฆ
action-search-bar.tsx
"use client"
import { useState, useEffect, useRef } from "react"
import { Search, X, Command, Filter, ArrowRight } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { cn } from "@/lib/utils"

type SearchResult = {
  id: string
  title: string
  category: string
  description: string
}

type ActionSearchBarProps = {
  placeholder?: string
  onSearch?: (query: string) => void
  onResultClick?: (result: SearchResult) => void
  className?: string
  initialResults?: SearchResult[]
}

// Move sampleResults outside the component
const sampleResults: {
  id: string
  title: string
  category: string
  description: string
}[] = [
  {
    id: "1",
    title: "Create New Project",
    category: "Projects",
    description: "Start a new project with custom settings",
  },
  {
    id: "2",
    title: "Add Team Member",
    category: "Team",
    description: "Invite a new member to your team",
  },
  {
    id: "3",
    title: "Generate Report",
    category: "Analytics",
    description: "Create a detailed analytics report",
  },
  {
    id: "4",
    title: "Update Settings",
    category: "Settings",
    description: "Modify your account or project settings",
  },
  {
    id: "5",
    title: "Deploy to Production",
    category: "Deployment",
    description: "Push your changes to the production environment",
  },
  {
    id: "6",
    title: "Schedule Meeting",
    category: "Calendar",
    description: "Set up a new meeting with your team",
  },
]

export function ActionSearchBar({
  placeholder = "Search actions...",
  onSearch,
  onResultClick,
  className,
  initialResults = [],
}: ActionSearchBarProps) {
  const [query, setQuery] = useState("")
  const [isFocused, setIsFocused] = useState(false)
  const [results, setResults] = useState<SearchResult[]>(initialResults)
  const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)

  // Filter by category
  const filteredResults = selectedCategory ? results.filter((result) => result.category === selectedCategory) : results

  // Get unique categories
  const categories = Array.from(new Set(results.map((result) => result.category)))

  // Handle click outside to close
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
        setIsFocused(false)
      }
    }

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

  // Handle keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Cmd/Ctrl + K to focus search
      if ((e.metaKey || e.ctrlKey) && e.key === "k") {
        e.preventDefault()
        inputRef.current?.focus()
        setIsFocused(true)
      }

      // Escape to blur
      if (e.key === "Escape") {
        inputRef.current?.blur()
        setIsFocused(false)
      }
    }

    window.addEventListener("keydown", handleKeyDown)
    return () => {
      window.removeEventListener("keydown", handleKeyDown)
    }
  }, [])

  // Replace the useEffect for filtering results with this:
  useEffect(() => {
    if (query.trim() === "") {
      setResults(initialResults.length ? initialResults : sampleResults)
    } else {
      const filtered = sampleResults.filter(
        (result) =>
          result.title.toLowerCase().includes(query.toLowerCase()) ||
          result.description.toLowerCase().includes(query.toLowerCase()),
      )

      setResults(filtered)
    }

    if (onSearch) {
      onSearch(query)
    }
  }, [query, initialResults, onSearch])

  return (
    <div ref={containerRef} className={cn("relative w-full max-w-3xl mx-auto", className)}>
      {/* Search Input */}
      <div className="relative">
        <motion.div
          className={cn(
            "flex items-center rounded-xl border bg-white shadow-sm transition-all duration-300",
            isFocused ? "border-blue-300 shadow-md ring-2 ring-blue-100" : "border-gray-200 hover:border-gray-300",
          )}
          animate={{
            scale: isFocused ? 1.01 : 1,
          }}
          transition={{ type: "spring", stiffness: 400, damping: 25 }}
        >
          <div className="flex items-center px-3 py-2">
            <Search className="h-5 w-5 text-gray-400" />
          </div>
          <input
            ref={inputRef}
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onFocus={() => setIsFocused(true)}
            placeholder={placeholder}
            className="flex-1 border-0 bg-transparent py-3 px-2 text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0"
          />
          {query && (
            <button
              onClick={() => setQuery("")}
              className="mr-2 rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
            >
              <X className="h-4 w-4" />
            </button>
          )}
          <div className="hidden md:flex items-center border-l border-gray-200 pl-2 pr-3">
            <kbd className="hidden md:inline-flex h-5 select-none items-center gap-1 rounded border border-gray-200 bg-gray-50 px-1.5 font-mono text-xs text-gray-500">
              <span className="text-xs">โŒ˜</span>K
            </kbd>
          </div>
        </motion.div>
      </div>

      {/* Search Results */}
      <AnimatePresence>
        {isFocused && (
          <motion.div
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 10 }}
            transition={{ duration: 0.2 }}
            className="absolute mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg z-50"
          >
            {/* Categories */}
            <div className="flex items-center gap-2 p-3 overflow-x-auto scrollbar-hide">
              <button
                onClick={() => setSelectedCategory(null)}
                className={cn(
                  "flex items-center gap-1 rounded-full px-3 py-1 text-sm font-medium transition-colors",
                  selectedCategory === null
                    ? "bg-blue-100 text-blue-700"
                    : "bg-gray-100 text-gray-700 hover:bg-gray-200",
                )}
              >
                <Filter className="h-3 w-3" />
                All
              </button>
              {categories.map((category) => (
                <button
                  key={category}
                  onClick={() => setSelectedCategory(category === selectedCategory ? null : category)}
                  className={cn(
                    "rounded-full px-3 py-1 text-sm font-medium transition-colors whitespace-nowrap",
                    selectedCategory === category
                      ? "bg-blue-100 text-blue-700"
                      : "bg-gray-100 text-gray-700 hover:bg-gray-200",
                  )}
                >
                  {category}
                </button>
              ))}
            </div>

            <div className="max-h-[60vh] overflow-y-auto p-2">
              {filteredResults.length > 0 ? (
                <div className="grid gap-1">
                  {filteredResults.map((result) => (
                    <motion.button
                      key={result.id}
                      whileHover={{ scale: 1.01, backgroundColor: "rgba(243, 244, 246, 1)" }}
                      onClick={() => {
                        onResultClick?.(result)
                        setIsFocused(false)
                      }}
                      className="flex items-start gap-3 rounded-lg p-3 text-left transition-colors hover:bg-gray-100"
                    >
                      <div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600">
                        <Command className="h-4 w-4" />
                      </div>
                      <div className="flex-1">
                        <div className="flex items-center justify-between">
                          <h4 className="font-medium text-gray-900">{result.title}</h4>
                          <ArrowRight className="h-4 w-4 text-gray-400" />
                        </div>
                        <p className="text-sm text-gray-500">{result.description}</p>
                        <div className="mt-1">
                          <span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
                            {result.category}
                          </span>
                        </div>
                      </div>
                    </motion.button>
                  ))}
                </div>
              ) : (
                <div className="py-10 text-center">
                  <p className="text-gray-500">No results found</p>
                </div>
              )}
            </div>

            <div className="border-t border-gray-100 p-3">
              <div className="flex items-center justify-between text-xs text-gray-500">
                <span>
                  Press <kbd className="rounded border border-gray-200 bg-gray-50 px-1.5 font-mono">โ†‘</kbd>{" "}
                  <kbd className="rounded border border-gray-200 bg-gray-50 px-1.5 font-mono">โ†“</kbd> to navigate
                </span>
                <span>
                  Press <kbd className="rounded border border-gray-200 bg-gray-50 px-1.5 font-mono">Enter</kbd> to
                  select
                </span>
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}

Installation

npx shadcn@latest add @react-market/action-search-bar

Usage

import { ActionSearchBar } from "@/components/action-search-bar"
<ActionSearchBar />