All files / src/components/ui select.tsx

0% Statements 0/69
0% Branches 0/1
0% Functions 0/1
0% Lines 0/69

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                                                                                                                                                                                           
import * as React from "react";
import { ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
 
interface SelectOption<T extends string | number> {
	value: T;
	label: string;
}
 
interface SelectProps<T extends string | number> {
	value: T;
	onChange: (value: T) => void;
	options: SelectOption<T>[];
	placeholder?: string;
	className?: string;
	"aria-label"?: string;
}
 
export const Select = <T extends string | number>({
	value,
	onChange,
	options,
	placeholder,
	className,
	"aria-label": ariaLabel,
}: SelectProps<T>) => {
	const [open, setOpen] = React.useState(false);
	const ref = React.useRef<HTMLDivElement>(null);
 
	const selected = options.find((o) => o.value === value);
 
	React.useEffect(() => {
		const handler = (e: MouseEvent) => {
			if (ref.current && !ref.current.contains(e.target as Node)) {
				setOpen(false);
			}
		};
		if (open) document.addEventListener("mousedown", handler);
		return () => document.removeEventListener("mousedown", handler);
	}, [open]);
 
	return (
		<div ref={ref} className={cn("relative", className)}>
			<button
				type="button"
				aria-label={ariaLabel}
				aria-expanded={open}
				aria-haspopup="listbox"
				onClick={() => setOpen((o) => !o)}
				className="flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-secondary px-3 py-2.5 text-sm text-foreground transition-colors hover:bg-accent focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
			>
				<span className="truncate">{selected?.label ?? placeholder ?? "Select…"}</span>
				<ChevronDown
					className={cn(
						"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150",
						open && "rotate-180"
					)}
				/>
			</button>
 
			{open && (
				<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border border-border bg-popover shadow-lg animate-in fade-in-0 zoom-in-95">
					<ul
						role="listbox"
						aria-label={ariaLabel}
						className="py-1 max-h-48 overflow-auto"
					>
						{options.map((opt) => (
							<li
								key={opt.value}
								role="option"
								aria-selected={opt.value === value}
								onClick={() => {
									onChange(opt.value as T);
									setOpen(false);
								}}
								className="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
							>
								<Check
									className={cn(
										"h-3.5 w-3.5 shrink-0 text-primary",
										opt.value === value ? "opacity-100" : "opacity-0"
									)}
								/>
								<span className="truncate">{opt.label}</span>
							</li>
						))}
					</ul>
				</div>
			)}
		</div>
	);
};