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 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 = format!(
65 "<div class=\"{}\" aria-label=\"{}\"",
66 class("figure", opts),
67 escape(&figure_text(figure))
68 );
69 // Neutral is the ordinary fact, and `figure_rules` styles the bare class
70 // for it. `data-tone="content-muted"` would match a rule that is not there.
71 if figure.tone != Tone::Neutral {
72 let _ = write!(html, " data-tone=\"{}\"", figure.tone.token());
73 }
74 // `aria-hidden` on both, because the element above has already said the
75 // whole thing. Without it a reader gets the number twice and the noun
76 // twice, in the order the eye wants rather than the order the ear does.
77 let _ = write!(
78 html,
79 "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
80 <span class=\"{}\" aria-hidden=\"true\">{}</span>",
81 class("figure-value", opts),
82 escape(figure.value),
83 class("figure-caption", opts),
84 escape(figure.caption),
85 );
86 // 0.13.0. Its own element rather than more of the caption, so a stylesheet
87 // can set it smaller and a renderer with one line can drop it first. The
88 // tone is already on the wrapper and the rule keys off it from there, which
89 // is why the delta carries no `data-tone` of its own: two elements claiming
90 // one tone is how they end up disagreeing.
91 if let Some(change) = figure.change {
92 let _ = write!(
93 html,
94 "<span class=\"{}\" aria-hidden=\"true\">{}</span>",
95 class("figure-change", opts),
96 escape(change),
97 );
98 }
99 html.push_str("</div>");
100 html
101}
102
103/// Several figures as one strip.
104///
105/// An empty set emits the container and nothing in it, for the reason a meter
106/// over nothing and a select with no options both render: it is what an app with
107/// an unloaded count actually has, and an empty strip says so on screen rather
108/// than in a log.
109#[must_use]
110pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
111 let mut html = format!("<div class=\"{}\">", class("figures", opts));
112 for figure in figures {
113 html.push_str(&figure_html(figure, opts));
114 }
115 html.push_str("</div>");
116 html
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn the_noun_reaches_a_reader_before_the_number() {
125 // The problem the accessible name solves. Visually the value comes
126 // first; a reader that met "17" first would have to hold it until it
127 // found out what was counted.
128 let figure = Figure::new("17", "Current Streak");
129 assert_eq!(figure_text(&figure), "Current Streak: 17");
130
131 let html = figure_html(&figure, &Emit::default());
132 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
133 // And the spans are not read a second time in the other order.
134 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
135 }
136
137 #[test]
138 fn a_change_is_its_own_span_and_reaches_a_reader_through_the_name() {
139 // 0.13.0. The spans are all `aria-hidden`, so a delta that is not in the
140 // accessible name reaches a screen reader not at all.
141 let figure = Figure::new("1,204", "Views").change("+12.5%");
142 assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%");
143
144 let html = figure_html(&figure, &Emit::default());
145 assert!(html.contains("figure-change"), "{html}");
146 assert!(html.contains(">+12.5%<"), "{html}");
147 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3);
148
149 // A figure with nothing to compare against emits no empty span for it.
150 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
151 assert!(!plain.contains("figure-change"), "{plain}");
152 }
153
154 #[test]
155 fn a_change_carries_no_tone_of_its_own() {
156 // One element claims the figure's meaning and the sheet reaches the
157 // right span from there. Two would be two things able to disagree.
158 let html = figure_html(
159 &Figure::new("1,204", "Views")
160 .change("-4%")
161 .tone(Tone::Danger),
162 &Emit::default(),
163 );
164 assert_eq!(html.matches("data-tone").count(), 1, "{html}");
165 }
166
167 #[test]
168 fn a_change_is_text_and_cannot_become_markup() {
169 let html = figure_html(
170 &Figure::new("1", "Views").change("<img src=x onerror=alert(1)>"),
171 &Emit::default(),
172 );
173 assert!(!html.contains("<img"), "{html}");
174 }
175
176 #[test]
177 fn an_untoned_figure_emits_no_tone_attribute() {
178 // Same reason as the meter: the bare class is the untoned rule, so an
179 // attribute here would match nothing.
180 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
181 assert!(!plain.contains("data-tone"));
182
183 let toned = figure_html(
184 &Figure::new("0", "Current Streak").tone(Tone::Warning),
185 &Emit::default(),
186 );
187 assert!(toned.contains(r#"data-tone="warning""#));
188 }
189
190 #[test]
191 fn a_value_and_a_caption_are_escaped_like_every_other_string() {
192 // Both arrive from the app, the same as a field label does.
193 let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
194 assert!(html.contains("a & b"));
195 assert!(html.contains("<b>"));
196 assert!(!html.contains("<b>"));
197 }
198
199 #[test]
200 fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
201 let html = figures_html(
202 &[
203 Figure::new("17", "Current Streak"),
204 Figure::new("84%", "Completion Rate"),
205 ],
206 &Emit::default(),
207 );
208 assert!(html.starts_with(r#"<div class="figures">"#));
209 assert_eq!(html.matches(r#"class="figure""#).count(), 2);
210 }
211
212 #[test]
213 fn an_empty_strip_renders_as_an_empty_strip() {
214 // Sayable, so it has to be emittable, and visibly empty rather than
215 // absent.
216 let html = figures_html(&[], &Emit::default());
217 assert_eq!(html, r#"<div class="figures"></div>"#);
218 assert!(!html.contains("figure-"));
219 }
220
221 #[test]
222 fn the_prefix_reaches_every_class() {
223 // A prefixed build claims its own names, and the two inner spans are
224 // descendant selectors in the emitted CSS.
225 let opts = Emit {
226 class_prefix: "mo-",
227 ..Emit::default()
228 };
229 let html = figures_html(&[Figure::new("17", "Total")], &opts);
230 assert!(html.contains(r#"class="mo-figures""#));
231 assert!(html.contains(r#"class="mo-figure""#));
232 assert!(html.contains(r#"class="mo-figure-value""#));
233 assert!(html.contains(r#"class="mo-figure-caption""#));
234 }
235}