"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 = (