makeover-webview 0.22.1

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser.
Documentation
//! A proportion, rendered as a bar.
//!
//! The third phase-B emitter, beside [`form`](crate::form) and
//! [`list`](crate::list). It is much the smallest, and it is here rather than in
//! the app because the trough it fills has been in phase A since before anything
//! could describe one: `progress_rules` emitted `.progress` and
//! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
//! was to concatenate it into a heading.
//!
//! # What the pair buys, at the last layer
//!
//! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
//! and the reason shows up here. A bar that is full because it landed exactly
//! and a bar that is full because it ran over are the same width and are not the
//! same fact, so the width is not allowed to be the only thing emitted. The
//! over-run leaves as `data-over="true"`, and the accessible name keeps both
//! true numbers.
//!
//! No CSS rule is emitted for `data-over`. What an over-run should look like is
//! app taste — goingson already says it with `Tone::Danger` — and a renderer
//! that picked a stripe for everyone would be decorating rather than describing.

use crate::form::escape;
use crate::{Emit, class};
use makeover_layout::{Intent, Meter, Tone};
use std::fmt::Write as _;

/// The accessible name for a meter: the two numbers, and the noun if it has one.
///
/// The description carries the noun alone, so the sentence is built here. That
/// is the whole reason `Meter::label` is not the assembled string: a tooltip
/// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
/// description that shipped either one would have chosen for both.
///
/// The true `done` is used, not the clamped one. This is the text that says an
/// over-run happened.
#[must_use]
pub fn meter_text(meter: &Meter<'_>) -> String {
    match meter.label {
        Some(label) => format!("{} of {} {label}", meter.done, meter.total),
        None => format!("{} of {}", meter.done, meter.total),
    }
}

/// A meter as a filled trough.
///
/// ```
/// use makeover_layout::{Meter, Tone};
/// use makeover_webview::{Emit, meter::meter_html};
///
/// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
/// let html = meter_html(&meter, &Emit::default());
///
/// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
/// assert!(html.contains(r#"data-tone="success""#));
/// assert!(html.contains("width: 42%"));
/// ```
///
/// `aria-valuenow` is clamped to `aria-valuemax`, because a value outside the
/// range is invalid ARIA and a screen reader is entitled to ignore the whole
/// element. The unclamped truth is in the accessible name, which is read either
/// way.
#[must_use]
pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
    let progress = class("progress", opts);
    let fill = class("progress-fill", opts);
    let reported = meter.done.min(meter.total);

    let mut html = format!(
        "<div class=\"{progress}\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
         aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{}\">",
        meter.total,
        escape(&meter_text(meter))
    );

    let _ = write!(html, "<div class=\"{fill}\"");
    // Neutral is the untoned bar, and `progress_rules` gives it `--action`
    // rather than a tone attribute. Emitting `data-tone="content-muted"` would
    // match a rule that does not exist and read as disabled if it did.
    if meter.tone != Tone::Neutral {
        let _ = write!(html, " data-tone=\"{}\"", meter.tone.token());
    }
    if meter.overflowing() {
        html.push_str(" data-over=\"true\"");
    }
    let _ = write!(html, " style=\"width: {}%\"></div></div>", meter.percent());
    html
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_full_bar_says_whether_it_ran_over() {
        // The two facts a percentage could not tell apart, and the reason the
        // description carries a pair. Both are 100% wide.
        let exact = meter_html(&Meter::new(30, 30), &Emit::default());
        let over = meter_html(&Meter::new(45, 30), &Emit::default());

        assert!(exact.contains("width: 100%"));
        assert!(over.contains("width: 100%"));
        assert!(!exact.contains("data-over"));
        assert!(over.contains(r#"data-over="true""#));
    }

    #[test]
    fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
        // The bar is clamped and the name is not. Losing this is how an
        // over-run becomes invisible to anyone not looking at the colour.
        let over = Meter::new(45, 30).label("minutes");
        assert_eq!(meter_text(&over), "45 of 30 minutes");
        assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
    }

    #[test]
    fn aria_valuenow_stays_inside_its_range() {
        // Outside it, the element is invalid and a reader may drop it whole,
        // which would lose the label above along with it.
        let html = meter_html(&Meter::new(45, 30), &Emit::default());
        assert!(html.contains(r#"aria-valuenow="30""#));
        assert!(html.contains(r#"aria-valuemax="30""#));
    }

    #[test]
    fn an_untoned_bar_emits_no_tone_attribute() {
        // `progress_rules` styles the untoned bar with `--action` on the bare
        // class. A `data-tone="content-muted"` here would match no rule.
        let plain = meter_html(&Meter::new(1, 2), &Emit::default());
        assert!(!plain.contains("data-tone"));

        let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
        assert!(toned.contains(r#"data-tone="danger""#));
    }

    #[test]
    fn an_empty_set_renders_an_empty_trough() {
        // Sayable, so it has to be emittable. Nothing here divides by zero.
        let html = meter_html(&Meter::new(0, 0), &Emit::default());
        assert!(html.contains("width: 0%"));
        assert!(html.contains(r#"aria-valuemax="0""#));
    }

    #[test]
    fn the_label_is_escaped_like_every_other_string() {
        // It arrives from the app the same as a field label does.
        let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
        assert!(html.contains("a &amp; b"));
        assert!(!html.contains("a & b"));
    }

    #[test]
    fn the_prefix_reaches_both_classes() {
        // A prefixed build claims its own names, and the fill is a descendant
        // selector in the emitted CSS: miss one and the rule stops matching.
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        let html = meter_html(&Meter::new(1, 2), &opts);
        assert!(html.contains(r#"class="mo-progress""#));
        assert!(html.contains(r#"class="mo-progress-fill""#));
    }
}