"use client"
/**
* KeyMetrics — WCAG 2.1 AA reusable KPI panel
*
* Variants:
* "card" (default) — one Card wraps the whole KPI strip (hairline grid inside)
* "cards" — each KPI in its own Card tile (responsive grid); preferred for hubs
* "compact" — single Card, metrics only (no header)
* "flat" — grouped KPI strip (hairline cells, one band); same MetricItem
* features as cards (size, alert, chart on md/lg, click). Not for hubs —
* hubs use `cards`. Use flat when KPIs must read as one grouped strip.
*
* Mini charts: `sm` places the plot on the **right** of the value; `md` / `lg`
* stack a taller full-bleed chart under the value (no bottom card padding).
*
* AA checklist:
* ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
* ✓ `trend` matches signed change; `trendPolarity` flips sentiment when “up” is bad (see `docs/kpi-trend-pattern.md`)
* ✓ Trend icons have aria-hidden; chip `aria-label` uses `metricTrendAriaQualifier` (1.1.1)
* ✓ Select has accessible label via aria-label (4.1.2)
* ✓ Insight action button has descriptive text (4.1.2)
* ✓ Decorative dividers are aria-hidden (1.1.1)
* ✓ Contrast: value text foreground ≥ 17:1, trend colours ≥ 4.5:1 (1.4.3)
*/
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select"
import { Separator } from "./separator"
import { Button } from "./button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "./tooltip"
import { cn } from "../../lib/utils"
import { HorizontalScrollRegion } from "./horizontal-scroll-region"
import { useKeyMetricsContext } from "./key-metrics-context"
import { useCompactMetricsStrip } from "../../hooks/use-compact-metrics-strip"
const MetricMiniChart = React.lazy(() =>
import("./key-metrics-mini-chart").then(m => ({ default: m.MetricMiniChart })),
)
const KEY_METRICS_CARD_SURFACE_STYLE: React.CSSProperties = {
backgroundColor: "var(--card)",
}
export type KeyMetricsSize = "sm" | "md" | "lg"
/** Padding / type scale for card tiles and strip cells. */
function keyMetricsSizeClasses(size: KeyMetricsSize, layout: "strip" | "cardTile"): {
cardPadding?: string
/** Top + horizontal padding when a full-bleed bottom chart is present (no bottom pad). */
cardPaddingWithChart?: string
/** Negative horizontal margin so the chart spans edge-to-edge under the padding. */
chartBleed?: string
stackGap?: string
label: string
value?: string
valueRow?: string
valueDefault?: string
valueHero?: string
labelMinH?: string
stripPadding?: string
trend: string
description: string
/** Pixel height for mini charts; `0` means charts are suppressed. */
chartHeight?: number
} {
if (layout === "cardTile") {
switch (size) {
case "sm":
return {
cardPadding: "p-3",
cardPaddingWithChart: "p-3",
stackGap: "gap-2",
label: "text-xs line-clamp-2",
value: "text-lg",
valueRow: "min-h-7 items-center",
trend: "text-xs",
description: "text-xs line-clamp-2",
chartHeight: 28,
}
case "lg":
return {
cardPadding: "p-5",
cardPaddingWithChart: "px-5 pt-5 pb-0",
chartBleed: "-mx-5",
stackGap: "gap-3",
label: "text-sm line-clamp-2",
value: "text-4xl tracking-tight sm:text-5xl",
valueRow: "min-h-12 items-baseline gap-2.5",
trend: "text-sm sm:text-base",
description: "text-sm line-clamp-2",
chartHeight: 88,
}
case "md":
default:
return {
cardPadding: "p-4",
cardPaddingWithChart: "px-4 pt-4 pb-0",
chartBleed: "-mx-4",
stackGap: "gap-2.5",
label: "text-sm line-clamp-2",
value: "text-3xl tracking-tight sm:text-4xl",
valueRow: "min-h-10 items-baseline gap-2",
trend: "text-sm",
description: "text-sm line-clamp-2",
chartHeight: 72,
}
}
}
switch (size) {
case "sm":
return {
stripPadding: "gap-1.5 p-2 sm:px-3 sm:py-2.5",
stackGap: "gap-1.5",
labelMinH: "min-h-[1.75rem] gap-y-0.5",
label: "text-xs line-clamp-2",
valueDefault: "text-base sm:text-lg",
valueHero: "text-lg sm:text-xl",
valueRow: "min-h-7 items-center",
trend: "text-xs",
description: "text-xs line-clamp-2",
chartHeight: 28,
}
case "lg":
return {
stripPadding: "gap-2.5 p-3.5 sm:px-6 sm:py-5",
stackGap: "gap-3",
labelMinH: "min-h-[2.75rem] gap-y-0.5",
label: "text-sm line-clamp-2",
valueDefault: "text-2xl sm:text-3xl",
valueHero: "text-3xl sm:text-4xl",
valueRow: "min-h-10 items-center",
trend: "text-sm",
description: "text-sm line-clamp-2",
chartHeight: 64,
}
case "md":
default:
return {
stripPadding: "gap-2 p-3 sm:px-5 sm:py-4",
stackGap: "gap-2.5",
labelMinH: "min-h-[2.625rem] gap-y-0.5",
label: "text-sm line-clamp-2",
valueDefault: "text-xl sm:text-2xl",
valueHero: "text-2xl sm:text-3xl",
valueRow: "min-h-8 items-center",
trend: "text-xs sm:text-sm",
description: "text-sm line-clamp-2",
chartHeight: 48,
}
}
}
export {
KeyMetricsProvider,
useKeyMetricsContext,
type KeyMetricsContextValue,
} from "./key-metrics-context"
/**
* Tooltip around the default insight CTA. Renders the shortcut hint
* injected by `KeyMetricsProvider` (typically `⌘⌥K` for Ask Leo) when
* the CTA still uses the default action label — i.e. the consumer did
* NOT override `actionLabel` on the individual `MetricInsight`. Any
* custom `actionLabel` ("Open ticket", "Acknowledge", …) suppresses
* the shortcut hint since it no longer maps to the default chord.
*/
function InsightDefaultTooltip({
actionLabel,
children,
}: {
actionLabel?: string
children: React.ReactNode
}) {
const { shortcutHint, defaultActionLabel = "Ask Leo" } = useKeyMetricsContext()
const label = actionLabel ?? defaultActionLabel
const showShortcut =
!!shortcutHint && (!actionLabel || actionLabel === defaultActionLabel)
if (!showShortcut) {
return (
{children}{label}
)
}
return (
{children}{label}
{shortcutHint}
)
}
/** Insight card CTA — falls back to `KeyMetricsProvider.defaultInsightAction`. */
function InsightActionCta({
insight,
compact = false,
className,
wrapperClassName,
}: {
insight: MetricInsight
compact?: boolean
className?: string
wrapperClassName?: string
}) {
const { defaultInsightAction, defaultActionLabel = "Ask Leo" } = useKeyMetricsContext()
const onInsightAction = insight.onAction ?? defaultInsightAction
if (!onInsightAction) return null
const label = insight.actionLabel ?? defaultActionLabel
const button = (
)
if (!wrapperClassName) return button
return
{button}
}
/* ── Types ────────────────────────────────────────────────────────────────── */
/**
* Whether an **up** arrow should read as “good news” for tinting and assistive text.
* - **`higher_is_better`** (default) — revenue, pass rate, approved count: up = favorable.
* - **`lower_is_better`** — defects, overdue, **low PBI / quality flags**: more flags + up arrow = unfavorable.
* - **`informational`** — volume or mix only; keep arrows **muted** (direction without value judgment).
*/
export type MetricTrendPolarity = "higher_is_better" | "lower_is_better" | "informational"
export type MetricTrendTone = "positive" | "negative" | "muted"
/** Maps `trend` + polarity to semantic tone for colours (arrow direction still follows `trend`). */
export function metricTrendTone(
trend: "up" | "down" | "neutral",
polarity: MetricTrendPolarity = "higher_is_better",
): MetricTrendTone {
if (trend === "neutral") return "muted"
if (polarity === "informational") return "muted"
if (polarity === "higher_is_better") {
return trend === "up" ? "positive" : "negative"
}
return trend === "up" ? "negative" : "positive"
}
/** Short clause for `aria-label` on the trend chip (paired with the delta string). */
export function metricTrendAriaQualifier(
trend: "up" | "down" | "neutral",
polarity: MetricTrendPolarity = "higher_is_better",
): string {
if (trend === "neutral") return "no net change"
if (polarity === "informational") {
return trend === "up" ? "increased" : "decreased"
}
if (polarity === "higher_is_better") {
return trend === "up" ? "increased, favorable" : "decreased, unfavorable"
}
return trend === "up" ? "increased, unfavorable" : "decreased, favorable"
}
export interface MetricItem {
/** Unique identifier for React keying */
id: string
/** Short label shown above the value */
label: string
/** Displayed value — e.g. "23", "98%", "1,250" */
value: string | number
/**
* Change **count** for the trend chip — e.g. `"+5"`, `"-3"`, `"+12%"`.
*
* Pass an **empty string** (or `0`) when there is no comparison delta to show.
* In that case the **trend chip is hidden entirely** (the previous `—` placeholder
* is dropped) — see `MetricCell`. Put contextual prose like
* `"left + right"` / `"vs last week"` in `description`, **never** here.
*/
delta: string | number
/**
* Visual trend direction (arrow follows the signed change in the underlying metric).
* `"neutral"` paired with an empty `delta` suppresses the chip — use `description`
* for any caption you want to show below the value instead.
*/
trend: "up" | "down" | "neutral"
/**
* Optional short caption rendered **below** the value + trend row (muted, small).
* Use for **what** the number means or **how** it breaks down
* (e.g. `"left + right"`, `"vs last week"`, `"across 4 sites"`) — NOT for delta counts.
*/
description?: string
/**
* How to **tint** the trend chip. Omit = **`higher_is_better`** (legacy behaviour).
* Arrows always match `trend`; sentiment colours flip for **`lower_is_better`**.
*/
trendPolarity?: MetricTrendPolarity
/** Makes the cell a link */
href?: string
/** Makes the cell a button */
onClick?: () => void
/**
* "hero" — primary KPI (e.g. total count): larger value, same structure as siblings.
* "default" — standard KPI strip cell.
*/
metricVariant?: "default" | "hero"
/**
* Mini bar fill (`cards` / `flat`). When `progressMax` is set,
* treated as a raw count against that max; otherwise 0–100 percent.
* Ignored when `chart` is set at `md` / `lg` (one visual per tile).
*/
progress?: number
progressMax?: number
/** Mini bar colour. Default `brand` for KPI cards (non-judgmental quota). */
progressTone?: MetricProgressTone
/**
* Optional mini chart (`cards` or `flat`). `sm`: compact plot on the right of the value;
* `md` / `lg`: taller plot below. Limited kinds: `sparkline` | `bars`.
* Mutually exclusive with `progress` when shown.
*/
chart?: MetricChart
/**
* Surfaces an attention state on the tile (something wrong / needs action).
* Use with `trendPolarity: "lower_is_better"` when the count rising is bad.
* `warning` = needs review; `danger` = blocking / critical.
*/
alert?: MetricAlert
}
/** Attention tone for a KPI tile — not the same as trend arrow colour. */
export type MetricAlert = "warning" | "danger"
export type MetricProgressTone = "auto" | "success" | "warning" | "danger" | "info" | "brand"
/** One point in a KPI mini chart (`label` shown in the hover tooltip). */
export type MetricChartPoint = {
value: number
label?: string
}
/** Closed set of embeddable KPI graphs — full plots stay in ChartCard. */
export type MetricChartKind = "sparkline" | "bars"
export type MetricChart = {
kind: MetricChartKind
/** 4–12 points recommended. Numbers or `{ value, label }` for hover copy. */
series: Array
/** Stroke / fill tone. Default `brand`. */
tone?: MetricProgressTone
}
export function normalizeMetricChartSeries(
series: MetricChart["series"],
): MetricChartPoint[] {
return series.map((point, index) => {
if (typeof point === "number") {
return { value: point, label: String(index + 1) }
}
return {
value: point.value,
label: point.label?.trim() || String(index + 1),
}
})
}
/** Resolve 0–100 fill for card-tile mini bars. */
export function metricProgressPercent(
progress: number | undefined,
progressMax?: number,
): number | null {
if (progress === undefined || Number.isNaN(progress)) return null
if (progressMax != null && progressMax > 0) {
return Math.max(0, Math.min(100, Math.round((progress / progressMax) * 100)))
}
return Math.max(0, Math.min(100, Math.round(progress)))
}
export interface MetricInsight {
/** Optional single line for custom copy; rail prefers `title` + `description` when both are set */
statement?: string
/** Card headline */
title: string
/** Supporting body copy */
description?: string
/** Optional deep-link for the ↗ button */
href?: string
/** CTA label — defaults to "Ask Leo" */
actionLabel?: string
/** Font Awesome class for the CTA icon — defaults to fa-wand-magic-sparkles */
actionIcon?: string
/** Callback for the CTA button */
onAction?: () => void
/** Severity determines the badge colour (default: warning) */
severity?: "warning" | "info" | "error"
}
export interface PeriodOption {
value: string
label: string
}
export interface KeyMetricsProps {
/**
* "card" — one Card wraps the whole strip (dashboard tile)
* "cards" — each KPI in its own Card (hubs / overview grids) — preferred for list hubs
* "flat" — grouped KPI strip (hairline cells); same features as cards, no per-tile Card
* "compact" — Card shell, metrics only
*/
variant?: "card" | "cards" | "flat" | "compact"
/**
* Typography + padding scale. Hub strips: `sm`. Dashboard card tile: `md` (default).
* Catalog / hero demos: `lg`.
*/
size?: KeyMetricsSize
/** Panel title */
title?: string
/** Subtitle / description below title */
description?: string
/** Array of KPI items — by default split into rows of 3 */
metrics: MetricItem[]
/** When true, all metrics share one horizontal row (md+ and compact mobile grid) */
metricsSingleRow?: boolean
/**
* When true with `metricsSingleRow`, use a 2-column KPI grid so half-width dashboard cards
* fit 1–4 KPIs without horizontal overflow (pair rows on md+; 2-col grid on small screens).
* The insight rail (if any) stacks below the KPI grid instead of sitting beside it on md+.
*/
metricsHalfWidthLayout?: boolean
/**
* Force the horizontal scroll KPI strip on desktop (e.g. token index with many category
* shortcuts). Default: scroll only on compact viewports / reflow zoom.
*/
metricsStripScroll?: boolean
/** Optional insight card — see `insightFullWidth` */
insight?: MetricInsight
/**
* When true, the insight sits on its own full-width row under the metrics (not a narrow side rail).
*/
insightFullWidth?: boolean
/** Comparison-period options for the Select */
periods?: PeriodOption[]
/** Initially-selected period value */
defaultPeriod?: string
/** Called with the new period value when the Select changes */
onPeriodChange?: (period: string) => void
/** When false, hides the title/description/period-selector header row (default: true) */
showHeader?: boolean
/**
* Tighter insight card: one short title + line of body, no vertical filler;
* aligns visually with a single-row KPI band.
*/
insightCompact?: boolean
className?: string
}
/**
* KPI grid column step patterns — Tailwind v4 container-query classes.
*
* We deliberately AVOID `repeat(auto-fit, minmax(...))` here because it
* produces awkward "N + leftover" layouts at intermediate widths (e.g. 3
* tiles in row 1 + 1 lonely tile in row 2 for a 4-KPI strip). Instead we
* step the column count through values that evenly divide the row size:
* 1 → 2 → 4 for a 4-KPI strip (3 is skipped on purpose).
*
* The breakpoints are container-query based (`@[Xrem]:…`) so they react to
* the metrics strip's OWN width, not the viewport — that's what makes the
* 2×2 fallback kick in when the primary sidebar + secondary panel are
* both open and the strip column is ~360 px wide, even on a 1280 px display.
*
* `metricsHalfWidthLayout` = strip shares its row with the insight rail
* (3fr / 2fr split). Tighter breakpoints because available width is ~60%
* of the section.
*/
/**
* Flat KPI hairlines — cell borders only (no grid gap fill / no surface).
* Four tiles: default 4-across verticals; 2×2 hairlines only when @container is narrow.
*/
function flatMetricsHairlineClass(
itemCount: number,
metricsHalfWidthLayout: boolean,
): string {
if (itemCount <= 1) return "gap-0"
const childBorder = "[&>*]:border-[color:var(--key-metrics-flat-divider)]"
if (itemCount === 2) {
return cn("gap-0", childBorder, "[&>*:first-child]:border-e")
}
if (itemCount === 4) {
const narrow2x2 = metricsHalfWidthLayout
? "@[max-width:23.99rem]"
: "@[max-width:23.99rem]"
return cn(
"gap-0",
childBorder,
/* Wide strip (matches `@[24rem]:grid-cols-4`) — verticals between all tiles, no horizontal */
"[&>*:not(:last-child)]:border-e",
/* Narrow strip (`@[18rem]`–`@[24rem]` 2×2) */
`${narrow2x2}:[&>*:not(:last-child)]:border-e-0`,
`${narrow2x2}:[&>*:nth-child(odd)]:border-e`,
`${narrow2x2}:[&>*:not(:nth-last-child(-n+2))]:border-b`,
)
}
if (itemCount === 3) {
return cn("gap-0", childBorder, "[&>*:not(:last-child)]:border-e")
}
if (itemCount === 5) {
const narrow3x2 = metricsHalfWidthLayout
? "@[max-width:35.99rem]"
: "@[max-width:39.99rem]"
return cn(
"gap-0",
childBorder,
"[&>*:not(:last-child)]:border-e",
`${narrow3x2}:[&>*:nth-child(3)]:border-e-0`,
`${narrow3x2}:[&>*:not(:nth-last-child(-n+2))]:border-b`,
)
}
return cn("gap-0", childBorder, "[&>*:not(:last-child)]:border-e")
}
/**
* Below `lg` the strip uses a horizontal scroll row (see `KeyMetricsInner`).
* Desktop row column steps live in `metricsRowColumnsClass`.
*/
function metricsRowColumnsClass(rowLength: number, metricsHalfWidthLayout: boolean): string {
const half = metricsHalfWidthLayout
switch (rowLength) {
case 1:
return "grid-cols-1"
case 2:
return half
? "grid-cols-1 @[14rem]:grid-cols-2"
: "grid-cols-1 @[18rem]:grid-cols-2"
case 3:
// 3 tiles divide evenly already — step 1 → 3.
return half
? "grid-cols-1 @[18rem]:grid-cols-3"
: "grid-cols-1 @[24rem]:grid-cols-3"
case 4:
// Step 1 → 2 (2×2 grid) → 4. Skip 3 — that's the awkward 3+1 layout.
// 4-col from ~24rem so sidebar + secondary panel + moderate zoom still fit one row.
return half
? "grid-cols-1 @[14rem]:grid-cols-2 @[24rem]:grid-cols-4"
: "grid-cols-1 @[18rem]:grid-cols-2 @[24rem]:grid-cols-4"
case 5:
return half
? "grid-cols-1 @[14rem]:grid-cols-2 @[28rem]:grid-cols-3 @[38rem]:grid-cols-5"
: "grid-cols-1 @[18rem]:grid-cols-2 @[32rem]:grid-cols-3 @[48rem]:grid-cols-5"
default:
// 5+ KPIs (`exxat-kpi-max-four` caps the strip at 4, but key-metrics
// is a generic primitive — fall back to a sensible step). 1 → 2 → 3 → 6.
return half
? "grid-cols-1 @[14rem]:grid-cols-2 @[26rem]:grid-cols-3 @[40rem]:grid-cols-6"
: "grid-cols-1 @[18rem]:grid-cols-2 @[30rem]:grid-cols-3 @[56rem]:grid-cols-6"
}
}
/* ── Default data ─────────────────────────────────────────────────────────── */
const DEFAULT_PERIODS: PeriodOption[] = [
{ value: "week", label: "vs last week" },
{ value: "month", label: "vs last month" },
{ value: "quarter", label: "vs last quarter" },
{ value: "year", label: "vs last year" },
]
/* ── Sub-components ───────────────────────────────────────────────────────── */
function metricProgressToneClass(tone: MetricProgressTone = "brand"): string {
switch (tone) {
case "success":
return "bg-chart-2"
case "warning":
return "bg-chart-4"
case "danger":
return "bg-destructive"
case "info":
return "bg-primary"
case "auto": {
return "bg-primary"
}
case "brand":
default:
return "bg-[color:var(--brand-color)]"
}
}
/** Mini track under card-tile values — full width, 6px tall. */
function MetricMiniBar({
percent,
tone = "brand",
}: {
percent: number
tone?: MetricProgressTone
}) {
const fillClass =
tone === "auto"
? percent < 34
? "bg-destructive"
: percent < 67
? "bg-chart-4"
: "bg-chart-2"
: metricProgressToneClass(tone)
return (
)
}
/** Single KPI cell inside the metrics grid */
const MetricCell = React.memo(function MetricCell({
label,
value,
delta,
trend,
trendPolarity = "higher_is_better",
description,
progress,
progressMax,
progressTone = "brand",
chart,
alert,
href,
onClick,
metricVariant = "default",
dense = false,
mobileStrip = false,
edgeGutter = true,
layout = "strip",
size = "md",
shellInteractive = false,
}: Omit & {
dense?: boolean
mobileStrip?: boolean
edgeGutter?: boolean
/** `cardTile` — one KPI per Card (`variant="cards"`); tighter, aligned stack */
layout?: "strip" | "cardTile"
size?: KeyMetricsSize
/**
* When true, the parent Card / link owns the click target — render a non-interactive
* inner stack (still shows the affordance arrow).
*/
shellInteractive?: boolean
}) {
const isUp = trend === "up"
const isDown = trend === "down"
const tone = metricTrendTone(trend, trendPolarity)
const isInteractive = shellInteractive || !!(href || onClick)
const isHero = metricVariant === "hero"
const isCardTile = layout === "cardTile"
const sizeCls = keyMetricsSizeClasses(size, isCardTile ? "cardTile" : "strip")
const chartHeight = sizeCls.chartHeight ?? 0
const showChart =
chartHeight > 0 && chart != null && chart.series.length >= 2
/** `sm`: chart sits to the right of the value so the tile stays short. */
const chartInline = showChart && size === "sm"
const barPercent =
!showChart ? metricProgressPercent(progress, progressMax) : null
const ariaAlert = metricAlertAria(alert)
// Hide the trend chip entirely when there's no direction *and* no count to
// surface. This avoids the noisy `—` placeholder for purely informational
// metrics — see `docs/kpi-trend-pattern.md`.
const deltaText = typeof delta === "number"
? (delta === 0 ? "" : String(delta))
: String(delta ?? "").trim()
const showTrendChip = isUp || isDown || deltaText.length > 0
const labelRow = (