Skip to main content

topcoat_view/html/
class.rs

1use std::borrow::Cow;
2
3use topcoat_core::context::Cx;
4
5use crate::{AttributeValueViewParts, PartsWriter, PromotedStr, StaticStr, Unescaped};
6
7/// Converts a value used as a class list entry into view parts.
8///
9/// Implement this trait to use a type in
10/// [`class!`](https://docs.rs/topcoat/latest/topcoat/view/macro.class.html)
11/// or a [`Class`] value.
12///
13/// [`is_present`](Self::is_present) controls whether an entry contributes
14/// text and a separator. For example, `None` and empty strings are absent.
15pub trait ClassViewParts {
16    /// Returns whether this value contributes to the class list.
17    ///
18    /// An absent value is skipped entirely and does not produce a separating
19    /// space.
20    fn is_present(&self) -> bool;
21
22    /// Appends this value to the class list being built.
23    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>);
24}
25
26impl ClassViewParts for &str {
27    #[inline]
28    fn is_present(&self) -> bool {
29        !self.is_empty()
30    }
31
32    #[inline]
33    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
34        parts.push_str(self);
35    }
36}
37
38impl ClassViewParts for String {
39    #[inline]
40    fn is_present(&self) -> bool {
41        !self.is_empty()
42    }
43
44    #[inline]
45    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
46        parts.push_string(self);
47    }
48}
49
50impl ClassViewParts for &String {
51    #[inline]
52    fn is_present(&self) -> bool {
53        !self.is_empty()
54    }
55
56    #[inline]
57    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
58        ClassViewParts::into_view_parts(self.as_str(), cx, parts);
59    }
60}
61
62impl ClassViewParts for PromotedStr {
63    #[inline]
64    fn is_present(&self) -> bool {
65        !self.0.is_empty()
66    }
67
68    #[inline]
69    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
70        parts.push_promoted_str(self.0);
71    }
72}
73
74impl ClassViewParts for StaticStr {
75    #[inline]
76    fn is_present(&self) -> bool {
77        !self.0.is_empty()
78    }
79
80    #[inline]
81    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
82        parts.push_static_str(self.0);
83    }
84}
85
86impl ClassViewParts for Unescaped<PromotedStr> {
87    #[inline]
88    fn is_present(&self) -> bool {
89        !self.0.0.is_empty()
90    }
91
92    #[inline]
93    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
94        parts.push_promoted_str_unescaped(self.0.0);
95    }
96}
97
98impl ClassViewParts for Unescaped<StaticStr> {
99    #[inline]
100    fn is_present(&self) -> bool {
101        !self.0.0.is_empty()
102    }
103
104    #[inline]
105    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
106        parts.push_static_str_unescaped(self.0.0);
107    }
108}
109
110impl ClassViewParts for Cow<'static, str> {
111    #[inline]
112    fn is_present(&self) -> bool {
113        !self.is_empty()
114    }
115
116    #[inline]
117    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
118        match self {
119            Cow::Borrowed(value) => parts.push_static_str(value),
120            Cow::Owned(value) => parts.push_string(value),
121        };
122    }
123}
124
125impl<T> ClassViewParts for Option<T>
126where
127    T: ClassViewParts,
128{
129    #[inline]
130    fn is_present(&self) -> bool {
131        self.as_ref().is_some_and(T::is_present)
132    }
133
134    #[inline]
135    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
136        if let Some(value) = self {
137            value.into_view_parts(cx, parts);
138        }
139    }
140}
141
142/// A conditional class list entry holding whichever branch was taken.
143///
144/// The `class!` macro lowers an `if`/`else` entry to this enum when the two
145/// branches have different types.
146#[doc(hidden)]
147#[derive(Debug, Clone, Copy)]
148pub enum ClassBranch<A, B> {
149    Then(A),
150    Else(B),
151}
152
153impl<A, B> ClassViewParts for ClassBranch<A, B>
154where
155    A: ClassViewParts,
156    B: ClassViewParts,
157{
158    #[inline]
159    fn is_present(&self) -> bool {
160        match self {
161            Self::Then(inner) => inner.is_present(),
162            Self::Else(inner) => inner.is_present(),
163        }
164    }
165
166    #[inline]
167    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
168        match self {
169            Self::Then(inner) => inner.into_view_parts(cx, parts),
170            Self::Else(inner) => inner.into_view_parts(cx, parts),
171        }
172    }
173}
174
175/// A writer that separates class list entries with single spaces.
176///
177/// Absent entries are skipped without producing a separator.
178pub struct ClassWriter<'a, 'b> {
179    parts: &'b mut PartsWriter<'a>,
180    first: bool,
181}
182
183impl<'a, 'b> ClassWriter<'a, 'b> {
184    /// Creates a writer over `parts` with no entries written yet.
185    #[inline]
186    pub(crate) fn new(parts: &'b mut PartsWriter<'a>) -> Self {
187        Self { parts, first: true }
188    }
189
190    /// Appends an entry, separated from the previous entry by a single
191    /// space.
192    ///
193    /// An absent entry (for example [`None`] or an empty string) is skipped
194    /// entirely.
195    #[inline]
196    pub fn entry(&mut self, cx: &Cx, value: impl ClassViewParts) -> &mut Self {
197        if value.is_present() {
198            if !self.first {
199                self.parts.push_promoted_str(&" ");
200            }
201            value.into_view_parts(cx, self.parts);
202            self.first = false;
203        }
204        self
205    }
206}
207
208/// One or more class list entries written through a [`ClassWriter`].
209///
210/// Entries can combine different types through tuples or collections.
211pub trait ClassEntries {
212    /// Returns whether any entry contributes to the class list.
213    fn any_present(&self) -> bool;
214
215    /// Writes every entry through `writer`.
216    fn write_entries(self, cx: &Cx, writer: &mut ClassWriter<'_, '_>);
217}
218
219impl<T> ClassEntries for T
220where
221    T: ClassViewParts,
222{
223    #[inline]
224    fn any_present(&self) -> bool {
225        self.is_present()
226    }
227
228    #[inline]
229    fn write_entries(self, cx: &Cx, writer: &mut ClassWriter<'_, '_>) {
230        writer.entry(cx, self);
231    }
232}
233
234impl ClassEntries for () {
235    #[inline]
236    fn any_present(&self) -> bool {
237        false
238    }
239
240    #[inline]
241    fn write_entries(self, _cx: &Cx, _writer: &mut ClassWriter<'_, '_>) {}
242}
243
244impl<T> ClassEntries for Vec<T>
245where
246    T: ClassEntries,
247{
248    #[inline]
249    fn any_present(&self) -> bool {
250        self.iter().any(ClassEntries::any_present)
251    }
252
253    #[inline]
254    fn write_entries(self, cx: &Cx, writer: &mut ClassWriter<'_, '_>) {
255        for entry in self {
256            entry.write_entries(cx, writer);
257        }
258    }
259}
260
261impl<T, const N: usize> ClassEntries for [T; N]
262where
263    T: ClassEntries,
264{
265    #[inline]
266    fn any_present(&self) -> bool {
267        self.iter().any(ClassEntries::any_present)
268    }
269
270    #[inline]
271    fn write_entries(self, cx: &Cx, writer: &mut ClassWriter<'_, '_>) {
272        for entry in self {
273            entry.write_entries(cx, writer);
274        }
275    }
276}
277
278macro_rules! impl_tuple {
279    ($($ty:ident),+) => {
280        impl<$($ty),+> ClassEntries for ($($ty,)+)
281        where
282            $($ty: ClassEntries,)+
283        {
284            #[inline]
285            #[allow(non_snake_case)]
286            fn any_present(&self) -> bool {
287                let ($($ty,)+) = self;
288                $($ty.any_present())||+
289            }
290
291            #[inline]
292            #[allow(non_snake_case)]
293            fn write_entries(self, cx: &Cx, writer: &mut ClassWriter<'_, '_>) {
294                let ($($ty,)+) = self;
295                $($ty.write_entries(cx, writer);)+
296            }
297        }
298    };
299}
300
301impl_tuple!(T1);
302impl_tuple!(T1, T2);
303impl_tuple!(T1, T2, T3);
304impl_tuple!(T1, T2, T3, T4);
305impl_tuple!(T1, T2, T3, T4, T5);
306impl_tuple!(T1, T2, T3, T4, T5, T6);
307impl_tuple!(T1, T2, T3, T4, T5, T6, T7);
308impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8);
309impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9);
310impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10);
311impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11);
312impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12);
313
314/// A space-separated list of HTML classes.
315///
316/// Prefer constructing `Class` with [`class!`](../view/macro.class.html).
317/// Entries are written directly into the view when rendered.
318///
319/// A `Class` is used in the attribute value position of an element, where a
320/// class list without present entries omits the whole attribute:
321///
322/// ```rust
323/// # use topcoat::view::{View, class, component, view};
324/// # #[component]
325/// # async fn example() -> topcoat::Result<impl View> {
326/// # let is_active = true;
327/// Ok(view! {
328///     <button class=(class!("btn", "active" if is_active))>"Save"</button>
329/// })
330/// # }
331/// ```
332#[derive(Debug, Default, Clone, Copy)]
333pub struct Class<T>(pub T);
334
335impl<T> AttributeValueViewParts for Class<T>
336where
337    T: ClassEntries,
338{
339    #[inline]
340    fn attribute_present(&self) -> bool {
341        self.0.any_present()
342    }
343
344    #[inline]
345    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
346        self.0.write_entries(cx, &mut ClassWriter::new(parts));
347    }
348}
349
350impl<T> ClassViewParts for Class<T>
351where
352    T: ClassEntries,
353{
354    #[inline]
355    fn is_present(&self) -> bool {
356        self.0.any_present()
357    }
358
359    #[inline]
360    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
361        // The surrounding class list has already written any separator for
362        // this entry, so the nested list starts fresh and only separates its
363        // own entries.
364        self.0.write_entries(cx, &mut ClassWriter::new(parts));
365    }
366}
367
368/// The type of a class list built from string literals alone.
369///
370/// Use this type to store a literal [`class!`](../view/macro.class.html)
371/// value in a constant:
372///
373/// ```rust
374/// use topcoat::view::{StaticClass, class};
375///
376/// const BUTTON: StaticClass = class!("btn btn-lg rounded");
377/// ```
378pub type StaticClass = Class<Unescaped<PromotedStr>>;
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::{AttributeValue, Attributes, internal::Builder};
384
385    fn render(class: Class<impl ClassEntries>) -> String {
386        let cx = Cx::default();
387        Builder::build(&cx, |b| b.attribute_value(class)).render(&cx)
388    }
389
390    #[test]
391    fn no_entries_is_absent() {
392        let class = Class(());
393        assert!(!class.attribute_present());
394        assert_eq!(render(class), "");
395    }
396
397    #[test]
398    fn single_entry_renders_without_separator() {
399        let class = Class("btn");
400        assert!(class.attribute_present());
401        assert_eq!(render(class), "btn");
402    }
403
404    #[test]
405    fn entries_are_separated_by_single_spaces() {
406        let class = Class(("btn", "btn-lg".to_owned(), Cow::Borrowed("active")));
407        assert_eq!(render(class), "btn btn-lg active");
408    }
409
410    #[test]
411    fn none_option_is_skipped_without_a_separator() {
412        let class = Class(("a", Option::<&str>::None, "b"));
413        assert_eq!(render(class), "a b");
414    }
415
416    #[test]
417    fn some_option_is_rendered() {
418        assert_eq!(render(Class(Some("active"))), "active");
419    }
420
421    #[test]
422    fn empty_strings_are_skipped_without_a_separator() {
423        let class = Class(("a", "", String::new(), Some(""), "b"));
424        assert_eq!(render(class), "a b");
425    }
426
427    #[test]
428    fn all_entries_absent_omits_the_attribute() {
429        let class = Class(("", Option::<&str>::None));
430        assert!(!class.attribute_present());
431        assert_eq!(render(class), "");
432    }
433
434    #[test]
435    fn skipped_leading_entry_produces_no_leading_space() {
436        let class = Class((Option::<&str>::None, "a"));
437        assert_eq!(render(class), "a");
438    }
439
440    #[test]
441    fn entries_are_escaped_for_the_attribute_value_position() {
442        assert_eq!(render(Class("a\"b")), "a&quot;b");
443    }
444
445    #[test]
446    fn branch_entries_render_the_taken_branch() {
447        let class = Class((
448            ClassBranch::<&str, String>::Then("on"),
449            ClassBranch::<&str, String>::Else("off".to_owned()),
450        ));
451        assert_eq!(render(class), "on off");
452    }
453
454    #[test]
455    fn nested_class_is_spliced_with_separators() {
456        let class = Class(("card", Class(("btn", "btn-lg"))));
457        assert_eq!(render(class), "card btn btn-lg");
458    }
459
460    #[test]
461    fn empty_nested_class_is_skipped_without_a_separator() {
462        let class = Class(("a", Class(()), "b"));
463        assert_eq!(render(class), "a b");
464    }
465
466    #[test]
467    fn nested_tuples_flatten_with_separators() {
468        let class = Class((("a", "b"), ("c", "d")));
469        assert_eq!(render(class), "a b c d");
470    }
471
472    #[test]
473    fn vec_entries_render_with_separators() {
474        let class = Class(vec!["a".to_owned(), String::new(), "b".to_owned()]);
475        assert_eq!(render(class), "a b");
476    }
477
478    #[test]
479    fn array_entries_render_with_separators() {
480        let class = Class([Some("a"), None, Some("b")]);
481        assert_eq!(render(class), "a b");
482    }
483
484    #[test]
485    fn attribute_value_entries_are_spliced_as_captured() {
486        let cx = Cx::default();
487        let mut attrs = Attributes::new();
488        attrs.insert(&cx, "class", "[&>*]:mt-2");
489        let value = attrs.remove("class").unwrap();
490        assert_eq!(render(Class(("btn", &value))), "btn [&amp;>*]:mt-2");
491    }
492
493    #[test]
494    fn absent_attribute_value_entry_is_skipped_without_a_separator() {
495        let value = AttributeValue::absent();
496        assert_eq!(render(Class(("a", &value, "b"))), "a b");
497    }
498
499    #[test]
500    fn promoted_and_static_entries_render_with_separators() {
501        let class = Class((PromotedStr(&"btn"), StaticStr("btn-lg")));
502        assert_eq!(render(class), "btn btn-lg");
503    }
504
505    #[test]
506    fn empty_promoted_and_static_entries_are_skipped_without_a_separator() {
507        let class = Class((StaticStr("a"), PromotedStr(&""), StaticStr(""), "b"));
508        assert!(class.attribute_present());
509        assert_eq!(render(class), "a b");
510    }
511
512    #[test]
513    fn promoted_and_static_entries_are_escaped_for_the_attribute_value_position() {
514        let class = Class((PromotedStr(&"a\"b"), StaticStr("c\"d")));
515        assert_eq!(render(class), "a&quot;b c&quot;d");
516    }
517
518    #[test]
519    fn unescaped_entries_render_verbatim() {
520        let class = Class((
521            Unescaped::new_unchecked(PromotedStr(&"a&quot;b")),
522            Unescaped::new_unchecked(StaticStr("c&amp;d")),
523        ));
524        assert_eq!(render(class), "a&quot;b c&amp;d");
525    }
526
527    #[test]
528    fn empty_unescaped_entries_are_skipped_without_a_separator() {
529        let class = Class((
530            "a",
531            Unescaped::new_unchecked(PromotedStr(&"")),
532            Unescaped::new_unchecked(StaticStr("")),
533            "b",
534        ));
535        assert_eq!(render(class), "a b");
536    }
537
538    #[test]
539    fn borrowed_cow_entries_render_without_reallocating() {
540        let class = Class((
541            Cow::Borrowed("static"),
542            Cow::<'static, str>::Owned("owned".to_owned()),
543        ));
544        assert_eq!(render(class), "static owned");
545    }
546}