178 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|