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