//! 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,
) {
emit_placeholder(state, message, action, opts, out, None);
}
/// A placeholder, saying where the way out landed.
///
/// Byte-identical to [`placeholder_html_into`], and where there is a way out it
/// sets `placed` to the offsets in `out` between which the whole
/// `placeholder-action` block was written -- the wrapper included, since the
/// wrapper is what goes with it. `None` stays `None` when there is no action.
///
/// Same reason as the other placed forms in this crate: a caller compiling this
/// markup into a template has to know which bytes the way out produced, and the
/// way out is a caller-supplied string that may appear elsewhere in the
/// document.
pub fn placeholder_html_placed(
state: Readiness,
message: &str,
action: Option>,
opts: &Emit,
out: &mut String,
placed: &mut Option>,
) {
emit_placeholder(state, message, action, opts, out, Some(placed));
}
fn emit_placeholder(
state: Readiness,
message: &str,
action: Option>,
opts: &Emit,
out: &mut String,
placed: Option<&mut Option>>,
) {
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 {
let at = out.len();
out.push_str("
");
out.push_str(markup);
out.push_str("
");
if let Some(placed) = placed {
*placed = Some(at..out.len());
}
}
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""#));
}
}