fix(stat-card): only look interactive when it actually is #1
@@ -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
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user