Coverage for src / lexigram / ui / molecules / stat_card.py: 15%
46 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui.core.base import Component, el
8class StatCard(Component):
9 """Dashboard metric card."""
11 def __init__(
12 self,
13 label: str,
14 value: Any,
15 delta: str | None = None,
16 delta_color: str = "green",
17 icon: str | None = None,
18 sparkline_data: list[float] | None = None,
19 sparkline_color: str = "indigo",
20 **props,
21 ) -> None:
22 super().__init__(
23 label=label,
24 value=value,
25 delta=delta,
26 delta_color=delta_color,
27 icon=icon,
28 sparkline_data=sparkline_data,
29 sparkline_color=sparkline_color,
30 **props,
31 )
32 self.label = label
33 self.value = value
34 self.delta = delta
35 self.delta_color = delta_color
36 self.icon = icon
37 self.sparkline_data = sparkline_data
38 self.sparkline_color = sparkline_color
40 def _render_sparkline(self) -> Any:
41 if not self.sparkline_data:
42 return None
44 values = list(map(float, self.sparkline_data))
45 max_val = max(values, default=1.0)
46 if max_val == 0:
47 max_val = 1.0
49 width = 80
50 height = 30
52 points = []
53 n = len(values)
54 for i, val in enumerate(values):
55 x = (i / (n - 1)) * width if n > 1 else width / 2
56 y = height - (val / max_val * height)
57 points.append(f"{x:.1f},{y:.1f}")
59 pts = " ".join(points)
61 return el(
62 "div",
63 el(
64 "svg",
65 el(
66 "polyline",
67 points=pts,
68 fill="none",
69 stroke="currentColor",
70 stroke_width="2.5",
71 stroke_linecap="round",
72 stroke_linejoin="round",
73 ),
74 viewBox=f"0 0 {width} {height}",
75 class_="w-full h-full text-primary",
76 aria_hidden="true",
77 ),
78 class_="w-20 h-8 ml-auto self-end mb-1 opacity-80",
79 )
81 def render(self) -> Any:
82 # Merge classes
83 cls = "bg-card p-6 rounded-2xl shadow-sm border border-border hover:shadow-md transition-all duration-300"
84 custom_cls = self.props.get("class_", self.props.get("class"))
85 if custom_cls:
86 cls = f"{cls} {custom_cls}"
88 attrs = dict(
89 filter(
90 lambda item: (
91 item[0]
92 not in (
93 "label",
94 "value",
95 "delta",
96 "delta_color",
97 "icon",
98 "sparkline_data",
99 "sparkline_color",
100 "class_",
101 "class",
102 )
103 ),
104 self.props.items(),
105 ),
106 )
108 # Icon placeholder (could integrate with Heroicons later)
109 icon_el = None
110 if self.icon:
111 from lexigram.ui.atoms.icons import get_icon
113 icon_node = get_icon(self.icon, class_name="w-6 h-6")
114 icon_el = el(
115 "div",
116 icon_node,
117 class_="p-3 rounded-full bg-primary/10 text-primary mr-4 shadow-sm border border-primary/20",
118 )
120 delta_el = None
121 if self.delta:
122 color_cls = (
123 "text-success"
124 if self.delta_color == "green"
125 else "text-destructive"
126 )
127 delta_el = el(
128 "span",
129 self.delta,
130 class_=f"text-sm font-semibold {color_cls} ml-2",
131 )
133 return el(
134 "div",
135 el(
136 "div",
137 el(
138 "div",
139 icon_el,
140 el(
141 "div",
142 el(
143 "p",
144 self.label,
145 class_="text-sm font-semibold text-muted-foreground truncate uppercase tracking-wider",
146 ),
147 el(
148 "div",
149 el(
150 "h3",
151 str(self.value),
152 class_="text-3xl font-extrabold text-foreground",
153 ),
154 delta_el,
155 class_="flex items-baseline mt-1",
156 ),
157 ),
158 class_="flex items-center",
159 ),
160 self._render_sparkline(),
161 class_="flex justify-between items-center",
162 ),
163 class_=cls,
164 **attrs,
165 )