feat(web): add per-level search filter to category grid
Deep category levels are hard to scan — Elektrik has 62 children, Kaporta 58. Add a name filter input that appears when a level has more than 8 items and filters the current drill level live (Turkish-aware lowercasing for İ/ı), with a clear button and a "no match" message. Resets automatically when navigating between levels. Benefits the vehicle, catalog, and category sub-page flows since they all share CategoryGrid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Card, CardContent, Input } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { ChevronRight, Loader2, Search, X } from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
@@ -66,6 +66,22 @@ export function CategoryGrid({
|
||||
const current = stack[stack.length - 1];
|
||||
const currentCategories = current.categories;
|
||||
|
||||
// Filter the current drill level by name — deep levels (e.g. Elektrik has 62
|
||||
// children) are hard to scan otherwise. Turkish-aware lowercasing for İ/ı.
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
// Reset the filter whenever the visible level changes (drill in/out).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional — current is the level identity
|
||||
useEffect(() => {
|
||||
setFilter("");
|
||||
}, [current]);
|
||||
|
||||
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
|
||||
const visibleCategories = trimmedFilter
|
||||
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: currentCategories;
|
||||
const showFilter = currentCategories.length > 8;
|
||||
|
||||
// Prefetch schema images for leaf categories in batches of 2
|
||||
useEffect(() => {
|
||||
prefetchedRef.current.clear();
|
||||
@@ -195,33 +211,62 @@ export function CategoryGrid({
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{currentCategories.map((category) => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
const isSelectLoading = loadingId === category.id;
|
||||
|
||||
return (
|
||||
{showFilter && (
|
||||
<div className="relative mb-3 max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Kategori ara…"
|
||||
aria-label="Kategori ara"
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{filter && (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(category)}
|
||||
disabled={category.unavailable || isSelectLoading}
|
||||
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
|
||||
onClick={() => setFilter("")}
|
||||
aria-label="Aramayı temizle"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CategoryCard
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
/>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleCategories.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
"{filter}" ile eşleşen kategori yok.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{visibleCategories.map((category) => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
const isSelectLoading = loadingId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(category)}
|
||||
disabled={category.unavailable || isSelectLoading}
|
||||
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
|
||||
>
|
||||
<CategoryCard
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user