use crate::form::{Markup, escape_into};
use crate::{Emit, push_class};
use makeover_layout::{Intent, Readiness, Tone};
use std::fmt::Write as _;
pub const PLACEHOLDER_CLASSES: &[&str] = &["placeholder", "placeholder-text", "placeholder-action"];
#[must_use]
pub fn placeholder_html(
state: Readiness,
message: &str,
action: Option<Markup<'_>>,
opts: &Emit,
) -> String {
let mut html = String::new();
placeholder_html_into(state, message, action, opts, &mut html);
html
}
pub fn placeholder_html_into(
state: Readiness,
message: &str,
action: Option<Markup<'_>>,
opts: &Emit,
out: &mut String,
) {
if state.shows_content() {
return;
}
let name = state_name(state);
out.push_str("<div class=\"");
push_class(out, "placeholder", opts);
let _ = write!(out, "\" data-state=\"{name}\"");
if state.tone() != Tone::Neutral {
let _ = write!(out, " data-tone=\"{}\"", state.tone().token());
}
if state.tone() == Tone::Danger {
out.push_str(" role=\"alert\"");
} else {
out.push_str(" role=\"status\" aria-live=\"polite\"");
}
out.push_str("><p class=\"");
push_class(out, "placeholder-text", opts);
out.push_str("\">");
escape_into(message, out);
out.push_str("</p>");
if let Some(Markup(markup)) = action {
out.push_str("<div class=\"");
push_class(out, "placeholder-action", opts);
out.push_str("\">");
out.push_str(markup);
out.push_str("</div>");
}
out.push_str("</div>");
}
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 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("<button>go</button>"))] {
let mut streamed = String::new();
placeholder_html_into(state, "none & <yet>", action, &opts, &mut streamed);
assert_eq!(
streamed,
placeholder_html(state, "none & <yet>", action, &opts)
);
}
}
}
#[test]
fn the_state_that_shows_content_draws_no_stand_in() {
assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
}
#[test]
fn an_empty_region_is_not_announced_as_a_fault() {
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() {
let html = placeholder_html(
Readiness::Empty,
"No <b>projects</b> yet",
Some(Markup("<button>Add one</button>")),
&Emit::default(),
);
assert!(html.contains("<b>"));
assert!(!html.contains("<b>"));
assert!(html.contains("<button>Add one</button>"));
}
#[test]
fn a_state_with_no_action_emits_no_action_container() {
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() {
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""#));
}
}