Skip to main content

max / makeover-webview

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