//! 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_into; use crate::{Emit, push_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 mut html = String::new(); meter_html_into(meter, opts, &mut html); html } /// A meter, written into a buffer the caller already has. /// /// [`meter_html`]'s streaming form, byte-identical to it. The accessible name is /// written a piece at a time rather than built and then escaped: the numbers /// carry nothing an escaper would encode, so only the noun goes through one. pub fn meter_html_into(meter: &Meter<'_>, opts: &Emit, out: &mut String) { let reported = meter.done.min(meter.total); out.push_str("
"); out.push_str("
", meter.percent()); } #[cfg(test)] mod tests { use super::*; use crate::form::escape; /// The accessible name is built by [`meter_text`] in one form and written a /// piece at a time in the other, and the over-run case is the one where the /// numbers differ from what the bar draws. #[test] fn a_streamed_meter_is_the_meter_the_other_form_returns() { let opts = Emit { class_prefix: "mk-", ..Emit::default() }; for meter in [ Meter::new(0, 0), Meter::new(3, 7).label("sub & tasks"), Meter::new(9, 7).tone(Tone::Danger).label(""), ] { let mut streamed = String::new(); meter_html_into(&meter, &opts, &mut streamed); assert_eq!(streamed, meter_html(&meter, &opts)); assert!( streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))), "{streamed}" ); } } #[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""#)); } }