Skip to main content

max / makeover-webview

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