Skip to main content

max / makeover-webview

8.8 KB · 238 lines History Blame Raw
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
27 use crate::form::{Markup, escape_into};
28 use crate::{Emit, push_class};
29 use makeover_layout::{Intent, Readiness, Tone};
30 use 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.
37 pub 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]
58 pub 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.
74 pub 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.
123 fn 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)]
134 mod 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("&lt;b&gt;"));
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 }
238