-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add MetricCard component for dashboard KPIs #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3504299
1c939f7
9f11676
b0099e4
85eba63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| "@tailor-platform/app-shell": minor | ||
| --- | ||
|
|
||
| Add `MetricCard` component for dashboard KPI summaries (title, value, optional trend and description). | ||
|
|
||
| ```tsx | ||
| import { MetricCard } from "@tailor-platform/app-shell"; | ||
|
|
||
| <MetricCard | ||
| title="Net total payment" | ||
| value="$1,500.00" | ||
| trend={{ direction: "up", value: "+5%" }} | ||
| description="vs last month" | ||
| />; | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| --- | ||
| title: MetricCard | ||
| description: Compact card for dashboard KPI summaries with title, value, optional trend and description | ||
| --- | ||
|
|
||
| # MetricCard | ||
|
|
||
| `MetricCard` is a presentational card for displaying a single KPI (key performance indicator) on dashboards. It shows a small title, a prominent value, and optionally a trend indicator and supplementary description text. In v1 the component is static (no click handler or internal actions). | ||
|
|
||
| ## Import | ||
|
|
||
| ```tsx | ||
| import { MetricCard } from "@tailor-platform/app-shell"; | ||
| ``` | ||
|
|
||
| ## Basic Usage | ||
|
|
||
| ```tsx | ||
| <MetricCard | ||
| title="Net total payment" | ||
| value="$1,500.00" | ||
| trend={{ direction: "up", value: "+5%" }} | ||
| description="vs last month" | ||
| /> | ||
| ``` | ||
|
|
||
| ## Props | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | ------------- | ----------------------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | | ||
| | `title` | `string` | **Required** | Short title / header (e.g. "Net total", "Revenue") | | ||
| | `value` | `React.ReactNode` | **Required** | Main value (string, number, or custom content) | | ||
| | `trend` | `{ direction: "up" \| "down" \| "neutral"; value: string }` | - | Optional trend (e.g. "+12%", "-5%", "0%") | | ||
| | `description` | `string` | - | Optional supplementary text (e.g. "vs last month", "this week"). Empty strings are treated as absent and not rendered. | | ||
| | `icon` | `React.ReactNode` | - | Optional icon in the title row | | ||
| | `className` | `string` | - | Additional CSS classes for the card root | | ||
|
|
||
| ## Trend Directions | ||
|
|
||
| - **up** — Positive change (success styling, e.g. green). | ||
| - **down** — Negative change (destructive styling, e.g. red). | ||
| - **neutral** — No change or neutral (muted styling). | ||
|
|
||
| ```tsx | ||
| <MetricCard | ||
| title="Revenue" | ||
| value="$2,400" | ||
| trend={{ direction: "up", value: "+12%" }} | ||
| description="vs last month" | ||
| /> | ||
|
|
||
| <MetricCard | ||
| title="Costs" | ||
| value="$800" | ||
| trend={{ direction: "down", value: "-5%" }} | ||
| description="vs last quarter" | ||
| /> | ||
|
|
||
| <MetricCard | ||
| title="Balance" | ||
| value="$0" | ||
| trend={{ direction: "neutral", value: "0%" }} | ||
| /> | ||
| ``` | ||
|
|
||
| ## With Icon | ||
|
|
||
| ```tsx | ||
| <MetricCard title="Total orders" value="142" icon={<OrderIcon />} description="this week" /> | ||
| ``` | ||
|
|
||
| ## Related | ||
|
|
||
| - [Layout](./layout.md) — Page layout for placing MetricCards in a grid. | ||
| - [DescriptionCard](./description-card.md) — Structured key-value cards for detail views. | ||
| - [Badge](./badge.md) — Status badges that can complement metric displays. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { afterEach, describe, expect, it } from "vitest"; | ||
| import { cleanup, render, screen } from "@testing-library/react"; | ||
| import { MetricCard } from "./MetricCard"; | ||
|
|
||
| const MockIcon = () => <span data-testid="mock-icon">icon</span>; | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| }); | ||
|
|
||
| describe("MetricCard", () => { | ||
| it("renders required title and value", () => { | ||
| render(<MetricCard title="Net total" value="$1,500.00" />); | ||
| expect(screen.getByText("Net total")).toBeDefined(); | ||
| expect(screen.getByText("$1,500.00")).toBeDefined(); | ||
| }); | ||
|
|
||
| it("renders optional icon when provided", () => { | ||
| render(<MetricCard title="Total" value="$100" icon={<MockIcon />} />); | ||
| expect(screen.getByTestId("mock-icon")).toBeDefined(); | ||
| expect(screen.getByText("Total")).toBeDefined(); | ||
| expect(screen.getByText("$100")).toBeDefined(); | ||
| }); | ||
|
|
||
| it("renders trend up with value", () => { | ||
| render( | ||
| <MetricCard title="Revenue" value="$2,000" trend={{ direction: "up", value: "+12%" }} />, | ||
| ); | ||
| expect(screen.getByText("+12%")).toBeDefined(); | ||
| const trendEl = screen.getByText("+12%").closest("[data-direction]"); | ||
| expect(trendEl?.getAttribute("data-direction")).toBe("up"); | ||
| }); | ||
|
|
||
| it("renders trend down with value", () => { | ||
| render(<MetricCard title="Costs" value="$500" trend={{ direction: "down", value: "-5%" }} />); | ||
| expect(screen.getByText("-5%")).toBeDefined(); | ||
| const trendEl = screen.getByText("-5%").closest("[data-direction]"); | ||
| expect(trendEl?.getAttribute("data-direction")).toBe("down"); | ||
| }); | ||
|
|
||
| it("renders trend neutral with value", () => { | ||
| render(<MetricCard title="Balance" value="$0" trend={{ direction: "neutral", value: "0%" }} />); | ||
| expect(screen.getByText("0%")).toBeDefined(); | ||
| const trendEl = screen.getByText("0%").closest("[data-direction]"); | ||
| expect(trendEl?.getAttribute("data-direction")).toBe("neutral"); | ||
| }); | ||
|
|
||
| it("renders description text when provided", () => { | ||
| render(<MetricCard title="Sales" value="$3,000" description="vs last month" />); | ||
| expect(screen.getByText("vs last month")).toBeDefined(); | ||
| }); | ||
|
|
||
| it("does not render description when description is empty string", () => { | ||
| const { container } = render(<MetricCard title="Sales" value="$3,000" description="" />); | ||
| expect(container.querySelector('[data-slot="metric-card"]')?.children.length).toBe(2); | ||
| }); | ||
|
|
||
| it("renders trend and description together", () => { | ||
| render( | ||
| <MetricCard | ||
| title="Profit" | ||
| value="$1,200" | ||
| trend={{ direction: "up", value: "+8%" }} | ||
| description="vs last quarter" | ||
| />, | ||
| ); | ||
| expect(screen.getByText("+8%")).toBeDefined(); | ||
| expect(screen.getByText("vs last quarter")).toBeDefined(); | ||
| }); | ||
|
|
||
| it("does not render meta row when trend and description are absent", () => { | ||
| const { container } = render(<MetricCard title="Title" value="Value" />); | ||
| expect(container.querySelector('[data-slot="metric-card"]')?.children.length).toBe(2); | ||
| }); | ||
|
|
||
| it("accepts custom className", () => { | ||
| const { container } = render( | ||
| <MetricCard title="KPI" value="42" className="custom-metric-card" />, | ||
| ); | ||
| const card = container.firstChild as HTMLElement; | ||
| expect(card.className).toContain("custom-metric-card"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { cn } from "../../lib/utils"; | ||
| import type { MetricCardProps, MetricCardTrendDirection } from "./types"; | ||
|
|
||
| // ============================================================================ | ||
| // CONSTANTS | ||
| // ============================================================================ | ||
|
|
||
| const iconSlotClasses = | ||
| "astw:flex astw:size-4 astw:items-center astw:justify-center astw:shrink-0 astw:text-muted-foreground"; | ||
|
|
||
| const trendDirectionClasses: Record<MetricCardTrendDirection, string> = { | ||
| up: "astw:text-green-600 dark:astw:text-green-400", | ||
| down: "astw:text-red-600 dark:astw:text-red-400", | ||
| neutral: "astw:text-muted-foreground", | ||
| }; | ||
|
|
||
| // ============================================================================ | ||
| // METRIC CARD | ||
| // ============================================================================ | ||
|
|
||
| /** | ||
| * MetricCard — Compact card for dashboard KPI display (title, value, optional trend and description). | ||
| * | ||
| * Static display only in v1. Use for summary metrics (e.g. net total, discount total). | ||
| * Styling follows existing card conventions (bg-card, border, rounded-xl). | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * <MetricCard | ||
| * title="Net total payment" | ||
| * value="$1,500.00" | ||
| * trend={{ direction: "up", value: "+5%" }} | ||
| * description="vs last month" | ||
| * /> | ||
| * ``` | ||
| */ | ||
| export function MetricCard({ title, value, trend, description, icon, className }: MetricCardProps) { | ||
| const hasMeta = trend != null || (description != null && description !== ""); | ||
|
|
||
| return ( | ||
| <div | ||
| data-slot="metric-card" | ||
| className={cn( | ||
| "astw:min-w-0 astw:w-full astw:bg-card astw:text-card-foreground astw:rounded-xl astw:border astw:px-4 astw:py-4", | ||
| className, | ||
| )} | ||
| > | ||
| {/* Top row: optional icon + title */} | ||
| <div className="astw:flex astw:items-center astw:gap-2 astw:mb-6"> | ||
| {icon != null && ( | ||
| <span className={iconSlotClasses} aria-hidden> | ||
| {icon} | ||
| </span> | ||
| )} | ||
| <span className="astw:text-sm astw:font-medium astw:text-muted-foreground astw:min-w-0 astw:truncate"> | ||
| {title} | ||
| </span> | ||
| </div> | ||
|
|
||
| {/* Main value */} | ||
| <div className="astw:text-2xl astw:font-bold astw:tracking-tight astw:break-words"> | ||
| {value} | ||
| </div> | ||
|
|
||
| {/* Optional meta row: trend + description */} | ||
| {hasMeta && ( | ||
| <div className="astw:mt-2 astw:flex astw:items-center astw:gap-2 astw:flex-wrap"> | ||
| {trend != null && ( | ||
| <span | ||
| className={cn( | ||
| "astw:text-sm astw:font-medium", | ||
| trendDirectionClasses[trend.direction], | ||
| )} | ||
| data-direction={trend.direction} | ||
| > | ||
| {trend.value} | ||
| </span> | ||
| )} | ||
| {description != null && description !== "" && ( | ||
| <span className="astw:text-sm astw:text-muted-foreground astw:truncate"> | ||
| {description} | ||
| </span> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // ============================================================================ | ||
| // EXPORTS | ||
| // ============================================================================ | ||
|
|
||
| export default MetricCard; |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,2 @@ | ||||||||||||||
| export { MetricCard, default } from "./MetricCard"; | ||||||||||||||
| export type { MetricCardProps } from "./types"; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [1/2 — Low] These constructible sub-types are defined in
A consumer who wants to store or compute a trend value with an explicit type currently has no clean way to do it: // ❌ No exported type — deep imports are unsupported
import type { MetricCardTrend } from "`@tailor-platform/app-shell`/dist/...";
// ✅ Workaround (verbose)
type MetricCardTrend = NonNullable(MetricCardProps["trend"]);Suggested fix — add both types to the component barrel: export { MetricCard, default } from "./MetricCard";
-export type { MetricCardProps } from "./types";
+export type { MetricCardProps, MetricCardTrend, MetricCardTrendDirection } from "./types";And update the package root ( -export { MetricCard, type MetricCardProps } from "./components/metric-card";
+export { MetricCard, type MetricCardProps, type MetricCardTrend, type MetricCardTrendDirection } from "./components/metric-card";
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just by looking the API surface. I felt the API is better to be:
Changes:
labeltotitle... the currentlabelis more like header content, and that looks liketitlein this component. It aligns with similar components in other design systems (e.g., Ant Design's Statistic, MUI's card patterns).comparisontodescription... the currentcomparisonis also used withouttrend(e.g.comparison="this week"), which is not really a comparison but a supplementary text for the metric.descriptionbetter reflects that intent regardless of whethertrendis present or not.