fix(category-tree): meet 44px touch target + single hit area per row

Addresses insight cmpifhxfs000lvze9cy6ne0tx (P1 ux_friction, rage click on
/dashboard/vehicles/<id>/categories/<id>).

Before: each row had two competing click targets — a 20px chevron button and a
separate text button — both calling the same expand handler. Row height was
~28px (py-1.5), well below the 44px mobile guideline. No active/press feedback
made it hard to tell whether a tap registered, triggering rage clicks.

After:
- Whole row is a single button (non-leaf) or Link (leaf). One hit area, no
  ambiguity about what gets the click.
- min-h-[44px], gap-3, px-3 py-2 — meets mobile guideline with breathing room.
- transition-colors + hover + active:bg-accent/80 give immediate tap feedback.
- focus-visible ring for keyboard nav, aria-expanded for screen readers.
- Chevron becomes a decorative span that rotates 90deg on expand instead of
  swapping icons (no extra button), preserving the loading spinner in place.

Phase 2/8 of the UX audit follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 20:46:22 +03:00
parent 3478144c87
commit ae20ca9676

View File

@@ -3,7 +3,7 @@ import { getCategoryIcon } from "@/lib/category-icons";
import { cn } from "@sase/ui";
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { ChevronRight, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface Category {
@@ -157,70 +157,78 @@ function CategoryNode({
const Icon = getCategoryIcon(category.name);
const isShimmering = parentPrefetching && isLeaf && !category.schemaImageUrl;
const rowClass = cn(
"flex w-full items-center gap-3 rounded-lg text-left text-sm",
"min-h-[44px] px-3 py-2",
"transition-colors duration-150",
"hover:bg-accent",
"active:bg-accent/80",
"focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
category.unavailable && "pointer-events-none opacity-40",
);
const indentStyle = { paddingLeft: `${level * 16 + 12}px` };
const rowInner = (
<>
<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" />
) : isLeaf ? null : (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
expanded && "rotate-90",
)}
/>
)}
</span>
<SchemaIcon
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
name={category.name}
shimmer={isShimmering}
/>
<span className="min-w-0 flex-1 truncate">{category.name}</span>
{category.partCount != null && category.partCount > 0 && (
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{category.partCount}
</span>
)}
</>
);
return (
<div>
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent",
category.unavailable && "opacity-40",
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{loading ? (
<span className="flex h-5 w-5 items-center justify-center">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</span>
) : isLeaf ? (
<span className="h-5 w-5" />
) : (
<button
type="button"
onClick={handleExpand}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
)}
<SchemaIcon
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
name={category.name}
shimmer={isShimmering}
/>
{isLeaf ? (
<Link
to={
catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"
}
params={
catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }
}
search={catalogMode && variantSearch ? variantSearch : undefined}
className="flex-1 truncate hover:underline"
>
{category.name}
</Link>
) : (
<button
type="button"
onClick={handleExpand}
className="flex-1 truncate text-left hover:underline"
>
{category.name}
</button>
)}
{category.partCount != null && category.partCount > 0 && (
<span className="text-xs text-muted-foreground">{category.partCount}</span>
)}
</div>
{isLeaf ? (
<Link
to={
catalogMode
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
: "/dashboard/vehicles/$id/categories/$categoryId"
}
params={
catalogMode
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
: { id: vehicleId, categoryId: category.id }
}
search={catalogMode && variantSearch ? variantSearch : undefined}
className={cn(rowClass, "no-underline")}
style={indentStyle}
>
{rowInner}
</Link>
) : (
<button
type="button"
onClick={handleExpand}
aria-expanded={expanded}
className={rowClass}
style={indentStyle}
>
{rowInner}
</button>
)}
{hasChildren && expanded && (
<div>
{children.map((child) => (