Skip to main content

max / makeover-webview

8.4 KB · 214 lines History Blame Raw
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
23 use crate::form::escape_into;
24 use crate::{Emit, push_class};
25 use makeover_layout::{Intent, Meter, Tone};
26 use 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.
33 pub 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]
45 pub 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("width: 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]
71 pub 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.
82 pub 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 let _ = write!(out, " style=\"width: {}%\"></div></div>", meter.percent());
112 }
113
114 #[cfg(test)]
115 mod tests {
116 use super::*;
117 use crate::form::escape;
118
119 /// The accessible name is built by [`meter_text`] in one form and written a
120 /// piece at a time in the other, and the over-run case is the one where the
121 /// numbers differ from what the bar draws.
122 #[test]
123 fn a_streamed_meter_is_the_meter_the_other_form_returns() {
124 let opts = Emit {
125 class_prefix: "mk-",
126 ..Emit::default()
127 };
128 for meter in [
129 Meter::new(0, 0),
130 Meter::new(3, 7).label("sub & tasks"),
131 Meter::new(9, 7).tone(Tone::Danger).label("<tasks>"),
132 ] {
133 let mut streamed = String::new();
134 meter_html_into(&meter, &opts, &mut streamed);
135 assert_eq!(streamed, meter_html(&meter, &opts));
136 assert!(
137 streamed.contains(&format!("aria-label=\"{}\"", escape(&meter_text(&meter)))),
138 "{streamed}"
139 );
140 }
141 }
142
143 #[test]
144 fn a_full_bar_says_whether_it_ran_over() {
145 // The two facts a percentage could not tell apart, and the reason the
146 // description carries a pair. Both are 100% wide.
147 let exact = meter_html(&Meter::new(30, 30), &Emit::default());
148 let over = meter_html(&Meter::new(45, 30), &Emit::default());
149
150 assert!(exact.contains("width: 100%"));
151 assert!(over.contains("width: 100%"));
152 assert!(!exact.contains("data-over"));
153 assert!(over.contains(r#"data-over="true""#));
154 }
155
156 #[test]
157 fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
158 // The bar is clamped and the name is not. Losing this is how an
159 // over-run becomes invisible to anyone not looking at the colour.
160 let over = Meter::new(45, 30).label("minutes");
161 assert_eq!(meter_text(&over), "45 of 30 minutes");
162 assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
163 }
164
165 #[test]
166 fn aria_valuenow_stays_inside_its_range() {
167 // Outside it, the element is invalid and a reader may drop it whole,
168 // which would lose the label above along with it.
169 let html = meter_html(&Meter::new(45, 30), &Emit::default());
170 assert!(html.contains(r#"aria-valuenow="30""#));
171 assert!(html.contains(r#"aria-valuemax="30""#));
172 }
173
174 #[test]
175 fn an_untoned_bar_emits_no_tone_attribute() {
176 // `progress_rules` styles the untoned bar with `--action` on the bare
177 // class. A `data-tone="content-muted"` here would match no rule.
178 let plain = meter_html(&Meter::new(1, 2), &Emit::default());
179 assert!(!plain.contains("data-tone"));
180
181 let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
182 assert!(toned.contains(r#"data-tone="danger""#));
183 }
184
185 #[test]
186 fn an_empty_set_renders_an_empty_trough() {
187 // Sayable, so it has to be emittable. Nothing here divides by zero.
188 let html = meter_html(&Meter::new(0, 0), &Emit::default());
189 assert!(html.contains("width: 0%"));
190 assert!(html.contains(r#"aria-valuemax="0""#));
191 }
192
193 #[test]
194 fn the_label_is_escaped_like_every_other_string() {
195 // It arrives from the app the same as a field label does.
196 let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
197 assert!(html.contains("a &amp; b"));
198 assert!(!html.contains("a & b"));
199 }
200
201 #[test]
202 fn the_prefix_reaches_both_classes() {
203 // A prefixed build claims its own names, and the fill is a descendant
204 // selector in the emitted CSS: miss one and the rule stops matching.
205 let opts = Emit {
206 class_prefix: "mo-",
207 ..Emit::default()
208 };
209 let html = meter_html(&Meter::new(1, 2), &opts);
210 assert!(html.contains(r#"class="mo-progress""#));
211 assert!(html.contains(r#"class="mo-progress-fill""#));
212 }
213 }
214