Skip to main content

topcoat_view/
class.rs

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