src/features/reviews/full-search-controls.tsx
Linked repository file from this build.
"use client";
import { useEffect, useState } from "react";
import { Plus, SlidersHorizontal, Trash2 } from "lucide-react";
import {
FilterBuilder,
SearchFilterInputs,
FieldPicker,
actionClass,
committedFilters,
defaultFilter,
type SearchFilter,
} from "./filter-builder";
import { searchFiltersSchema, type SearchSort } from "./search-filters";
import { searchFields } from "./search-fields";
const PINNED_FIELDS = ["sampleMonth", "reviewNumber", "clientName"];
export function FullSearchControls({
query,
filters,
sorts,
onSearch,
autoFocus = true,
}: {
autoFocus?: boolean;
query: string;
filters: SearchFilter[];
sorts: SearchSort[];
onSearch(filters: SearchFilter[], sorts: SearchSort[]): void;
}) {
const fields = searchFields;
const [pinned, setPinned] = useState<SearchFilter[]>(() =>
PINNED_FIELDS.map(
(field) => filters.find((row) => row.field === field) ?? defaultFilter(field),
),
);
const [extra, setExtra] = useState<SearchFilter[]>(() => {
const seen = new Set<string>();
return filters.filter((row) => {
if (!PINNED_FIELDS.includes(row.field) || seen.has(row.field)) return true;
seen.add(row.field);
return false;
});
});
const [selectedSorts, setSelectedSorts] = useState(sorts);
const [error, setError] = useState("");
useEffect(() => {
if (autoFocus && window.location.hash === "#search-filters")
document.getElementById("search-filters")?.focus();
}, [autoFocus]);
function submit(nextSorts = selectedSorts) {
const result = searchFiltersSchema.safeParse(committedFilters([...pinned, ...extra]));
if (!result.success) {
setError("Check the filter values. Each range must start on or before its end.");
return;
}
setError("");
onSearch(result.data, nextSorts);
}
return (
<section
id="search-filters"
tabIndex={-1}
aria-label="Advanced search"
className="rounded-xl border bg-card p-4 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<div className="flex items-center gap-2">
<SlidersHorizontal className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold">Advanced search</h2>
</div>
<div className="grid gap-3 xl:grid-cols-[1.3fr_1fr_1fr]">
{pinned.map((filter, index) => (
<div
key={filter.field}
className="min-w-0 space-y-1.5"
role="group"
aria-label={`${fields.find((field) => field.key === filter.field)?.label ?? filter.field} filter`}
>
<h3 className="text-xs font-medium">
{fields.find((field) => field.key === filter.field)?.label ?? filter.field}
</h3>
<SearchFilterInputs
filter={filter}
fields={fields}
onChange={(value) =>
setPinned((rows) => rows.map((row, i) => (i === index ? value : row)))
}
/>
</div>
))}
</div>
<FilterBuilder
fields={fields}
filters={extra}
onChange={setExtra}
actions={
<button
type="button"
className={actionClass}
disabled={selectedSorts.length >= 10}
onClick={() => {
const field = fields.find(
(option) => !selectedSorts.some((sort) => sort.field === option.key),
);
if (field)
setSelectedSorts([...selectedSorts, { field: field.key, direction: "asc" }]);
}}
>
<Plus className="size-3" aria-hidden />
Add sort
</button>
}
/>
<div role="group" aria-label="Search sorting" className="space-y-2">
{!selectedSorts.length && (
<p className="text-xs text-muted-foreground">Sort: Relevance</p>
)}
{selectedSorts.map((sort, index) => (
<div key={index} className="flex items-center gap-2">
<span className="w-12 text-xs text-muted-foreground">{index ? "Then" : "Sort"}</span>
<div className="w-64 min-w-0">
<FieldPicker
value={sort.field}
fields={fields.filter(
(field) =>
field.key === sort.field ||
!selectedSorts.some((entry) => entry.field === field.key),
)}
label={`Sort ${index + 1} field`}
onChange={(field) =>
setSelectedSorts(
selectedSorts.map((entry, i) => (i === index ? { ...entry, field } : entry)),
)
}
/>
</div>
<button
type="button"
className={actionClass}
onClick={() =>
setSelectedSorts(
selectedSorts.map((entry, i) =>
i === index
? { ...entry, direction: entry.direction === "asc" ? "desc" : "asc" }
: entry,
),
)
}
>
{sort.direction === "asc" ? "Ascending" : "Descending"}
</button>
<button
type="button"
aria-label={`Remove sort ${index + 1}`}
className={actionClass}
onClick={() => setSelectedSorts(selectedSorts.filter((_, i) => i !== index))}
>
<Trash2 className="size-3" aria-hidden />
</button>
</div>
))}
</div>
{query && (
<p className="text-xs text-muted-foreground">
Also matching header search:{" "}
<span className="font-medium text-foreground">{query}</span>
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-3">
<button
type="button"
className={actionClass}
onClick={() => {
setSelectedSorts([]);
onSearch(filters, []);
}}
>
Clear sort
</button>
<button
type="button"
className={actionClass}
onClick={() => {
setPinned(PINNED_FIELDS.map((field) => defaultFilter(field)));
setExtra([]);
setError("");
onSearch([], selectedSorts);
}}
>
Clear filters
</button>
<button
type="submit"
className="inline-flex h-8 shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-transparent bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90"
>
Search reviews
</button>
</div>
</form>
</section>
);
}