Skip to main content

makeover_webview/
meter.rs

1//! A proportion, rendered as a bar.
2//!
3//! The third phase-B emitter, beside [`form`](crate::form) and
4//! [`list`](crate::list). It is much the smallest, and it is here rather than in
5//! the app because the trough it fills has been in phase A since before anything
6//! could describe one: `progress_rules` emitted `.progress` and
7//! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
8//! was to concatenate it into a heading.
9//!
10//! # What the pair buys, at the last layer
11//!
12//! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
13//! and the reason shows up here. A bar that is full because it landed exactly
14//! and a bar that is full because it ran over are the same width and are not the
15//! same fact, so the width is not allowed to be the only thing emitted. The
16//! over-run leaves as `data-over="true"`, and the accessible name keeps both
17//! true numbers.
18//!
19//! No CSS rule is emitted for `data-over`. What an over-run should look like is
20//! app taste — goingson already says it with `Tone::Danger` — and a renderer
21//! that picked a stripe for everyone would be decorating rather than describing.
22
23use crate::form::escape_into;
24use crate::{Emit, push_class};
25use makeover_layout::{Intent, Meter, Tone};
26use std::fmt::Write as _;
27
28/// Every class this module can put in markup.
29///
30/// [`crate::facet::FACET_CLASSES`]' obligation. Both carry rules, so the
31/// scraped vocabulary already holds them; the list is what keeps that true if
32/// a rule goes away.
33pub const METER_CLASSES: &[&str] = &["progress", "progress-fill"];
34
35/// The accessible name for a meter: the two numbers, and the noun if it has one.
36///
37/// The description carries the noun alone, so the sentence is built here. That
38/// is the whole reason `Meter::label` is not the assembled string: a tooltip
39/// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
40/// description that shipped either one would have chosen for both.
41///
42/// The true `done` is used, not the clamped one. This is the text that says an
43/// over-run happened.
44#[must_use]
45pub fn meter_text(meter: &Meter<'_>) -> String {
46    match meter.label {
47        Some(label) => format!("{} of {} {label}", meter.done, meter.total),
48        None => format!("{} of {}", meter.done, meter.total),
49    }
50}
51
52/// A meter as a filled trough.
53///
54/// ```
55/// use makeover_layout::{Meter, Tone};
56/// use makeover_webview::{Emit, meter::meter_html};
57///
58/// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
59/// let html = meter_html(&meter, &Emit::default());
60///
61/// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
62/// assert!(html.contains(r#"data-tone="success""#));
63/// assert!(html.contains("--meter-fill: 42%"));
64/// ```
65///
66/// `aria-valuenow` is clamped to `aria-valuemax`, because a value outside the
67/// range is invalid ARIA and a screen reader is entitled to ignore the whole
68/// element. The unclamped truth is in the accessible name, which is read either
69/// way.
70#[must_use]
71pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
72    let mut html = String::new();
73    meter_html_into(meter, opts, &mut html);
74    html
75}
76
77/// A meter, written into a buffer the caller already has.
78///
79/// [`meter_html`]'s streaming form, byte-identical to it. The accessible name is
80/// written a piece at a time rather than built and then escaped: the numbers
81/// carry nothing an escaper would encode, so only the noun goes through one.
82pub fn meter_html_into(meter: &Meter<'_>, opts: &Emit, out: &mut String) {
83    let reported = meter.done.min(meter.total);
84
85    out.push_str("<div class=\"");
86    push_class(out, "progress", opts);
87    let _ = write!(
88        out,
89        "\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
90         aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{} of {}",
91        meter.total, meter.done, meter.total
92    );
93    if let Some(label) = meter.label {
94        out.push(' ');
95        escape_into(label, out);
96    }
97    out.push_str("\">");
98
99    out.push_str("<div class=\"");
100    push_class(out, "progress-fill", opts);
101    out.push('"');
102    // Neutral is the untoned bar, and `progress_rules` gives it `--action`
103    // rather than a tone attribute. Emitting `data-tone="content-muted"` would
104    // match a rule that does not exist and read as disabled if it did.
105    if meter.tone != Tone::Neutral {
106        let _ = write!(out, " data-tone=\"{}\"", meter.tone.token());
107    }
108    if meter.overflowing() {
109        out.push_str(" data-over=\"true\"");
110    }
111    // The fill as a custom property the trough's rule reads, in `data-vars`
112    // rather than `style`: see [`crate::VARS_ATTR`].
113    let _ = write!(
114        out,
115        " {}=\"--meter-fill: {}%\"></div></div>",
116        crate::VARS_ATTR,
117        meter.percent()
118    );
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::form::escape;
125
126    /// The accessible name is built by [`meter_text`] in one form and written a
127    /// piece at a time in the other, and the over-run case is the one where the
128    /// numbers differ from what the bar draws.
129    #[test]
130    fn a_streamed_meter_is_the_meter_the_other_form_returns() {
131        let opts = Emit {
132            class_prefix: "mk-",
133            ..Emit::default()
134        };
135        for meter in [
136            Meter::new(0, 0),
137            Meter::new(3, 7).label("sub & tasks"),
138            Meter::new(9, 7).tone(Tone::Danger).label("<tasks>"),
139        ] {
140            let mut streamed = String::new();
141            meter_html_into(&meter, &opts, &mut streamed);
142            assert_eq!(streamed, meter_html(&meter, &opts));
143            assert!(
144                streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))),
145                "{streamed}"
146            );
147        }
148    }
149
150    #[test]
151    fn a_full_bar_says_whether_it_ran_over() {
152        // The two facts a percentage could not tell apart, and the reason the
153        // description carries a pair. Both are 100% wide.
154        let exact = meter_html(&Meter::new(30, 30), &Emit::default());
155        let over = meter_html(&Meter::new(45, 30), &Emit::default());
156
157        assert!(exact.contains("--meter-fill: 100%"));
158        assert!(over.contains("--meter-fill: 100%"));
159        assert!(!exact.contains("style="));
160        assert!(!exact.contains("data-over"));
161        assert!(over.contains(r#"data-over="true""#));
162    }
163
164    #[test]
165    fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
166        // The bar is clamped and the name is not. Losing this is how an
167        // over-run becomes invisible to anyone not looking at the colour.
168        let over = Meter::new(45, 30).label("minutes");
169        assert_eq!(meter_text(&over), "45 of 30 minutes");
170        assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
171    }
172
173    #[test]
174    fn aria_valuenow_stays_inside_its_range() {
175        // Outside it, the element is invalid and a reader may drop it whole,
176        // which would lose the label above along with it.
177        let html = meter_html(&Meter::new(45, 30), &Emit::default());
178        assert!(html.contains(r#"aria-valuenow="30""#));
179        assert!(html.contains(r#"aria-valuemax="30""#));
180    }
181
182    #[test]
183    fn an_untoned_bar_emits_no_tone_attribute() {
184        // `progress_rules` styles the untoned bar with `--action` on the bare
185        // class. A `data-tone="content-muted"` here would match no rule.
186        let plain = meter_html(&Meter::new(1, 2), &Emit::default());
187        assert!(!plain.contains("data-tone"));
188
189        let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
190        assert!(toned.contains(r#"data-tone="danger""#));
191    }
192
193    #[test]
194    fn an_empty_set_renders_an_empty_trough() {
195        // Sayable, so it has to be emittable. Nothing here divides by zero.
196        let html = meter_html(&Meter::new(0, 0), &Emit::default());
197        assert!(html.contains("--meter-fill: 0%"));
198        assert!(html.contains(r#"aria-valuemax="0""#));
199    }
200
201    #[test]
202    fn the_label_is_escaped_like_every_other_string() {
203        // It arrives from the app the same as a field label does.
204        let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
205        assert!(html.contains("a &amp; b"));
206        assert!(!html.contains("a & b"));
207    }
208
209    #[test]
210    fn the_prefix_reaches_both_classes() {
211        // A prefixed build claims its own names, and the fill is a descendant
212        // selector in the emitted CSS: miss one and the rule stops matching.
213        let opts = Emit {
214            class_prefix: "mo-",
215            ..Emit::default()
216        };
217        let html = meter_html(&Meter::new(1, 2), &opts);
218        assert!(html.contains(r#"class="mo-progress""#));
219        assert!(html.contains(r#"class="mo-progress-fill""#));
220    }
221}