Skip to main content

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/// 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]
51pub fn placeholder_html(
52    state: Readiness,
53    message: &str,
54    action: Option<Markup<'_>>,
55    opts: &Emit,
56) -> String {
57    let mut html = String::new();
58    placeholder_html_into(state, message, action, opts, &mut html);
59    html
60}
61
62/// A region's stand-in, written into a buffer the caller already has.
63///
64/// [`placeholder_html`]'s streaming form, byte-identical to it. A state that
65/// draws its own content appends nothing, which is what the empty string the
66/// other form returns means.
67pub fn placeholder_html_into(
68    state: Readiness,
69    message: &str,
70    action: Option<Markup<'_>>,
71    opts: &Emit,
72    out: &mut String,
73) {
74    if state.shows_content() {
75        return;
76    }
77
78    let name = state_name(state);
79    out.push_str("<div class=\"");
80    push_class(out, "placeholder", opts);
81    let _ = write!(out, "\" data-state=\"{name}\"");
82
83    // Derived, not carried. "Nothing here yet" and "this broke" mean the same
84    // thing in every app that will ever have them, which is what separates this
85    // from a meter's tone.
86    if state.tone() != Tone::Neutral {
87        let _ = write!(out, " data-tone=\"{}\"", state.tone().token());
88    }
89    if state.tone() == Tone::Danger {
90        out.push_str(" role=\"alert\"");
91    } else {
92        out.push_str(" role=\"status\" aria-live=\"polite\"");
93    }
94
95    out.push_str("><p class=\"");
96    push_class(out, "placeholder-text", opts);
97    out.push_str("\">");
98    escape_into(message, out);
99    out.push_str("</p>");
100    if let Some(Markup(markup)) = action {
101        out.push_str("<div class=\"");
102        push_class(out, "placeholder-action", opts);
103        out.push_str("\">");
104        out.push_str(markup);
105        out.push_str("</div>");
106    }
107    out.push_str("</div>");
108}
109
110/// The `data-state` value for a state.
111///
112/// A wildcard rather than a total match, because `Readiness` is
113/// `#[non_exhaustive]` as of 0.12.0. A state added upstream draws the plain
114/// stand-in with no state of its own, which is a box rendering without its
115/// colour rather than a build that stops.
116fn state_name(state: Readiness) -> &'static str {
117    match state {
118        Readiness::Ready => "ready",
119        Readiness::Pending => "pending",
120        Readiness::Empty => "empty",
121        Readiness::Failed => "failed",
122        _ => "unknown",
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    /// Including the state that draws nothing: appending nothing and returning
131    /// an empty string have to stay the same answer.
132    #[test]
133    fn a_streamed_placeholder_is_the_placeholder_the_other_form_returns() {
134        let opts = Emit {
135            class_prefix: "mk-",
136            ..Emit::default()
137        };
138        for state in [
139            Readiness::Ready,
140            Readiness::Pending,
141            Readiness::Empty,
142            Readiness::Failed,
143        ] {
144            for action in [None, Some(Markup("<button>go</button>"))] {
145                let mut streamed = String::new();
146                placeholder_html_into(state, "none & <yet>", action, &opts, &mut streamed);
147                assert_eq!(
148                    streamed,
149                    placeholder_html(state, "none & <yet>", action, &opts)
150                );
151            }
152        }
153    }
154
155    #[test]
156    fn the_state_that_shows_content_draws_no_stand_in() {
157        // Not an empty box: nothing at all, or every ready region gains an
158        // element that pushes its content down.
159        assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
160    }
161
162    #[test]
163    fn an_empty_region_is_not_announced_as_a_fault() {
164        // An empty list is the normal state of a new install. `role="alert"`
165        // interrupts a screen reader mid-sentence, which is the wrong thing to
166        // do about "no projects yet".
167        let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
168        assert!(empty.contains(r#"role="status""#));
169        assert!(!empty.contains("data-tone"));
170
171        let failed = placeholder_html(
172            Readiness::Failed,
173            "Failed to load events",
174            None,
175            &Emit::default(),
176        );
177        assert!(failed.contains(r#"role="alert""#));
178        assert!(failed.contains(r#"data-tone="danger""#));
179    }
180
181    #[test]
182    fn the_message_is_escaped_and_the_action_is_not() {
183        // The asymmetry is the whole point of `Markup`, and it is the same one
184        // a field's trailing block has: text from the app is escaped, and a
185        // block the caller has stated is markup is passed through.
186        let html = placeholder_html(
187            Readiness::Empty,
188            "No <b>projects</b> yet",
189            Some(Markup("<button>Add one</button>")),
190            &Emit::default(),
191        );
192        assert!(html.contains("&lt;b&gt;"));
193        assert!(!html.contains("<b>"));
194        assert!(html.contains("<button>Add one</button>"));
195    }
196
197    #[test]
198    fn a_state_with_no_action_emits_no_action_container() {
199        // 25 of goingson's 27 empty states have no way out. An empty container
200        // at each of them is a box the stylesheet has to know to collapse.
201        let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
202        assert!(!html.contains("placeholder-action"));
203    }
204
205    #[test]
206    fn pending_draws_the_same_anatomy_as_the_other_two() {
207        // Three states, one box. What differs is what the text means, which is
208        // what the description carries.
209        let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
210        assert!(html.contains(r#"data-state="pending""#));
211        assert!(html.contains("Loading"));
212    }
213
214    #[test]
215    fn the_prefix_reaches_every_class() {
216        let opts = Emit {
217            class_prefix: "mo-",
218            ..Emit::default()
219        };
220        let html = placeholder_html(
221            Readiness::Empty,
222            "None",
223            Some(Markup("<button>Go</button>")),
224            &opts,
225        );
226        assert!(html.contains(r#"class="mo-placeholder""#));
227        assert!(html.contains(r#"class="mo-placeholder-text""#));
228        assert!(html.contains(r#"class="mo-placeholder-action""#));
229    }
230}