Retour au catalogue

Search Command Palette

Palette de commandes CMD+K avec recherche fuzzy, groupes, raccourcis clavier et navigation au clavier.

searchmedium Both Responsive a11y
minimalboldsaassaascentered
Theme
"use client";

import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
  Search,
  FileText,
  Settings,
  Users,
  BarChart3,
  Hash,
  ArrowRight,
  Command,
  CornerDownLeft,
} from "lucide-react";

interface CommandItem {
  label: string;
  icon: string;
  shortcut?: string;
  group: string;
  description?: string;
}

interface SearchCommandPaletteProps {
  commands?: CommandItem[];
  placeholder?: string;
}

const iconMap: Record<string, React.ElementType> = {
  FileText,
  Settings,
  Users,
  BarChart3,
  Hash,
};

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

export default function SearchCommandPalette({
  commands = [],
  placeholder = "Tapez une commande ou recherchez...",
}: SearchCommandPaletteProps) {
  const [query, setQuery] = useState("");
  const [selectedIndex, setSelectedIndex] = useState(0);
  const [isOpen, setIsOpen] = useState(true);
  const inputRef = useRef<HTMLInputElement>(null);

  const filtered =
    query.length > 0
      ? commands.filter(
          (c) =>
            c.label.toLowerCase().includes(query.toLowerCase()) ||
            c.description?.toLowerCase().includes(query.toLowerCase())
        )
      : commands;

  const groups = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
    if (!acc[cmd.group]) acc[cmd.group] = [];
    acc[cmd.group].push(cmd);
    return acc;
  }, {});

  useEffect(() => {
    setSelectedIndex(0);
  }, [query]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setSelectedIndex((prev) => Math.max(prev - 1, 0));
    } else if (e.key === "Escape") {
      setIsOpen(false);
    }
  };

  let flatIndex = -1;

  return (
    <div className="min-h-[500px] flex items-start justify-center pt-16 px-6">
      <AnimatePresence>
        {isOpen && (
          <>
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="fixed inset-0 z-40"
              style={{ background: "var(--color-background)", opacity: 0.6 }}
              onClick={() => setIsOpen(false)}
            />
            <motion.div
              initial={{ opacity: 0, scale: 0.95, y: -12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.95, y: -12 }}
              transition={{ duration: 0.2, ease }}
              className="relative z-50 w-full max-w-xl rounded-2xl overflow-hidden"
              style={{
                background: "var(--color-background-card)",
                border: "1px solid var(--color-border)",
                boxShadow: "0 25px 60px -12px rgba(0,0,0,0.3)",
              }}
            >
              {/* Hint bar */}
              <div
                className="flex items-center justify-between px-4 py-2 text-[11px]"
                style={{
                  borderBottom: "1px solid var(--color-border)",
                  color: "var(--color-foreground-muted)",
                }}
              >
                <div className="flex items-center gap-1.5">
                  <Command size={12} />
                  <span>K pour ouvrir</span>
                </div>
                <div className="flex items-center gap-3">
                  <span className="flex items-center gap-1">
                    <CornerDownLeft size={12} /> Selectionner
                  </span>
                  <span>ESC Fermer</span>
                </div>
              </div>

              {/* Search input */}
              <div
                className="flex items-center gap-3 px-5 py-4"
                style={{ borderBottom: "1px solid var(--color-border)" }}
              >
                <Search
                  size={20}
                  style={{ color: "var(--color-accent)" }}
                />
                <input
                  ref={inputRef}
                  type="text"
                  value={query}
                  onChange={(e) => setQuery(e.target.value)}
                  onKeyDown={handleKeyDown}
                  placeholder={placeholder}
                  autoFocus
                  className="flex-1 bg-transparent text-base outline-none"
                  style={{ color: "var(--color-foreground)" }}
                />
                {query && (
                  <motion.button
                    initial={{ scale: 0 }}
                    animate={{ scale: 1 }}
                    onClick={() => setQuery("")}
                    className="text-xs px-2 py-1 rounded-md"
                    style={{
                      background: "var(--color-background-alt)",
                      color: "var(--color-foreground-muted)",
                    }}
                  >
                    Effacer
                  </motion.button>
                )}
              </div>

              {/* Results */}
              <div className="max-h-80 overflow-y-auto py-2">
                {Object.entries(groups).map(([group, items]) => (
                  <div key={group} className="px-2">
                    <div
                      className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest"
                      style={{ color: "var(--color-foreground-light)" }}
                    >
                      {group}
                    </div>
                    {items.map((cmd) => {
                      flatIndex++;
                      const idx = flatIndex;
                      const Icon = iconMap[cmd.icon] || Hash;
                      const isSelected = idx === selectedIndex;

                      return (
                        <motion.button
                          key={cmd.label}
                          layout
                          className="flex items-center gap-3 w-full px-3 py-3 rounded-xl text-sm transition-all"
                          style={{
                            background: isSelected
                              ? "var(--color-accent-subtle)"
                              : "transparent",
                            color: isSelected
                              ? "var(--color-foreground)"
                              : "var(--color-foreground-muted)",
                          }}
                          onMouseEnter={() => setSelectedIndex(idx)}
                        >
                          <div
                            className="w-8 h-8 rounded-lg flex items-center justify-center"
                            style={{
                              background: isSelected
                                ? "var(--color-accent)"
                                : "var(--color-background-alt)",
                            }}
                          >
                            <Icon
                              size={16}
                              style={{
                                color: isSelected
                                  ? "var(--color-background)"
                                  : "var(--color-foreground-muted)",
                              }}
                            />
                          </div>
                          <div className="flex-1 text-left">
                            <div className="font-medium">{cmd.label}</div>
                            {cmd.description && (
                              <div
                                className="text-xs mt-0.5"
                                style={{ color: "var(--color-foreground-light)" }}
                              >
                                {cmd.description}
                              </div>
                            )}
                          </div>
                          {cmd.shortcut && (
                            <kbd
                              className="text-[10px] px-2 py-1 rounded-md font-mono"
                              style={{
                                background: "var(--color-background)",
                                border: "1px solid var(--color-border)",
                                color: "var(--color-foreground-light)",
                              }}
                            >
                              {cmd.shortcut}
                            </kbd>
                          )}
                          {isSelected && (
                            <ArrowRight
                              size={14}
                              style={{ color: "var(--color-accent)" }}
                            />
                          )}
                        </motion.button>
                      );
                    })}
                  </div>
                ))}

                {filtered.length === 0 && (
                  <div
                    className="py-12 text-center text-sm"
                    style={{ color: "var(--color-foreground-muted)" }}
                  >
                    Aucun resultat pour &ldquo;{query}&rdquo;
                  </div>
                )}
              </div>

              {/* Footer */}
              <div
                className="flex items-center justify-between px-4 py-2.5 text-[11px]"
                style={{
                  borderTop: "1px solid var(--color-border)",
                  color: "var(--color-foreground-light)",
                }}
              >
                <span>{filtered.length} resultats</span>
                <span>Recherche instantanee</span>
              </div>
            </motion.div>
          </>
        )}
      </AnimatePresence>

      {!isOpen && (
        <motion.button
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          onClick={() => setIsOpen(true)}
          className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm"
          style={{
            background: "var(--color-background-card)",
            border: "1px solid var(--color-border)",
            color: "var(--color-foreground-muted)",
          }}
        >
          <Search size={16} />
          <span>Rechercher...</span>
          <kbd
            className="text-[10px] px-1.5 py-0.5 rounded ml-8 font-mono"
            style={{
              background: "var(--color-background-alt)",
              border: "1px solid var(--color-border)",
            }}
          >
            ⌘K
          </kbd>
        </motion.button>
      )}
    </div>
  );
}

Reviews

Search Command Palette — React Search Section — Incubator