Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | 194x 194x 194x 169x 8x 1x 12x 12x 194x 5x 5x 2x 3x 5x 194x 1x 1x 582x 194x 13x 161x 42x 42x 5x | import { useState, useRef, useEffect } from "react";
import { Filter, Check, X } from "lucide-react";
import { cn } from "../../lib/utils";
export interface FacetedFilterOption {
value: string;
label: string;
icon?: React.ReactNode;
count?: number;
}
interface FacetedFilterProps {
title: string;
options: FacetedFilterOption[];
selected: Set<string>;
onChange: (selected: Set<string>) => void;
}
export function FacetedFilter({
title,
options,
selected,
onChange,
}: FacetedFilterProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
function handleMouseDown(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [isOpen]);
const toggleOption = (value: string) => {
const next = new Set(selected);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
onChange(next);
};
const clearAll = () => {
onChange(new Set());
setIsOpen(false);
};
const selectedOptions = options.filter((o) => selected.has(o.value));
return (
<div ref={containerRef} className="relative">
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-dashed border-border-default px-3 h-9 text-sm font-medium transition-colors hover:bg-background-muted",
selected.size > 0 && "border-solid border-primary-300 bg-primary-50 dark:bg-primary-950",
)}
>
<Filter className="h-4 w-4 text-foreground-subtle" />
<span className="hidden sm:inline">{title}</span>
{selectedOptions.length > 0 && (
<>
{/* Count badge on mobile */}
<span className="sm:hidden inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
{selectedOptions.length}
</span>
{/* Label badges on desktop */}
<span className="hidden sm:block mx-1 h-4 w-px bg-border-default" />
{selectedOptions.length <= 2 ? (
selectedOptions.map((opt) => (
<span
key={opt.value}
className="hidden sm:inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200"
>
{opt.label}
</span>
))
) : (
<span className="hidden sm:inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
{selectedOptions.length} selected
</span>
)}
</>
)}
</button>
{/* Popover */}
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-50 w-52 rounded-lg border border-border-default bg-background-elevated shadow-lg">
{/* Options list */}
<div className="p-1">
{options.map((option) => {
const isSelected = selected.has(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleOption(option.value)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background-muted transition-colors"
>
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border",
isSelected
? "border-primary-500 bg-primary-500 text-white"
: "border-border-strong",
)}
>
{isSelected && <Check className="h-3 w-3" />}
</span>
{option.icon && (
<span className="shrink-0 [&_svg]:h-4 [&_svg]:w-4 text-foreground-subtle">
{option.icon}
</span>
)}
<span className="flex-1 text-left text-foreground-default">
{option.label}
</span>
{option.count !== undefined && (
<span className="text-xs text-foreground-subtle tabular-nums">
{option.count}
</span>
)}
</button>
);
})}
</div>
{/* Clear action */}
{selected.size > 0 && (
<>
<div className="border-t border-border-default" />
<button
type="button"
onClick={clearAll}
className="flex w-full items-center justify-center gap-1.5 px-2 py-1.5 text-sm text-foreground-muted hover:text-foreground-default hover:bg-background-muted transition-colors rounded-b-lg"
>
<X className="h-3.5 w-3.5" />
Clear filters
</button>
</>
)}
</div>
)}
</div>
);
}
|