diff --git a/bun.lock b/bun.lock index e58d5a6..464af56 100644 --- a/bun.lock +++ b/bun.lock @@ -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", }, }, diff --git a/src/stat-card/index.tsx b/src/stat-card/index.tsx index e9e2139..ce5bdfc 100644 --- a/src/stat-card/index.tsx +++ b/src/stat-card/index.tsx @@ -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 = ({ label, @@ -56,15 +66,33 @@ export const StatCard: FC = ({ icon: Icon, tone = "default", className, + onClick, }) => { const token = useAnimation("card"); + const interactive = onClick !== undefined; return ( { + 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, )} > diff --git a/src/stat-card/stat-card.stories.tsx b/src/stat-card/stat-card.stories.tsx index e00fb36..8230b89 100644 --- a/src/stat-card/stat-card.stories.tsx +++ b/src/stat-card/stat-card.stories.tsx @@ -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: () => ( +
+ + {}} + /> +
+ ), +}; + export const Grid: Story = { render: () => (