142 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|