@handled-ai/design-system 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/charts/empty-chart-state.d.ts +11 -0
  2. package/dist/charts/empty-chart-state.js +70 -0
  3. package/dist/charts/empty-chart-state.js.map +1 -0
  4. package/dist/charts/index.d.ts +1 -0
  5. package/dist/charts/index.js +1 -0
  6. package/dist/charts/index.js.map +1 -1
  7. package/dist/charts/pipeline-overview.d.ts +2 -1
  8. package/dist/charts/pipeline-overview.js +29 -1
  9. package/dist/charts/pipeline-overview.js.map +1 -1
  10. package/dist/components/actor-byline.d.ts +3 -0
  11. package/dist/components/actor-byline.js +5 -0
  12. package/dist/components/actor-byline.js.map +1 -0
  13. package/dist/components/days-open-cell.d.ts +16 -0
  14. package/dist/components/days-open-cell.js +73 -0
  15. package/dist/components/days-open-cell.js.map +1 -0
  16. package/dist/components/detail-drawer.d.ts +16 -0
  17. package/dist/components/detail-drawer.js +45 -0
  18. package/dist/components/detail-drawer.js.map +1 -0
  19. package/dist/components/insights-filter-bar.d.ts +2 -1
  20. package/dist/components/insights-filter-bar.js +13 -5
  21. package/dist/components/insights-filter-bar.js.map +1 -1
  22. package/dist/components/linked-entity-cell.d.ts +14 -0
  23. package/dist/components/linked-entity-cell.js +96 -0
  24. package/dist/components/linked-entity-cell.js.map +1 -0
  25. package/dist/components/metric-card.d.ts +14 -1
  26. package/dist/components/metric-card.js +86 -0
  27. package/dist/components/metric-card.js.map +1 -1
  28. package/dist/components/performance-metrics-table.d.ts +2 -1
  29. package/dist/components/performance-metrics-table.js +78 -46
  30. package/dist/components/performance-metrics-table.js.map +1 -1
  31. package/dist/components/pill.d.ts +26 -0
  32. package/dist/components/pill.js +77 -0
  33. package/dist/components/pill.js.map +1 -0
  34. package/dist/components/quick-segment.d.ts +13 -0
  35. package/dist/components/quick-segment.js +96 -0
  36. package/dist/components/quick-segment.js.map +1 -0
  37. package/dist/index.d.ts +7 -1
  38. package/dist/index.js +5 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/charts/__tests__/insights-charts.test.tsx +62 -0
  42. package/src/charts/empty-chart-state.tsx +44 -0
  43. package/src/charts/index.ts +1 -0
  44. package/src/charts/pipeline-overview.tsx +38 -1
  45. package/src/components/__tests__/insights-primitives.test.tsx +117 -0
  46. package/src/components/__tests__/performance-metrics-table.test.tsx +54 -0
  47. package/src/components/__tests__/user-display.test.tsx +75 -0
  48. package/src/components/actor-byline.tsx +1 -0
  49. package/src/components/days-open-cell.tsx +50 -0
  50. package/src/components/detail-drawer.tsx +60 -0
  51. package/src/components/insights-filter-bar.tsx +13 -4
  52. package/src/components/linked-entity-cell.tsx +74 -0
  53. package/src/components/metric-card.tsx +82 -0
  54. package/src/components/performance-metrics-table.tsx +99 -63
  55. package/src/components/pill.tsx +67 -0
  56. package/src/components/quick-segment.tsx +68 -0
  57. package/src/index.ts +5 -0
  58. package/src/lib/__tests__/user-display.test.ts +53 -11
@@ -0,0 +1,117 @@
1
+ import React from "react"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { render, screen, fireEvent } from "@testing-library/react"
4
+
5
+ import { DaysOpenCell, getDaysOpenIntent } from "../days-open-cell"
6
+ import { DetailDrawer } from "../detail-drawer"
7
+ import { InsightsFilterBar } from "../insights-filter-bar"
8
+ import { LinkedEntityCell } from "../linked-entity-cell"
9
+ import { KpiStrip } from "../metric-card"
10
+ import { Pill, StatusPill } from "../pill"
11
+ import { QuickSegment } from "../quick-segment"
12
+
13
+ describe("Insights primitives", () => {
14
+ it("renders compact InsightsFilterBar without changing default API", () => {
15
+ const { container } = render(
16
+ <InsightsFilterBar
17
+ variant="compact"
18
+ filters={[{ id: "status", label: "Status", options: ["All", "Open"], defaultValue: "All" }]}
19
+ values={{ status: "Open" }}
20
+ onChange={() => {}}
21
+ onClearAll={() => {}}
22
+ />
23
+ )
24
+
25
+ const bar = container.querySelector('[data-slot="insights-filter-bar"]')!
26
+ expect(bar.className).toContain("p-2")
27
+ expect(bar.className).toContain("gap-2")
28
+ expect(screen.getByRole("button", { name: /Status: Open/i }).className).toContain("h-7")
29
+ })
30
+
31
+ it("renders KpiStrip items and changes", () => {
32
+ const { container } = render(
33
+ <KpiStrip
34
+ items={[
35
+ { label: "New", value: 42, unit: "leads", change: { value: "8%", direction: "up" } },
36
+ { label: "Aging", value: 12, subtitle: "over SLA", change: { value: "3%", direction: "down", isGood: true } },
37
+ ]}
38
+ />
39
+ )
40
+
41
+ expect(container.querySelectorAll('[data-slot="kpi-strip-item"]')).toHaveLength(2)
42
+ expect(screen.getByText("New")).not.toBeNull()
43
+ expect(screen.getByText("42")).not.toBeNull()
44
+ expect(screen.getByText("8%")).not.toBeNull()
45
+ })
46
+
47
+ it("QuickSegment exposes selected state and invokes onSelect", () => {
48
+ const onSelect = vi.fn()
49
+ render(<QuickSegment label="At risk" value="risk" count={5} selected onSelect={onSelect} />)
50
+
51
+ const button = screen.getByRole("button", { name: /At risk/i })
52
+ expect(button.getAttribute("aria-pressed")).toBe("true")
53
+ expect(screen.getByText("5")).not.toBeNull()
54
+ fireEvent.click(button)
55
+ expect(onSelect).toHaveBeenCalledWith("risk")
56
+ })
57
+
58
+ it("LinkedEntityCell renders entity links and metadata", () => {
59
+ const onNavigate = vi.fn()
60
+ render(
61
+ <LinkedEntityCell
62
+ name="Acme Health"
63
+ href="/accounts/acme"
64
+ subtitle="Account"
65
+ meta="Tier 1"
66
+ onNavigate={onNavigate}
67
+ />
68
+ )
69
+
70
+ const link = screen.getByRole("link", { name: "Acme Health" })
71
+ expect(link.getAttribute("href")).toBe("/accounts/acme")
72
+ expect(screen.getByText(/Tier 1/)).not.toBeNull()
73
+ fireEvent.click(link)
74
+ expect(onNavigate).toHaveBeenCalled()
75
+ })
76
+
77
+ it("DaysOpenCell maps thresholds to status intents", () => {
78
+ expect(getDaysOpenIntent(2, 7, 30)).toBe("success")
79
+ expect(getDaysOpenIntent(10, 7, 30)).toBe("warning")
80
+ expect(getDaysOpenIntent(45, 7, 30)).toBe("error")
81
+
82
+ render(<DaysOpenCell days={45} />)
83
+ expect(screen.getByTestId("days-open-pill").getAttribute("data-variant")).toBe("error")
84
+ })
85
+
86
+ it("Pill and StatusPill render wrapper variants", () => {
87
+ const { container } = render(
88
+ <div>
89
+ <Pill variant="info">Info</Pill>
90
+ <StatusPill status="Blocked" intent="error" />
91
+ </div>
92
+ )
93
+
94
+ expect(container.querySelector('[data-slot="pill"]')?.getAttribute("data-variant")).toBe("info")
95
+ expect(container.querySelector('[data-slot="status-pill"]')?.getAttribute("data-variant")).toBe("error")
96
+ expect(screen.getByText("Blocked")).not.toBeNull()
97
+ })
98
+
99
+ it("DetailDrawer renders title, content, and footer when open", () => {
100
+ render(
101
+ <DetailDrawer
102
+ open
103
+ onOpenChange={() => {}}
104
+ title="Referral details"
105
+ description="A drawer for insights"
106
+ footer={<button type="button">Done</button>}
107
+ >
108
+ <div>Drawer body</div>
109
+ </DetailDrawer>
110
+ )
111
+
112
+ expect(screen.getByText("Referral details")).not.toBeNull()
113
+ expect(screen.getByText("A drawer for insights")).not.toBeNull()
114
+ expect(screen.getByText("Drawer body")).not.toBeNull()
115
+ expect(screen.getByRole("button", { name: "Done" })).not.toBeNull()
116
+ })
117
+ })
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, within } from "@testing-library/react"
4
+
5
+ import {
6
+ PerformanceMetricsTable,
7
+ type PerformanceMetricsTableRow,
8
+ } from "../performance-metrics-table"
9
+
10
+ const rows: PerformanceMetricsTableRow[] = [
11
+ {
12
+ id: "row-1",
13
+ label: "Acme Health",
14
+ avatarFallback: "AH",
15
+ role: "Coordinator",
16
+ primaryValue: 10,
17
+ primaryTarget: 20,
18
+ ratePercent: 71,
19
+ metricOne: 5,
20
+ metricTwo: "1.2h",
21
+ metricThree: 8,
22
+ metricFour: 3,
23
+ },
24
+ ]
25
+
26
+ function renderTable(primaryMetricDisplayMode?: "progress" | "value") {
27
+ return render(
28
+ <PerformanceMetricsTable
29
+ rows={rows}
30
+ primaryMetricDisplayMode={primaryMetricDisplayMode}
31
+ roleOptions={["All", "Coordinator"]}
32
+ viewOptions={["By Entity"]}
33
+ />,
34
+ )
35
+ }
36
+
37
+ describe("PerformanceMetricsTable", () => {
38
+ it("defaults the primary metric display to progress", () => {
39
+ renderTable()
40
+
41
+ const row = screen.getByText("Acme Health").closest("tr")!
42
+ expect(within(row).getByText("10/20")).not.toBeNull()
43
+ expect(within(row).getByText("50%")).not.toBeNull()
44
+ })
45
+
46
+ it("renders the primary metric as a plain value in value mode", () => {
47
+ renderTable("value")
48
+
49
+ const row = screen.getByText("Acme Health").closest("tr")!
50
+ expect(within(row).getByText("10")).not.toBeNull()
51
+ expect(within(row).queryByText("10/20")).toBeNull()
52
+ expect(within(row).queryByText("50%")).toBeNull()
53
+ })
54
+ })
@@ -0,0 +1,75 @@
1
+ import React from "react"
2
+ import { render, screen } from "@testing-library/react"
3
+ import { describe, expect, it } from "vitest"
4
+
5
+ import { ActorByline } from "../actor-byline"
6
+ import { UserPill } from "../user-pill"
7
+
8
+ describe("UserPill", () => {
9
+ it("renders a display name and derived initials from profile", () => {
10
+ const { container } = render(
11
+ <UserPill profile={{ first_name: "Sarah", last_name: "Mitchell", email: "sarah@example.com" }} />
12
+ )
13
+
14
+ expect(container.querySelector('[data-slot="user-pill"]')).not.toBeNull()
15
+ expect(screen.getByText("Sarah Mitchell")).not.toBeNull()
16
+ expect(screen.getByText("SM")).not.toBeNull()
17
+ })
18
+
19
+ it("does not require profile and can render direct name/email props with subtitle", () => {
20
+ render(<UserPill name="Marcus Webb" email="marcus@example.com" subtitle="Account executive" variant="compact" />)
21
+
22
+ expect(screen.getByText("Marcus Webb")).not.toBeNull()
23
+ expect(screen.getByText("Account executive")).not.toBeNull()
24
+ expect(screen.getByText("MW")).not.toBeNull()
25
+ })
26
+
27
+ it("falls back to email local part and initials from direct email prop", () => {
28
+ render(<UserPill email="fallback@example.com" />)
29
+
30
+ expect(screen.getByText("fallback")).not.toBeNull()
31
+ expect(screen.getByText("FA")).not.toBeNull()
32
+ })
33
+ })
34
+
35
+ describe("ActorByline", () => {
36
+ it("renders plain byline text with actor name, verb, subject, and string timestamp", () => {
37
+ const { container } = render(
38
+ <ActorByline
39
+ actor={{ first_name: "Cory", last_name: "Pitt", email: "cory@example.com" }}
40
+ verb="closed"
41
+ subject="this opportunity"
42
+ timestamp="3 min ago"
43
+ />
44
+ )
45
+
46
+ expect(container.querySelector('[data-slot="actor-byline"]')).not.toBeNull()
47
+ expect(container.textContent).toBe("Cory Pitt closed this opportunity · 3 min ago")
48
+ })
49
+
50
+
51
+ it("renders Date timestamps deterministically", () => {
52
+ const { container } = render(
53
+ <ActorByline
54
+ actor={{ name: "Cory Pitt", email: "cory@withhandled.com" }}
55
+ verb="closed"
56
+ subject="this opportunity"
57
+ timestamp={new Date("2026-05-24T18:30:00.000Z")}
58
+ />,
59
+ )
60
+
61
+ expect(container.textContent).toBe("Cory Pitt closed this opportunity · 2026-05-24T18:30:00.000Z")
62
+ })
63
+
64
+ it("omits missing pieces and null timestamp cleanly", () => {
65
+ const { container } = render(<ActorByline actor={{ name: "Handled AI", email: "agent@handled.ai" }} subject="a task" timestamp={null} />)
66
+
67
+ expect(container.textContent).toBe("Handled AI a task")
68
+ })
69
+
70
+ it("falls back through shared user-display rules", () => {
71
+ const { container } = render(<ActorByline actor={{ first_name: null, last_name: null, name: null, email: "fallback@example.com" }} />)
72
+
73
+ expect(container.textContent).toBe("fallback")
74
+ })
75
+ })
@@ -0,0 +1 @@
1
+ export { ActorByline, type ActorBylineProps } from "./user-display"
@@ -0,0 +1,50 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import { StatusPill, type PillStatus } from "./pill"
7
+
8
+ export interface DaysOpenCellProps extends React.HTMLAttributes<HTMLDivElement> {
9
+ days: number | null | undefined
10
+ warningAt?: number
11
+ criticalAt?: number
12
+ emptyLabel?: string
13
+ suffix?: string
14
+ }
15
+
16
+ function getDaysOpenIntent(days: number, warningAt: number, criticalAt: number): PillStatus {
17
+ if (days >= criticalAt) return "error"
18
+ if (days >= warningAt) return "warning"
19
+ return "success"
20
+ }
21
+
22
+ export function DaysOpenCell({
23
+ days,
24
+ warningAt = 7,
25
+ criticalAt = 30,
26
+ emptyLabel = "—",
27
+ suffix = "d open",
28
+ className,
29
+ ...props
30
+ }: DaysOpenCellProps) {
31
+ if (days === null || days === undefined) {
32
+ return (
33
+ <div data-slot="days-open-cell" className={cn("text-sm text-muted-foreground", className)} {...props}>
34
+ {emptyLabel}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ const intent = getDaysOpenIntent(days, warningAt, criticalAt)
40
+
41
+ return (
42
+ <div data-slot="days-open-cell" className={cn("inline-flex items-center", className)} {...props}>
43
+ <StatusPill data-testid="days-open-pill" status={`${days} ${suffix}`} intent={intent}>
44
+ {days} {suffix}
45
+ </StatusPill>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ export { getDaysOpenIntent }
@@ -0,0 +1,60 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetFooter,
11
+ SheetHeader,
12
+ SheetTitle,
13
+ } from "./sheet"
14
+
15
+ export interface DetailDrawerProps {
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ title: React.ReactNode
19
+ description?: React.ReactNode
20
+ children: React.ReactNode
21
+ footer?: React.ReactNode
22
+ side?: "right" | "left"
23
+ className?: string
24
+ contentClassName?: string
25
+ }
26
+
27
+ export function DetailDrawer({
28
+ open,
29
+ onOpenChange,
30
+ title,
31
+ description,
32
+ children,
33
+ footer,
34
+ side = "right",
35
+ className,
36
+ contentClassName,
37
+ }: DetailDrawerProps) {
38
+ return (
39
+ <Sheet open={open} onOpenChange={onOpenChange}>
40
+ <SheetContent
41
+ data-slot="detail-drawer"
42
+ side={side}
43
+ className={cn("w-full gap-0 p-0 sm:max-w-xl", className)}
44
+ >
45
+ <SheetHeader data-slot="detail-drawer-header" className="border-b border-border p-5">
46
+ <SheetTitle>{title}</SheetTitle>
47
+ {description ? <SheetDescription>{description}</SheetDescription> : null}
48
+ </SheetHeader>
49
+ <div data-slot="detail-drawer-content" className={cn("flex-1 overflow-y-auto p-5", contentClassName)}>
50
+ {children}
51
+ </div>
52
+ {footer ? (
53
+ <SheetFooter data-slot="detail-drawer-footer" className="border-t border-border p-5">
54
+ {footer}
55
+ </SheetFooter>
56
+ ) : null}
57
+ </SheetContent>
58
+ </Sheet>
59
+ )
60
+ }
@@ -23,6 +23,7 @@ export interface FilterDefinition {
23
23
 
24
24
  export interface InsightsFilterBarProps {
25
25
  filters: FilterDefinition[]
26
+ variant?: "default" | "compact"
26
27
  values: Record<string, string>
27
28
  onChange: (filterId: string, value: string) => void
28
29
  onClearAll?: () => void
@@ -45,6 +46,7 @@ function InsightsFilterBar({
45
46
  onChange,
46
47
  onClearAll,
47
48
  className,
49
+ variant = "default",
48
50
  }: InsightsFilterBarProps) {
49
51
  const showClearAll = onClearAll && hasNonDefaultValue(filters, values)
50
52
 
@@ -52,11 +54,12 @@ function InsightsFilterBar({
52
54
  <div
53
55
  data-slot="insights-filter-bar"
54
56
  className={cn(
55
- "flex flex-wrap items-center gap-3 rounded-md border border-border bg-card p-4 shadow-sm",
57
+ "flex flex-wrap items-center rounded-md border border-border bg-card shadow-sm",
58
+ variant === "compact" ? "gap-2 p-2" : "gap-3 p-4",
56
59
  className
57
60
  )}
58
61
  >
59
- <div className="flex items-center gap-2">
62
+ <div className={cn("flex items-center gap-2", variant === "compact" && "sr-only")}>
60
63
  <FilterIcon className="h-4 w-4 text-muted-foreground" />
61
64
  <span className="text-sm font-medium text-muted-foreground">
62
65
  Filters:
@@ -80,7 +83,10 @@ function InsightsFilterBar({
80
83
  <Button
81
84
  variant="outline"
82
85
  size="sm"
83
- className="h-8 gap-1.5 text-xs font-normal shadow-none"
86
+ className={cn(
87
+ "gap-1.5 text-xs font-normal shadow-none",
88
+ variant === "compact" ? "h-7 px-2" : "h-8"
89
+ )}
84
90
  >
85
91
  {IconComp ? (
86
92
  <IconComp className="h-3.5 w-3.5 text-muted-foreground" />
@@ -118,7 +124,10 @@ function InsightsFilterBar({
118
124
  <Button
119
125
  variant="ghost"
120
126
  size="sm"
121
- className="h-8 text-xs text-destructive hover:text-destructive"
127
+ className={cn(
128
+ "text-xs text-destructive hover:text-destructive",
129
+ variant === "compact" ? "h-7 px-2" : "h-8"
130
+ )}
122
131
  onClick={onClearAll}
123
132
  >
124
133
  Clear All
@@ -0,0 +1,74 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ExternalLink } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ export interface LinkedEntityCellProps extends React.HTMLAttributes<HTMLDivElement> {
9
+ name: React.ReactNode
10
+ href?: string
11
+ subtitle?: React.ReactNode
12
+ meta?: React.ReactNode
13
+ icon?: React.ReactNode
14
+ external?: boolean
15
+ onNavigate?: () => void
16
+ }
17
+
18
+ export function LinkedEntityCell({
19
+ name,
20
+ href,
21
+ subtitle,
22
+ meta,
23
+ icon,
24
+ external = false,
25
+ onNavigate,
26
+ className,
27
+ ...props
28
+ }: LinkedEntityCellProps) {
29
+ const content = (
30
+ <>
31
+ <span className="truncate">{name}</span>
32
+ {external ? <ExternalLink className="h-3 w-3 shrink-0 opacity-60" aria-hidden="true" /> : null}
33
+ </>
34
+ )
35
+
36
+ return (
37
+ <div
38
+ data-slot="linked-entity-cell"
39
+ className={cn("flex min-w-0 items-center gap-2", className)}
40
+ {...props}
41
+ >
42
+ {icon ? (
43
+ <span data-slot="linked-entity-cell-icon" className="shrink-0 text-muted-foreground">
44
+ {icon}
45
+ </span>
46
+ ) : null}
47
+ <div className="min-w-0 flex-1">
48
+ {href ? (
49
+ <a
50
+ data-slot="linked-entity-cell-link"
51
+ href={href}
52
+ target={external ? "_blank" : undefined}
53
+ rel={external ? "noreferrer" : undefined}
54
+ onClick={onNavigate}
55
+ className="inline-flex max-w-full items-center gap-1 truncate font-medium text-foreground underline-offset-4 hover:text-primary hover:underline"
56
+ >
57
+ {content}
58
+ </a>
59
+ ) : (
60
+ <span data-slot="linked-entity-cell-name" className="block truncate font-medium text-foreground">
61
+ {name}
62
+ </span>
63
+ )}
64
+ {subtitle || meta ? (
65
+ <div data-slot="linked-entity-cell-meta" className="mt-0.5 truncate text-xs text-muted-foreground">
66
+ {subtitle}
67
+ {subtitle && meta ? <span className="px-1">·</span> : null}
68
+ {meta}
69
+ </div>
70
+ ) : null}
71
+ </div>
72
+ </div>
73
+ )
74
+ }
@@ -24,6 +24,88 @@ export interface MetricCardProps {
24
24
  showInfo?: boolean
25
25
  }
26
26
 
27
+ export interface KpiStripItem {
28
+ id?: string
29
+ label: React.ReactNode
30
+ value: React.ReactNode
31
+ unit?: React.ReactNode
32
+ subtitle?: React.ReactNode
33
+ change?: MetricCardProps["change"]
34
+ }
35
+
36
+ export interface KpiStripProps extends React.HTMLAttributes<HTMLDivElement> {
37
+ items: KpiStripItem[]
38
+ columns?: 2 | 3 | 4
39
+ }
40
+
41
+ export function KpiStrip({ items, columns = 4, className, ...props }: KpiStripProps) {
42
+ return (
43
+ <div
44
+ data-slot="kpi-strip"
45
+ className={cn(
46
+ "grid gap-3 rounded-xl border border-border bg-card p-3 shadow-sm",
47
+ columns === 2 && "sm:grid-cols-2",
48
+ columns === 3 && "sm:grid-cols-3",
49
+ columns === 4 && "sm:grid-cols-2 lg:grid-cols-4",
50
+ className
51
+ )}
52
+ {...props}
53
+ >
54
+ {items.map((item, index) => {
55
+ const isGoodDirection = item.change
56
+ ? item.change.isGood !== undefined
57
+ ? item.change.isGood
58
+ : item.change.direction === "up"
59
+ : false
60
+ const ChangeIcon = item.change?.direction === "down" ? ArrowDown : ArrowUp
61
+
62
+ return (
63
+ <div
64
+ key={item.id ?? index}
65
+ data-slot="kpi-strip-item"
66
+ className="min-w-0 rounded-lg bg-muted/40 px-3 py-2"
67
+ >
68
+ <div data-slot="kpi-strip-label" className="truncate text-xs font-medium text-muted-foreground">
69
+ {item.label}
70
+ </div>
71
+ <div className="mt-1 flex items-baseline gap-1">
72
+ <span data-slot="kpi-strip-value" className="truncate text-2xl font-bold tracking-tight text-foreground">
73
+ {item.value}
74
+ </span>
75
+ {item.unit ? (
76
+ <span data-slot="kpi-strip-unit" className="text-sm font-semibold text-muted-foreground">
77
+ {item.unit}
78
+ </span>
79
+ ) : null}
80
+ </div>
81
+ {item.subtitle || item.change ? (
82
+ <div className="mt-1 flex items-center gap-2 text-xs">
83
+ {item.change ? (
84
+ <span
85
+ data-slot="kpi-strip-change"
86
+ className={cn(
87
+ "inline-flex items-center gap-0.5 font-semibold",
88
+ isGoodDirection ? "text-emerald-600" : "text-red-600"
89
+ )}
90
+ >
91
+ <ChangeIcon className="h-3 w-3 stroke-[3]" />
92
+ {item.change.value}
93
+ </span>
94
+ ) : null}
95
+ {item.subtitle ? (
96
+ <span data-slot="kpi-strip-subtitle" className="truncate text-muted-foreground">
97
+ {item.subtitle}
98
+ </span>
99
+ ) : null}
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ )
104
+ })}
105
+ </div>
106
+ )
107
+ }
108
+
27
109
  export function MetricCard({
28
110
  title,
29
111
  value,