makeover-webview 0.22.1

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser.
Documentation
//! What a region shows when it is not showing its content.
//!
//! The fifth phase-B emitter. `makeover_layout::Readiness` grew from two states
//! to four at 0.12.0, and this is where the two new ones become markup: goingson
//! drew an empty state at 27 sites across 12 files and Balanced Breakfast at 9,
//! each app with its own class family, and the families had already drifted
//! into `empty-state--error` against `error-state` for the same fact.
//!
//! # Why one function for three states
//!
//! `Pending`, `Empty` and `Failed` are the same anatomy — a region-sized box
//! with a line of text in it — differing in what the text means and what colour
//! it takes. Three emitters would be three copies of a `<div>` and a `<p>`, and
//! the interesting thing about them is precisely the state, which the
//! description carries. `Ready` renders nothing here by construction: it is the
//! state that shows content, so there is no stand-in to draw.
//!
//! # The action, and why it arrives as markup
//!
//! Two of goingson's 27 empty states offer a way out — "No projects yet" with an
//! "Add your first project" button under it. A button is an address, and no
//! crate in this family names one. So it arrives through [`Markup`], the
//! existing named hole in the escaping, the same way a field's trailing block
//! does. The caller states that what it is passing is trusted; nothing here can
//! check that for them.

use crate::form::{Markup, escape};
use crate::{Emit, class};
use makeover_layout::{Intent, Readiness, Tone};
use std::fmt::Write as _;

/// A region's stand-in, or nothing at all when the region has its content.
///
/// ```
/// use makeover_layout::Readiness;
/// use makeover_webview::{Emit, placeholder::placeholder_html};
///
/// let html = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
/// assert!(html.contains(r#"data-state="empty""#));
/// assert!(html.contains("No projects yet"));
///
/// // The one state that draws its own content draws no stand-in.
/// assert!(placeholder_html(Readiness::Ready, "unused", None, &Emit::default()).is_empty());
/// ```
///
/// `role="status"` rather than `alert` for everything but a failure, on the same
/// reasoning `Node::Notice` uses: an empty list is not an interruption. A
/// failure is, because the user is looking at a region that should have had
/// something in it and nothing else on the page will say so.
#[must_use]
pub fn placeholder_html(
    state: Readiness,
    message: &str,
    action: Option<Markup<'_>>,
    opts: &Emit,
) -> String {
    if state.shows_content() {
        return String::new();
    }

    let name = state_name(state);
    let mut html = format!(
        "<div class=\"{}\" data-state=\"{name}\"",
        class("placeholder", opts)
    );

    // Derived, not carried. "Nothing here yet" and "this broke" mean the same
    // thing in every app that will ever have them, which is what separates this
    // from a meter's tone.
    if state.tone() != Tone::Neutral {
        let _ = write!(html, " data-tone=\"{}\"", state.tone().token());
    }
    if state.tone() == Tone::Danger {
        html.push_str(" role=\"alert\"");
    } else {
        html.push_str(" role=\"status\" aria-live=\"polite\"");
    }

    let _ = write!(
        html,
        "><p class=\"{}\">{}</p>",
        class("placeholder-text", opts),
        escape(message)
    );
    if let Some(Markup(markup)) = action {
        let _ = write!(
            html,
            "<div class=\"{}\">{markup}</div>",
            class("placeholder-action", opts)
        );
    }
    html.push_str("</div>");
    html
}

/// The `data-state` value for a state.
///
/// A wildcard rather than a total match, because `Readiness` is
/// `#[non_exhaustive]` as of 0.12.0. A state added upstream draws the plain
/// stand-in with no state of its own, which is a box rendering without its
/// colour rather than a build that stops.
fn state_name(state: Readiness) -> &'static str {
    match state {
        Readiness::Ready => "ready",
        Readiness::Pending => "pending",
        Readiness::Empty => "empty",
        Readiness::Failed => "failed",
        _ => "unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_state_that_shows_content_draws_no_stand_in() {
        // Not an empty box: nothing at all, or every ready region gains an
        // element that pushes its content down.
        assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
    }

    #[test]
    fn an_empty_region_is_not_announced_as_a_fault() {
        // An empty list is the normal state of a new install. `role="alert"`
        // interrupts a screen reader mid-sentence, which is the wrong thing to
        // do about "no projects yet".
        let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
        assert!(empty.contains(r#"role="status""#));
        assert!(!empty.contains("data-tone"));

        let failed = placeholder_html(
            Readiness::Failed,
            "Failed to load events",
            None,
            &Emit::default(),
        );
        assert!(failed.contains(r#"role="alert""#));
        assert!(failed.contains(r#"data-tone="danger""#));
    }

    #[test]
    fn the_message_is_escaped_and_the_action_is_not() {
        // The asymmetry is the whole point of `Markup`, and it is the same one
        // a field's trailing block has: text from the app is escaped, and a
        // block the caller has stated is markup is passed through.
        let html = placeholder_html(
            Readiness::Empty,
            "No <b>projects</b> yet",
            Some(Markup("<button>Add one</button>")),
            &Emit::default(),
        );
        assert!(html.contains("&lt;b&gt;"));
        assert!(!html.contains("<b>"));
        assert!(html.contains("<button>Add one</button>"));
    }

    #[test]
    fn a_state_with_no_action_emits_no_action_container() {
        // 25 of goingson's 27 empty states have no way out. An empty container
        // at each of them is a box the stylesheet has to know to collapse.
        let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
        assert!(!html.contains("placeholder-action"));
    }

    #[test]
    fn pending_draws_the_same_anatomy_as_the_other_two() {
        // Three states, one box. What differs is what the text means, which is
        // what the description carries.
        let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
        assert!(html.contains(r#"data-state="pending""#));
        assert!(html.contains("Loading"));
    }

    #[test]
    fn the_prefix_reaches_every_class() {
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        let html = placeholder_html(
            Readiness::Empty,
            "None",
            Some(Markup("<button>Go</button>")),
            &opts,
        );
        assert!(html.contains(r#"class="mo-placeholder""#));
        assert!(html.contains(r#"class="mo-placeholder-text""#));
        assert!(html.contains(r#"class="mo-placeholder-action""#));
    }
}