130 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|