trptk/components/FilterDropdown.tsx
2026-02-24 17:14:07 +01:00

142 lines
5.3 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { IoFilterOutline } from "react-icons/io5";
import { useClickOutside } from "@/hooks/useClickOutside";
import { IconButton } from "@/components/IconButton";
export type FilterGroup = {
label: string;
param: string;
options: { value: string; label: string }[];
};
type Props = {
groups: FilterGroup[];
values: Record<string, string[]>;
onChange: (param: string, selected: string[]) => void;
menuClassName?: string;
};
export function FilterDropdown({ groups, values, onChange, menuClassName }: Props) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const activeCount = Object.values(values).reduce((sum, arr) => sum + arr.length, 0);
useClickOutside([buttonRef, menuRef], () => setOpen(false), open);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
const toggle = (param: string, value: string) => {
const current = values[param] ?? [];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
onChange(param, next);
};
return (
<div className="relative">
<IconButton
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
className="text-lg"
aria-haspopup="true"
aria-expanded={open}
aria-controls={menuId}
aria-label="Filter releases"
>
<span className="relative">
<IoFilterOutline />
{activeCount > 0 ? (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-trptkblue text-xs leading-none font-bold text-white dark:bg-white dark:text-lighttext">
{activeCount}
</span>
) : null}
</span>
</IconButton>
<AnimatePresence>
{open ? (
<motion.div
ref={menuRef}
id={menuId}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.25 }}
className={
menuClassName ??
"absolute right-0 z-20 mt-4 min-w-52 overflow-hidden rounded-2xl bg-lightbg p-4 shadow-lg ring-1 ring-lightline transition-[box-shadow,color,background-color] duration-300 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
}
>
{groups.map((group, gi) => (
<div key={group.param}>
{gi > 0 ? <div className="mt-4 border-t border-lightline-mid pt-4 dark:border-darkline-mid" /> : null}
<p className="mb-1 text-lightsec dark:text-darksec">{group.label}</p>
<div className="flex flex-col gap-2">
{group.options.map((opt) => {
const checked = (values[group.param] ?? []).includes(opt.value);
return (
<button
key={opt.value}
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggle(group.param, opt.value)}
className={[
"flex w-full items-center gap-3 text-left text-sm",
"transition-colors duration-200 ease-in-out",
checked ? "font-medium text-current" : "text-lightsec dark:text-darksec",
"hover:text-trptkblue dark:hover:text-white",
].join(" ")}
>
<span
className={[
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors duration-200",
checked
? "border-trptkblue bg-trptkblue text-darktext dark:border-white dark:bg-white dark:text-lighttext"
: "border-lightline-strong dark:border-darkline-strong",
].join(" ")}
>
{checked ? (
<svg
viewBox="0 0 12 12"
className="h-2.5 w-2.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 6l3 3 5-5" />
</svg>
) : null}
</span>
<span>{opt.label}</span>
</button>
);
})}
</div>
</div>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}