makeover_webview/placeholder.rs
1//! What a region shows when it is not showing its content.
2//!
3//! The fifth phase-B emitter. `makeover_layout::Readiness` grew from two states
4//! to four at 0.12.0, and this is where the two new ones become markup: goingson
5//! drew an empty state at 27 sites across 12 files and Balanced Breakfast at 9,
6//! each app with its own class family, and the families had already drifted
7//! into `empty-state--error` against `error-state` for the same fact.
8//!
9//! # Why one function for three states
10//!
11//! `Pending`, `Empty` and `Failed` are the same anatomy — a region-sized box
12//! with a line of text in it — differing in what the text means and what colour
13//! it takes. Three emitters would be three copies of a `<div>` and a `<p>`, and
14//! the interesting thing about them is precisely the state, which the
15//! description carries. `Ready` renders nothing here by construction: it is the
16//! state that shows content, so there is no stand-in to draw.
17//!
18//! # The action, and why it arrives as markup
19//!
20//! Two of goingson's 27 empty states offer a way out — "No projects yet" with an
21//! "Add your first project" button under it. A button is an address, and no
22//! crate in this family names one. So it arrives through [`Markup`], the
23//! existing named hole in the escaping, the same way a field's trailing block
24//! does. The caller states that what it is passing is trusted; nothing here can
25//! check that for them.
26
27use crate::form::{Markup, escape_into};
28use crate::{Emit, push_class};
29use makeover_layout::{Intent, Readiness, Tone};
30use std::fmt::Write as _;
31
32/// Every class this module can put in markup.
33///
34/// [`crate::facet::FACET_CLASSES`]' obligation. `placeholder-action` is
35/// unruled: what a way out of an empty state looks like is the button inside
36/// it, and the wrapper only says where it goes.
37pub const PLACEHOLDER_CLASSES: &[&str] = &["placeholder", "placeholder-text", "placeholder-action"];
38
39/// A region's stand-in, or nothing at all when the region has its content.
40///
41/// ```
42/// use makeover_layout::Readiness;
43/// use makeover_webview::{Emit, placeholder::placeholder_html};
44///
45/// let html = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
46/// assert!(html.contains(r#"data-state="empty""#));
47/// assert!(html.contains("No projects yet"));
48///
49/// // The one state that draws its own content draws no stand-in.
50/// assert!(placeholder_html(Readiness::Ready, "unused", None, &Emit::default()).is_empty());
51/// ```
52///
53/// `role="status"` rather than `alert` for everything but a failure, on the same
54/// reasoning `Node::Notice` uses: an empty list is not an interruption. A
55/// failure is, because the user is looking at a region that should have had
56/// something in it and nothing else on the page will say so.
57#[must_use]
58pub fn placeholder_html(
59 state: Readiness,
60 message: &str,
61 action: Option<Markup<'_>>,
62 opts: &Emit,
63) -> String {
64 let mut html = String::new();
65 placeholder_html_into(state, message, action, opts, &mut html);
66 html
67}
68
69/// A region's stand-in, written into a buffer the caller already has.
70///
71/// [`placeholder_html`]'s streaming form, byte-identical to it. A state that
72/// draws its own content appends nothing, which is what the empty string the
73/// other form returns means.
74pub fn placeholder_html_into(
75 state: Readiness,
76 message: &str,
77 action: Option<Markup<'_>>,
78 opts: &Emit,
79 out: &mut String,
80) {
81 if state.shows_content() {
82 return;
83 }
84
85 let name = state_name(state);
86 out.push_str("<div class=\"");
87 push_class(out, "placeholder", opts);
88 let _ = write!(out, "\" data-state=\"{name}\"");
89
90 // Derived, not carried. "Nothing here yet" and "this broke" mean the same
91 // thing in every app that will ever have them, which is what separates this
92 // from a meter's tone.
93 if state.tone() != Tone::Neutral {
94 let _ = write!(out, " data-tone=\"{}\"", state.tone().token());
95 }
96 if state.tone() == Tone::Danger {
97 out.push_str(" role=\"alert\"");
98 } else {
99 out.push_str(" role=\"status\" aria-live=\"polite\"");
100 }
101
102 out.push_str("><p class=\"");
103 push_class(out, "placeholder-text", opts);
104 out.push_str("\">");
105 escape_into(message, out);
106 out.push_str("</p>");
107 if let Some(Markup(markup)) = action {
108 out.push_str("<div class=\"");
109 push_class(out, "placeholder-action", opts);
110 out.push_str("\">");
111 out.push_str(markup);
112 out.push_str("</div>");
113 }
114 out.push_str("</div>");
115}
116
117/// The `data-state` value for a state.
118///
119/// A wildcard rather than a total match, because `Readiness` is
120/// `#[non_exhaustive]` as of 0.12.0. A state added upstream draws the plain
121/// stand-in with no state of its own, which is a box rendering without its
122/// colour rather than a build that stops.
123fn state_name(state: Readiness) -> &'static str {
124 match state {
125 Readiness::Ready => "ready",
126 Readiness::Pending => "pending",
127 Readiness::Empty => "empty",
128 Readiness::Failed => "failed",
129 _ => "unknown",
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 /// Including the state that draws nothing: appending nothing and returning
138 /// an empty string have to stay the same answer.
139 #[test]
140 fn a_streamed_placeholder_is_the_placeholder_the_other_form_returns() {
141 let opts = Emit {
142 class_prefix: "mk-",
143 ..Emit::default()
144 };
145 for state in [
146 Readiness::Ready,
147 Readiness::Pending,
148 Readiness::Empty,
149 Readiness::Failed,
150 ] {
151 for action in [None, Some(Markup("<button>go</button>"))] {
152 let mut streamed = String::new();
153 placeholder_html_into(state, "none & <yet>", action, &opts, &mut streamed);
154 assert_eq!(
155 streamed,
156 placeholder_html(state, "none & <yet>", action, &opts)
157 );
158 }
159 }
160 }
161
162 #[test]
163 fn the_state_that_shows_content_draws_no_stand_in() {
164 // Not an empty box: nothing at all, or every ready region gains an
165 // element that pushes its content down.
166 assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
167 }
168
169 #[test]
170 fn an_empty_region_is_not_announced_as_a_fault() {
171 // An empty list is the normal state of a new install. `role="alert"`
172 // interrupts a screen reader mid-sentence, which is the wrong thing to
173 // do about "no projects yet".
174 let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
175 assert!(empty.contains(r#"role="status""#));
176 assert!(!empty.contains("data-tone"));
177
178 let failed = placeholder_html(
179 Readiness::Failed,
180 "Failed to load events",
181 None,
182 &Emit::default(),
183 );
184 assert!(failed.contains(r#"role="alert""#));
185 assert!(failed.contains(r#"data-tone="danger""#));
186 }
187
188 #[test]
189 fn the_message_is_escaped_and_the_action_is_not() {
190 // The asymmetry is the whole point of `Markup`, and it is the same one
191 // a field's trailing block has: text from the app is escaped, and a
192 // block the caller has stated is markup is passed through.
193 let html = placeholder_html(
194 Readiness::Empty,
195 "No <b>projects</b> yet",
196 Some(Markup("<button>Add one</button>")),
197 &Emit::default(),
198 );
199 assert!(html.contains("<b>"));
200 assert!(!html.contains("<b>"));
201 assert!(html.contains("<button>Add one</button>"));
202 }
203
204 #[test]
205 fn a_state_with_no_action_emits_no_action_container() {
206 // 25 of goingson's 27 empty states have no way out. An empty container
207 // at each of them is a box the stylesheet has to know to collapse.
208 let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
209 assert!(!html.contains("placeholder-action"));
210 }
211
212 #[test]
213 fn pending_draws_the_same_anatomy_as_the_other_two() {
214 // Three states, one box. What differs is what the text means, which is
215 // what the description carries.
216 let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
217 assert!(html.contains(r#"data-state="pending""#));
218 assert!(html.contains("Loading"));
219 }
220
221 #[test]
222 fn the_prefix_reaches_every_class() {
223 let opts = Emit {
224 class_prefix: "mo-",
225 ..Emit::default()
226 };
227 let html = placeholder_html(
228 Readiness::Empty,
229 "None",
230 Some(Markup("<button>Go</button>")),
231 &opts,
232 );
233 assert!(html.contains(r#"class="mo-placeholder""#));
234 assert!(html.contains(r#"class="mo-placeholder-text""#));
235 assert!(html.contains(r#"class="mo-placeholder-action""#));
236 }
237}