Retour au catalogue

Search Faceted Filters

Interface de recherche a facettes avec filtres dynamiques, compteurs et tags selectionnables.

searchcomplex Both Responsive a11y
minimalcorporateecommercesaassplit
Theme
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
  Search,
  SlidersHorizontal,
  X,
  ChevronDown,
  Check,
} from "lucide-react";

interface FacetOption {
  label: string;
  count: number;
}

interface Facet {
  label: string;
  key: string;
  options: FacetOption[];
}

interface ResultItem {
  title: string;
  description: string;
  tags: string[];
}

interface SearchFacetedFiltersProps {
  facets?: Facet[];
  results?: ResultItem[];
  placeholder?: string;
  title?: string;
}

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

export default function SearchFacetedFilters({
  facets = [],
  results = [],
  placeholder = "Rechercher...",
  title = "Explorer le catalogue",
}: SearchFacetedFiltersProps) {
  const [query, setQuery] = useState("");
  const [activeFilters, setActiveFilters] = useState<Record<string, Set<string>>>({});
  const [expandedFacets, setExpandedFacets] = useState<Set<string>>(
    new Set(facets.map((f) => f.key))
  );

  const toggleFilter = (facetKey: string, optionLabel: string) => {
    setActiveFilters((prev) => {
      const next = { ...prev };
      if (!next[facetKey]) next[facetKey] = new Set();
      const set = new Set(next[facetKey]);
      if (set.has(optionLabel)) set.delete(optionLabel);
      else set.add(optionLabel);
      next[facetKey] = set;
      return next;
    });
  };

  const toggleFacet = (key: string) => {
    setExpandedFacets((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });
  };

  const totalActive = Object.values(activeFilters).reduce(
    (sum, set) => sum + set.size,
    0
  );

  const clearAll = () => setActiveFilters({});

  const filteredResults = results.filter((r) =>
    query ? r.title.toLowerCase().includes(query.toLowerCase()) : true
  );

  return (
    <section className="py-16 px-6">
      <div className="max-w-6xl mx-auto">
        <motion.h2
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease }}
          className="text-3xl font-bold mb-8"
          style={{ color: "var(--color-foreground)" }}
        >
          {title}
        </motion.h2>

        <div className="flex gap-8 flex-col lg:flex-row">
          {/* Sidebar filters */}
          <motion.aside
            initial={{ opacity: 0, x: -20 }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ duration: 0.4, delay: 0.1, ease }}
            className="w-full lg:w-72 shrink-0"
          >
            <div
              className="flex items-center justify-between mb-4 pb-3"
              style={{ borderBottom: "1px solid var(--color-border)" }}
            >
              <div className="flex items-center gap-2">
                <SlidersHorizontal
                  size={16}
                  style={{ color: "var(--color-accent)" }}
                />
                <span
                  className="text-sm font-semibold"
                  style={{ color: "var(--color-foreground)" }}
                >
                  Filtres
                </span>
                {totalActive > 0 && (
                  <span
                    className="text-xs px-2 py-0.5 rounded-full font-medium"
                    style={{
                      background: "var(--color-accent)",
                      color: "var(--color-background)",
                    }}
                  >
                    {totalActive}
                  </span>
                )}
              </div>
              {totalActive > 0 && (
                <button
                  onClick={clearAll}
                  className="text-xs underline"
                  style={{ color: "var(--color-accent)" }}
                >
                  Tout effacer
                </button>
              )}
            </div>

            <div className="flex flex-col gap-1">
              {facets.map((facet) => {
                const isExpanded = expandedFacets.has(facet.key);
                return (
                  <div key={facet.key} className="mb-2">
                    <button
                      onClick={() => toggleFacet(facet.key)}
                      className="flex items-center justify-between w-full py-2 text-sm font-medium"
                      style={{ color: "var(--color-foreground)" }}
                    >
                      {facet.label}
                      <motion.div
                        animate={{ rotate: isExpanded ? 180 : 0 }}
                        transition={{ duration: 0.2 }}
                      >
                        <ChevronDown
                          size={14}
                          style={{ color: "var(--color-foreground-light)" }}
                        />
                      </motion.div>
                    </button>
                    <AnimatePresence initial={false}>
                      {isExpanded && (
                        <motion.div
                          initial={{ height: 0, opacity: 0 }}
                          animate={{ height: "auto", opacity: 1 }}
                          exit={{ height: 0, opacity: 0 }}
                          transition={{ duration: 0.25, ease }}
                          className="overflow-hidden"
                        >
                          <div className="flex flex-col gap-1 pb-2">
                            {facet.options.map((opt) => {
                              const isActive =
                                activeFilters[facet.key]?.has(opt.label) ?? false;
                              return (
                                <button
                                  key={opt.label}
                                  onClick={() =>
                                    toggleFilter(facet.key, opt.label)
                                  }
                                  className="flex items-center gap-2.5 py-1.5 px-2 rounded-lg text-sm transition-all"
                                  style={{
                                    background: isActive
                                      ? "var(--color-accent-subtle)"
                                      : "transparent",
                                    color: isActive
                                      ? "var(--color-foreground)"
                                      : "var(--color-foreground-muted)",
                                  }}
                                >
                                  <div
                                    className="w-4 h-4 rounded flex items-center justify-center"
                                    style={{
                                      border: isActive
                                        ? "none"
                                        : "1.5px solid var(--color-border)",
                                      background: isActive
                                        ? "var(--color-accent)"
                                        : "transparent",
                                    }}
                                  >
                                    {isActive && (
                                      <Check
                                        size={12}
                                        style={{
                                          color: "var(--color-background)",
                                        }}
                                      />
                                    )}
                                  </div>
                                  <span className="flex-1 text-left">
                                    {opt.label}
                                  </span>
                                  <span
                                    className="text-xs"
                                    style={{
                                      color: "var(--color-foreground-light)",
                                    }}
                                  >
                                    {opt.count}
                                  </span>
                                </button>
                              );
                            })}
                          </div>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </div>
                );
              })}
            </div>
          </motion.aside>

          {/* Main content */}
          <div className="flex-1">
            {/* Search bar */}
            <motion.div
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.3, delay: 0.2, ease }}
              className="flex items-center gap-3 px-4 py-3 rounded-xl mb-6"
              style={{
                background: "var(--color-background-card)",
                border: "1px solid var(--color-border)",
              }}
            >
              <Search
                size={18}
                style={{ color: "var(--color-foreground-light)" }}
              />
              <input
                type="text"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder={placeholder}
                className="flex-1 bg-transparent text-sm outline-none"
                style={{ color: "var(--color-foreground)" }}
              />
              {query && (
                <button onClick={() => setQuery("")}>
                  <X
                    size={16}
                    style={{ color: "var(--color-foreground-muted)" }}
                  />
                </button>
              )}
            </motion.div>

            {/* Active filters chips */}
            <AnimatePresence>
              {totalActive > 0 && (
                <motion.div
                  initial={{ opacity: 0, height: 0 }}
                  animate={{ opacity: 1, height: "auto" }}
                  exit={{ opacity: 0, height: 0 }}
                  className="flex flex-wrap gap-2 mb-6"
                >
                  {Object.entries(activeFilters).map(([key, vals]) =>
                    Array.from(vals).map((val) => (
                      <motion.button
                        key={`${key}-${val}`}
                        initial={{ scale: 0 }}
                        animate={{ scale: 1 }}
                        exit={{ scale: 0 }}
                        onClick={() => toggleFilter(key, val)}
                        className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium"
                        style={{
                          background: "var(--color-accent-subtle)",
                          color: "var(--color-foreground)",
                        }}
                      >
                        {val}
                        <X size={12} />
                      </motion.button>
                    ))
                  )}
                </motion.div>
              )}
            </AnimatePresence>

            {/* Results */}
            <div className="flex flex-col gap-3">
              {filteredResults.map((item, i) => (
                <motion.div
                  key={item.title}
                  initial={{ opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.3, delay: i * 0.05, ease }}
                  className="p-4 rounded-xl transition-all cursor-pointer"
                  style={{
                    background: "var(--color-background-card)",
                    border: "1px solid var(--color-border)",
                  }}
                  whileHover={{
                    y: -2,
                    boxShadow: "0 8px 24px -8px rgba(0,0,0,0.15)",
                  }}
                >
                  <h3
                    className="font-semibold text-sm mb-1"
                    style={{ color: "var(--color-foreground)" }}
                  >
                    {item.title}
                  </h3>
                  <p
                    className="text-xs mb-3"
                    style={{ color: "var(--color-foreground-muted)" }}
                  >
                    {item.description}
                  </p>
                  <div className="flex flex-wrap gap-1.5">
                    {item.tags.map((tag) => (
                      <span
                        key={tag}
                        className="text-[10px] px-2 py-0.5 rounded-full"
                        style={{
                          background: "var(--color-background-alt)",
                          color: "var(--color-foreground-light)",
                        }}
                      >
                        {tag}
                      </span>
                    ))}
                  </div>
                </motion.div>
              ))}
            </div>

            <div
              className="mt-6 text-center text-xs"
              style={{ color: "var(--color-foreground-light)" }}
            >
              {filteredResults.length} resultats affiches
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Avis

Search Faceted Filters — React Search Section — Incubator