Skip to main content

tablo_ui/components/composites/
stat.rs

1use topcoat::{
2    Result,
3    context::Cx,
4    // Renamed: the tile's `icon` prop takes the name.
5    icon::{IconData, icon as icon_view},
6    view::{Attributes, StaticClass, View, class, component, view},
7};
8
9const STAT: StaticClass = class!(
10    "flex flex-col gap-3 rounded-xl border border-border bg-card p-5 text-card-foreground",
11    "shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
12);
13/// A linked tile answers the pointer, as the list rows it leads to do.
14const STAT_LINK: StaticClass = class!("hover:border-foreground/20 hover:bg-muted/40");
15const STAT_LABEL: StaticClass = class!(
16    "flex items-center gap-2 text-sm font-medium text-muted-foreground",
17    "[&>svg]:size-4 [&>svg]:shrink-0",
18);
19const STAT_VALUE: StaticClass =
20    class!("text-3xl leading-none font-semibold tracking-tight text-foreground tabular-nums");
21const STAT_DETAIL: StaticClass =
22    class!("flex items-center gap-1 text-sm [&>svg]:size-4 [&>svg]:shrink-0");
23const STAT_CHART: StaticClass = class!("-mx-1 h-8 w-[calc(100%+0.5rem)] overflow-visible");
24
25/// The sparkline's drawing box: as wide as 100 units, stretched to the tile's width.
26const CHART_WIDTH: f64 = 100.0;
27const CHART_HEIGHT: f64 = 24.0;
28/// Room above and below the line, so the stroke at the extremes stays inside the box.
29const CHART_PAD: f64 = 2.0;
30
31/// One figure for a dashboard: what it counts, the value, and an optional line under it. With an
32/// `href` the whole tile is a link, such as to the list the figure counts.
33///
34/// Lay several out in a grid of your own:
35///
36/// ```rust
37/// # use tablo_ui::{icons, stat};
38/// # use topcoat::{context::Cx, view::{BoxView, ViewExt, view}};
39/// # fn stats(cx: &Cx) -> BoxView<'_> {
40/// view! {
41///     <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
42///         stat(
43///             label: "Posts",
44///             value: "66",
45///             detail: "65 drafts",
46///             icon: icons::FILE_TEXT,
47///             href: "/admin/posts".to_string(),
48///         )
49///         stat(
50///             label: "Comments",
51///             value: "4",
52///             detail: "2 more than last week",
53///             detail_icon: icons::ARROW_UP,
54///             accent: "text-success",
55///             chart: vec![1.0, 3.0, 2.0, 4.0],
56///         )
57///     </div>
58/// }
59/// # .boxed()
60/// # }
61/// ```
62#[component]
63pub async fn stat(
64    cx: &Cx,
65    /// What the value counts, e.g. `"Posts"`.
66    #[into]
67    label: String,
68    /// The figure, formatted, e.g. `"1,204"` or `"98%"`.
69    #[into]
70    value: String,
71    /// Optional line under the value, e.g. `"12 this week"`.
72    #[into]
73    #[default]
74    detail: String,
75    /// Optional icon before the detail line, such as an arrow for its trend.
76    #[into]
77    #[default]
78    detail_icon: Option<IconData>,
79    /// What the detail icon says to a screen reader, e.g. `"Up"`; the icon alone is hidden from
80    /// it.
81    #[into]
82    #[default]
83    detail_icon_label: String,
84    /// The text-color class of the detail line and the chart, e.g. `"text-success"`; muted when
85    /// empty.
86    #[into]
87    #[default]
88    accent: String,
89    /// Optional values the tile draws as a sparkline under the value, oldest first. Fewer than two
90    /// finite values draw none.
91    #[default]
92    chart: Vec<f64>,
93    /// Optional icon before the label.
94    #[into]
95    #[default]
96    icon: Option<IconData>,
97    /// Optional URL the tile links to.
98    #[into]
99    #[default]
100    href: Option<String>,
101    /// Extra attributes for the tile.
102    #[default]
103    mut attrs: Attributes,
104) -> Result<impl View> {
105    let accent = if accent.is_empty() {
106        "text-muted-foreground".to_string()
107    } else {
108        accent
109    };
110    let line = sparkline_points(&chart);
111    let area = line
112        .as_ref()
113        .map(|line| format!("0,{CHART_HEIGHT} {line} {CHART_WIDTH},{CHART_HEIGHT}"));
114    let view_box = format!("0 0 {CHART_WIDTH} {CHART_HEIGHT}");
115    let body = view! {
116        <p class=(STAT_LABEL)>
117            if let Some(glyph) = icon {
118                icon_view(
119                    data: glyph,
120                    attrs: topcoat::view::attributes! { aria-hidden="true" }
121                )
122            }
123            (label)
124        </p>
125        <p class=(STAT_VALUE)>(value)</p>
126        if !detail.is_empty() {
127            <p class=(class!(STAT_DETAIL, accent.clone()))>
128                if let Some(glyph) = detail_icon {
129                    icon_view(
130                        data: glyph,
131                        attrs: topcoat::view::attributes! { aria-hidden="true" }
132                    )
133                }
134                if !detail_icon_label.is_empty() {
135                    <span class="sr-only">
136                        (detail_icon_label)
137                        " "
138                    </span>
139                }
140                (detail)
141            </p>
142        }
143        if let (Some(line), Some(area)) = (line, area) {
144            <svg
145                class=(class!(STAT_CHART, accent))
146                viewBox=(view_box)
147                preserveAspectRatio="none"
148                aria-hidden="true"
149            >
150                <polygon points=(area) fill="currentColor" fill-opacity="0.1" />
151                <polyline
152                    points=(line)
153                    fill="none"
154                    stroke="currentColor"
155                    stroke-width="2"
156                    stroke-linejoin="round"
157                    stroke-linecap="round"
158                    vector-effect="non-scaling-stroke"
159                />
160            </svg>
161        }
162    };
163    Ok(view! {
164        if let Some(href) = href {
165            <a
166                href=(href)
167                class=(class!(STAT, STAT_LINK, attrs.remove("class")))
168                (attrs)
169            >
170                (body)
171            </a>
172        } else {
173            <div class=(class!(STAT, attrs.remove("class"))) (attrs)>(body)</div>
174        }
175    })
176}
177
178/// The `points` of a sparkline over `values` in the chart's drawing box: evenly spaced from the
179/// left edge to the right, the largest value at the top and the smallest at the bottom, a flat
180/// series across the middle. Non-finite values are dropped; fewer than two left draw no line.
181fn sparkline_points(values: &[f64]) -> Option<String> {
182    let values: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
183    if values.len() < 2 {
184        return None;
185    }
186    let (min, max) = values
187        .iter()
188        .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &v| {
189            (min.min(v), max.max(v))
190        });
191    let span = max - min;
192    let last = (values.len() - 1) as f64;
193    let points: Vec<String> = values
194        .iter()
195        .enumerate()
196        .map(|(index, &value)| {
197            let x = index as f64 * CHART_WIDTH / last;
198            // A span that overflows to infinity or is zero has no usable scale.
199            let share = if span.is_finite() && span > 0.0 {
200                (max - value) / span
201            } else {
202                0.5
203            };
204            let y = CHART_PAD + share * (CHART_HEIGHT - 2.0 * CHART_PAD);
205            format!("{x:.2},{y:.2}")
206        })
207        .collect();
208    Some(points.join(" "))
209}
210
211#[cfg(test)]
212mod tests;