All files / src/components/ui faceted-filter.tsx

100% Statements 28/28
96.15% Branches 25/26
100% Functions 12/12
100% Lines 24/24

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