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/// 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("&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}