Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/metriccard-component.md
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"
/>;
```
76 changes: 76 additions & 0 deletions docs/components/metric-card.md
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"
/>
```
Comment on lines +18 to +25
Copy link
Copy Markdown
Contributor

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:

<MetricCard
  title="Net total payment"
  value="$1,500.00"
  description="vs last month"
  trend={{ 
    direction: "up", 
    value: "+5%",
  }}
/>

Changes:

  • label to title ... the current label is more like header content, and that looks like title in this component. It aligns with similar components in other design systems (e.g., Ant Design's Statistic, MUI's card patterns).
  • comparison to description ... the current comparison is also used without trend (e.g. comparison="this week"), which is not really a comparison but a supplementary text for the metric. description better reflects that intent regardless of whether trend is present or not.


## 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.
64 changes: 64 additions & 0 deletions examples/app-module/src/custom-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Select,
Combobox,
Autocomplete,
MetricCard,
type Guard,
} from "@tailor-platform/app-shell";
import type { SVGProps } from "react";
Expand Down Expand Up @@ -159,6 +160,57 @@ const actionPanelDemoResource = defineResource({
component: ActionPanelDemoPage,
});

// ============================================================================
// DEMO: MetricCard (KPI row)
// ============================================================================

const MetricCardDemoPage = () => (
<Layout>
<Layout.Header title="MetricCard Demo" />
<Layout.Column>
<p className="astw:text-sm astw:text-muted-foreground astw:mb-4">
Dashboard KPI cards: title, value, optional trend and description.
</p>
<div className="astw:flex astw:flex-row astw:flex-wrap astw:gap-4">
<div className="astw:min-w-[200px] astw:flex-1">
<MetricCard
title="Net total"
value="$1,500.00"
trend={{ direction: "up", value: "+5%" }}
description="vs last month"
/>
</div>
<div className="astw:min-w-[200px] astw:flex-1">
<MetricCard
title="Discount total"
value="$120.00"
trend={{ direction: "down", value: "-2%" }}
description="vs last month"
/>
</div>
<div className="astw:min-w-[200px] astw:flex-1">
<MetricCard
title="Orders"
value="42"
trend={{ direction: "neutral", value: "0%" }}
description="this week"
icon={<ZapIcon style={{ width: 14, height: 14 }} />}
/>
</div>
<div className="astw:min-w-[200px] astw:flex-1">
<MetricCard title="Revenue (MTD)" value="$8,200" description="vs last month" />
</div>
</div>
</Layout.Column>
</Layout>
);

const metricCardDemoResource = defineResource({
path: "metric-card-demo",
meta: { title: "MetricCard Demo" },
component: MetricCardDemoPage,
});

// ============================================================================
// DEMO: Purchase Order Detail Page
// ============================================================================
Expand Down Expand Up @@ -1817,6 +1869,17 @@ export const customPageModule = defineModule({
View Action Panel Demo
</Link>
</p>
<p>
<Link
to="/custom-page/metric-card-demo"
style={{
color: "hsl(var(--primary))",
textDecoration: "underline",
}}
>
View MetricCard Demo (KPI cards)
</Link>
</p>
<p>
<Link
to="/custom-page/layout-1-column"
Expand Down Expand Up @@ -1908,6 +1971,7 @@ export const customPageModule = defineModule({
adminOnlyResource,
purchaseOrderDemoResource,
actionPanelDemoResource,
metricCardDemoResource,
oneColumnLayoutResource,
twoColumnLayoutResource,
threeColumnLayoutResource,
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/components/metric-card/MetricCard.test.tsx
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");
});
});
94 changes: 94 additions & 0 deletions packages/core/src/components/metric-card/MetricCard.tsx
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;
2 changes: 2 additions & 0 deletions packages/core/src/components/metric-card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { MetricCard, default } from "./MetricCard";
export type { MetricCardProps } from "./types";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[1/2 — Low] MetricCardTrend and MetricCardTrendDirection not exported from public API

These constructible sub-types are defined in types.ts and exported there, but they're never re-exported at the component barrel or the package root — inconsistent with the established pattern for other components:

Component Constructible sub-type Exported from barrel?
ActionPanel ActionItem
DescriptionCard FieldConfig, FieldDefinition, etc.
MetricCard MetricCardTrend

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 (packages/core/src/index.ts):

-export { MetricCard, type MetricCardProps } from "./components/metric-card";
+export { MetricCard, type MetricCardProps, type MetricCardTrend, type MetricCardTrendDirection } from "./components/metric-card";

Not blocking — consumers can work around it with NonNullable(MetricCardProps["trend"]) — but exporting the named type is more ergonomic and consistent with peers.

Loading
Loading