//! A figure with a caption, and a strip of them.
//!
//! The fourth phase-B emitter. `makeover_layout::Figure` arrived at 0.11.0 after
//! goingson turned out to have five of these across five screens, each with its
//! own class names for the one shape: `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 the
/// per-figure `String` used to 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#"