fix(web): instant loading feedback on category node clicks (#73)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Drilling the category tree gave a 0–300 ms feedback gap between click and
the next render — no disabled state, no visible change — so users re-tapped
(one real session: 9 of 22 clicks were rage clicks, 158 s, zero results).

Add an immediate, prominent pending state on all three category views:

- grid: track the clicked card in `pendingId`; the button goes
  disabled + aria-busy and a 28px centred spinner overlays the card (both
  card variants) until the route swap unmounts the grid.
- tree: disable the expand button + aria-busy while children fetch; grow
  the chevron-slot spinner 14px → 20px and tint it primary.
- columns: disable the row + aria-busy while the child column lazy-fetches;
  same 20px primary spinner.

Tests: grid click paints disabled+aria-busy on the tapped card only;
tree/columns rows lock + show a spinner while the fetch is in flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:15:04 +03:00
parent e73b899e11
commit 6bcb7ac2a5
6 changed files with 144 additions and 5 deletions

View File

@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// handleSelect lazy-fetches children via queryClient.fetchQuery; a promise that
// never resolves keeps loadingId pinned so we can assert the loading state.
vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn() } }));
vi.mock("@/lib/category-icons", () => ({ getCategoryIcon: () => () => null }));
vi.mock("@sase/ui", () => ({
cn: (...a: unknown[]) => a.filter(Boolean).join(" "),
}));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ fetchQuery: () => new Promise(() => {}) }),
}));
import { CategoryColumns } from "../category-columns";
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());
describe("CategoryColumns selection loading state (#73)", () => {
it("disables the row and shows a spinner while the child column is being fetched", () => {
// children undefined → handleSelect takes the lazy-fetch branch.
render(<CategoryColumns vehicleId="v1" categories={[{ id: "c1", name: "Motor" }]} />);
const button = screen.getByRole("button", { name: /Motor/ });
expect(button.hasAttribute("disabled")).toBe(false);
expect(button.getAttribute("aria-busy")).toBe("false");
fireEvent.click(button);
expect(button.hasAttribute("disabled")).toBe(true);
expect(button.getAttribute("aria-busy")).toBe("true");
expect(button.querySelector(".animate-spin")).not.toBeNull();
});
});

View File

@@ -47,6 +47,35 @@ describe("CategoryGrid navigation", () => {
);
});
it("paints an immediate disabled + aria-busy pending state on the clicked card (#73)", () => {
render(
<CategoryGrid
vehicleId="v1"
categories={[
{ id: "c1", name: "Motor", parentId: null, children: [{ id: "c2", name: "Silindir" }] },
{ id: "c3", name: "Fren", parentId: null, children: [{ id: "c4", name: "Disk" }] },
]}
/>,
);
const clicked = screen.getByText("Motor").closest("button");
const other = screen.getByText("Fren").closest("button");
if (!clicked || !other) throw new Error("category buttons not found");
// Before the click neither card claims to be busy.
expect(clicked.getAttribute("aria-busy")).toBe("false");
expect(clicked.hasAttribute("disabled")).toBe(false);
fireEvent.click(clicked);
// The tapped card is immediately disabled + aria-busy so the user sees the
// tap registered (and cannot re-fire it); siblings stay interactive.
expect(clicked.hasAttribute("disabled")).toBe(true);
expect(clicked.getAttribute("aria-busy")).toBe("true");
expect(other.hasAttribute("disabled")).toBe(false);
expect(other.getAttribute("aria-busy")).toBe("false");
});
it("catalog mode: parent click routes to the catalog page, preserving variant + mgp, without seeding", () => {
render(
<CategoryGrid

View File

@@ -0,0 +1,40 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// handleExpand drives loading through queryClient.fetchQuery; hand it a promise
// that never resolves so the node stays in its loading state for assertions.
vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn() } }));
vi.mock("@/lib/category-icons", () => ({ getCategoryIcon: () => () => null }));
vi.mock("@sase/ui", () => ({
cn: (...a: unknown[]) => a.filter(Boolean).join(" "),
}));
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, ...p }: any) => <a {...p}>{children}</a>,
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ fetchQuery: () => new Promise(() => {}) }),
}));
import { CategoryTree } from "../category-tree";
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());
describe("CategoryTree node loading state (#73)", () => {
it("disables the node and shows a spinner while its children are being fetched", () => {
// children undefined → not yet fetched → renders an expandable button.
render(<CategoryTree vehicleId="v1" categories={[{ id: "c1", name: "Motor" }]} />);
const button = screen.getByRole("button", { name: /Motor/ });
expect(button.hasAttribute("disabled")).toBe(false);
expect(button.getAttribute("aria-busy")).toBe("false");
fireEvent.click(button);
// Lazy fetch is in flight (never resolves here) → node is locked + busy and
// an animated spinner is rendered in place of the chevron.
expect(button.hasAttribute("disabled")).toBe(true);
expect(button.getAttribute("aria-busy")).toBe("true");
expect(button.querySelector(".animate-spin")).not.toBeNull();
});
});

View File

@@ -225,6 +225,8 @@ function ColumnPanel({
<button
key={category.id}
type="button"
disabled={isLoading}
aria-busy={isLoading}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
"hover:bg-accent hover:text-accent-foreground",
@@ -244,7 +246,9 @@ function ColumnPanel({
)}
<span className="flex-1 truncate">{category.name}</span>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
// 20px primary spinner (was 14px muted) — clear feedback that the
// lazy child fetch is in flight; button is disabled meanwhile (#73).
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-primary" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}

View File

@@ -45,6 +45,11 @@ export function CategoryGrid({
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
// The card the user just clicked. Drilling is a route navigation, so there is
// a 0300 ms gap before the next page renders during which the card gave no
// feedback → users re-tapped (rage clicks, issue #73). Track it to show an
// immediate disabled + spinner-overlay state until this component unmounts.
const [pendingId, setPendingId] = useState<string | null>(null);
const prefetchedRef = useRef<Set<string>>(new Set());
// Filter the current level by name — deep levels (e.g. Elektrik has 62
@@ -128,6 +133,9 @@ export function CategoryGrid({
// ancestors) refetches in the background.
const navigateToCategory = useCallback(
(category: Category) => {
// Paint the pending state on the same tick as the click so feedback is
// instant; it clears naturally when the route swap unmounts the grid.
setPendingId(category.id);
if (!catalogMode && category.children && category.children.length > 0) {
queryClient.setQueryData(["category-parts", vehicleId, category.id], {
id: category.id,
@@ -195,12 +203,15 @@ export function CategoryGrid({
const isLeaf = category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
const isPending = pendingId === category.id;
return (
<button
key={category.id}
type="button"
onClick={() => navigateToCategory(category)}
disabled={category.unavailable}
disabled={category.unavailable || isPending}
aria-busy={isPending}
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
@@ -210,6 +221,7 @@ export function CategoryGrid({
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id)}
isPending={isPending}
/>
</button>
);
@@ -227,6 +239,7 @@ function CategoryCard({
schemaImageUrl,
isLeaf,
isLoading,
isPending,
}: {
name: string;
partCount?: number;
@@ -234,12 +247,23 @@ function CategoryCard({
schemaImageUrl?: string | null;
isLeaf?: boolean;
isLoading?: boolean;
isPending?: boolean;
}) {
const [imgLoaded, setImgLoaded] = useState(false);
// Click-pending overlay: a large (28px) centred spinner over a dimmed card,
// shown on either card variant. This is the primary "your tap registered"
// signal the previous design lacked (#73).
const pendingOverlay = isPending ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-background/60 backdrop-blur-[1px]">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
</div>
) : null;
if (schemaImageUrl || (isLeaf && isLoading)) {
return (
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
<Card className="relative border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
{pendingOverlay}
<CardContent className="p-0">
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
{(!schemaImageUrl || !imgLoaded) && (
@@ -268,7 +292,8 @@ function CategoryCard({
}
return (
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
<Card className="relative border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
{pendingOverlay}
<CardContent className="p-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors">

View File

@@ -173,7 +173,10 @@ function CategoryNode({
<>
<span aria-hidden className="flex h-5 w-5 shrink-0 items-center justify-center">
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
// Bigger (20px), primary-tinted spinner so the click feedback reads
// clearly under a thumb on mobile — the old 14px muted one was missed
// and drove re-taps (#73).
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : isLeaf ? null : (
<ChevronRight
className={cn(
@@ -222,6 +225,8 @@ function CategoryNode({
<button
type="button"
onClick={handleExpand}
disabled={loading}
aria-busy={loading}
aria-expanded={expanded}
className={rowClass}
style={indentStyle}