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