//! 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!( "
", meter.total, escape(&meter_text(meter)) ); let _ = write!(html, "
", 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 & 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""#)); } }