Start a project

Artelier UI Kit · Rust · Data display

Data display

The reason the screen exists. Numbers are monospaced.

AdsStat

URLs crawled1,284up+37
Median latency74msprev 84ms
rust
AdsStat { value: "1,284", label: "URLs crawled", delta: "+37", direction: DeltaDirection::Up }
AdsStat { value: "74", label: "Median latency", unit: "ms", previous: "84" }
Source · ads/ads-rs/src/ui/stat.rs
rust
//! Stat tile, meter, progress, structured list, and data grid.

use std::cmp::Ordering;

use dioxus::prelude::*;

use crate::ui::{AdsDelta, AdsStatus, DeltaDirection};

/// A KPI tile. The number is monospaced and large, the label is small and
/// quiet — and the figure always travels with a comparison, because a number
/// on its own is a label with extra steps. Give it a `delta` (read with
/// `direction`), the `previous` period, a `target`, or at the very least a
/// `note` saying what it is against.
#[component]
pub fn AdsStat(
    #[props(into)] value: String,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
    /// The change, already formatted ("12.4%"). Read with `direction`.
    #[props(into, optional)]
    delta: Option<String>,
    #[props(default)] direction: DeltaDirection,
    /// The same figure last period, shown as "prev …".
    #[props(into, optional)]
    previous: Option<String>,
    /// What the figure is aiming at, shown as "target …".
    #[props(into, optional)]
    target: Option<String>,
    /// A free-form comparison: "+37 this run", "of 100".
    #[props(into, optional)]
    note: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let delta = delta.filter(|d| !d.is_empty());
    let note = note.filter(|n| !n.is_empty());
    let has_comparison = delta.is_some() || previous.is_some() || target.is_some() || note.is_some();
    rsx! {
        div { class: "ads-stat card-press",
            span { class: "eyebrow", "{label}" }
            span { class: "num ads-stat__value",
                "{value}"
                if !unit.is_empty() { span { class: "ads-stat__unit", "{unit}" } }
            }
            if has_comparison {
                span { class: "ads-stat__compare",
                    if let Some(d) = &delta { AdsDelta { direction, value: d.clone() } }
                    if let Some(p) = &previous { span { class: "num ads-stat__note", "prev {p}{unit}" } }
                    if let Some(t) = &target { span { class: "num ads-stat__note", "target {t}{unit}" } }
                    if let Some(n) = &note { span { class: "num ads-stat__note", "{n}" } }
                }
            }
        }
    }
}

/// A position along `max`, as a CSS percentage clamped to the track.
fn at(n: u32, max: u32) -> String {
    let pct = if max == 0 { 0.0 } else { (f64::from(n) / f64::from(max) * 100.0).clamp(0.0, 100.0) };
    format!("{pct}%")
}

/// A quantity against a threshold. Distinct from progress, which is a task
/// moving toward completion. The threshold is required: it draws the mark the
/// reader needs to know whether the fill is good news, and a meter without one
/// is decoration. The label is required too — it is the meter's name, for the
/// eye and for a screen reader alike.
#[component]
pub fn AdsMeter(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let value_text = format!("{value}{unit} of {max}{unit}, threshold {threshold}{unit}");
    let fill = at(value, max);
    let mark = at(threshold, max);
    rsx! {
        div { class: "ads-meter__wrap",
            span { class: "ads-meter__head",
                span { class: "ads-meter__label", "{label}" }
                span { class: "num ads-meter__value", "{value}{unit}" }
            }
            div {
                class: "ads-meter",
                role: "meter",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{value_text}",
                i { style: "width:{fill}" }
                b { style: "left:{mark}" }
            }
        }
    }
}

/// A task moving toward completion. Distinct from a meter, which is a quantity
/// against a threshold — so there is no threshold here, only an end. The label
/// names the task ("Crawling"); a progress bar with no name is a bar a screen
/// reader can only call "progress".
#[component]
pub fn AdsProgress(
    value: u32,
    #[props(default = 100u32)] max: u32,
    #[props(into)] label: String,
) -> Element {
    let percent = if max == 0 {
        0
    } else {
        (f64::from(value) / f64::from(max) * 100.0).clamp(0.0, 100.0).round() as u32
    };
    rsx! {
        div { class: "ads-progress__wrap",
            span { class: "ads-progress__head",
                span { class: "ads-progress__label", "{label}" }
                span { class: "num ads-progress__value", "{percent}%" }
            }
            div {
                class: "ads-progress",
                role: "progressbar",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                i { style: "width:{percent}%" }
            }
        }
    }
}

/// Key-value detail, which is what a selected row almost always needs. A
/// two-column grid rather than a table, so the values can wrap.
#[component]
pub fn AdsDl(
    #[props(into)] items: Vec<(String, String)>,
) -> Element {
    rsx! {
        dl { class: "ads-dl",
            for (term, value) in &items {
                dt { "{term}" }
                dd { "{value}" }
            }
        }
    }
}

/// A data-grid column. Rows are aligned to columns by index.
#[derive(Clone, PartialEq)]
pub struct GridColumn {
    pub key: String,
    pub label: String,
    /// Shown in the header after the label: "Latency (ms)".
    pub unit: Option<String>,
    /// Right-aligned, tabular. Inferred (`None`) when every cell in the column
    /// parses as a number.
    pub numeric: Option<bool>,
    /// Monospaced but left-aligned: identifiers, hashes, paths.
    pub mono: bool,
    /// Cells 1–4 rendered as a luminance grade with a dot and a word.
    pub grade: bool,
}

impl GridColumn {
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
        Self { key: key.into(), label: label.into(), unit: None, numeric: None, mono: false, grade: false }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    /// Force the column numeric (or not), instead of inferring it.
    #[must_use]
    pub fn numeric(mut self, numeric: bool) -> Self {
        self.numeric = Some(numeric);
        self
    }
    #[must_use]
    pub fn mono(mut self) -> Self {
        self.mono = true;
        self
    }
    #[must_use]
    pub fn grade(mut self) -> Self {
        self.grade = true;
        self
    }
}

/// A cell as a number, if it reads as one ("1,204" and "12.5" both do).
fn as_number(cell: &str) -> Option<f64> {
    let cleaned: String = cell.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
    if cleaned.is_empty() {
        return None;
    }
    cleaned.parse::<f64>().ok()
}

fn compare_cells(x: Option<&String>, y: Option<&String>) -> Ordering {
    let x = x.map(String::as_str).unwrap_or("");
    let y = y.map(String::as_str).unwrap_or("");
    match (as_number(x), as_number(y)) {
        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
        _ => x.cmp(y),
    }
}

/// A missing cell reads as a dash, not as nothing.
fn display(cell: Option<&String>) -> String {
    cell.filter(|v| !v.is_empty()).map_or_else(|| "—".to_owned(), Clone::clone)
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const GRADE_LABEL: [&str; 5] = ["", "low", "medium", "high", "critical"];

fn as_grade(cell: Option<&String>) -> u8 {
    cell.and_then(|c| c.trim().parse::<f64>().ok())
        .filter(|n| *n != 0.0 && n.is_finite())
        .map_or(1, |n| n.clamp(1.0, 4.0) as u8)
}

/// The table from the app, on the web.
///
/// Rows are separated by a whisper rather than a rule — never striped, because
/// a stripe is a second signal competing with the cursor and the grades.
/// Numeric columns are right-aligned in tabular figures so they read by
/// magnitude down the page; text stays left. A column's unit lives in its
/// header, once, not in every cell. The header sorts, and a keyboard cursor
/// moves with the arrow keys; Enter (or a click) selects the row, which is the
/// cue to open it in a drawer beside the table — never in a modal over it.
#[component]
pub fn AdsDataGrid(
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, optional)] caption: Option<String>,
    /// Fires with the row (as displayed, after sorting) and its index.
    #[props(optional)]
    onselect: Option<EventHandler<(Vec<String>, usize)>>,
) -> Element {
    let mut sort_key = use_signal(|| None::<usize>);
    let mut sort_asc = use_signal(|| true);
    let mut cursor = use_signal(|| None::<usize>);

    let is_num: Vec<bool> = columns
        .iter()
        .enumerate()
        .map(|(ci, c)| {
            !c.grade
                && c.numeric.unwrap_or_else(|| {
                    !rows.is_empty() && rows.iter().all(|r| r.get(ci).is_some_and(|v| as_number(v).is_some()))
                })
        })
        .collect();

    let mut sorted = rows.clone();
    if let Some(k) = sort_key() {
        let asc = sort_asc();
        sorted.sort_by(|a, b| {
            let cmp = compare_cells(a.get(k), b.get(k));
            if asc { cmp } else { cmp.reverse() }
        });
    }
    let len = sorted.len();
    // The rows may have shrunk under the cursor.
    let cur = cursor().filter(|_| len > 0).map(|c| c.min(len - 1));

    // The cursor is announced, since a moving highlight is invisible to a
    // screen reader: the first column names the row.
    let announce = match cur {
        Some(c) if !columns.is_empty() => {
            format!("{}, row {} of {len}", display(sorted[c].first()), c + 1)
        }
        _ => String::new(),
    };
    let group_label = format!("{}. Arrow keys move, Enter opens the row.", caption.as_deref().unwrap_or("Data table"));

    let keyed = sorted.clone();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let at = cur.map_or(-1, |c| c as i64);
        let to = match e.key() {
            Key::ArrowDown => at + 1,
            Key::ArrowUp => at - 1,
            Key::PageDown => at + 10,
            Key::PageUp => at - 10,
            Key::Home => 0,
            Key::End => last as i64,
            Key::Enter => {
                if let (Some(c), Some(h)) = (cur, &onselect) {
                    h.call((keyed[c].clone(), c));
                }
                if cur.is_some() {
                    e.prevent_default();
                }
                return;
            }
            _ => return,
        };
        e.prevent_default();
        cursor.set(Some(to.clamp(0, last as i64) as usize));
    };

    rsx! {
        div {
            class: "ads-data-grid",
            tabindex: "0",
            role: "group",
            "aria-label": "{group_label}",
            onkeydown: onkey,
            table { class: "ads-data-grid__table",
                if let Some(c) = &caption { caption { class: "sr-only", "{c}" } }
                thead {
                    tr {
                        for (ci, c) in columns.iter().enumerate() {
                            th {
                                key: "{c.key}",
                                scope: "col",
                                class: if is_num[ci] { "ads-data-grid__th is-num" } else { "ads-data-grid__th" },
                                "aria-sort": match (sort_key() == Some(ci), sort_asc()) {
                                    (true, true) => "ascending",
                                    (true, false) => "descending",
                                    _ => "none",
                                },
                                button {
                                    r#type: "button",
                                    class: "ads-data-grid__sort",
                                    onclick: move |_| {
                                        if sort_key() == Some(ci) {
                                            sort_asc.toggle();
                                        } else {
                                            sort_key.set(Some(ci));
                                            sort_asc.set(true);
                                        }
                                    },
                                    "{c.label}"
                                    if let Some(u) = &c.unit { span { class: "ads-data-grid__unit", " ({u})" } }
                                    span {
                                        class: crate::class::join(&[
                                            "ads-data-grid__caret",
                                            if sort_key() == Some(ci) { "is-active" } else { "" },
                                            if sort_key() == Some(ci) && !sort_asc() { "is-desc" } else { "" },
                                        ]),
                                        "aria-hidden": "true",
                                        "↑"
                                    }
                                }
                            }
                        }
                    }
                }
                tbody {
                    for (i, r) in sorted.iter().enumerate() {
                        tr {
                            key: "{i}",
                            class: if cur == Some(i) { "ads-data-grid__row is-cursor" } else { "ads-data-grid__row" },
                            "aria-current": if cur == Some(i) { Some("true") } else { None },
                            onmouseenter: move |_| cursor.set(Some(i)),
                            onclick: {
                                let row = r.clone();
                                move |_| {
                                    if let Some(h) = &onselect {
                                        h.call((row.clone(), i));
                                    }
                                }
                            },
                            for (ci, c) in columns.iter().enumerate() {
                                td {
                                    key: "{c.key}",
                                    class: crate::class::join(&[
                                        "ads-data-grid__td",
                                        if is_num[ci] { "is-num" } else { "" },
                                        if is_num[ci] || c.mono { "num" } else { "" },
                                    ]),
                                    if c.grade {
                                        AdsStatus {
                                            grade: as_grade(r.get(ci)),
                                            label: GRADE_LABEL[as_grade(r.get(ci)) as usize],
                                        }
                                    } else {
                                        {display(r.get(ci))}
                                    }
                                }
                            }
                        }
                    }
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{announce}" }
        }
    }
}

AdsDelta

up12.4%down3.1%
rust
AdsDelta { value: "12.4%", direction: DeltaDirection::Up }
Source · ads/ads-rs/src/ui/chip.rs
rust
//! Badge, status, delta, and tag.

use dioxus::prelude::*;

/// A count or code pill. The accent variant is the one number on screen that
/// matters; the quiet variant sits inside the chrome. A bare "5" means nothing
/// to a screen reader, so `label` says what is being counted ("unread").
#[component]
pub fn AdsBadge(
    #[props(into)] value: String,
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] quiet: bool,
) -> Element {
    let class = crate::class::join(&["ads-badge num", if quiet { "is-quiet" } else { "" }]);
    rsx! {
        span { class: "{class}",
            "{value}"
            if let Some(l) = label.as_deref().filter(|l| !l.is_empty()) {
                span { class: "sr-only", " {l}" }
            }
        }
    }
}

/// A single state outside dense data: the three semantic colours, spoken for.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StatusTone {
    Ok,
    Warn,
    Bad,
}

impl StatusTone {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Warn => "warn",
            Self::Bad => "bad",
        }
    }
}

/// Severity in a monochrome system is luminance: the worse it is, the brighter
/// it is. The glyph (dot) carries it too, and the label is required, so the
/// rank survives a greyscale print and a colour-vision deficiency alike.
///
/// `grade` (1–4) is for dense data — a severity column, a heat grid. `tone` is
/// for a single state outside it (a health check, a job), where the three
/// semantic colours are allowed; it wins over `grade` when both are given.
/// There is no colour prop on purpose: state has three colours and they are
/// spoken for.
#[component]
pub fn AdsStatus(
    #[props(into)] label: String,
    #[props(default = 1u8)] grade: u8,
    #[props(optional)] tone: Option<StatusTone>,
) -> Element {
    let modifier = match tone {
        Some(t) => format!("is-{}", t.as_str()),
        None => format!("g{}", grade.clamp(1, 4)),
    };
    let class = crate::class::join(&["ads-status", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "ads-dot", "aria-hidden": "true" }
            "{label}"
        }
    }
}

/// A change against the previous period. The direction glyph is mandatory — a
/// delta without one is a number nobody can read at speed — so `direction` is
/// required rather than defaulted, and it is spoken as a word too, because a
/// screen reader does not reliably read a CSS triangle.
#[component]
pub fn AdsDelta(
    #[props(into)] value: String,
    direction: DeltaDirection,
) -> Element {
    let modifier = format!("is-{}", direction.as_str());
    let class = crate::class::join(&["ads-delta num", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "sr-only", "{direction.spoken()} " }
            "{value}"
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum DeltaDirection {
    Up,
    Down,
    #[default]
    Flat,
}

impl DeltaDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "flat",
        }
    }

    /// The word a screen reader says in place of the triangle.
    #[must_use]
    pub const fn spoken(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "unchanged",
        }
    }
}

/// A filter the reader applied and can take off again. Always removable: the
/// dismiss affordance is the whole difference from a status chip, which
/// describes the data and cannot be removed — for that, use `AdsStatus`.
#[component]
pub fn AdsTag(
    #[props(into)] label: String,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "ads-tag",
            "{label}"
            button {
                r#type: "button",
                "aria-label": "Remove filter {label}",
                onclick: move |e| {
                    if let Some(h) = &onremove {
                        h.call(e);
                    }
                },
                "×"
            }
        }
    }
}

AdsBadge

5unreaddraft
rust
AdsBadge { value: "5", label: "unread" }
Source · ads/ads-rs/src/ui/chip.rs
rust
//! Badge, status, delta, and tag.

use dioxus::prelude::*;

/// A count or code pill. The accent variant is the one number on screen that
/// matters; the quiet variant sits inside the chrome. A bare "5" means nothing
/// to a screen reader, so `label` says what is being counted ("unread").
#[component]
pub fn AdsBadge(
    #[props(into)] value: String,
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] quiet: bool,
) -> Element {
    let class = crate::class::join(&["ads-badge num", if quiet { "is-quiet" } else { "" }]);
    rsx! {
        span { class: "{class}",
            "{value}"
            if let Some(l) = label.as_deref().filter(|l| !l.is_empty()) {
                span { class: "sr-only", " {l}" }
            }
        }
    }
}

/// A single state outside dense data: the three semantic colours, spoken for.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StatusTone {
    Ok,
    Warn,
    Bad,
}

impl StatusTone {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Warn => "warn",
            Self::Bad => "bad",
        }
    }
}

/// Severity in a monochrome system is luminance: the worse it is, the brighter
/// it is. The glyph (dot) carries it too, and the label is required, so the
/// rank survives a greyscale print and a colour-vision deficiency alike.
///
/// `grade` (1–4) is for dense data — a severity column, a heat grid. `tone` is
/// for a single state outside it (a health check, a job), where the three
/// semantic colours are allowed; it wins over `grade` when both are given.
/// There is no colour prop on purpose: state has three colours and they are
/// spoken for.
#[component]
pub fn AdsStatus(
    #[props(into)] label: String,
    #[props(default = 1u8)] grade: u8,
    #[props(optional)] tone: Option<StatusTone>,
) -> Element {
    let modifier = match tone {
        Some(t) => format!("is-{}", t.as_str()),
        None => format!("g{}", grade.clamp(1, 4)),
    };
    let class = crate::class::join(&["ads-status", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "ads-dot", "aria-hidden": "true" }
            "{label}"
        }
    }
}

/// A change against the previous period. The direction glyph is mandatory — a
/// delta without one is a number nobody can read at speed — so `direction` is
/// required rather than defaulted, and it is spoken as a word too, because a
/// screen reader does not reliably read a CSS triangle.
#[component]
pub fn AdsDelta(
    #[props(into)] value: String,
    direction: DeltaDirection,
) -> Element {
    let modifier = format!("is-{}", direction.as_str());
    let class = crate::class::join(&["ads-delta num", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "sr-only", "{direction.spoken()} " }
            "{value}"
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum DeltaDirection {
    Up,
    Down,
    #[default]
    Flat,
}

impl DeltaDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "flat",
        }
    }

    /// The word a screen reader says in place of the triangle.
    #[must_use]
    pub const fn spoken(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "unchanged",
        }
    }
}

/// A filter the reader applied and can take off again. Always removable: the
/// dismiss affordance is the whole difference from a status chip, which
/// describes the data and cannot be removed — for that, use `AdsStatus`.
#[component]
pub fn AdsTag(
    #[props(into)] label: String,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "ads-tag",
            "{label}"
            button {
                r#type: "button",
                "aria-label": "Remove filter {label}",
                onclick: move |e| {
                    if let Some(h) = &onremove {
                        h.call(e);
                    }
                },
                "×"
            }
        }
    }
}

AdsStatus

Healthy Critical Job failed
rust
AdsStatus { label: "Critical", grade: 4 }
AdsStatus { label: "Job failed", tone: StatusTone::Bad }
Source · ads/ads-rs/src/ui/chip.rs
rust
//! Badge, status, delta, and tag.

use dioxus::prelude::*;

/// A count or code pill. The accent variant is the one number on screen that
/// matters; the quiet variant sits inside the chrome. A bare "5" means nothing
/// to a screen reader, so `label` says what is being counted ("unread").
#[component]
pub fn AdsBadge(
    #[props(into)] value: String,
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] quiet: bool,
) -> Element {
    let class = crate::class::join(&["ads-badge num", if quiet { "is-quiet" } else { "" }]);
    rsx! {
        span { class: "{class}",
            "{value}"
            if let Some(l) = label.as_deref().filter(|l| !l.is_empty()) {
                span { class: "sr-only", " {l}" }
            }
        }
    }
}

/// A single state outside dense data: the three semantic colours, spoken for.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StatusTone {
    Ok,
    Warn,
    Bad,
}

impl StatusTone {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Warn => "warn",
            Self::Bad => "bad",
        }
    }
}

/// Severity in a monochrome system is luminance: the worse it is, the brighter
/// it is. The glyph (dot) carries it too, and the label is required, so the
/// rank survives a greyscale print and a colour-vision deficiency alike.
///
/// `grade` (1–4) is for dense data — a severity column, a heat grid. `tone` is
/// for a single state outside it (a health check, a job), where the three
/// semantic colours are allowed; it wins over `grade` when both are given.
/// There is no colour prop on purpose: state has three colours and they are
/// spoken for.
#[component]
pub fn AdsStatus(
    #[props(into)] label: String,
    #[props(default = 1u8)] grade: u8,
    #[props(optional)] tone: Option<StatusTone>,
) -> Element {
    let modifier = match tone {
        Some(t) => format!("is-{}", t.as_str()),
        None => format!("g{}", grade.clamp(1, 4)),
    };
    let class = crate::class::join(&["ads-status", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "ads-dot", "aria-hidden": "true" }
            "{label}"
        }
    }
}

/// A change against the previous period. The direction glyph is mandatory — a
/// delta without one is a number nobody can read at speed — so `direction` is
/// required rather than defaulted, and it is spoken as a word too, because a
/// screen reader does not reliably read a CSS triangle.
#[component]
pub fn AdsDelta(
    #[props(into)] value: String,
    direction: DeltaDirection,
) -> Element {
    let modifier = format!("is-{}", direction.as_str());
    let class = crate::class::join(&["ads-delta num", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "sr-only", "{direction.spoken()} " }
            "{value}"
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum DeltaDirection {
    Up,
    Down,
    #[default]
    Flat,
}

impl DeltaDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "flat",
        }
    }

    /// The word a screen reader says in place of the triangle.
    #[must_use]
    pub const fn spoken(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "unchanged",
        }
    }
}

/// A filter the reader applied and can take off again. Always removable: the
/// dismiss affordance is the whole difference from a status chip, which
/// describes the data and cannot be removed — for that, use `AdsStatus`.
#[component]
pub fn AdsTag(
    #[props(into)] label: String,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "ads-tag",
            "{label}"
            button {
                r#type: "button",
                "aria-label": "Remove filter {label}",
                onclick: move |e| {
                    if let Some(h) = &onremove {
                        h.call(e);
                    }
                },
                "×"
            }
        }
    }
}

AdsMeter

Budget used68%
rust
AdsMeter { value: 68, threshold: 80, label: "Budget used", unit: "%" }
Source · ads/ads-rs/src/ui/stat.rs
rust
//! Stat tile, meter, progress, structured list, and data grid.

use std::cmp::Ordering;

use dioxus::prelude::*;

use crate::ui::{AdsDelta, AdsStatus, DeltaDirection};

/// A KPI tile. The number is monospaced and large, the label is small and
/// quiet — and the figure always travels with a comparison, because a number
/// on its own is a label with extra steps. Give it a `delta` (read with
/// `direction`), the `previous` period, a `target`, or at the very least a
/// `note` saying what it is against.
#[component]
pub fn AdsStat(
    #[props(into)] value: String,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
    /// The change, already formatted ("12.4%"). Read with `direction`.
    #[props(into, optional)]
    delta: Option<String>,
    #[props(default)] direction: DeltaDirection,
    /// The same figure last period, shown as "prev …".
    #[props(into, optional)]
    previous: Option<String>,
    /// What the figure is aiming at, shown as "target …".
    #[props(into, optional)]
    target: Option<String>,
    /// A free-form comparison: "+37 this run", "of 100".
    #[props(into, optional)]
    note: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let delta = delta.filter(|d| !d.is_empty());
    let note = note.filter(|n| !n.is_empty());
    let has_comparison = delta.is_some() || previous.is_some() || target.is_some() || note.is_some();
    rsx! {
        div { class: "ads-stat card-press",
            span { class: "eyebrow", "{label}" }
            span { class: "num ads-stat__value",
                "{value}"
                if !unit.is_empty() { span { class: "ads-stat__unit", "{unit}" } }
            }
            if has_comparison {
                span { class: "ads-stat__compare",
                    if let Some(d) = &delta { AdsDelta { direction, value: d.clone() } }
                    if let Some(p) = &previous { span { class: "num ads-stat__note", "prev {p}{unit}" } }
                    if let Some(t) = &target { span { class: "num ads-stat__note", "target {t}{unit}" } }
                    if let Some(n) = &note { span { class: "num ads-stat__note", "{n}" } }
                }
            }
        }
    }
}

/// A position along `max`, as a CSS percentage clamped to the track.
fn at(n: u32, max: u32) -> String {
    let pct = if max == 0 { 0.0 } else { (f64::from(n) / f64::from(max) * 100.0).clamp(0.0, 100.0) };
    format!("{pct}%")
}

/// A quantity against a threshold. Distinct from progress, which is a task
/// moving toward completion. The threshold is required: it draws the mark the
/// reader needs to know whether the fill is good news, and a meter without one
/// is decoration. The label is required too — it is the meter's name, for the
/// eye and for a screen reader alike.
#[component]
pub fn AdsMeter(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let value_text = format!("{value}{unit} of {max}{unit}, threshold {threshold}{unit}");
    let fill = at(value, max);
    let mark = at(threshold, max);
    rsx! {
        div { class: "ads-meter__wrap",
            span { class: "ads-meter__head",
                span { class: "ads-meter__label", "{label}" }
                span { class: "num ads-meter__value", "{value}{unit}" }
            }
            div {
                class: "ads-meter",
                role: "meter",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{value_text}",
                i { style: "width:{fill}" }
                b { style: "left:{mark}" }
            }
        }
    }
}

/// A task moving toward completion. Distinct from a meter, which is a quantity
/// against a threshold — so there is no threshold here, only an end. The label
/// names the task ("Crawling"); a progress bar with no name is a bar a screen
/// reader can only call "progress".
#[component]
pub fn AdsProgress(
    value: u32,
    #[props(default = 100u32)] max: u32,
    #[props(into)] label: String,
) -> Element {
    let percent = if max == 0 {
        0
    } else {
        (f64::from(value) / f64::from(max) * 100.0).clamp(0.0, 100.0).round() as u32
    };
    rsx! {
        div { class: "ads-progress__wrap",
            span { class: "ads-progress__head",
                span { class: "ads-progress__label", "{label}" }
                span { class: "num ads-progress__value", "{percent}%" }
            }
            div {
                class: "ads-progress",
                role: "progressbar",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                i { style: "width:{percent}%" }
            }
        }
    }
}

/// Key-value detail, which is what a selected row almost always needs. A
/// two-column grid rather than a table, so the values can wrap.
#[component]
pub fn AdsDl(
    #[props(into)] items: Vec<(String, String)>,
) -> Element {
    rsx! {
        dl { class: "ads-dl",
            for (term, value) in &items {
                dt { "{term}" }
                dd { "{value}" }
            }
        }
    }
}

/// A data-grid column. Rows are aligned to columns by index.
#[derive(Clone, PartialEq)]
pub struct GridColumn {
    pub key: String,
    pub label: String,
    /// Shown in the header after the label: "Latency (ms)".
    pub unit: Option<String>,
    /// Right-aligned, tabular. Inferred (`None`) when every cell in the column
    /// parses as a number.
    pub numeric: Option<bool>,
    /// Monospaced but left-aligned: identifiers, hashes, paths.
    pub mono: bool,
    /// Cells 1–4 rendered as a luminance grade with a dot and a word.
    pub grade: bool,
}

impl GridColumn {
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
        Self { key: key.into(), label: label.into(), unit: None, numeric: None, mono: false, grade: false }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    /// Force the column numeric (or not), instead of inferring it.
    #[must_use]
    pub fn numeric(mut self, numeric: bool) -> Self {
        self.numeric = Some(numeric);
        self
    }
    #[must_use]
    pub fn mono(mut self) -> Self {
        self.mono = true;
        self
    }
    #[must_use]
    pub fn grade(mut self) -> Self {
        self.grade = true;
        self
    }
}

/// A cell as a number, if it reads as one ("1,204" and "12.5" both do).
fn as_number(cell: &str) -> Option<f64> {
    let cleaned: String = cell.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
    if cleaned.is_empty() {
        return None;
    }
    cleaned.parse::<f64>().ok()
}

fn compare_cells(x: Option<&String>, y: Option<&String>) -> Ordering {
    let x = x.map(String::as_str).unwrap_or("");
    let y = y.map(String::as_str).unwrap_or("");
    match (as_number(x), as_number(y)) {
        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
        _ => x.cmp(y),
    }
}

/// A missing cell reads as a dash, not as nothing.
fn display(cell: Option<&String>) -> String {
    cell.filter(|v| !v.is_empty()).map_or_else(|| "—".to_owned(), Clone::clone)
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const GRADE_LABEL: [&str; 5] = ["", "low", "medium", "high", "critical"];

fn as_grade(cell: Option<&String>) -> u8 {
    cell.and_then(|c| c.trim().parse::<f64>().ok())
        .filter(|n| *n != 0.0 && n.is_finite())
        .map_or(1, |n| n.clamp(1.0, 4.0) as u8)
}

/// The table from the app, on the web.
///
/// Rows are separated by a whisper rather than a rule — never striped, because
/// a stripe is a second signal competing with the cursor and the grades.
/// Numeric columns are right-aligned in tabular figures so they read by
/// magnitude down the page; text stays left. A column's unit lives in its
/// header, once, not in every cell. The header sorts, and a keyboard cursor
/// moves with the arrow keys; Enter (or a click) selects the row, which is the
/// cue to open it in a drawer beside the table — never in a modal over it.
#[component]
pub fn AdsDataGrid(
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, optional)] caption: Option<String>,
    /// Fires with the row (as displayed, after sorting) and its index.
    #[props(optional)]
    onselect: Option<EventHandler<(Vec<String>, usize)>>,
) -> Element {
    let mut sort_key = use_signal(|| None::<usize>);
    let mut sort_asc = use_signal(|| true);
    let mut cursor = use_signal(|| None::<usize>);

    let is_num: Vec<bool> = columns
        .iter()
        .enumerate()
        .map(|(ci, c)| {
            !c.grade
                && c.numeric.unwrap_or_else(|| {
                    !rows.is_empty() && rows.iter().all(|r| r.get(ci).is_some_and(|v| as_number(v).is_some()))
                })
        })
        .collect();

    let mut sorted = rows.clone();
    if let Some(k) = sort_key() {
        let asc = sort_asc();
        sorted.sort_by(|a, b| {
            let cmp = compare_cells(a.get(k), b.get(k));
            if asc { cmp } else { cmp.reverse() }
        });
    }
    let len = sorted.len();
    // The rows may have shrunk under the cursor.
    let cur = cursor().filter(|_| len > 0).map(|c| c.min(len - 1));

    // The cursor is announced, since a moving highlight is invisible to a
    // screen reader: the first column names the row.
    let announce = match cur {
        Some(c) if !columns.is_empty() => {
            format!("{}, row {} of {len}", display(sorted[c].first()), c + 1)
        }
        _ => String::new(),
    };
    let group_label = format!("{}. Arrow keys move, Enter opens the row.", caption.as_deref().unwrap_or("Data table"));

    let keyed = sorted.clone();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let at = cur.map_or(-1, |c| c as i64);
        let to = match e.key() {
            Key::ArrowDown => at + 1,
            Key::ArrowUp => at - 1,
            Key::PageDown => at + 10,
            Key::PageUp => at - 10,
            Key::Home => 0,
            Key::End => last as i64,
            Key::Enter => {
                if let (Some(c), Some(h)) = (cur, &onselect) {
                    h.call((keyed[c].clone(), c));
                }
                if cur.is_some() {
                    e.prevent_default();
                }
                return;
            }
            _ => return,
        };
        e.prevent_default();
        cursor.set(Some(to.clamp(0, last as i64) as usize));
    };

    rsx! {
        div {
            class: "ads-data-grid",
            tabindex: "0",
            role: "group",
            "aria-label": "{group_label}",
            onkeydown: onkey,
            table { class: "ads-data-grid__table",
                if let Some(c) = &caption { caption { class: "sr-only", "{c}" } }
                thead {
                    tr {
                        for (ci, c) in columns.iter().enumerate() {
                            th {
                                key: "{c.key}",
                                scope: "col",
                                class: if is_num[ci] { "ads-data-grid__th is-num" } else { "ads-data-grid__th" },
                                "aria-sort": match (sort_key() == Some(ci), sort_asc()) {
                                    (true, true) => "ascending",
                                    (true, false) => "descending",
                                    _ => "none",
                                },
                                button {
                                    r#type: "button",
                                    class: "ads-data-grid__sort",
                                    onclick: move |_| {
                                        if sort_key() == Some(ci) {
                                            sort_asc.toggle();
                                        } else {
                                            sort_key.set(Some(ci));
                                            sort_asc.set(true);
                                        }
                                    },
                                    "{c.label}"
                                    if let Some(u) = &c.unit { span { class: "ads-data-grid__unit", " ({u})" } }
                                    span {
                                        class: crate::class::join(&[
                                            "ads-data-grid__caret",
                                            if sort_key() == Some(ci) { "is-active" } else { "" },
                                            if sort_key() == Some(ci) && !sort_asc() { "is-desc" } else { "" },
                                        ]),
                                        "aria-hidden": "true",
                                        "↑"
                                    }
                                }
                            }
                        }
                    }
                }
                tbody {
                    for (i, r) in sorted.iter().enumerate() {
                        tr {
                            key: "{i}",
                            class: if cur == Some(i) { "ads-data-grid__row is-cursor" } else { "ads-data-grid__row" },
                            "aria-current": if cur == Some(i) { Some("true") } else { None },
                            onmouseenter: move |_| cursor.set(Some(i)),
                            onclick: {
                                let row = r.clone();
                                move |_| {
                                    if let Some(h) = &onselect {
                                        h.call((row.clone(), i));
                                    }
                                }
                            },
                            for (ci, c) in columns.iter().enumerate() {
                                td {
                                    key: "{c.key}",
                                    class: crate::class::join(&[
                                        "ads-data-grid__td",
                                        if is_num[ci] { "is-num" } else { "" },
                                        if is_num[ci] || c.mono { "num" } else { "" },
                                    ]),
                                    if c.grade {
                                        AdsStatus {
                                            grade: as_grade(r.get(ci)),
                                            label: GRADE_LABEL[as_grade(r.get(ci)) as usize],
                                        }
                                    } else {
                                        {display(r.get(ci))}
                                    }
                                }
                            }
                        }
                    }
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{announce}" }
        }
    }
}

AdsProgress

Crawling46%
rust
AdsProgress { value: 46, label: "Crawling" }
Source · ads/ads-rs/src/ui/stat.rs
rust
//! Stat tile, meter, progress, structured list, and data grid.

use std::cmp::Ordering;

use dioxus::prelude::*;

use crate::ui::{AdsDelta, AdsStatus, DeltaDirection};

/// A KPI tile. The number is monospaced and large, the label is small and
/// quiet — and the figure always travels with a comparison, because a number
/// on its own is a label with extra steps. Give it a `delta` (read with
/// `direction`), the `previous` period, a `target`, or at the very least a
/// `note` saying what it is against.
#[component]
pub fn AdsStat(
    #[props(into)] value: String,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
    /// The change, already formatted ("12.4%"). Read with `direction`.
    #[props(into, optional)]
    delta: Option<String>,
    #[props(default)] direction: DeltaDirection,
    /// The same figure last period, shown as "prev …".
    #[props(into, optional)]
    previous: Option<String>,
    /// What the figure is aiming at, shown as "target …".
    #[props(into, optional)]
    target: Option<String>,
    /// A free-form comparison: "+37 this run", "of 100".
    #[props(into, optional)]
    note: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let delta = delta.filter(|d| !d.is_empty());
    let note = note.filter(|n| !n.is_empty());
    let has_comparison = delta.is_some() || previous.is_some() || target.is_some() || note.is_some();
    rsx! {
        div { class: "ads-stat card-press",
            span { class: "eyebrow", "{label}" }
            span { class: "num ads-stat__value",
                "{value}"
                if !unit.is_empty() { span { class: "ads-stat__unit", "{unit}" } }
            }
            if has_comparison {
                span { class: "ads-stat__compare",
                    if let Some(d) = &delta { AdsDelta { direction, value: d.clone() } }
                    if let Some(p) = &previous { span { class: "num ads-stat__note", "prev {p}{unit}" } }
                    if let Some(t) = &target { span { class: "num ads-stat__note", "target {t}{unit}" } }
                    if let Some(n) = &note { span { class: "num ads-stat__note", "{n}" } }
                }
            }
        }
    }
}

/// A position along `max`, as a CSS percentage clamped to the track.
fn at(n: u32, max: u32) -> String {
    let pct = if max == 0 { 0.0 } else { (f64::from(n) / f64::from(max) * 100.0).clamp(0.0, 100.0) };
    format!("{pct}%")
}

/// A quantity against a threshold. Distinct from progress, which is a task
/// moving toward completion. The threshold is required: it draws the mark the
/// reader needs to know whether the fill is good news, and a meter without one
/// is decoration. The label is required too — it is the meter's name, for the
/// eye and for a screen reader alike.
#[component]
pub fn AdsMeter(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let value_text = format!("{value}{unit} of {max}{unit}, threshold {threshold}{unit}");
    let fill = at(value, max);
    let mark = at(threshold, max);
    rsx! {
        div { class: "ads-meter__wrap",
            span { class: "ads-meter__head",
                span { class: "ads-meter__label", "{label}" }
                span { class: "num ads-meter__value", "{value}{unit}" }
            }
            div {
                class: "ads-meter",
                role: "meter",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{value_text}",
                i { style: "width:{fill}" }
                b { style: "left:{mark}" }
            }
        }
    }
}

/// A task moving toward completion. Distinct from a meter, which is a quantity
/// against a threshold — so there is no threshold here, only an end. The label
/// names the task ("Crawling"); a progress bar with no name is a bar a screen
/// reader can only call "progress".
#[component]
pub fn AdsProgress(
    value: u32,
    #[props(default = 100u32)] max: u32,
    #[props(into)] label: String,
) -> Element {
    let percent = if max == 0 {
        0
    } else {
        (f64::from(value) / f64::from(max) * 100.0).clamp(0.0, 100.0).round() as u32
    };
    rsx! {
        div { class: "ads-progress__wrap",
            span { class: "ads-progress__head",
                span { class: "ads-progress__label", "{label}" }
                span { class: "num ads-progress__value", "{percent}%" }
            }
            div {
                class: "ads-progress",
                role: "progressbar",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                i { style: "width:{percent}%" }
            }
        }
    }
}

/// Key-value detail, which is what a selected row almost always needs. A
/// two-column grid rather than a table, so the values can wrap.
#[component]
pub fn AdsDl(
    #[props(into)] items: Vec<(String, String)>,
) -> Element {
    rsx! {
        dl { class: "ads-dl",
            for (term, value) in &items {
                dt { "{term}" }
                dd { "{value}" }
            }
        }
    }
}

/// A data-grid column. Rows are aligned to columns by index.
#[derive(Clone, PartialEq)]
pub struct GridColumn {
    pub key: String,
    pub label: String,
    /// Shown in the header after the label: "Latency (ms)".
    pub unit: Option<String>,
    /// Right-aligned, tabular. Inferred (`None`) when every cell in the column
    /// parses as a number.
    pub numeric: Option<bool>,
    /// Monospaced but left-aligned: identifiers, hashes, paths.
    pub mono: bool,
    /// Cells 1–4 rendered as a luminance grade with a dot and a word.
    pub grade: bool,
}

impl GridColumn {
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
        Self { key: key.into(), label: label.into(), unit: None, numeric: None, mono: false, grade: false }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    /// Force the column numeric (or not), instead of inferring it.
    #[must_use]
    pub fn numeric(mut self, numeric: bool) -> Self {
        self.numeric = Some(numeric);
        self
    }
    #[must_use]
    pub fn mono(mut self) -> Self {
        self.mono = true;
        self
    }
    #[must_use]
    pub fn grade(mut self) -> Self {
        self.grade = true;
        self
    }
}

/// A cell as a number, if it reads as one ("1,204" and "12.5" both do).
fn as_number(cell: &str) -> Option<f64> {
    let cleaned: String = cell.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
    if cleaned.is_empty() {
        return None;
    }
    cleaned.parse::<f64>().ok()
}

fn compare_cells(x: Option<&String>, y: Option<&String>) -> Ordering {
    let x = x.map(String::as_str).unwrap_or("");
    let y = y.map(String::as_str).unwrap_or("");
    match (as_number(x), as_number(y)) {
        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
        _ => x.cmp(y),
    }
}

/// A missing cell reads as a dash, not as nothing.
fn display(cell: Option<&String>) -> String {
    cell.filter(|v| !v.is_empty()).map_or_else(|| "—".to_owned(), Clone::clone)
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const GRADE_LABEL: [&str; 5] = ["", "low", "medium", "high", "critical"];

fn as_grade(cell: Option<&String>) -> u8 {
    cell.and_then(|c| c.trim().parse::<f64>().ok())
        .filter(|n| *n != 0.0 && n.is_finite())
        .map_or(1, |n| n.clamp(1.0, 4.0) as u8)
}

/// The table from the app, on the web.
///
/// Rows are separated by a whisper rather than a rule — never striped, because
/// a stripe is a second signal competing with the cursor and the grades.
/// Numeric columns are right-aligned in tabular figures so they read by
/// magnitude down the page; text stays left. A column's unit lives in its
/// header, once, not in every cell. The header sorts, and a keyboard cursor
/// moves with the arrow keys; Enter (or a click) selects the row, which is the
/// cue to open it in a drawer beside the table — never in a modal over it.
#[component]
pub fn AdsDataGrid(
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, optional)] caption: Option<String>,
    /// Fires with the row (as displayed, after sorting) and its index.
    #[props(optional)]
    onselect: Option<EventHandler<(Vec<String>, usize)>>,
) -> Element {
    let mut sort_key = use_signal(|| None::<usize>);
    let mut sort_asc = use_signal(|| true);
    let mut cursor = use_signal(|| None::<usize>);

    let is_num: Vec<bool> = columns
        .iter()
        .enumerate()
        .map(|(ci, c)| {
            !c.grade
                && c.numeric.unwrap_or_else(|| {
                    !rows.is_empty() && rows.iter().all(|r| r.get(ci).is_some_and(|v| as_number(v).is_some()))
                })
        })
        .collect();

    let mut sorted = rows.clone();
    if let Some(k) = sort_key() {
        let asc = sort_asc();
        sorted.sort_by(|a, b| {
            let cmp = compare_cells(a.get(k), b.get(k));
            if asc { cmp } else { cmp.reverse() }
        });
    }
    let len = sorted.len();
    // The rows may have shrunk under the cursor.
    let cur = cursor().filter(|_| len > 0).map(|c| c.min(len - 1));

    // The cursor is announced, since a moving highlight is invisible to a
    // screen reader: the first column names the row.
    let announce = match cur {
        Some(c) if !columns.is_empty() => {
            format!("{}, row {} of {len}", display(sorted[c].first()), c + 1)
        }
        _ => String::new(),
    };
    let group_label = format!("{}. Arrow keys move, Enter opens the row.", caption.as_deref().unwrap_or("Data table"));

    let keyed = sorted.clone();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let at = cur.map_or(-1, |c| c as i64);
        let to = match e.key() {
            Key::ArrowDown => at + 1,
            Key::ArrowUp => at - 1,
            Key::PageDown => at + 10,
            Key::PageUp => at - 10,
            Key::Home => 0,
            Key::End => last as i64,
            Key::Enter => {
                if let (Some(c), Some(h)) = (cur, &onselect) {
                    h.call((keyed[c].clone(), c));
                }
                if cur.is_some() {
                    e.prevent_default();
                }
                return;
            }
            _ => return,
        };
        e.prevent_default();
        cursor.set(Some(to.clamp(0, last as i64) as usize));
    };

    rsx! {
        div {
            class: "ads-data-grid",
            tabindex: "0",
            role: "group",
            "aria-label": "{group_label}",
            onkeydown: onkey,
            table { class: "ads-data-grid__table",
                if let Some(c) = &caption { caption { class: "sr-only", "{c}" } }
                thead {
                    tr {
                        for (ci, c) in columns.iter().enumerate() {
                            th {
                                key: "{c.key}",
                                scope: "col",
                                class: if is_num[ci] { "ads-data-grid__th is-num" } else { "ads-data-grid__th" },
                                "aria-sort": match (sort_key() == Some(ci), sort_asc()) {
                                    (true, true) => "ascending",
                                    (true, false) => "descending",
                                    _ => "none",
                                },
                                button {
                                    r#type: "button",
                                    class: "ads-data-grid__sort",
                                    onclick: move |_| {
                                        if sort_key() == Some(ci) {
                                            sort_asc.toggle();
                                        } else {
                                            sort_key.set(Some(ci));
                                            sort_asc.set(true);
                                        }
                                    },
                                    "{c.label}"
                                    if let Some(u) = &c.unit { span { class: "ads-data-grid__unit", " ({u})" } }
                                    span {
                                        class: crate::class::join(&[
                                            "ads-data-grid__caret",
                                            if sort_key() == Some(ci) { "is-active" } else { "" },
                                            if sort_key() == Some(ci) && !sort_asc() { "is-desc" } else { "" },
                                        ]),
                                        "aria-hidden": "true",
                                        "↑"
                                    }
                                }
                            }
                        }
                    }
                }
                tbody {
                    for (i, r) in sorted.iter().enumerate() {
                        tr {
                            key: "{i}",
                            class: if cur == Some(i) { "ads-data-grid__row is-cursor" } else { "ads-data-grid__row" },
                            "aria-current": if cur == Some(i) { Some("true") } else { None },
                            onmouseenter: move |_| cursor.set(Some(i)),
                            onclick: {
                                let row = r.clone();
                                move |_| {
                                    if let Some(h) = &onselect {
                                        h.call((row.clone(), i));
                                    }
                                }
                            },
                            for (ci, c) in columns.iter().enumerate() {
                                td {
                                    key: "{c.key}",
                                    class: crate::class::join(&[
                                        "ads-data-grid__td",
                                        if is_num[ci] { "is-num" } else { "" },
                                        if is_num[ci] || c.mono { "num" } else { "" },
                                    ]),
                                    if c.grade {
                                        AdsStatus {
                                            grade: as_grade(r.get(ci)),
                                            label: GRADE_LABEL[as_grade(r.get(ci)) as usize],
                                        }
                                    } else {
                                        {display(r.get(ci))}
                                    }
                                }
                            }
                        }
                    }
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{announce}" }
        }
    }
}

AdsDl

Status
200 · indexable
Words
1,286
rust
AdsDl { items: vec![("Status".into(), "200 · indexable".into())] }
Source · ads/ads-rs/src/ui/stat.rs
rust
//! Stat tile, meter, progress, structured list, and data grid.

use std::cmp::Ordering;

use dioxus::prelude::*;

use crate::ui::{AdsDelta, AdsStatus, DeltaDirection};

/// A KPI tile. The number is monospaced and large, the label is small and
/// quiet — and the figure always travels with a comparison, because a number
/// on its own is a label with extra steps. Give it a `delta` (read with
/// `direction`), the `previous` period, a `target`, or at the very least a
/// `note` saying what it is against.
#[component]
pub fn AdsStat(
    #[props(into)] value: String,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
    /// The change, already formatted ("12.4%"). Read with `direction`.
    #[props(into, optional)]
    delta: Option<String>,
    #[props(default)] direction: DeltaDirection,
    /// The same figure last period, shown as "prev …".
    #[props(into, optional)]
    previous: Option<String>,
    /// What the figure is aiming at, shown as "target …".
    #[props(into, optional)]
    target: Option<String>,
    /// A free-form comparison: "+37 this run", "of 100".
    #[props(into, optional)]
    note: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let delta = delta.filter(|d| !d.is_empty());
    let note = note.filter(|n| !n.is_empty());
    let has_comparison = delta.is_some() || previous.is_some() || target.is_some() || note.is_some();
    rsx! {
        div { class: "ads-stat card-press",
            span { class: "eyebrow", "{label}" }
            span { class: "num ads-stat__value",
                "{value}"
                if !unit.is_empty() { span { class: "ads-stat__unit", "{unit}" } }
            }
            if has_comparison {
                span { class: "ads-stat__compare",
                    if let Some(d) = &delta { AdsDelta { direction, value: d.clone() } }
                    if let Some(p) = &previous { span { class: "num ads-stat__note", "prev {p}{unit}" } }
                    if let Some(t) = &target { span { class: "num ads-stat__note", "target {t}{unit}" } }
                    if let Some(n) = &note { span { class: "num ads-stat__note", "{n}" } }
                }
            }
        }
    }
}

/// A position along `max`, as a CSS percentage clamped to the track.
fn at(n: u32, max: u32) -> String {
    let pct = if max == 0 { 0.0 } else { (f64::from(n) / f64::from(max) * 100.0).clamp(0.0, 100.0) };
    format!("{pct}%")
}

/// A quantity against a threshold. Distinct from progress, which is a task
/// moving toward completion. The threshold is required: it draws the mark the
/// reader needs to know whether the fill is good news, and a meter without one
/// is decoration. The label is required too — it is the meter's name, for the
/// eye and for a screen reader alike.
#[component]
pub fn AdsMeter(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let value_text = format!("{value}{unit} of {max}{unit}, threshold {threshold}{unit}");
    let fill = at(value, max);
    let mark = at(threshold, max);
    rsx! {
        div { class: "ads-meter__wrap",
            span { class: "ads-meter__head",
                span { class: "ads-meter__label", "{label}" }
                span { class: "num ads-meter__value", "{value}{unit}" }
            }
            div {
                class: "ads-meter",
                role: "meter",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{value_text}",
                i { style: "width:{fill}" }
                b { style: "left:{mark}" }
            }
        }
    }
}

/// A task moving toward completion. Distinct from a meter, which is a quantity
/// against a threshold — so there is no threshold here, only an end. The label
/// names the task ("Crawling"); a progress bar with no name is a bar a screen
/// reader can only call "progress".
#[component]
pub fn AdsProgress(
    value: u32,
    #[props(default = 100u32)] max: u32,
    #[props(into)] label: String,
) -> Element {
    let percent = if max == 0 {
        0
    } else {
        (f64::from(value) / f64::from(max) * 100.0).clamp(0.0, 100.0).round() as u32
    };
    rsx! {
        div { class: "ads-progress__wrap",
            span { class: "ads-progress__head",
                span { class: "ads-progress__label", "{label}" }
                span { class: "num ads-progress__value", "{percent}%" }
            }
            div {
                class: "ads-progress",
                role: "progressbar",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                i { style: "width:{percent}%" }
            }
        }
    }
}

/// Key-value detail, which is what a selected row almost always needs. A
/// two-column grid rather than a table, so the values can wrap.
#[component]
pub fn AdsDl(
    #[props(into)] items: Vec<(String, String)>,
) -> Element {
    rsx! {
        dl { class: "ads-dl",
            for (term, value) in &items {
                dt { "{term}" }
                dd { "{value}" }
            }
        }
    }
}

/// A data-grid column. Rows are aligned to columns by index.
#[derive(Clone, PartialEq)]
pub struct GridColumn {
    pub key: String,
    pub label: String,
    /// Shown in the header after the label: "Latency (ms)".
    pub unit: Option<String>,
    /// Right-aligned, tabular. Inferred (`None`) when every cell in the column
    /// parses as a number.
    pub numeric: Option<bool>,
    /// Monospaced but left-aligned: identifiers, hashes, paths.
    pub mono: bool,
    /// Cells 1–4 rendered as a luminance grade with a dot and a word.
    pub grade: bool,
}

impl GridColumn {
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
        Self { key: key.into(), label: label.into(), unit: None, numeric: None, mono: false, grade: false }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    /// Force the column numeric (or not), instead of inferring it.
    #[must_use]
    pub fn numeric(mut self, numeric: bool) -> Self {
        self.numeric = Some(numeric);
        self
    }
    #[must_use]
    pub fn mono(mut self) -> Self {
        self.mono = true;
        self
    }
    #[must_use]
    pub fn grade(mut self) -> Self {
        self.grade = true;
        self
    }
}

/// A cell as a number, if it reads as one ("1,204" and "12.5" both do).
fn as_number(cell: &str) -> Option<f64> {
    let cleaned: String = cell.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
    if cleaned.is_empty() {
        return None;
    }
    cleaned.parse::<f64>().ok()
}

fn compare_cells(x: Option<&String>, y: Option<&String>) -> Ordering {
    let x = x.map(String::as_str).unwrap_or("");
    let y = y.map(String::as_str).unwrap_or("");
    match (as_number(x), as_number(y)) {
        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
        _ => x.cmp(y),
    }
}

/// A missing cell reads as a dash, not as nothing.
fn display(cell: Option<&String>) -> String {
    cell.filter(|v| !v.is_empty()).map_or_else(|| "—".to_owned(), Clone::clone)
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const GRADE_LABEL: [&str; 5] = ["", "low", "medium", "high", "critical"];

fn as_grade(cell: Option<&String>) -> u8 {
    cell.and_then(|c| c.trim().parse::<f64>().ok())
        .filter(|n| *n != 0.0 && n.is_finite())
        .map_or(1, |n| n.clamp(1.0, 4.0) as u8)
}

/// The table from the app, on the web.
///
/// Rows are separated by a whisper rather than a rule — never striped, because
/// a stripe is a second signal competing with the cursor and the grades.
/// Numeric columns are right-aligned in tabular figures so they read by
/// magnitude down the page; text stays left. A column's unit lives in its
/// header, once, not in every cell. The header sorts, and a keyboard cursor
/// moves with the arrow keys; Enter (or a click) selects the row, which is the
/// cue to open it in a drawer beside the table — never in a modal over it.
#[component]
pub fn AdsDataGrid(
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, optional)] caption: Option<String>,
    /// Fires with the row (as displayed, after sorting) and its index.
    #[props(optional)]
    onselect: Option<EventHandler<(Vec<String>, usize)>>,
) -> Element {
    let mut sort_key = use_signal(|| None::<usize>);
    let mut sort_asc = use_signal(|| true);
    let mut cursor = use_signal(|| None::<usize>);

    let is_num: Vec<bool> = columns
        .iter()
        .enumerate()
        .map(|(ci, c)| {
            !c.grade
                && c.numeric.unwrap_or_else(|| {
                    !rows.is_empty() && rows.iter().all(|r| r.get(ci).is_some_and(|v| as_number(v).is_some()))
                })
        })
        .collect();

    let mut sorted = rows.clone();
    if let Some(k) = sort_key() {
        let asc = sort_asc();
        sorted.sort_by(|a, b| {
            let cmp = compare_cells(a.get(k), b.get(k));
            if asc { cmp } else { cmp.reverse() }
        });
    }
    let len = sorted.len();
    // The rows may have shrunk under the cursor.
    let cur = cursor().filter(|_| len > 0).map(|c| c.min(len - 1));

    // The cursor is announced, since a moving highlight is invisible to a
    // screen reader: the first column names the row.
    let announce = match cur {
        Some(c) if !columns.is_empty() => {
            format!("{}, row {} of {len}", display(sorted[c].first()), c + 1)
        }
        _ => String::new(),
    };
    let group_label = format!("{}. Arrow keys move, Enter opens the row.", caption.as_deref().unwrap_or("Data table"));

    let keyed = sorted.clone();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let at = cur.map_or(-1, |c| c as i64);
        let to = match e.key() {
            Key::ArrowDown => at + 1,
            Key::ArrowUp => at - 1,
            Key::PageDown => at + 10,
            Key::PageUp => at - 10,
            Key::Home => 0,
            Key::End => last as i64,
            Key::Enter => {
                if let (Some(c), Some(h)) = (cur, &onselect) {
                    h.call((keyed[c].clone(), c));
                }
                if cur.is_some() {
                    e.prevent_default();
                }
                return;
            }
            _ => return,
        };
        e.prevent_default();
        cursor.set(Some(to.clamp(0, last as i64) as usize));
    };

    rsx! {
        div {
            class: "ads-data-grid",
            tabindex: "0",
            role: "group",
            "aria-label": "{group_label}",
            onkeydown: onkey,
            table { class: "ads-data-grid__table",
                if let Some(c) = &caption { caption { class: "sr-only", "{c}" } }
                thead {
                    tr {
                        for (ci, c) in columns.iter().enumerate() {
                            th {
                                key: "{c.key}",
                                scope: "col",
                                class: if is_num[ci] { "ads-data-grid__th is-num" } else { "ads-data-grid__th" },
                                "aria-sort": match (sort_key() == Some(ci), sort_asc()) {
                                    (true, true) => "ascending",
                                    (true, false) => "descending",
                                    _ => "none",
                                },
                                button {
                                    r#type: "button",
                                    class: "ads-data-grid__sort",
                                    onclick: move |_| {
                                        if sort_key() == Some(ci) {
                                            sort_asc.toggle();
                                        } else {
                                            sort_key.set(Some(ci));
                                            sort_asc.set(true);
                                        }
                                    },
                                    "{c.label}"
                                    if let Some(u) = &c.unit { span { class: "ads-data-grid__unit", " ({u})" } }
                                    span {
                                        class: crate::class::join(&[
                                            "ads-data-grid__caret",
                                            if sort_key() == Some(ci) { "is-active" } else { "" },
                                            if sort_key() == Some(ci) && !sort_asc() { "is-desc" } else { "" },
                                        ]),
                                        "aria-hidden": "true",
                                        "↑"
                                    }
                                }
                            }
                        }
                    }
                }
                tbody {
                    for (i, r) in sorted.iter().enumerate() {
                        tr {
                            key: "{i}",
                            class: if cur == Some(i) { "ads-data-grid__row is-cursor" } else { "ads-data-grid__row" },
                            "aria-current": if cur == Some(i) { Some("true") } else { None },
                            onmouseenter: move |_| cursor.set(Some(i)),
                            onclick: {
                                let row = r.clone();
                                move |_| {
                                    if let Some(h) = &onselect {
                                        h.call((row.clone(), i));
                                    }
                                }
                            },
                            for (ci, c) in columns.iter().enumerate() {
                                td {
                                    key: "{c.key}",
                                    class: crate::class::join(&[
                                        "ads-data-grid__td",
                                        if is_num[ci] { "is-num" } else { "" },
                                        if is_num[ci] || c.mono { "num" } else { "" },
                                    ]),
                                    if c.grade {
                                        AdsStatus {
                                            grade: as_grade(r.get(ci)),
                                            label: GRADE_LABEL[as_grade(r.get(ci)) as usize],
                                        }
                                    } else {
                                        {display(r.get(ci))}
                                    }
                                }
                            }
                        }
                    }
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{announce}" }
        }
    }
}

AdsDataGrid

Top pages
/overview18,240 low
/pricing9,312 high
rust
AdsDataGrid {
    columns: vec![
        GridColumn::new("name", "Page").mono(),
        GridColumn::new("views", "Views"),
        GridColumn::new("sev", "Severity").grade(),
    ],
    rows: vec![vec!["/overview".into(), "18,240".into(), "1".into()]],
    caption: "Top pages",
    onselect: move |(row, i): (Vec<String>, usize)| open_drawer(row, i),
}
Source · ads/ads-rs/src/ui/stat.rs
rust
//! Stat tile, meter, progress, structured list, and data grid.

use std::cmp::Ordering;

use dioxus::prelude::*;

use crate::ui::{AdsDelta, AdsStatus, DeltaDirection};

/// A KPI tile. The number is monospaced and large, the label is small and
/// quiet — and the figure always travels with a comparison, because a number
/// on its own is a label with extra steps. Give it a `delta` (read with
/// `direction`), the `previous` period, a `target`, or at the very least a
/// `note` saying what it is against.
#[component]
pub fn AdsStat(
    #[props(into)] value: String,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
    /// The change, already formatted ("12.4%"). Read with `direction`.
    #[props(into, optional)]
    delta: Option<String>,
    #[props(default)] direction: DeltaDirection,
    /// The same figure last period, shown as "prev …".
    #[props(into, optional)]
    previous: Option<String>,
    /// What the figure is aiming at, shown as "target …".
    #[props(into, optional)]
    target: Option<String>,
    /// A free-form comparison: "+37 this run", "of 100".
    #[props(into, optional)]
    note: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let delta = delta.filter(|d| !d.is_empty());
    let note = note.filter(|n| !n.is_empty());
    let has_comparison = delta.is_some() || previous.is_some() || target.is_some() || note.is_some();
    rsx! {
        div { class: "ads-stat card-press",
            span { class: "eyebrow", "{label}" }
            span { class: "num ads-stat__value",
                "{value}"
                if !unit.is_empty() { span { class: "ads-stat__unit", "{unit}" } }
            }
            if has_comparison {
                span { class: "ads-stat__compare",
                    if let Some(d) = &delta { AdsDelta { direction, value: d.clone() } }
                    if let Some(p) = &previous { span { class: "num ads-stat__note", "prev {p}{unit}" } }
                    if let Some(t) = &target { span { class: "num ads-stat__note", "target {t}{unit}" } }
                    if let Some(n) = &note { span { class: "num ads-stat__note", "{n}" } }
                }
            }
        }
    }
}

/// A position along `max`, as a CSS percentage clamped to the track.
fn at(n: u32, max: u32) -> String {
    let pct = if max == 0 { 0.0 } else { (f64::from(n) / f64::from(max) * 100.0).clamp(0.0, 100.0) };
    format!("{pct}%")
}

/// A quantity against a threshold. Distinct from progress, which is a task
/// moving toward completion. The threshold is required: it draws the mark the
/// reader needs to know whether the fill is good news, and a meter without one
/// is decoration. The label is required too — it is the meter's name, for the
/// eye and for a screen reader alike.
#[component]
pub fn AdsMeter(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let value_text = format!("{value}{unit} of {max}{unit}, threshold {threshold}{unit}");
    let fill = at(value, max);
    let mark = at(threshold, max);
    rsx! {
        div { class: "ads-meter__wrap",
            span { class: "ads-meter__head",
                span { class: "ads-meter__label", "{label}" }
                span { class: "num ads-meter__value", "{value}{unit}" }
            }
            div {
                class: "ads-meter",
                role: "meter",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{value_text}",
                i { style: "width:{fill}" }
                b { style: "left:{mark}" }
            }
        }
    }
}

/// A task moving toward completion. Distinct from a meter, which is a quantity
/// against a threshold — so there is no threshold here, only an end. The label
/// names the task ("Crawling"); a progress bar with no name is a bar a screen
/// reader can only call "progress".
#[component]
pub fn AdsProgress(
    value: u32,
    #[props(default = 100u32)] max: u32,
    #[props(into)] label: String,
) -> Element {
    let percent = if max == 0 {
        0
    } else {
        (f64::from(value) / f64::from(max) * 100.0).clamp(0.0, 100.0).round() as u32
    };
    rsx! {
        div { class: "ads-progress__wrap",
            span { class: "ads-progress__head",
                span { class: "ads-progress__label", "{label}" }
                span { class: "num ads-progress__value", "{percent}%" }
            }
            div {
                class: "ads-progress",
                role: "progressbar",
                "aria-label": "{label}",
                "aria-valuenow": "{value}",
                "aria-valuemin": "0",
                "aria-valuemax": "{max}",
                i { style: "width:{percent}%" }
            }
        }
    }
}

/// Key-value detail, which is what a selected row almost always needs. A
/// two-column grid rather than a table, so the values can wrap.
#[component]
pub fn AdsDl(
    #[props(into)] items: Vec<(String, String)>,
) -> Element {
    rsx! {
        dl { class: "ads-dl",
            for (term, value) in &items {
                dt { "{term}" }
                dd { "{value}" }
            }
        }
    }
}

/// A data-grid column. Rows are aligned to columns by index.
#[derive(Clone, PartialEq)]
pub struct GridColumn {
    pub key: String,
    pub label: String,
    /// Shown in the header after the label: "Latency (ms)".
    pub unit: Option<String>,
    /// Right-aligned, tabular. Inferred (`None`) when every cell in the column
    /// parses as a number.
    pub numeric: Option<bool>,
    /// Monospaced but left-aligned: identifiers, hashes, paths.
    pub mono: bool,
    /// Cells 1–4 rendered as a luminance grade with a dot and a word.
    pub grade: bool,
}

impl GridColumn {
    #[must_use]
    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
        Self { key: key.into(), label: label.into(), unit: None, numeric: None, mono: false, grade: false }
    }
    #[must_use]
    pub fn unit(mut self, unit: impl Into<String>) -> Self {
        self.unit = Some(unit.into());
        self
    }
    /// Force the column numeric (or not), instead of inferring it.
    #[must_use]
    pub fn numeric(mut self, numeric: bool) -> Self {
        self.numeric = Some(numeric);
        self
    }
    #[must_use]
    pub fn mono(mut self) -> Self {
        self.mono = true;
        self
    }
    #[must_use]
    pub fn grade(mut self) -> Self {
        self.grade = true;
        self
    }
}

/// A cell as a number, if it reads as one ("1,204" and "12.5" both do).
fn as_number(cell: &str) -> Option<f64> {
    let cleaned: String = cell.chars().filter(|c| *c != ',' && !c.is_whitespace()).collect();
    if cleaned.is_empty() {
        return None;
    }
    cleaned.parse::<f64>().ok()
}

fn compare_cells(x: Option<&String>, y: Option<&String>) -> Ordering {
    let x = x.map(String::as_str).unwrap_or("");
    let y = y.map(String::as_str).unwrap_or("");
    match (as_number(x), as_number(y)) {
        (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
        _ => x.cmp(y),
    }
}

/// A missing cell reads as a dash, not as nothing.
fn display(cell: Option<&String>) -> String {
    cell.filter(|v| !v.is_empty()).map_or_else(|| "—".to_owned(), Clone::clone)
}

// Four luminance steps stand in for severity. There is no red here, so the
// rule is simply: the worse it is, the brighter it is — and the word says it.
const GRADE_LABEL: [&str; 5] = ["", "low", "medium", "high", "critical"];

fn as_grade(cell: Option<&String>) -> u8 {
    cell.and_then(|c| c.trim().parse::<f64>().ok())
        .filter(|n| *n != 0.0 && n.is_finite())
        .map_or(1, |n| n.clamp(1.0, 4.0) as u8)
}

/// The table from the app, on the web.
///
/// Rows are separated by a whisper rather than a rule — never striped, because
/// a stripe is a second signal competing with the cursor and the grades.
/// Numeric columns are right-aligned in tabular figures so they read by
/// magnitude down the page; text stays left. A column's unit lives in its
/// header, once, not in every cell. The header sorts, and a keyboard cursor
/// moves with the arrow keys; Enter (or a click) selects the row, which is the
/// cue to open it in a drawer beside the table — never in a modal over it.
#[component]
pub fn AdsDataGrid(
    #[props(into)] columns: Vec<GridColumn>,
    #[props(into)] rows: Vec<Vec<String>>,
    #[props(into, optional)] caption: Option<String>,
    /// Fires with the row (as displayed, after sorting) and its index.
    #[props(optional)]
    onselect: Option<EventHandler<(Vec<String>, usize)>>,
) -> Element {
    let mut sort_key = use_signal(|| None::<usize>);
    let mut sort_asc = use_signal(|| true);
    let mut cursor = use_signal(|| None::<usize>);

    let is_num: Vec<bool> = columns
        .iter()
        .enumerate()
        .map(|(ci, c)| {
            !c.grade
                && c.numeric.unwrap_or_else(|| {
                    !rows.is_empty() && rows.iter().all(|r| r.get(ci).is_some_and(|v| as_number(v).is_some()))
                })
        })
        .collect();

    let mut sorted = rows.clone();
    if let Some(k) = sort_key() {
        let asc = sort_asc();
        sorted.sort_by(|a, b| {
            let cmp = compare_cells(a.get(k), b.get(k));
            if asc { cmp } else { cmp.reverse() }
        });
    }
    let len = sorted.len();
    // The rows may have shrunk under the cursor.
    let cur = cursor().filter(|_| len > 0).map(|c| c.min(len - 1));

    // The cursor is announced, since a moving highlight is invisible to a
    // screen reader: the first column names the row.
    let announce = match cur {
        Some(c) if !columns.is_empty() => {
            format!("{}, row {} of {len}", display(sorted[c].first()), c + 1)
        }
        _ => String::new(),
    };
    let group_label = format!("{}. Arrow keys move, Enter opens the row.", caption.as_deref().unwrap_or("Data table"));

    let keyed = sorted.clone();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let at = cur.map_or(-1, |c| c as i64);
        let to = match e.key() {
            Key::ArrowDown => at + 1,
            Key::ArrowUp => at - 1,
            Key::PageDown => at + 10,
            Key::PageUp => at - 10,
            Key::Home => 0,
            Key::End => last as i64,
            Key::Enter => {
                if let (Some(c), Some(h)) = (cur, &onselect) {
                    h.call((keyed[c].clone(), c));
                }
                if cur.is_some() {
                    e.prevent_default();
                }
                return;
            }
            _ => return,
        };
        e.prevent_default();
        cursor.set(Some(to.clamp(0, last as i64) as usize));
    };

    rsx! {
        div {
            class: "ads-data-grid",
            tabindex: "0",
            role: "group",
            "aria-label": "{group_label}",
            onkeydown: onkey,
            table { class: "ads-data-grid__table",
                if let Some(c) = &caption { caption { class: "sr-only", "{c}" } }
                thead {
                    tr {
                        for (ci, c) in columns.iter().enumerate() {
                            th {
                                key: "{c.key}",
                                scope: "col",
                                class: if is_num[ci] { "ads-data-grid__th is-num" } else { "ads-data-grid__th" },
                                "aria-sort": match (sort_key() == Some(ci), sort_asc()) {
                                    (true, true) => "ascending",
                                    (true, false) => "descending",
                                    _ => "none",
                                },
                                button {
                                    r#type: "button",
                                    class: "ads-data-grid__sort",
                                    onclick: move |_| {
                                        if sort_key() == Some(ci) {
                                            sort_asc.toggle();
                                        } else {
                                            sort_key.set(Some(ci));
                                            sort_asc.set(true);
                                        }
                                    },
                                    "{c.label}"
                                    if let Some(u) = &c.unit { span { class: "ads-data-grid__unit", " ({u})" } }
                                    span {
                                        class: crate::class::join(&[
                                            "ads-data-grid__caret",
                                            if sort_key() == Some(ci) { "is-active" } else { "" },
                                            if sort_key() == Some(ci) && !sort_asc() { "is-desc" } else { "" },
                                        ]),
                                        "aria-hidden": "true",
                                        "↑"
                                    }
                                }
                            }
                        }
                    }
                }
                tbody {
                    for (i, r) in sorted.iter().enumerate() {
                        tr {
                            key: "{i}",
                            class: if cur == Some(i) { "ads-data-grid__row is-cursor" } else { "ads-data-grid__row" },
                            "aria-current": if cur == Some(i) { Some("true") } else { None },
                            onmouseenter: move |_| cursor.set(Some(i)),
                            onclick: {
                                let row = r.clone();
                                move |_| {
                                    if let Some(h) = &onselect {
                                        h.call((row.clone(), i));
                                    }
                                }
                            },
                            for (ci, c) in columns.iter().enumerate() {
                                td {
                                    key: "{c.key}",
                                    class: crate::class::join(&[
                                        "ads-data-grid__td",
                                        if is_num[ci] { "is-num" } else { "" },
                                        if is_num[ci] || c.mono { "num" } else { "" },
                                    ]),
                                    if c.grade {
                                        AdsStatus {
                                            grade: as_grade(r.get(ci)),
                                            label: GRADE_LABEL[as_grade(r.get(ci)) as usize],
                                        }
                                    } else {
                                        {display(r.get(ci))}
                                    }
                                }
                            }
                        }
                    }
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{announce}" }
        }
    }
}

ChartChoice

Choosing a chart form from the data's job
The data's jobFormWhat decides it
Change over timeLine, or a sparkline beside the figureMinimal grid; label the first and last value, never every point
Comparing categoriesHorizontal bars, ordered by valueNever a pie — angle is the hardest encoding to compare
One headline numberStat tile with a deltaA figure with no comparison is trivia; pair it with a target or a prior period
Part of a wholeStacked bar, or just the percentageTwo or three parts at most, or it becomes a pie lying down
DistributionHistogram or box plotAn average hides the shape; show the spread where the spread is the point
Density over two axesHeatmapLuminance steps for magnitude — the channel the eye reads fastest
Progress to a targetMeter with a threshold markThe mark is the component; without it the fill means nothing
RelationshipScatterThree series maximum — any two marks can sit side by side
rust
ChartChoice {}
Source · ads/ads-rs/src/charts/chart_choice.rs
rust
//! The chart chooser — the decision before the component.

use dioxus::prelude::*;

/// One row of the chooser: the data's job, the form, and what decides it.
pub type ChartChoiceRow = (String, String, String);

const ROWS: &[(&str, &str, &str)] = &[
    ("Change over time", "Line, or a sparkline beside the figure", "Minimal grid; label the first and last value, never every point"),
    ("Comparing categories", "Horizontal bars, ordered by value", "Never a pie — angle is the hardest encoding to compare"),
    ("One headline number", "Stat tile with a delta", "A figure with no comparison is trivia; pair it with a target or a prior period"),
    ("Part of a whole", "Stacked bar, or just the percentage", "Two or three parts at most, or it becomes a pie lying down"),
    ("Distribution", "Histogram or box plot", "An average hides the shape; show the spread where the spread is the point"),
    ("Density over two axes", "Heatmap", "Luminance steps for magnitude — the channel the eye reads fastest"),
    ("Progress to a target", "Meter with a threshold mark", "The mark is the component; without it the fill means nothing"),
    ("Relationship", "Scatter", "Three series maximum — any two marks can sit side by side"),
];

/// The decision before the component: pick the chart form from the job, not
/// from a gallery. Most bad charts are the wrong shape, not the wrong colour.
///
/// The table is styled through its own classes rather than bare `table`/`td`
/// selectors, so in the one global sheet it styles this table and no other.
#[component]
pub fn ChartChoice(
    #[props(into, optional)] rows: Option<Vec<ChartChoiceRow>>,
) -> Element {
    let rows = rows.unwrap_or_else(|| {
        ROWS.iter()
            .map(|(a, b, c)| ((*a).to_owned(), (*b).to_owned(), (*c).to_owned()))
            .collect()
    });
    rsx! {
        div { class: "ads-chart-choice",
            table { class: "ads-chart-choice__table",
                caption { class: "sr-only", "Choosing a chart form from the data's job" }
                thead {
                    tr {
                        th { scope: "col", class: "ads-chart-choice__th", "The data's job" }
                        th { scope: "col", class: "ads-chart-choice__th", "Form" }
                        th { scope: "col", class: "ads-chart-choice__th", "What decides it" }
                    }
                }
                tbody {
                    for (job, form, decides) in &rows {
                        tr { key: "{job}",
                            th { scope: "row", class: "ads-chart-choice__td ads-chart-choice__job", "{job}" }
                            td { class: "ads-chart-choice__td", "{form}" }
                            td { class: "ads-chart-choice__td ads-chart-choice__decides", "{decides}" }
                        }
                    }
                }
            }
        }
    }
}

AdsBars

Mon42
Tue58
Wed51
rust
AdsBars { data: vec![("Mon".into(), 42.0), ("Tue".into(), 58.0)], label: "Crawls by weekday" }
Source · ads/ads-rs/src/charts/bar.rs
rust
//! Horizontal bar chart — value maps to luminance as well as length.

use dioxus::prelude::*;

use crate::ui::count_up::format_number;

/// A bar chart where value maps to luminance as well as height. With no hue to
/// spend, brightness is the second channel — the tallest bar is also the
/// brightest, so the ranking survives being glanced at.
///
/// Every bar carries its category and its figure as real text, so the rows are
/// their own table fallback; `label` names the whole figure ("Sessions by day,
/// thousands") and is where the unit lives, not in every row.
#[component]
pub fn AdsBars(
    #[props(into)] data: Vec<(String, f32)>,
    #[props(into)] label: String,
) -> Element {
    let max = data.iter().map(|(_, v)| *v).fold(1.0f32, f32::max);
    let rows = data
        .iter()
        .enumerate()
        .map(|(i, (name, value))| {
            let w = value / max * 100.0;
            let o = 0.35 + value / max * 0.65;
            let style = format!("width:{w}%;opacity:{o};--d:{}ms", i * 90);
            (name.clone(), format_number(f64::from(*value)), style)
        })
        .collect::<Vec<_>>();
    rsx! {
        div { class: "ads-bars", role: "figure", "aria-label": "{label}",
            for (name, value, style) in rows {
                div { key: "{name}", class: "ads-bars__row",
                    span { class: "ads-bars__label", "{name}" }
                    span { class: "ads-bars__track", "aria-hidden": "true",
                        span { class: "ads-bars__bar grow", style: "{style}" }
                    }
                    span { class: "num ads-bars__value", "{value}" }
                }
            }
        }
    }
}

AdsSparkline

rust
AdsSparkline { points: vec![12.0, 19.0, 15.0, 24.0], label: "Sessions, last 7 weeks" }
Source · ads/ads-rs/src/charts/sparkline.rs
rust
//! Sparkline — a hand-drawn line with a fading area, named for a screen reader.

use std::sync::atomic::{AtomicUsize, Ordering};

use dioxus::prelude::*;

use crate::ui::count_up::format_number;

const W: f64 = 240.0;
const H: f64 = 64.0;

/// The smoothed line, the area under it, and the point coordinates.
fn path(values: &[f64], w: f64, h: f64) -> (String, String, Vec<(f64, f64)>) {
    if values.len() < 2 {
        return (String::new(), String::new(), Vec::new());
    }
    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
    let span = if max - min == 0.0 { 1.0 } else { max - min };
    let step = w / (values.len() - 1) as f64;
    // 4px of padding top and bottom so the stroke is never clipped.
    let coords = values
        .iter()
        .enumerate()
        .map(|(i, &v)| (i as f64 * step, h - 4.0 - ((v - min) / span) * (h - 8.0)))
        .collect::<Vec<_>>();

    // A Catmull-Rom-ish smoothing: each segment gets control points a third of
    // the way toward its neighbours, which reads as a curve without overshoot.
    let mut line = format!("M{} {:.2}", coords[0].0, coords[0].1);
    for pair in coords.windows(2) {
        let ((x0, y0), (x1, y1)) = (pair[0], pair[1]);
        let cx = (x0 + x1) / 2.0;
        line.push_str(&format!(" C{cx:.2} {y0:.2} {cx:.2} {y1:.2} {x1:.2} {y1:.2}"));
    }
    let area = format!("{line} L{w} {h} L0 {h} Z");
    (line, area, coords)
}

/// A sparkline, painted by hand. The arithmetic is a pure function so it can be
/// reasoned about without a chart library's opinions.
///
/// `label` is the chart's name and is required: the SVG's accessible name is
/// built from it plus the first, last, lowest and highest values, which is what
/// a reader glancing at a sparkline takes from it anyway. The arrow keys (and
/// Home, End, Escape) inspect it point by point — a crosshair, the point on
/// the line and the value — and the readout is spoken. `point_labels` names
/// each point in the readout ("2026-03-14"); without it the point is `#n`.
///
/// The Vue kit also inspects under the pointer; that needs the element's
/// measured width, so here inspection is from the keyboard only.
#[component]
pub fn AdsSparkline(
    #[props(into)] points: Vec<f64>,
    #[props(into)] label: String,
    #[props(default = true)] fill: bool,
    #[props(into, optional)] unit: Option<String>,
    #[props(into, optional)] point_labels: Option<Vec<String>>,
) -> Element {
    // An id per instance for the area's fade gradient.
    let fade_id = use_hook(|| {
        static NEXT: AtomicUsize = AtomicUsize::new(0);
        format!("ads-sparkline-{}", NEXT.fetch_add(1, Ordering::Relaxed))
    });
    let mut hover = use_signal(|| None::<usize>);

    let unit = unit.unwrap_or_default();
    let fmt = |n: f64| format!("{}{unit}", format_number(n));
    let (line, area, coords) = path(&points, W, H);

    let summary = match (points.first(), points.last()) {
        (Some(first), Some(last)) => {
            let low = points.iter().copied().fold(f64::INFINITY, f64::min);
            let high = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
            format!(
                "{label}: {} points, from {} to {}, low {}, high {}",
                points.len(),
                fmt(*first),
                fmt(*last),
                fmt(low),
                fmt(high)
            )
        }
        _ => format!("{label}: no data"),
    };

    // (x %, y %, value, point label, alignment class)
    let readout = hover().and_then(|i| {
        let (cx, cy) = *coords.get(i)?;
        let name = point_labels.as_ref().and_then(|l| l.get(i)).cloned().unwrap_or_else(|| format!("#{}", i + 1));
        // Keep the bubble inside the chart at either end.
        let align = if cx / W < 0.2 {
            "is-start"
        } else if cx / W > 0.8 {
            "is-end"
        } else {
            ""
        };
        Some((cx / W * 100.0, cy / H * 100.0, fmt(points[i]), name, align))
    });
    let spoken = readout.as_ref().map(|(_, _, v, n, _)| format!("{v}, {n}")).unwrap_or_default();

    let len = points.len();
    let onkey = move |e: KeyboardEvent| {
        if len == 0 {
            return;
        }
        let last = len - 1;
        let i = hover().unwrap_or(last);
        let next = match e.key() {
            Key::ArrowLeft => Some(i.saturating_sub(1)),
            Key::ArrowRight => Some((i + 1).min(last)),
            Key::Home => Some(0),
            Key::End => Some(last),
            Key::Escape => None,
            _ => return,
        };
        e.prevent_default();
        hover.set(next);
    };

    rsx! {
        div {
            class: "ads-sparkline",
            role: "group",
            tabindex: "0",
            "aria-label": "{label}",
            onkeydown: onkey,
            onblur: move |_| hover.set(None),
            svg {
                view_box: "0 0 240 64",
                preserve_aspect_ratio: "none",
                class: "ads-sparkline__svg",
                role: "img",
                "aria-label": "{summary}",
                defs {
                    linearGradient { id: "{fade_id}", x1: "0", y1: "0", x2: "0", y2: "1",
                        stop { offset: "0%", stop_color: "currentColor", stop_opacity: "0.22" }
                        stop { offset: "100%", stop_color: "currentColor", stop_opacity: "0" }
                    }
                }
                if fill {
                    path { d: "{area}", fill: "url(#{fade_id})" }
                }
                path {
                    d: "{line}",
                    "pathLength": "1",
                    fill: "none",
                    stroke: "currentColor",
                    stroke_width: "1.8",
                    vector_effect: "non-scaling-stroke",
                    stroke_linecap: "round",
                }
            }
            if let Some((x, y, value, name, align)) = &readout {
                span { class: "ads-sparkline__cross", style: "left:{x}%" }
                span { class: "ads-sparkline__point", style: "left:{x}%;top:{y}%" }
                span {
                    class: crate::class::join(&["ads-sparkline__bubble num", align]),
                    style: "left:{x}%",
                    "aria-hidden": "true",
                    b { "{value}" }
                    " {name}"
                }
            }
            span { class: "sr-only", "aria-live": "polite", "{spoken}" }
        }
    }
}

AdsGauge

rust
AdsGauge { value: 68, threshold: 80, label: "coverage", unit: "%" }
Source · ads/ads-rs/src/charts/gauge.rs
rust
//! Gauge — a dial for a changing rate, with its threshold notched in.

use dioxus::prelude::*;

const SWEEP: f64 = 260.0; // degrees of arc the scale covers
const START: f64 = 140.0; // where it begins, measured clockwise from 3 o'clock
const R: f64 = 54.0;
const CX: f64 = 64.0;
const CY: f64 = 64.0;

fn clamp01(n: f64) -> f64 {
    n.clamp(0.0, 1.0)
}

// Rounded to two decimals, which is finer than a device pixel at any size this
// gauge is drawn at — and the same figures the Vue kit's server render writes.
fn polar(angle_deg: f64, radius: f64) -> (f64, f64) {
    let rad = angle_deg.to_radians();
    (
        ((CX + radius * rad.cos()) * 100.0).round() / 100.0,
        ((CY + radius * rad.sin()) * 100.0).round() / 100.0,
    )
}

fn arc(from: f64, to: f64, radius: f64) -> String {
    let (x1, y1) = polar(from, radius);
    let (x2, y2) = polar(to, radius);
    let large = i32::from((to - from).abs() > 180.0);
    format!("M{x1} {y1} A{radius} {radius} 0 {large} 1 {x2} {y2}")
}

/// A tick on the scale.
struct Tick {
    x1: f64,
    y1: f64,
    x2: f64,
    y2: f64,
    major: bool,
}

/// A dial. A dial shows a changing rate far better than a number does: the eye
/// reads the position of the needle without reading the digits. It is a meter
/// bent into a circle, so it obeys the meter's rule: the threshold mark is
/// required, because without it the reader cannot tell whether the arc is good
/// news.
///
/// The threshold is a notch cut through the arc in the dial's own surface, the
/// way the meter's mark is cut in the canvas, with a full-strength tick outside
/// it so it still reads where the arc is unfilled. The Vue kit eases the needle
/// between values; here the needle is set, which is also what the Vue kit's
/// first render does.
#[component]
pub fn AdsGauge(
    value: u32,
    #[props(default = 100u32)] max: u32,
    threshold: u32,
    #[props(into)] label: String,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let max_f = if max == 0 { 1.0 } else { f64::from(max) };
    let fraction = clamp01(f64::from(value) / max_f);

    let track = arc(START, START + SWEEP, R);
    let progress = arc(START, START + SWEEP * fraction.max(0.001), R);

    // The scale: fourteen ticks across the sweep, every third one major.
    let ticks = (0..14)
        .map(|i| {
            let angle = START + (SWEEP / 13.0) * f64::from(i);
            let (x1, y1) = polar(angle, R + 7.0);
            let (x2, y2) = polar(angle, R + if i % 3 == 0 { 12.0 } else { 10.0 });
            Tick { x1, y1, x2, y2, major: i % 3 == 0 }
        })
        .collect::<Vec<_>>();

    let notch_angle = START + SWEEP * clamp01(f64::from(threshold) / max_f);
    let (cx1, cy1) = polar(notch_angle, R - 6.0);
    let (cx2, cy2) = polar(notch_angle, R + 6.0);
    let (tx1, ty1) = polar(notch_angle, R + 6.0);
    let (tx2, ty2) = polar(notch_angle, R + 14.0);

    let (nx, ny) = polar(START + SWEEP * fraction, R - 13.0);
    let name = format!("{label}: {value}{unit}, threshold {threshold}{unit}");

    rsx! {
        div { class: "ads-gauge",
            svg { view_box: "0 0 128 128", class: "ads-gauge__svg", role: "img", "aria-label": "{name}",
                g { class: "ads-gauge__ticks",
                    for (i, t) in ticks.iter().enumerate() {
                        line {
                            key: "{i}",
                            x1: "{t.x1}",
                            y1: "{t.y1}",
                            x2: "{t.x2}",
                            y2: "{t.y2}",
                            opacity: if t.major { "0.55" } else { "0.22" },
                        }
                    }
                }
                path { d: "{track}", class: "ads-gauge__track" }
                path { d: "{progress}", class: "ads-gauge__progress" }
                line { x1: "{cx1}", y1: "{cy1}", x2: "{cx2}", y2: "{cy2}", class: "ads-gauge__notch" }
                line { x1: "{tx1}", y1: "{ty1}", x2: "{tx2}", y2: "{ty2}", class: "ads-gauge__threshold" }
                line { x1: "{CX}", y1: "{CY}", x2: "{nx}", y2: "{ny}", class: "ads-gauge__needle" }
                // The cap is drawn over the needle so the reading is never
                // crossed by it.
                circle { cx: "{CX}", cy: "{CY}", r: "31", class: "ads-gauge__cap" }
            }
            div { class: "ads-gauge__read", "aria-hidden": "true",
                span { class: "num ads-gauge__value",
                    "{value}"
                    span { class: "ads-gauge__unit", "{unit}" }
                }
                span { class: "eyebrow", "{label}" }
            }
        }
    }
}

AdsHeatmap

rust
AdsHeatmap {
    values: vec![vec![0.0, 4.0, 12.0], vec![3.0, 0.0, 40.0]],
    label: "Crawl errors by weekday and hour",
    row_labels: vec!["Mon".into(), "Tue".into()],
}
Source · ads/ads-rs/src/charts/heatmap.rs
rust
//! Heatmap — a density grid, quantised into the four luminance grades.

use dioxus::prelude::*;

use crate::ui::count_up::format_number;

/// 0 → the empty well; anything above zero lands on grade 1–4.
fn step(v: f64, top: f64) -> u8 {
    if v <= 0.0 || top <= 0.0 {
        return 0;
    }
    ((v / top) * 4.0).ceil().clamp(1.0, 4.0) as u8
}

/// A density grid. Magnitude is carried by the four luminance grades — the
/// channel the eye reads fastest, and the one dense data keeps when hue would
/// be noise: forty cells in three hues is a pattern, not a reading. Brighter is
/// more, in either theme, because the grade tokens invert with it.
///
/// The grid takes real values and quantises them itself into five steps: an
/// empty well for nothing, then grades one to four. The axes are recessive —
/// small, subdued, only as many labels as are given — and a compact legend
/// says which end is which. The accessible name carries the shape and range,
/// since a reader cannot inspect four hundred cells one by one.
#[component]
pub fn AdsHeatmap(
    #[props(into)] values: Vec<Vec<f64>>,
    #[props(into)] label: String,
    #[props(into, optional)] row_labels: Option<Vec<String>>,
    #[props(into, optional)] col_labels: Option<Vec<String>>,
    #[props(optional)] max: Option<f64>,
    #[props(into, optional)] unit: Option<String>,
) -> Element {
    let unit = unit.unwrap_or_default();
    let row_labels = row_labels.filter(|l| !l.is_empty());
    let col_labels = col_labels.filter(|l| !l.is_empty());
    let fmt = |n: f64| format!("{}{unit}", format_number(n));

    let flat = values.iter().flatten().copied().collect::<Vec<_>>();
    let top = max.unwrap_or_else(|| flat.iter().copied().fold(0.0, f64::max));
    let low = if flat.is_empty() { 0.0 } else { flat.iter().copied().fold(f64::INFINITY, f64::min) };

    // Every row shares one column template so the axis labels and the cells
    // line up; the row-label column is fixed rather than max-content for the
    // same reason.
    let n_cols = values.first().map_or(0, Vec::len);
    let columns = format!(
        "grid-template-columns:{}repeat({n_cols}, minmax(0, 1fr))",
        if row_labels.is_some() { "3.5rem " } else { "" }
    );

    let n_rows = values.len();
    let name = if n_rows == 0 || n_cols == 0 {
        format!("{label}: no data")
    } else {
        format!("{label}: {n_rows} by {n_cols} grid, values {} to {}", fmt(low), fmt(top))
    };

    let grid = values
        .iter()
        .enumerate()
        .map(|(y, row)| {
            let row_name = row_labels.as_ref().and_then(|l| l.get(y)).cloned();
            let cells = row
                .iter()
                .enumerate()
                .map(|(x, v)| {
                    let r = row_name.clone().unwrap_or_else(|| format!("Row {}", y + 1));
                    let c = col_labels
                        .as_ref()
                        .and_then(|l| l.get(x))
                        .cloned()
                        .unwrap_or_else(|| format!("column {}", x + 1));
                    (format!("ads-heatmap__cell is-step-{}", step(*v, top)), format!("{r}, {c}: {}", fmt(*v)))
                })
                .collect::<Vec<_>>();
            (row_name.unwrap_or_default(), cells)
        })
        .collect::<Vec<_>>();
    let (low_text, top_text) = (fmt(low), fmt(top));

    rsx! {
        figure { class: "ads-heatmap",
            div { class: "ads-heatmap__plot sweep", role: "img", "aria-label": "{name}",
                if let Some(cols) = &col_labels {
                    div { class: "ads-heatmap__row", style: "{columns}", "aria-hidden": "true",
                        if row_labels.is_some() { span { class: "ads-heatmap__axis" } }
                        for (x, c) in cols.iter().enumerate() {
                            span { key: "{x}", class: "ads-heatmap__axis ads-heatmap__axis-col", "{c}" }
                        }
                    }
                }
                for (y, (row_name, cells)) in grid.iter().enumerate() {
                    div { key: "{y}", class: "ads-heatmap__row", style: "{columns}",
                        if row_labels.is_some() {
                            span { class: "ads-heatmap__axis", "aria-hidden": "true", "{row_name}" }
                        }
                        for (x, (class, title)) in cells.iter().enumerate() {
                            span { key: "{x}", class: "{class}", title: "{title}" }
                        }
                    }
                }
            }
            figcaption { class: "ads-heatmap__legend", "aria-hidden": "true",
                span { class: "num", "{low_text}" }
                for s in 0..5u8 {
                    span { key: "{s}", class: "ads-heatmap__cell ads-heatmap__key is-step-{s}" }
                }
                span { class: "num", "{top_text}" }
            }
        }
    }
}