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>
);
}