fix(stat-card): only look interactive when it actually is #1

Merged
enricobuehler merged 2 commits from worktree-statcard-affordance into main 2026-08-09 14:05:08 +00:00
3 changed files with 57 additions and 3 deletions
+4
View File
@@ -29,6 +29,10 @@
},
"peerDependencies": {
"@unom/style": "^0.4.4",
"@unom/ui": "^0.9.1",
"class-variance-authority": "^0.7.1",
"lucide-react": "^1.17.0",
"motion": "^12.40.0",
"react": "^19.0.0",
},
},
+31 -3
View File
@@ -43,11 +43,21 @@ export type StatCardProps = {
icon?: LucideIcon;
tone?: StatCardTone;
className?: string;
/**
* Makes the card a button — and only then does it lift on hover.
*
* A scale-on-hover is the web's plainest "you can click this" signal, so a card that lifts
* without doing anything is a promise the UI does not keep. This used to be unconditional
* while the component had no click handler at all, which meant every stat on every dashboard
* invited a click that did nothing.
*/
onClick?: () => void;
};
/**
* A single metric on translucent console chrome. Enters with the card
* animation token (staggered by the surrounding Section) and lifts on hover.
* A single metric on translucent console chrome. Enters with the card animation token (staggered
* by the surrounding Section). Interactive only when given an `onClick`, and it looks interactive
* only when it is.
*/
export const StatCard: FC<StatCardProps> = ({
label,
@@ -56,15 +66,33 @@ export const StatCard: FC<StatCardProps> = ({
icon: Icon,
tone = "default",
className,
onClick,
}) => {
const token = useAnimation("card");
const interactive = onClick !== undefined;
return (
<motion.div
variants={token.variants}
transition={token.transition}
whileHover={{ scale: 1.02 }}
whileHover={interactive ? { scale: 1.02 } : undefined}
whileTap={interactive ? { scale: 0.99 } : undefined}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={
interactive
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}
: undefined
}
className={cn(
"flex flex-col gap-1 rounded-card bg-neutral/40 p-padding-card ring-1 ring-accent/40",
interactive &&
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
+22
View File
@@ -29,6 +29,28 @@ export const Default: Story = {
},
};
/**
* Two cards side by side: the left one is inert, the right one has an `onClick`.
*
* Only the interactive one lifts, shows a pointer and takes focus. Hover both — if the static
* card moves, the component is promising a click it cannot deliver, which is the regression this
* story exists to catch.
*/
export const InteractiveVsStatic: Story = {
render: () => (
<div className="grid max-w-lg gap-4 sm:grid-cols-2">
<StatCard label="Static — no onClick" value={1284} icon={Gamepad2} />
<StatCard
label="Interactive — has onClick"
value={342}
hint="click me"
icon={Gauge}
onClick={() => {}}
/>
</div>
),
};
export const Grid: Story = {
render: () => (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">