//! A figure with a caption, and a strip of them. //! //! The fourth phase-B emitter, for `makeover_layout::Figure`. Left to an app, //! one shape grows a class name per screen: `task-overview-stat`, `stat-box`, //! `month-stat-item`, `contact-summary-stat`, `sync-stat`. //! //! # Why the strip has its own function //! //! Four tiles in a row and four tiles down a column are different things, and a //! renderer handed one figure at a time cannot tell it is looking at a set. So //! the set is what gets emitted, and a lone figure is a set of one. //! //! # The reading order is markup, not CSS //! //! Visually the value is set large over a small caption, which is what four of //! the five sites drew. A screen reader meeting "17" before it knows what was //! counted has to hold the number until the noun arrives, so the figure carries //! its own accessible name — "Current Streak: 17" — and the two spans are hidden //! from the reader that has already been told. //! //! Solving it that way rather than by inverting the markup and turning it back //! with `column-reverse` is deliberate: the arrangement and the type scale are //! the app's, and a renderer that emitted them would be naming sizes. Same line //! `meter_html` holds when it emits the tones and never the width. use crate::form::escape_into; use crate::{Emit, push_class}; use makeover_layout::{Figure, Intent, Tone}; use std::fmt::Write as _; /// Every class this module can put in markup. /// /// [`crate::facet::FACET_CLASSES`]' obligation. `figures` is the strip around /// them and carries no rule of its own -- how tiles sit in a row is the app's /// layout -- so a scraped set cannot see it. pub const FIGURE_CLASSES: &[&str] = &[ "figures", "figure", "figure-value", "figure-caption", "figure-change", ]; /// The accessible name for a figure: the noun, then the number. /// /// Built here rather than carried, for the reason [`meter_text`] is: a strip /// wants "Current Streak: 17" and a terminal at one line wants something else, /// and a description that shipped either would have chosen for both. /// /// [`meter_text`]: crate::meter::meter_text #[must_use] pub fn figure_text(figure: &Figure<'_>) -> String { figure.change.map_or_else( || format!("{}: {}", figure.caption, figure.value), // The delta reaches a reader as part of the one name, because the spans // below are all `aria-hidden` and it would otherwise reach them not at // all. "Views: 1,204, +12.5%" rather than a bare number after a comma. |change| format!("{}: {}, {change}", figure.caption, figure.value), ) } /// One figure, as its own element. /// /// ``` /// use makeover_layout::{Figure, Tone}; /// use makeover_webview::{Emit, figure::figure_html}; /// /// let figure = Figure::new("17", "Current Streak").tone(Tone::Success); /// let html = figure_html(&figure, &Emit::default()); /// /// assert!(html.contains(r#"data-tone="success""#)); /// assert!(html.contains(r#"aria-label="Current Streak: 17""#)); /// ``` #[must_use] pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String { let mut html = String::new(); figure_html_into(figure, opts, &mut html); html } /// One figure, written into a buffer the caller already has. /// /// [`figure_html`]'s streaming form, byte-identical to it. The accessible name /// is escaped a piece at a time rather than built and then escaped, which is /// the same output for one allocation fewer: the separators [`figure_text`] /// puts between the pieces contain nothing an escaper would encode. pub fn figure_html_into(figure: &Figure<'_>, opts: &Emit, out: &mut String) { out.push_str("
"); escape_into(figure.value, out); out.push_str(""); escape_into(figure.caption, out); out.push_str(""); // 0.13.0. Its own element rather than more of the caption, so a stylesheet // can set it smaller and a renderer with one line can drop it first. The // tone is already on the wrapper and the rule keys off it from there, which // is why the delta carries no `data-tone` of its own: two elements claiming // one tone is how they end up disagreeing. if let Some(change) = figure.change { out.push_str(""); escape_into(change, out); out.push_str(""); } out.push_str("
"); } /// Several figures as one strip. /// /// An empty set emits the container and nothing in it, for the reason a meter /// over nothing and a select with no options both render: it is what an app with /// an unloaded count actually has, and an empty strip says so on screen rather /// than in a log. #[must_use] pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String { let mut html = String::new(); figures_html_into(figures, opts, &mut html); html } /// Several figures as one strip, written into a buffer the caller already has. /// /// [`figures_html`]'s streaming form, byte-identical to it. A strip is where a /// per-figure `String` would otherwise be paid for once per tile. pub fn figures_html_into(figures: &[Figure<'_>], opts: &Emit, out: &mut String) { out.push_str("
"); for figure in figures { figure_html_into(figure, opts, out); } out.push_str("
"); } #[cfg(test)] mod tests { use super::*; use crate::form::escape; #[test] fn the_noun_reaches_a_reader_before_the_number() { // The problem the accessible name solves. Visually the value comes // first; a reader that met "17" first would have to hold it until it // found out what was counted. let figure = Figure::new("17", "Current Streak"); assert_eq!(figure_text(&figure), "Current Streak: 17"); let html = figure_html(&figure, &Emit::default()); assert!(html.contains(r#"aria-label="Current Streak: 17""#)); // And the spans are not read a second time in the other order. assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2); } #[test] fn a_change_is_its_own_span_and_reaches_a_reader_through_the_name() { // 0.13.0. The spans are all `aria-hidden`, so a delta that is not in the // accessible name reaches a screen reader not at all. let figure = Figure::new("1,204", "Views").change("+12.5%"); assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%"); let html = figure_html(&figure, &Emit::default()); assert!(html.contains("figure-change"), "{html}"); assert!(html.contains(">+12.5%<"), "{html}"); assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3); // A figure with nothing to compare against emits no empty span for it. let plain = figure_html(&Figure::new("17", "Total"), &Emit::default()); assert!(!plain.contains("figure-change"), "{plain}"); } #[test] fn a_change_carries_no_tone_of_its_own() { // One element claims the figure's meaning and the sheet reaches the // right span from there. Two would be two things able to disagree. let html = figure_html( &Figure::new("1,204", "Views") .change("-4%") .tone(Tone::Danger), &Emit::default(), ); assert_eq!(html.matches("data-tone").count(), 1, "{html}"); } #[test] fn a_change_is_text_and_cannot_become_markup() { let html = figure_html( &Figure::new("1", "Views").change(""), &Emit::default(), ); assert!(!html.contains("3", "a & b"), &Emit::default()); assert!(html.contains("a & b")); assert!(html.contains("<b>")); assert!(!html.contains("")); } #[test] fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() { let html = figures_html( &[ Figure::new("17", "Current Streak"), Figure::new("84%", "Completion Rate"), ], &Emit::default(), ); assert!(html.starts_with(r#"
"#)); assert_eq!(html.matches(r#"class="figure""#).count(), 2); } #[test] fn an_empty_strip_renders_as_an_empty_strip() { // Sayable, so it has to be emittable, and visibly empty rather than // absent. let html = figures_html(&[], &Emit::default()); assert_eq!(html, r#"
"#); assert!(!html.contains("figure-")); } /// The accessible name is assembled by [`figure_text`] in one form and /// escaped a piece at a time in the other, so this is the assertion holding /// those two readings of the same sentence together. #[test] fn a_streamed_figure_is_the_figure_the_other_form_returns() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; for figure in [ Figure::new("17", "Total"), Figure::new("3", "a & b").tone(Tone::Danger), Figure::new("1,204", "Views & co").change("+12.5% "), ] { let mut streamed = String::new(); figure_html_into(&figure, &opts, &mut streamed); assert_eq!(streamed, figure_html(&figure, &opts)); assert!( streamed.contains(&format!("aria-label=\"{}\"", escape(&figure_text(&figure)))), "{streamed}" ); let mut strip = String::new(); figures_html_into(&[figure], &opts, &mut strip); assert_eq!(strip, figures_html(&[figure], &opts)); } } #[test] fn the_prefix_reaches_every_class() { // A prefixed build claims its own names, and the two inner spans are // descendant selectors in the emitted CSS. let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let html = figures_html(&[Figure::new("17", "Total")], &opts); assert!(html.contains(r#"class="mo-figures""#)); assert!(html.contains(r#"class="mo-figure""#)); assert!(html.contains(r#"class="mo-figure-value""#)); assert!(html.contains(r#"class="mo-figure-caption""#)); } }