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;
28use crate::{Emit, 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 format!("{}: {}", figure.caption, figure.value)
42}
43
44/// One figure, as its own element.
45///
46/// ```
47/// use makeover_layout::{Figure, Tone};
48/// use makeover_webview::{Emit, figure::figure_html};
49///
50/// let figure = Figure::new("17", "Current Streak").tone(Tone::Success);
51/// let html = figure_html(&figure, &Emit::default());
52///
53/// assert!(html.contains(r#"data-tone="success""#));
54/// assert!(html.contains(r#"aria-label="Current Streak: 17""#));
55/// ```
56#[must_use]
57pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
58 let mut html = format!(
59 "<div class=\"{}\" aria-label=\"{}\"",
60 class("figure", opts),
61 escape(&figure_text(figure))
62 );
63 // Neutral is the ordinary fact, and `figure_rules` styles the bare class
64 // for it. `data-tone="content-muted"` would match a rule that is not there.
65 if figure.tone != Tone::Neutral {
66 let _ = write!(html, " data-tone=\"{}\"", figure.tone.token());
67 }
68 // `aria-hidden` on both, because the element above has already said the
69 // whole thing. Without it a reader gets the number twice and the noun
70 // twice, in the order the eye wants rather than the order the ear does.
71 let _ = write!(
72 html,
73 "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
74 <span class=\"{}\" aria-hidden=\"true\">{}</span></div>",
75 class("figure-value", opts),
76 escape(figure.value),
77 class("figure-caption", opts),
78 escape(figure.caption),
79 );
80 html
81}
82
83/// Several figures as one strip.
84///
85/// An empty set emits the container and nothing in it, for the reason a meter
86/// over nothing and a select with no options both render: it is what an app with
87/// an unloaded count actually has, and an empty strip says so on screen rather
88/// than in a log.
89#[must_use]
90pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
91 let mut html = format!("<div class=\"{}\">", class("figures", opts));
92 for figure in figures {
93 html.push_str(&figure_html(figure, opts));
94 }
95 html.push_str("</div>");
96 html
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn the_noun_reaches_a_reader_before_the_number() {
105 // The problem the accessible name solves. Visually the value comes
106 // first; a reader that met "17" first would have to hold it until it
107 // found out what was counted.
108 let figure = Figure::new("17", "Current Streak");
109 assert_eq!(figure_text(&figure), "Current Streak: 17");
110
111 let html = figure_html(&figure, &Emit::default());
112 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
113 // And the spans are not read a second time in the other order.
114 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
115 }
116
117 #[test]
118 fn an_untoned_figure_emits_no_tone_attribute() {
119 // Same reason as the meter: the bare class is the untoned rule, so an
120 // attribute here would match nothing.
121 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
122 assert!(!plain.contains("data-tone"));
123
124 let toned = figure_html(
125 &Figure::new("0", "Current Streak").tone(Tone::Warning),
126 &Emit::default(),
127 );
128 assert!(toned.contains(r#"data-tone="warning""#));
129 }
130
131 #[test]
132 fn a_value_and_a_caption_are_escaped_like_every_other_string() {
133 // Both arrive from the app, the same as a field label does.
134 let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
135 assert!(html.contains("a & b"));
136 assert!(html.contains("<b>"));
137 assert!(!html.contains("<b>"));
138 }
139
140 #[test]
141 fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
142 let html = figures_html(
143 &[
144 Figure::new("17", "Current Streak"),
145 Figure::new("84%", "Completion Rate"),
146 ],
147 &Emit::default(),
148 );
149 assert!(html.starts_with(r#"<div class="figures">"#));
150 assert_eq!(html.matches(r#"class="figure""#).count(), 2);
151 }
152
153 #[test]
154 fn an_empty_strip_renders_as_an_empty_strip() {
155 // Sayable, so it has to be emittable, and visibly empty rather than
156 // absent.
157 let html = figures_html(&[], &Emit::default());
158 assert_eq!(html, r#"<div class="figures"></div>"#);
159 assert!(!html.contains("figure-"));
160 }
161
162 #[test]
163 fn the_prefix_reaches_every_class() {
164 // A prefixed build claims its own names, and the two inner spans are
165 // descendant selectors in the emitted CSS.
166 let opts = Emit {
167 class_prefix: "mo-",
168 ..Emit::default()
169 };
170 let html = figures_html(&[Figure::new("17", "Total")], &opts);
171 assert!(html.contains(r#"class="mo-figures""#));
172 assert!(html.contains(r#"class="mo-figure""#));
173 assert!(html.contains(r#"class="mo-figure-value""#));
174 assert!(html.contains(r#"class="mo-figure-caption""#));
175 }
176}