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:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user