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

178 lines
5.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { IoCloseOutline } from "react-icons/io5";
import { useDebounced } from "@/hooks/useDebounced";
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
import { IconButton } from "@/components/IconButton";
const EMPTY_FILTERS: Record<string, string[]> = {};
type Props<T extends string> = {
initialQuery: string;
initialSort?: T;
initialPage?: number;
defaultSort: T;
placeholder: string;
sortOptions: SortOption<T>[];
sortAriaLabel: string;
sortMenuClassName?: string;
filterGroups?: FilterGroup[];
initialFilters?: Record<string, string[]>;
filterMenuClassName?: string;
};
export function SearchBar<T extends string>({
initialQuery,
initialSort,
initialPage = 1,
defaultSort,
placeholder,
sortOptions,
sortAriaLabel,
sortMenuClassName,
filterGroups,
initialFilters,
filterMenuClassName,
}: Props<T>) {
const sort0 = initialSort ?? defaultSort;
const filters0 = initialFilters ?? EMPTY_FILTERS;
const router = useRouter();
const pathname = usePathname();
const [q, setQ] = useState(initialQuery);
const debouncedQ = useDebounced(q, 250);
const [sort, setSort] = useState<T>(sort0);
const [filters, setFilters] = useState<Record<string, string[]>>(filters0);
const prevPropsRef = useRef({ initialQuery, initialSort: sort0, initialFilters: filters0 });
const lastPushedQRef = useRef(initialQuery);
useEffect(() => {
if (initialQuery !== lastPushedQRef.current) {
setQ(initialQuery);
}
lastPushedQRef.current = initialQuery;
}, [initialQuery]);
useEffect(() => {
setSort(sort0);
}, [sort0]);
useEffect(() => {
setFilters(filters0);
}, [filters0]);
const handleFilterChange = useCallback((param: string, selected: string[]) => {
setFilters((prev) => ({ ...prev, [param]: selected }));
}, []);
const filtersKey = useMemo(
() => JSON.stringify(filters, Object.keys(filters).sort()),
[filters],
);
const filters0Key = useMemo(
() => JSON.stringify(filters0, Object.keys(filters0).sort()),
[filters0],
);
const nextUrl = useMemo(() => {
const params = new URLSearchParams();
const normalizedQ = debouncedQ.trim();
const q0 = (initialQuery ?? "").trim();
const qChanged = normalizedQ !== q0;
const sortChanged = sort !== sort0;
const filtersChanged = filtersKey !== filters0Key;
if (normalizedQ) params.set("q", normalizedQ);
if (sort !== defaultSort) params.set("sort", sort);
for (const [param, selected] of Object.entries(filters)) {
if (selected.length > 0) params.set(param, selected.join(","));
}
if (!qChanged && !sortChanged && !filtersChanged && initialPage > 1) {
params.set("page", String(initialPage));
}
const qs = params.toString();
return qs ? `${pathname}?${qs}` : pathname;
}, [debouncedQ, initialQuery, initialPage, sort0, defaultSort, pathname, sort, filtersKey, filters0Key, filters]);
useEffect(() => {
const propsChanged =
prevPropsRef.current.initialQuery !== initialQuery ||
prevPropsRef.current.initialSort !== sort0 ||
JSON.stringify(prevPropsRef.current.initialFilters) !== JSON.stringify(filters0);
prevPropsRef.current = { initialQuery, initialSort: sort0, initialFilters: filters0 };
if (propsChanged) {
return;
}
const params = new URLSearchParams();
const q0 = (initialQuery ?? "").trim();
if (q0) params.set("q", q0);
if (sort0 && sort0 !== defaultSort) params.set("sort", sort0);
for (const [param, selected] of Object.entries(filters0)) {
if (selected.length > 0) params.set(param, selected.join(","));
}
if (initialPage > 1) params.set("page", String(initialPage));
const qs = params.toString();
const currentUrlFromProps = qs ? `${pathname}?${qs}` : pathname;
if (nextUrl !== currentUrlFromProps) {
lastPushedQRef.current = debouncedQ.trim();
router.replace(nextUrl, { scroll: false });
}
}, [nextUrl, debouncedQ, initialQuery, sort0, defaultSort, initialPage, pathname, router, filters0]);
return (
<div className="flex items-center gap-3">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={placeholder}
className="no-ring relative z-10 w-full rounded-xl border border-lightline bg-lightbg px-6 py-3 text-base text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:bg-darkbg dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
aria-label={placeholder}
/>
{q || Object.values(filters).some((arr) => arr.length > 0) ? (
<IconButton
onClick={() => {
setQ("");
setFilters(Object.fromEntries(Object.keys(filters).map((k) => [k, []])));
}}
className="text-lg"
aria-label="Clear search and filters"
>
<IoCloseOutline />
</IconButton>
) : null}
{filterGroups && filterGroups.length > 0 ? (
<FilterDropdown
groups={filterGroups}
values={filters}
onChange={handleFilterChange}
menuClassName={filterMenuClassName}
/>
) : null}
<SortDropdown
options={sortOptions}
value={sort}
onChange={setSort}
ariaLabel={sortAriaLabel}
menuClassName={sortMenuClassName}
/>
</div>
);
}