Skip to main content

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 &amp; b"));
223        assert!(html.contains("&lt;b&gt;"));
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}