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

130 lines
4.2 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { IoListOutline, IoArrowUpOutline, IoArrowDownOutline } from "react-icons/io5";
import { useClickOutside } from "@/hooks/useClickOutside";
import { IconButton } from "@/components/IconButton";
export type SortOption<T extends string = string> = {
value: T;
label: string;
iconDirection?: "asc" | "desc";
};
type Props<T extends string> = {
options: SortOption<T>[];
value: T;
onChange: (v: T) => void;
ariaLabel: string;
className?: string;
menuClassName?: string;
};
export function SortDropdown<T extends string>({
options,
value,
onChange,
ariaLabel,
className,
menuClassName,
}: Props<T>) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const active = options.find((o) => o.value === value) ?? options[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]);
return (
<div className={`relative ${className ?? ""}`}>
<IconButton
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
className="text-lg"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
>
<IoListOutline />
</IconButton>
<AnimatePresence>
{open ? (
<motion.div
ref={menuRef}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
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-40 overflow-hidden rounded-2xl bg-lightbg shadow-lg ring-1 ring-lightline transition-colors duration-300 ease-in-out hover:ring-lightline-hover dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
}
>
<div className="flex flex-col">
{options.map((opt, idx) => {
const selected = opt.value === active.value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={[
"relative w-full p-4 text-left text-sm",
"transition-colors duration-300 ease-in-out",
selected
? "bg-lightline font-medium text-current dark:bg-darkline"
: "text-lightsec dark:text-darksec",
"hover:bg-lightline dark:hover:bg-darkline",
idx === 0 && "rounded-t-2xl",
idx === options.length - 1 && "rounded-b-2xl",
]
.filter(Boolean)
.join(" ")}
>
<span className="flex items-center justify-between gap-3">
<span>{opt.label}</span>
{opt.iconDirection ? (
<span className="shrink-0 text-base opacity-80">
{opt.iconDirection === "asc" ? (
<IoArrowUpOutline aria-hidden="true" />
) : (
<IoArrowDownOutline aria-hidden="true" />
)}
</span>
) : null}
</span>
</button>
);
})}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}