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, 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
25use crate::form::{Markup, escape_into};
26use crate::{Emit, push_class};
27use makeover_layout::{Intent, Readiness, Tone};
28use std::fmt::Write as _;
29
30/// Every class this module can put in markup.
31///
32/// [`crate::facet::FACET_CLASSES`]' obligation. What a way out of an empty
33/// state looks like is the button inside `placeholder-action`; the wrapper's
34/// rule says only where it goes, a group gap under the text.
35pub 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]
56pub 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.
72pub fn placeholder_html_into(
73    state: Readiness,
74    message: &str,
75    action: Option<Markup<'_>>,
76    opts: &Emit,
77    out: &mut String,
78) {
79    emit_placeholder(state, message, action, opts, out, None);
80}
81
82/// A placeholder, saying where the way out landed.
83///
84/// Byte-identical to [`placeholder_html_into`], and where there is a way out it
85/// sets `placed` to the offsets in `out` between which the whole
86/// `placeholder-action` block was written -- the wrapper included, since the
87/// wrapper is what goes with it. `None` stays `None` when there is no action.
88///
89/// Same reason as the other placed forms in this crate: a caller compiling this
90/// markup into a template has to know which bytes the way out produced, and the
91/// way out is a caller-supplied string that may appear elsewhere in the
92/// document.
93pub fn placeholder_html_placed(
94    state: Readiness,
95    message: &str,
96    action: Option<Markup<'_>>,
97    opts: &Emit,
98    out: &mut String,
99    placed: &mut Option<core::ops::Range<usize>>,
100) {
101    emit_placeholder(state, message, action, opts, out, Some(placed));
102}
103
104fn emit_placeholder(
105    state: Readiness,
106    message: &str,
107    action: Option<Markup<'_>>,
108    opts: &Emit,
109    out: &mut String,
110    placed: Option<&mut Option<core::ops::Range<usize>>>,
111) {
112    if state.shows_content() {
113        return;
114    }
115
116    let name = state_name(state);
117    out.push_str("<div class=\"");
118    push_class(out, "placeholder", opts);
119    let _ = write!(out, "\" data-state=\"{name}\"");
120
121    // Derived, not carried. "Nothing here yet" and "this broke" mean the same
122    // thing in every app that will ever have them, which is what separates this
123    // from a meter's tone.
124    if state.tone() != Tone::Neutral {
125        let _ = write!(out, " data-tone=\"{}\"", state.tone().token());
126    }
127    if state.tone() == Tone::Danger {
128        out.push_str(" role=\"alert\"");
129    } else {
130        out.push_str(" role=\"status\" aria-live=\"polite\"");
131    }
132
133    out.push_str("><p class=\"");
134    push_class(out, "placeholder-text", opts);
135    out.push_str("\">");
136    escape_into(message, out);
137    out.push_str("</p>");
138    if let Some(Markup(markup)) = action {
139        let at = out.len();
140        out.push_str("<div class=\"");
141        push_class(out, "placeholder-action", opts);
142        out.push_str("\">");
143        out.push_str(markup);
144        out.push_str("</div>");
145        if let Some(placed) = placed {
146            *placed = Some(at..out.len());
147        }
148    }
149    out.push_str("</div>");
150}
151
152/// The `data-state` value for a state.
153///
154/// A wildcard rather than a total match, because `Readiness` is
155/// `#[non_exhaustive]`. A state added upstream draws the plain
156/// stand-in with no state of its own, which is a box rendering without its
157/// colour rather than a build that stops.
158fn state_name(state: Readiness) -> &'static str {
159    match state {
160        Readiness::Ready => "ready",
161        Readiness::Pending => "pending",
162        Readiness::Empty => "empty",
163        Readiness::Failed => "failed",
164        _ => "unknown",
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    /// Including the state that draws nothing: appending nothing and returning
173    /// an empty string have to stay the same answer.
174    #[test]
175    fn a_streamed_placeholder_is_the_placeholder_the_other_form_returns() {
176        let opts = Emit {
177            class_prefix: "mk-",
178            ..Emit::default()
179        };
180        for state in [
181            Readiness::Ready,
182            Readiness::Pending,
183            Readiness::Empty,
184            Readiness::Failed,
185        ] {
186            for action in [None, Some(Markup("<button>go</button>"))] {
187                let mut streamed = String::new();
188                placeholder_html_into(state, "none & <yet>", action, &opts, &mut streamed);
189                assert_eq!(
190                    streamed,
191                    placeholder_html(state, "none & <yet>", action, &opts)
192                );
193            }
194        }
195    }
196
197    #[test]
198    fn the_state_that_shows_content_draws_no_stand_in() {
199        // Not an empty box: nothing at all, or every ready region gains an
200        // element that pushes its content down.
201        assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
202    }
203
204    #[test]
205    fn an_empty_region_is_not_announced_as_a_fault() {
206        // An empty list is the normal state of a new install. `role="alert"`
207        // interrupts a screen reader mid-sentence, which is the wrong thing to
208        // do about "no projects yet".
209        let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
210        assert!(empty.contains(r#"role="status""#));
211        assert!(!empty.contains("data-tone"));
212
213        let failed = placeholder_html(
214            Readiness::Failed,
215            "Failed to load events",
216            None,
217            &Emit::default(),
218        );
219        assert!(failed.contains(r#"role="alert""#));
220        assert!(failed.contains(r#"data-tone="danger""#));
221    }
222
223    #[test]
224    fn the_message_is_escaped_and_the_action_is_not() {
225        // The asymmetry is the whole point of `Markup`, and it is the same one
226        // a field's trailing block has: text from the app is escaped, and a
227        // block the caller has stated is markup is passed through.
228        let html = placeholder_html(
229            Readiness::Empty,
230            "No <b>projects</b> yet",
231            Some(Markup("<button>Add one</button>")),
232            &Emit::default(),
233        );
234        assert!(html.contains("&lt;b&gt;"));
235        assert!(!html.contains("<b>"));
236        assert!(html.contains("<button>Add one</button>"));
237    }
238
239    #[test]
240    fn a_state_with_no_action_emits_no_action_container() {
241        // 25 of goingson's 27 empty states have no way out. An empty container
242        // at each of them is a box the stylesheet has to know to collapse.
243        let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
244        assert!(!html.contains("placeholder-action"));
245    }
246
247    #[test]
248    fn pending_draws_the_same_anatomy_as_the_other_two() {
249        // Three states, one box. What differs is what the text means, which is
250        // what the description carries.
251        let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
252        assert!(html.contains(r#"data-state="pending""#));
253        assert!(html.contains("Loading"));
254    }
255
256    #[test]
257    fn the_prefix_reaches_every_class() {
258        let opts = Emit {
259            class_prefix: "mo-",
260            ..Emit::default()
261        };
262        let html = placeholder_html(
263            Readiness::Empty,
264            "None",
265            Some(Markup("<button>Go</button>")),
266            &opts,
267        );
268        assert!(html.contains(r#"class="mo-placeholder""#));
269        assert!(html.contains(r#"class="mo-placeholder-text""#));
270        assert!(html.contains(r#"class="mo-placeholder-action""#));
271    }
272}