Skip to main content

max / makeover-webview

7.1 KB · 192 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};
28 use crate::{Emit, class};
29 use makeover_layout::{Intent, Readiness, Tone};
30 use std::fmt::Write as _;
31
32 /// A region's stand-in, or nothing at all when the region has its content.
33 ///
34 /// ```
35 /// use makeover_layout::Readiness;
36 /// use makeover_webview::{Emit, placeholder::placeholder_html};
37 ///
38 /// let html = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
39 /// assert!(html.contains(r#"data-state="empty""#));
40 /// assert!(html.contains("No projects yet"));
41 ///
42 /// // The one state that draws its own content draws no stand-in.
43 /// assert!(placeholder_html(Readiness::Ready, "unused", None, &Emit::default()).is_empty());
44 /// ```
45 ///
46 /// `role="status"` rather than `alert` for everything but a failure, on the same
47 /// reasoning `Node::Notice` uses: an empty list is not an interruption. A
48 /// failure is, because the user is looking at a region that should have had
49 /// something in it and nothing else on the page will say so.
50 #[must_use]
51 pub fn placeholder_html(
52 state: Readiness,
53 message: &str,
54 action: Option<Markup<'_>>,
55 opts: &Emit,
56 ) -> String {
57 if state.shows_content() {
58 return String::new();
59 }
60
61 let name = state_name(state);
62 let mut html = format!(
63 "<div class=\"{}\" data-state=\"{name}\"",
64 class("placeholder", opts)
65 );
66
67 // Derived, not carried. "Nothing here yet" and "this broke" mean the same
68 // thing in every app that will ever have them, which is what separates this
69 // from a meter's tone.
70 if state.tone() != Tone::Neutral {
71 let _ = write!(html, " data-tone=\"{}\"", state.tone().token());
72 }
73 if state.tone() == Tone::Danger {
74 html.push_str(" role=\"alert\"");
75 } else {
76 html.push_str(" role=\"status\" aria-live=\"polite\"");
77 }
78
79 let _ = write!(
80 html,
81 "><p class=\"{}\">{}</p>",
82 class("placeholder-text", opts),
83 escape(message)
84 );
85 if let Some(Markup(markup)) = action {
86 let _ = write!(
87 html,
88 "<div class=\"{}\">{markup}</div>",
89 class("placeholder-action", opts)
90 );
91 }
92 html.push_str("</div>");
93 html
94 }
95
96 /// The `data-state` value for a state.
97 ///
98 /// A wildcard rather than a total match, because `Readiness` is
99 /// `#[non_exhaustive]` as of 0.12.0. A state added upstream draws the plain
100 /// stand-in with no state of its own, which is a box rendering without its
101 /// colour rather than a build that stops.
102 fn state_name(state: Readiness) -> &'static str {
103 match state {
104 Readiness::Ready => "ready",
105 Readiness::Pending => "pending",
106 Readiness::Empty => "empty",
107 Readiness::Failed => "failed",
108 _ => "unknown",
109 }
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn the_state_that_shows_content_draws_no_stand_in() {
118 // Not an empty box: nothing at all, or every ready region gains an
119 // element that pushes its content down.
120 assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
121 }
122
123 #[test]
124 fn an_empty_region_is_not_announced_as_a_fault() {
125 // An empty list is the normal state of a new install. `role="alert"`
126 // interrupts a screen reader mid-sentence, which is the wrong thing to
127 // do about "no projects yet".
128 let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
129 assert!(empty.contains(r#"role="status""#));
130 assert!(!empty.contains("data-tone"));
131
132 let failed = placeholder_html(
133 Readiness::Failed,
134 "Failed to load events",
135 None,
136 &Emit::default(),
137 );
138 assert!(failed.contains(r#"role="alert""#));
139 assert!(failed.contains(r#"data-tone="danger""#));
140 }
141
142 #[test]
143 fn the_message_is_escaped_and_the_action_is_not() {
144 // The asymmetry is the whole point of `Markup`, and it is the same one
145 // a field's trailing block has: text from the app is escaped, and a
146 // block the caller has stated is markup is passed through.
147 let html = placeholder_html(
148 Readiness::Empty,
149 "No <b>projects</b> yet",
150 Some(Markup("<button>Add one</button>")),
151 &Emit::default(),
152 );
153 assert!(html.contains("&lt;b&gt;"));
154 assert!(!html.contains("<b>"));
155 assert!(html.contains("<button>Add one</button>"));
156 }
157
158 #[test]
159 fn a_state_with_no_action_emits_no_action_container() {
160 // 25 of goingson's 27 empty states have no way out. An empty container
161 // at each of them is a box the stylesheet has to know to collapse.
162 let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
163 assert!(!html.contains("placeholder-action"));
164 }
165
166 #[test]
167 fn pending_draws_the_same_anatomy_as_the_other_two() {
168 // Three states, one box. What differs is what the text means, which is
169 // what the description carries.
170 let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
171 assert!(html.contains(r#"data-state="pending""#));
172 assert!(html.contains("Loading"));
173 }
174
175 #[test]
176 fn the_prefix_reaches_every_class() {
177 let opts = Emit {
178 class_prefix: "mo-",
179 ..Emit::default()
180 };
181 let html = placeholder_html(
182 Readiness::Empty,
183 "None",
184 Some(Markup("<button>Go</button>")),
185 &opts,
186 );
187 assert!(html.contains(r#"class="mo-placeholder""#));
188 assert!(html.contains(r#"class="mo-placeholder-text""#));
189 assert!(html.contains(r#"class="mo-placeholder-action""#));
190 }
191 }
192