makeover_webview/figure.rs
1//! A figure with a caption, and a strip of them.
2//!
3//! The fourth phase-B emitter. `makeover_layout::Figure` arrived at 0.11.0 after
4//! goingson turned out to have five of these across five screens, each with its
5//! own class names for the one shape: `task-overview-stat`, `stat-box`,
6//! `month-stat-item`, `contact-summary-stat`, `sync-stat`.
7//!
8//! # Why the strip has its own function
9//!
10//! Four tiles in a row and four tiles down a column are different things, and a
11//! renderer handed one figure at a time cannot tell it is looking at a set. So
12//! the set is what gets emitted, and a lone figure is a set of one.
13//!
14//! # The reading order is markup, not CSS
15//!
16//! Visually the value is set large over a small caption, which is what four of
17//! the five sites drew. A screen reader meeting "17" before it knows what was
18//! counted has to hold the number until the noun arrives, so the figure carries
19//! its own accessible name — "Current Streak: 17" — and the two spans are hidden
20//! from the reader that has already been told.
21//!
22//! Solving it that way rather than by inverting the markup and turning it back
23//! with `column-reverse` is deliberate: the arrangement and the type scale are
24//! the app's, and a renderer that emitted them would be naming sizes. Same line
25//! `meter_html` holds when it emits the tones and never the width.
26
27use crate::form::escape_into;
28use crate::{Emit, push_class};
29use makeover_layout::{Figure, Intent, Tone};
30use std::fmt::Write as _;
31
32/// The accessible name for a figure: the noun, then the number.
33///
34/// Built here rather than carried, for the reason [`meter_text`] is: a strip
35/// wants "Current Streak: 17" and a terminal at one line wants something else,
36/// and a description that shipped either would have chosen for both.
37///
38/// [`meter_text`]: crate::meter::meter_text
39#[must_use]
40pub fn figure_text(figure: &Figure<'_>) -> String {
41 figure.change.map_or_else(
42 || format!("{}: {}", figure.caption, figure.value),
43 // The delta reaches a reader as part of the one name, because the spans
44 // below are all `aria-hidden` and it would otherwise reach them not at
45 // all. "Views: 1,204, +12.5%" rather than a bare number after a comma.
46 |change| format!("{}: {}, {change}", figure.caption, figure.value),
47 )
48}
49
50/// One figure, as its own element.
51///
52/// ```
53/// use makeover_layout::{Figure, Tone};
54/// use makeover_webview::{Emit, figure::figure_html};
55///
56/// let figure = Figure::new("17", "Current Streak").tone(Tone::Success);
57/// let html = figure_html(&figure, &Emit::default());
58///
59/// assert!(html.contains(r#"data-tone="success""#));
60/// assert!(html.contains(r#"aria-label="Current Streak: 17""#));
61/// ```
62#[must_use]
63pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
64 let mut html = String::new();
65 figure_html_into(figure, opts, &mut html);
66 html
67}
68
69/// One figure, written into a buffer the caller already has.
70///
71/// [`figure_html`]'s streaming form, byte-identical to it. The accessible name
72/// is escaped a piece at a time rather than built and then escaped, which is
73/// the same output for one allocation fewer: the separators [`figure_text`]
74/// puts between the pieces contain nothing an escaper would encode.
75pub fn figure_html_into(figure: &Figure<'_>, opts: &Emit, out: &mut String) {
76 out.push_str("<div class=\"");
77 push_class(out, "figure", opts);
78 out.push_str("\" aria-label=\"");
79 escape_into(figure.caption, out);
80 out.push_str(": ");
81 escape_into(figure.value, out);
82 if let Some(change) = figure.change {
83 out.push_str(", ");
84 escape_into(change, out);
85 }
86 out.push('"');
87 // Neutral is the ordinary fact, and `figure_rules` styles the bare class
88 // for it. `data-tone="content-muted"` would match a rule that is not there.
89 if figure.tone != Tone::Neutral {
90 let _ = write!(out, " data-tone=\"{}\"", figure.tone.token());
91 }
92 // `aria-hidden` on both, because the element above has already said the
93 // whole thing. Without it a reader gets the number twice and the noun
94 // twice, in the order the eye wants rather than the order the ear does.
95 out.push_str("><span class=\"");
96 push_class(out, "figure-value", opts);
97 out.push_str("\" aria-hidden=\"true\">");
98 escape_into(figure.value, out);
99 out.push_str("</span><span class=\"");
100 push_class(out, "figure-caption", opts);
101 out.push_str("\" aria-hidden=\"true\">");
102 escape_into(figure.caption, out);
103 out.push_str("</span>");
104 // 0.13.0. Its own element rather than more of the caption, so a stylesheet
105 // can set it smaller and a renderer with one line can drop it first. The
106 // tone is already on the wrapper and the rule keys off it from there, which
107 // is why the delta carries no `data-tone` of its own: two elements claiming
108 // one tone is how they end up disagreeing.
109 if let Some(change) = figure.change {
110 out.push_str("<span class=\"");
111 push_class(out, "figure-change", opts);
112 out.push_str("\" aria-hidden=\"true\">");
113 escape_into(change, out);
114 out.push_str("</span>");
115 }
116 out.push_str("</div>");
117}
118
119/// Several figures as one strip.
120///
121/// An empty set emits the container and nothing in it, for the reason a meter
122/// over nothing and a select with no options both render: it is what an app with
123/// an unloaded count actually has, and an empty strip says so on screen rather
124/// than in a log.
125#[must_use]
126pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
127 let mut html = String::new();
128 figures_html_into(figures, opts, &mut html);
129 html
130}
131
132/// Several figures as one strip, written into a buffer the caller already has.
133///
134/// [`figures_html`]'s streaming form, byte-identical to it. A strip is where the
135/// per-figure `String` used to be paid for once per tile.
136pub fn figures_html_into(figures: &[Figure<'_>], opts: &Emit, out: &mut String) {
137 out.push_str("<div class=\"");
138 push_class(out, "figures", opts);
139 out.push_str("\">");
140 for figure in figures {
141 figure_html_into(figure, opts, out);
142 }
143 out.push_str("</div>");
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::form::escape;
150
151 #[test]
152 fn the_noun_reaches_a_reader_before_the_number() {
153 // The problem the accessible name solves. Visually the value comes
154 // first; a reader that met "17" first would have to hold it until it
155 // found out what was counted.
156 let figure = Figure::new("17", "Current Streak");
157 assert_eq!(figure_text(&figure), "Current Streak: 17");
158
159 let html = figure_html(&figure, &Emit::default());
160 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
161 // And the spans are not read a second time in the other order.
162 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
163 }
164
165 #[test]
166 fn a_change_is_its_own_span_and_reaches_a_reader_through_the_name() {
167 // 0.13.0. The spans are all `aria-hidden`, so a delta that is not in the
168 // accessible name reaches a screen reader not at all.
169 let figure = Figure::new("1,204", "Views").change("+12.5%");
170 assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%");
171
172 let html = figure_html(&figure, &Emit::default());
173 assert!(html.contains("figure-change"), "{html}");
174 assert!(html.contains(">+12.5%<"), "{html}");
175 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3);
176
177 // A figure with nothing to compare against emits no empty span for it.
178 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
179 assert!(!plain.contains("figure-change"), "{plain}");
180 }
181
182 #[test]
183 fn a_change_carries_no_tone_of_its_own() {
184 // One element claims the figure's meaning and the sheet reaches the
185 // right span from there. Two would be two things able to disagree.
186 let html = figure_html(
187 &Figure::new("1,204", "Views")
188 .change("-4%")
189 .tone(Tone::Danger),
190 &Emit::default(),
191 );
192 assert_eq!(html.matches("data-tone").count(), 1, "{html}");
193 }
194
195 #[test]
196 fn a_change_is_text_and_cannot_become_markup() {
197 let html = figure_html(
198 &Figure::new("1", "Views").change("<img src=x onerror=alert(1)>"),
199 &Emit::default(),
200 );
201 assert!(!html.contains("<img"), "{html}");
202 }
203
204 #[test]
205 fn an_untoned_figure_emits_no_tone_attribute() {
206 // Same reason as the meter: the bare class is the untoned rule, so an
207 // attribute here would match nothing.
208 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
209 assert!(!plain.contains("data-tone"));
210
211 let toned = figure_html(
212 &Figure::new("0", "Current Streak").tone(Tone::Warning),
213 &Emit::default(),
214 );
215 assert!(toned.contains(r#"data-tone="warning""#));
216 }
217
218 #[test]
219 fn a_value_and_a_caption_are_escaped_like_every_other_string() {
220 // Both arrive from the app, the same as a field label does.
221 let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
222 assert!(html.contains("a & b"));
223 assert!(html.contains("<b>"));
224 assert!(!html.contains("<b>"));
225 }
226
227 #[test]
228 fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
229 let html = figures_html(
230 &[
231 Figure::new("17", "Current Streak"),
232 Figure::new("84%", "Completion Rate"),
233 ],
234 &Emit::default(),
235 );
236 assert!(html.starts_with(r#"<div class="figures">"#));
237 assert_eq!(html.matches(r#"class="figure""#).count(), 2);
238 }
239
240 #[test]
241 fn an_empty_strip_renders_as_an_empty_strip() {
242 // Sayable, so it has to be emittable, and visibly empty rather than
243 // absent.
244 let html = figures_html(&[], &Emit::default());
245 assert_eq!(html, r#"<div class="figures"></div>"#);
246 assert!(!html.contains("figure-"));
247 }
248
249 /// The accessible name is assembled by [`figure_text`] in one form and
250 /// escaped a piece at a time in the other, so this is the assertion holding
251 /// those two readings of the same sentence together.
252 #[test]
253 fn a_streamed_figure_is_the_figure_the_other_form_returns() {
254 let opts = Emit {
255 class_prefix: "mo-",
256 ..Emit::default()
257 };
258 for figure in [
259 Figure::new("17", "Total"),
260 Figure::new("<b>3</b>", "a & b").tone(Tone::Danger),
261 Figure::new("1,204", "Views & co").change("+12.5% <up>"),
262 ] {
263 let mut streamed = String::new();
264 figure_html_into(&figure, &opts, &mut streamed);
265 assert_eq!(streamed, figure_html(&figure, &opts));
266 assert!(
267 streamed.contains(&format!("aria-label=\"{}\"", escape(&figure_text(&figure)))),
268 "{streamed}"
269 );
270
271 let mut strip = String::new();
272 figures_html_into(&[figure], &opts, &mut strip);
273 assert_eq!(strip, figures_html(&[figure], &opts));
274 }
275 }
276
277 #[test]
278 fn the_prefix_reaches_every_class() {
279 // A prefixed build claims its own names, and the two inner spans are
280 // descendant selectors in the emitted CSS.
281 let opts = Emit {
282 class_prefix: "mo-",
283 ..Emit::default()
284 };
285 let html = figures_html(&[Figure::new("17", "Total")], &opts);
286 assert!(html.contains(r#"class="mo-figures""#));
287 assert!(html.contains(r#"class="mo-figure""#));
288 assert!(html.contains(r#"class="mo-figure-value""#));
289 assert!(html.contains(r#"class="mo-figure-caption""#));
290 }
291}