maud-ui 0.2.1

64 headless, accessible UI components for Rust web apps — shadcn Base UI API parity. Plus block templates, a live theme customiser, and shell hooks for 15 third-party widgets (Monaco, xyflow, Excalidraw, Three.js, AG Grid, Leaflet, FullCalendar, SortableJS, and more). Built on maud + htmx, styled like shadcn/ui.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! Chart component — lightweight inline SVG bar and line charts, no JS needed.
//!
//! ## shadcn parity notes
//!
//! shadcn's `ChartContainer` accepts a `config: ChartConfig` prop — a map of
//! series-key → `{ label, color, icon }` — that downstream legends/tooltips read.
//! Our chart is a fundamentally different beast: it renders a single series of
//! labeled data points directly to SVG and doesn't carry a tooltip/legend layer,
//! so a full `ChartConfig` map has no consumer here.
//!
//! We still expose `config: Option<ChartConfig>` as an **additive, no-op stub**
//! for API-shape parity — a caller porting from shadcn can pass their existing
//! config without a compile break, and when/if a legend is added it can read
//! from this field. `accessibility_layer` is also wired in the shadcn spirit:
//! emits `data-accessibility-layer="true"` on the container so downstream CSS
//! or AT hooks can adapt (focus rings, hit-target enlargement, etc.).

use maud::{html, Markup, PreEscaped};

/// A single data point with a label and numeric value.
#[derive(Debug, Clone)]
pub struct DataPoint {
    pub label: String,
    pub value: f64,
}

/// Chart type: bar or line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChartType {
    Bar,
    Line,
}

/// A single entry in a [`ChartConfig`] — maps a series key to a label and optional color.
///
/// Mirrors shadcn's `ChartConfig[key] = { label, color }` shape. Currently a no-op
/// stub: the chart renderer does not consume these fields yet. Kept for API parity
/// and forward compatibility with a future legend/tooltip layer.
#[derive(Debug, Clone)]
pub struct ChartConfigEntry {
    /// Series key — matches a `DataPoint.label` or a dataset identifier.
    pub key: String,
    /// Human-readable label shown in legends/tooltips.
    pub label: String,
    /// CSS color value (e.g. `"var(--mui-accent)"`, `"#ff0080"`). Optional.
    pub color: Option<String>,
}

/// Ordered series-configuration list, analogous to shadcn's `ChartConfig`.
///
/// Currently unused by the renderer — see module docs.
pub type ChartConfig = Vec<ChartConfigEntry>;

/// Chart rendering properties.
#[derive(Debug, Clone)]
pub struct Props {
    /// Unique ID for the chart container
    pub id: String,
    /// Bar or Line
    pub chart_type: ChartType,
    /// Data points to plot
    pub data: Vec<DataPoint>,
    /// Optional title above the chart
    pub title: Option<String>,
    /// SVG width in px (default 400)
    pub width: u32,
    /// SVG height in px (default 200)
    pub height: u32,
    /// CSS color for bars/line/dots; defaults to var(--mui-accent)
    pub color: Option<String>,
    /// Optional series configuration (shadcn-parity stub — currently not consumed).
    /// See module-level docs.
    pub config: Option<ChartConfig>,
    /// When true, emits `data-accessibility-layer="true"` on the container so CSS
    /// or AT hooks can adapt (enlarged hit targets, visible focus rings, etc.).
    /// Mirrors shadcn's `accessibilityLayer` prop.
    pub accessibility_layer: bool,
}

impl Default for Props {
    fn default() -> Self {
        Self {
            id: "chart".into(),
            chart_type: ChartType::Bar,
            data: Vec::new(),
            title: None,
            width: 400,
            height: 200,
            color: None,
            config: None,
            accessibility_layer: false,
        }
    }
}

// Layout constants — padding for axis labels and breathing room
const PAD_LEFT: f64 = 48.0;
const PAD_TOP: f64 = 12.0;
const PAD_RIGHT: f64 = 12.0;
const PAD_BOTTOM: f64 = 32.0;

/// Scale a value into the drawable area (y-axis is flipped in SVG).
fn scale_y(value: f64, max_value: f64, height: u32) -> f64 {
    let plot_h = height as f64 - PAD_TOP - PAD_BOTTOM;
    if max_value <= 0.0 {
        return height as f64 - PAD_BOTTOM;
    }
    height as f64 - PAD_BOTTOM - (value / max_value) * plot_h
}

/// Build SVG markup for a bar chart.
fn render_bar(props: &Props, color: &str) -> String {
    let w = props.width;
    let h = props.height;
    let n = props.data.len();
    if n == 0 {
        return format!(
            r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
        );
    }

    let max_value = props
        .data
        .iter()
        .map(|d| d.value)
        .fold(f64::NEG_INFINITY, f64::max)
        .max(0.0);

    let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
    let slot_w = plot_w / n as f64;
    let gap = (slot_w * 0.2).max(2.0);
    let bar_w = slot_w - gap;
    let baseline_y = h as f64 - PAD_BOTTOM;

    let mut svg = format!(
        r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
    );

    // Y-axis line
    svg.push_str(&format!(
        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
        PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
    ));
    // X-axis line
    svg.push_str(&format!(
        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
        PAD_LEFT,
        baseline_y,
        w as f64 - PAD_RIGHT,
        baseline_y
    ));

    // Y-axis tick labels (0, 25%, 50%, 75%, max)
    for i in 0..=4 {
        let frac = i as f64 / 4.0;
        let val = max_value * frac;
        let y = scale_y(val, max_value, h);
        // Grid line
        svg.push_str(&format!(
            r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
            PAD_LEFT,
            w as f64 - PAD_RIGHT,
        ));
        // Value label
        let label = if val >= 1000.0 {
            format!("{:.0}k", val / 1000.0)
        } else if val == val.floor() {
            format!("{:.0}", val)
        } else {
            format!("{:.1}", val)
        };
        svg.push_str(&format!(
            r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
            PAD_LEFT - 4.0,
            y + 3.0,
            label
        ));
    }

    // Bars + X labels
    for i in 0..n {
        let dp = &props.data[i];
        let bar_x = PAD_LEFT + (i as f64 * slot_w) + gap / 2.0;
        let bar_y = scale_y(dp.value, max_value, h);
        let bar_h = baseline_y - bar_y;
        let center_x = bar_x + bar_w / 2.0;

        svg.push_str(&format!(
            r#"<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" height="{bar_h}" rx="3" fill="{color}" opacity="0.85" />"#
        ));
        svg.push_str(&format!(
            r#"<text x="{center_x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
            h as f64 - 10.0,
            html_escape(&dp.label)
        ));
    }

    svg.push_str("</svg>");
    svg
}

/// Build SVG markup for a line chart.
fn render_line(props: &Props, color: &str) -> String {
    let w = props.width;
    let h = props.height;
    let n = props.data.len();
    if n == 0 {
        return format!(
            r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg"></svg>"#
        );
    }

    let max_value = props
        .data
        .iter()
        .map(|d| d.value)
        .fold(f64::NEG_INFINITY, f64::max)
        .max(0.0);

    let plot_w = w as f64 - PAD_LEFT - PAD_RIGHT;
    let baseline_y = h as f64 - PAD_BOTTOM;

    let mut svg = format!(
        r#"<svg viewBox="0 0 {w} {h}" class="mui-chart__svg" xmlns="http://www.w3.org/2000/svg">"#
    );

    // Y-axis line
    svg.push_str(&format!(
        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
        PAD_LEFT, PAD_TOP, PAD_LEFT, baseline_y
    ));
    // X-axis line
    svg.push_str(&format!(
        r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="currentColor" stroke-opacity="0.2" />"#,
        PAD_LEFT,
        baseline_y,
        w as f64 - PAD_RIGHT,
        baseline_y
    ));

    // Y-axis tick labels
    for i in 0..=4 {
        let frac = i as f64 / 4.0;
        let val = max_value * frac;
        let y = scale_y(val, max_value, h);
        svg.push_str(&format!(
            r#"<line x1="{}" y1="{y}" x2="{}" y2="{y}" stroke="currentColor" stroke-opacity="0.1" />"#,
            PAD_LEFT,
            w as f64 - PAD_RIGHT,
        ));
        let label = if val >= 1000.0 {
            format!("{:.0}k", val / 1000.0)
        } else if val == val.floor() {
            format!("{:.0}", val)
        } else {
            format!("{:.1}", val)
        };
        svg.push_str(&format!(
            r#"<text x="{}" y="{}" text-anchor="end" class="mui-chart__value">{}</text>"#,
            PAD_LEFT - 4.0,
            y + 3.0,
            label
        ));
    }

    // Compute (x, y) for each data point
    let mut points: Vec<(f64, f64)> = Vec::with_capacity(n);
    for i in 0..n {
        let x = if n == 1 {
            PAD_LEFT + plot_w / 2.0
        } else {
            PAD_LEFT + (i as f64 / (n - 1) as f64) * plot_w
        };
        let y = scale_y(props.data[i].value, max_value, h);
        points.push((x, y));
    }

    // Gradient definition for area fill
    let grad_id = format!("{}-area-grad", props.id);
    svg.push_str(&format!(
        r#"<defs><linearGradient id="{grad_id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="{color}" stop-opacity="0.25"/><stop offset="100%" stop-color="{color}" stop-opacity="0.03"/></linearGradient></defs>"#
    ));

    // Area fill (smooth gradient)
    if points.len() >= 2 {
        let mut area = String::from("<polygon points=\"");
        // Start at baseline under first point
        area.push_str(&format!("{},{} ", points[0].0, baseline_y));
        for (x, y) in &points {
            area.push_str(&format!("{x},{y} "));
        }
        // Close back to baseline under last point
        area.push_str(&format!("{},{}", points[points.len() - 1].0, baseline_y));
        area.push_str(&format!(r#"" fill="url(#{grad_id})" />"#));
        svg.push_str(&area);
    }

    // Polyline
    let pts: Vec<String> = points.iter().map(|(x, y)| format!("{x},{y}")).collect();
    svg.push_str(&format!(
        r#"<polyline points="{}" fill="none" stroke="{color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />"#,
        pts.join(" ")
    ));

    // Data-point circles
    for (x, y) in &points {
        svg.push_str(&format!(
            r#"<circle cx="{x}" cy="{y}" r="3" fill="{color}" />"#
        ));
    }

    // X labels
    for i in 0..n {
        let x = points[i].0;
        svg.push_str(&format!(
            r#"<text x="{x}" y="{}" text-anchor="middle" class="mui-chart__label">{}</text>"#,
            h as f64 - 10.0,
            html_escape(&props.data[i].label)
        ));
    }

    svg.push_str("</svg>");
    svg
}

/// Render a chart with the given properties.
pub fn render(props: Props) -> Markup {
    let color = props
        .color
        .clone()
        .unwrap_or_else(|| "var(--mui-accent)".into());

    let svg = match props.chart_type {
        ChartType::Bar => render_bar(&props, &color),
        ChartType::Line => render_line(&props, &color),
    };

    // Reserved for a future legend pass — silences unused-field warnings while
    // keeping the shadcn-parity API surface additive.
    let _ = &props.config;

    let a11y_attr = if props.accessibility_layer {
        Some("true")
    } else {
        None
    };

    html! {
        div.mui-chart id=(props.id) data-accessibility-layer=[a11y_attr] {
            @if let Some(ref title) = props.title {
                p.mui-chart__title { (title) }
            }
            (PreEscaped(svg))
        }
    }
}

/// Minimal HTML entity escaping for label text inside SVG.
fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

/// Showcase bar and line charts with sample data.
pub fn showcase() -> Markup {
    let monthly_data = vec![
        DataPoint {
            label: "Jan".into(),
            value: 186.0,
        },
        DataPoint {
            label: "Feb".into(),
            value: 305.0,
        },
        DataPoint {
            label: "Mar".into(),
            value: 237.0,
        },
        DataPoint {
            label: "Apr".into(),
            value: 73.0,
        },
        DataPoint {
            label: "May".into(),
            value: 209.0,
        },
        DataPoint {
            label: "Jun".into(),
            value: 214.0,
        },
    ];

    html! {
        div.mui-showcase__grid {
            div {
                p.mui-showcase__caption { "Bar chart" }
                (render(Props {
                    id: "chart-bar-demo".into(),
                    chart_type: ChartType::Bar,
                    data: monthly_data.clone(),
                    title: Some("Monthly Revenue (USD)".into()),
                    ..Default::default()
                }))
            }
            div {
                p.mui-showcase__caption { "Line chart with area fill" }
                (render(Props {
                    id: "chart-line-demo".into(),
                    chart_type: ChartType::Line,
                    data: monthly_data.clone(),
                    title: Some("Active Users Over Time".into()),
                    ..Default::default()
                }))
            }
            div {
                p.mui-showcase__caption { "Custom color" }
                (render(Props {
                    id: "chart-custom-color".into(),
                    chart_type: ChartType::Bar,
                    data: monthly_data.clone(),
                    title: Some("Conversion Rate by Month".into()),
                    color: Some("var(--mui-success)".into()),
                    ..Default::default()
                }))
            }
            div {
                p.mui-showcase__caption { "Wide line chart" }
                (render(Props {
                    id: "chart-line-wide".into(),
                    chart_type: ChartType::Line,
                    data: monthly_data.clone(),
                    title: Some("Pageviews Trend (6 months)".into()),
                    width: 600,
                    height: 250,
                    color: Some("var(--mui-warning)".into()),
                    ..Default::default()
                }))
            }
            div {
                p.mui-showcase__caption { "Accessibility layer (emits data-accessibility-layer)" }
                (render(Props {
                    id: "chart-a11y-demo".into(),
                    chart_type: ChartType::Bar,
                    data: monthly_data,
                    title: Some("Sessions (a11y layer on)".into()),
                    accessibility_layer: true,
                    ..Default::default()
                }))
            }
        }
    }
}