//! What a region shows when it is not showing its content. //! //! The fifth phase-B emitter, where `makeover_layout::Readiness` becomes //! markup. Left to the apps, each grows its own class family and the families //! drift: `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 `
` and a `

`, 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_into}; use crate::{Emit, push_class}; use makeover_layout::{Intent, Readiness, Tone}; use std::fmt::Write as _; /// Every class this module can put in markup. /// /// [`crate::facet::FACET_CLASSES`]' obligation. `placeholder-action` is /// unruled: what a way out of an empty state looks like is the button inside /// it, and the wrapper only says where it goes. pub const PLACEHOLDER_CLASSES: &[&str] = &["placeholder", "placeholder-text", "placeholder-action"]; /// 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>, opts: &Emit, ) -> String { let mut html = String::new(); placeholder_html_into(state, message, action, opts, &mut html); html } /// A region's stand-in, written into a buffer the caller already has. /// /// [`placeholder_html`]'s streaming form, byte-identical to it. A state that /// draws its own content appends nothing, which is what the empty string the /// other form returns means. pub fn placeholder_html_into( state: Readiness, message: &str, action: Option>, opts: &Emit, out: &mut String, ) { if state.shows_content() { return; } let name = state_name(state); out.push_str("

"); escape_into(message, out); out.push_str("

"); if let Some(Markup(markup)) = action { out.push_str("
"); out.push_str(markup); out.push_str("
"); } out.push_str("
"); } /// The `data-state` value for a state. /// /// A wildcard rather than a total match, because `Readiness` is /// `#[non_exhaustive]`. 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::*; /// Including the state that draws nothing: appending nothing and returning /// an empty string have to stay the same answer. #[test] fn a_streamed_placeholder_is_the_placeholder_the_other_form_returns() { let opts = Emit { class_prefix: "mk-", ..Emit::default() }; for state in [ Readiness::Ready, Readiness::Pending, Readiness::Empty, Readiness::Failed, ] { for action in [None, Some(Markup(""))] { let mut streamed = String::new(); placeholder_html_into(state, "none & ", action, &opts, &mut streamed); assert_eq!( streamed, placeholder_html(state, "none & ", action, &opts) ); } } } #[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 projects yet", Some(Markup("")), &Emit::default(), ); assert!(html.contains("<b>")); assert!(!html.contains("")); assert!(html.contains("")); } #[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("")), &opts, ); assert!(html.contains(r#"class="mo-placeholder""#)); assert!(html.contains(r#"class="mo-placeholder-text""#)); assert!(html.contains(r#"class="mo-placeholder-action""#)); } }