Skip to main content

topcoat_view/html/attribute/
value.rs

1use std::borrow::Cow;
2
3use topcoat_core::context::Cx;
4
5use crate::{
6    Captured, ClassViewParts, HtmlContext, PartsWriter, PromotedStr, StaticStr, Unescaped,
7};
8
9/// Converts a value used as an attribute value into view parts.
10///
11/// Implement this trait to use a type as an attribute value in
12/// [`view!`](https://docs.rs/topcoat/latest/topcoat/view/macro.view.html):
13///
14/// ```rust
15/// # use topcoat::view::{View, component, view};
16/// # #[component]
17/// # async fn example() -> topcoat::Result<impl View> {
18/// # let my_value = "primary";
19/// Ok(view! {
20///     <div class=(my_value)></div>
21/// })
22/// # }
23/// ```
24///
25/// For [boolean HTML attributes], a false value must be omitted from the markup entirely.
26/// [`attribute_present`](Self::attribute_present) is the hook that makes that decision.
27/// The built-in `bool` and `Option<T>` implementations use this so `false` and `None` omit the
28/// whole attribute, while `true` renders the attribute with an empty value (`disabled=""`).
29///
30/// [boolean HTML attributes]: https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
31pub trait AttributeValueViewParts {
32    /// Returns whether the containing attribute should be rendered.
33    ///
34    /// For [boolean HTML attributes], a false value must be omitted from the markup entirely.
35    ///
36    /// [boolean HTML attributes]: https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
37    fn attribute_present(&self) -> bool;
38
39    /// Appends this attribute value to the view being built.
40    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>);
41}
42
43macro_rules! impl_primitive {
44    ($ty:ty, $method:ident) => {
45        impl AttributeValueViewParts for $ty {
46            #[inline]
47            fn attribute_present(&self) -> bool {
48                true
49            }
50
51            #[inline]
52            fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
53                parts.$method(self);
54            }
55        }
56    };
57    ($ty:ty, $method:ident, ref) => {
58        impl_primitive!($ty, $method);
59
60        impl AttributeValueViewParts for &$ty {
61            #[inline]
62            fn attribute_present(&self) -> bool {
63                (*self).attribute_present()
64            }
65
66            #[inline]
67            fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
68                (*self).into_view_parts(cx, parts);
69            }
70        }
71    };
72}
73
74impl_primitive!(char, push_char, ref);
75impl_primitive!(i8, push_i8, ref);
76impl_primitive!(i16, push_i16, ref);
77impl_primitive!(i32, push_i32, ref);
78impl_primitive!(i64, push_i64, ref);
79impl_primitive!(i128, push_i128, ref);
80impl_primitive!(isize, push_isize, ref);
81impl_primitive!(u8, push_u8, ref);
82impl_primitive!(u16, push_u16, ref);
83impl_primitive!(u32, push_u32, ref);
84impl_primitive!(u64, push_u64, ref);
85impl_primitive!(u128, push_u128, ref);
86impl_primitive!(usize, push_usize, ref);
87impl_primitive!(f32, push_f32, ref);
88impl_primitive!(f64, push_f64, ref);
89impl_primitive!(String, push_string);
90
91impl AttributeValueViewParts for Cow<'static, str> {
92    #[inline]
93    fn attribute_present(&self) -> bool {
94        true
95    }
96
97    #[inline]
98    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
99        match self {
100            Cow::Borrowed(value) => parts.push_static_str(value),
101            Cow::Owned(value) => parts.push_string(value),
102        };
103    }
104}
105
106impl AttributeValueViewParts for &str {
107    #[inline]
108    fn attribute_present(&self) -> bool {
109        true
110    }
111
112    #[inline]
113    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
114        parts.push_str(self);
115    }
116}
117
118impl AttributeValueViewParts for PromotedStr {
119    #[inline]
120    fn attribute_present(&self) -> bool {
121        true
122    }
123
124    #[inline]
125    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
126        parts.push_promoted_str(self.0);
127    }
128}
129
130impl AttributeValueViewParts for StaticStr {
131    #[inline]
132    fn attribute_present(&self) -> bool {
133        true
134    }
135
136    #[inline]
137    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
138        parts.push_static_str(self.0);
139    }
140}
141
142impl AttributeValueViewParts for Unescaped<String> {
143    #[inline]
144    fn attribute_present(&self) -> bool {
145        true
146    }
147
148    #[inline]
149    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
150        parts.push_string_unescaped(self.0);
151    }
152}
153
154impl AttributeValueViewParts for Unescaped<&'static str> {
155    #[inline]
156    fn attribute_present(&self) -> bool {
157        true
158    }
159
160    #[inline]
161    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
162        parts.push_static_str_unescaped(self.0);
163    }
164}
165
166impl AttributeValueViewParts for Unescaped<PromotedStr> {
167    #[inline]
168    fn attribute_present(&self) -> bool {
169        true
170    }
171
172    #[inline]
173    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
174        parts.push_promoted_str_unescaped(self.0.0);
175    }
176}
177
178impl AttributeValueViewParts for Unescaped<StaticStr> {
179    #[inline]
180    fn attribute_present(&self) -> bool {
181        true
182    }
183
184    #[inline]
185    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
186        parts.push_static_str_unescaped(self.0.0);
187    }
188}
189
190impl AttributeValueViewParts for &String {
191    #[inline]
192    fn attribute_present(&self) -> bool {
193        self.as_str().attribute_present()
194    }
195
196    #[inline]
197    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
198        AttributeValueViewParts::into_view_parts(self.as_str(), cx, parts);
199    }
200}
201
202impl AttributeValueViewParts for bool {
203    #[inline]
204    fn attribute_present(&self) -> bool {
205        *self
206    }
207
208    #[inline]
209    fn into_view_parts(self, _cx: &Cx, _parts: &mut PartsWriter<'_>) {
210        // A true value renders the attribute with an empty value
211        // (`disabled=""`), matching the boolean HTML attribute convention.
212    }
213}
214
215impl AttributeValueViewParts for &bool {
216    #[inline]
217    fn attribute_present(&self) -> bool {
218        (*self).attribute_present()
219    }
220
221    #[inline]
222    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
223        (*self).into_view_parts(cx, parts);
224    }
225}
226
227impl<'b, T: ?Sized> AttributeValueViewParts for &&'b T
228where
229    &'b T: AttributeValueViewParts,
230{
231    #[inline]
232    fn attribute_present(&self) -> bool {
233        (**self).attribute_present()
234    }
235
236    #[inline]
237    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
238        (*self).into_view_parts(cx, parts);
239    }
240}
241
242impl<T> AttributeValueViewParts for Option<T>
243where
244    T: AttributeValueViewParts,
245{
246    #[inline]
247    fn attribute_present(&self) -> bool {
248        self.as_ref()
249            .is_some_and(AttributeValueViewParts::attribute_present)
250    }
251
252    #[inline]
253    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
254        if let Some(value) = self {
255            value.into_view_parts(cx, parts);
256        }
257    }
258}
259
260macro_rules! impl_tuple {
261    ($($ty:ident),+) => {
262        impl<$($ty),+> AttributeValueViewParts for ($($ty,)+)
263        where
264            $($ty: AttributeValueViewParts,)+
265        {
266            #[inline]
267            #[allow(non_snake_case)]
268            fn attribute_present(&self) -> bool {
269                let ($($ty,)+) = self;
270                $($ty.attribute_present())||+
271            }
272
273            #[inline]
274            #[allow(non_snake_case)]
275            fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
276                let ($($ty,)+) = self;
277                $($ty.into_view_parts(cx, parts);)+
278            }
279        }
280    };
281}
282
283impl_tuple!(T1);
284impl_tuple!(T1, T2);
285impl_tuple!(T1, T2, T3);
286impl_tuple!(T1, T2, T3, T4);
287impl_tuple!(T1, T2, T3, T4, T5);
288impl_tuple!(T1, T2, T3, T4, T5, T6);
289impl_tuple!(T1, T2, T3, T4, T5, T6, T7);
290impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8);
291impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9);
292impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10);
293impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11);
294impl_tuple!(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12);
295
296/// An attribute value captured from any [`AttributeValueViewParts`] value.
297///
298/// Values retain their HTML context for escaping when rendered. Reusing a
299/// captured value as an attribute value or class entry preserves its output.
300#[non_exhaustive]
301#[derive(Debug, Default, Clone)]
302pub enum AttributeValue {
303    /// Marks the attribute as not rendered.
304    ///
305    /// An absent value keeps its key in an [`Attributes`](crate::Attributes)
306    /// collection, so it still replaces an earlier value when collections
307    /// are merged, but the attribute is not rendered.
308    #[default]
309    Absent,
310    /// A present value that renders nothing, like a `true` boolean
311    /// attribute (`disabled=""`).
312    Empty,
313    /// A static string held by reference.
314    PromotedStr {
315        value: &'static &'static str,
316        context: HtmlContext,
317    },
318    /// A static string.
319    StaticStr {
320        value: &'static str,
321        context: HtmlContext,
322    },
323    /// An owned string.
324    String { value: String, context: HtmlContext },
325}
326
327impl AttributeValue {
328    /// Returns the value that marks its attribute as absent.
329    #[inline]
330    #[must_use]
331    pub fn absent() -> Self {
332        Self::Absent
333    }
334
335    /// Returns whether the attribute holding this value should be rendered.
336    #[inline]
337    #[must_use]
338    pub fn is_present(&self) -> bool {
339        !matches!(self, Self::Absent)
340    }
341
342    /// Writes the value into `parts` under the context it was captured
343    /// with.
344    #[inline]
345    fn push_into(self, parts: &mut PartsWriter<'_>) {
346        match self {
347            Self::Absent | Self::Empty => {}
348            Self::PromotedStr { value, context } => {
349                parts.in_context(context, |parts| {
350                    parts.push_promoted_str(value);
351                });
352            }
353            Self::StaticStr { value, context } => {
354                parts.in_context(context, |parts| {
355                    parts.push_static_str(value);
356                });
357            }
358            Self::String { value, context } => {
359                parts.in_context(context, |parts| {
360                    parts.push_string(value);
361                });
362            }
363        }
364    }
365
366    /// Writes the value into `parts` under the context it was captured
367    /// with, copying an owned string instead of moving it.
368    #[inline]
369    fn push_ref_into(&self, parts: &mut PartsWriter<'_>) {
370        match self {
371            Self::Absent | Self::Empty => {}
372            Self::PromotedStr { value, context } => {
373                parts.in_context(*context, |parts| {
374                    parts.push_promoted_str(value);
375                });
376            }
377            Self::StaticStr { value, context } => {
378                parts.in_context(*context, |parts| {
379                    parts.push_static_str(value);
380                });
381            }
382            Self::String { value, context } => {
383                parts.in_context(*context, |parts| {
384                    parts.push_str(value);
385                });
386            }
387        }
388    }
389}
390
391impl AttributeValueViewParts for AttributeValue {
392    #[inline]
393    fn attribute_present(&self) -> bool {
394        self.is_present()
395    }
396
397    #[inline]
398    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
399        self.push_into(parts);
400    }
401}
402
403impl AttributeValueViewParts for &AttributeValue {
404    #[inline]
405    fn attribute_present(&self) -> bool {
406        self.is_present()
407    }
408
409    #[inline]
410    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
411        self.push_ref_into(parts);
412    }
413}
414
415/// A captured attribute value spliced in as a single class list entry, such
416/// as one taken from an [`Attributes`](crate::Attributes) collection with
417/// [`remove`](crate::Attributes::remove). An absent value is skipped.
418impl ClassViewParts for AttributeValue {
419    #[inline]
420    fn is_present(&self) -> bool {
421        AttributeValue::is_present(self)
422    }
423
424    #[inline]
425    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
426        self.push_into(parts);
427    }
428}
429
430impl ClassViewParts for &AttributeValue {
431    #[inline]
432    fn is_present(&self) -> bool {
433        AttributeValue::is_present(self)
434    }
435
436    #[inline]
437    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
438        self.push_ref_into(parts);
439    }
440}
441
442impl Captured for AttributeValue {
443    #[inline]
444    fn empty() -> Self {
445        Self::Empty
446    }
447
448    #[inline]
449    fn promoted_str(value: &'static &'static str, context: HtmlContext) -> Self {
450        Self::PromotedStr { value, context }
451    }
452
453    #[inline]
454    fn static_str(value: &'static str, context: HtmlContext) -> Self {
455        Self::StaticStr { value, context }
456    }
457
458    #[inline]
459    fn string(value: String, context: HtmlContext) -> Self {
460        Self::String { value, context }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::{Attributes, internal::Builder};
468
469    /// Captures `value` the way [`Attributes::insert`] does.
470    fn capture(value: impl AttributeValueViewParts) -> AttributeValue {
471        let cx = Cx::default();
472        let mut attrs = Attributes::new();
473        attrs.insert(&cx, "x", value);
474        attrs.remove("x").unwrap()
475    }
476
477    /// Writes a captured value in attribute value position and renders it.
478    fn render(value: &AttributeValue) -> String {
479        let cx = Cx::default();
480        Builder::build(&cx, |b| b.attribute_value(value)).render(&cx)
481    }
482
483    #[test]
484    fn a_single_promoted_string_is_kept_as_is() {
485        let value = capture(PromotedStr(&"a\"b"));
486        assert!(matches!(
487            value,
488            AttributeValue::PromotedStr {
489                value: &"a\"b",
490                context: HtmlContext::AttributeValue,
491            }
492        ));
493        assert_eq!(render(&value), "a&quot;b");
494    }
495
496    #[test]
497    fn a_single_static_string_is_kept_as_is() {
498        let value = capture(StaticStr("a"));
499        assert!(matches!(
500            value,
501            AttributeValue::StaticStr { value: "a", .. }
502        ));
503        assert_eq!(render(&value), "a");
504    }
505
506    #[test]
507    fn a_single_borrowed_string_is_owned_but_not_escaped_yet() {
508        let value = capture("a\"b");
509        let AttributeValue::String { value, context } = &value else {
510            panic!("expected an owned string");
511        };
512        assert_eq!(value, "a\"b");
513        assert_eq!(*context, HtmlContext::AttributeValue);
514    }
515
516    #[test]
517    fn an_unescaped_string_keeps_its_context() {
518        let value = capture(Unescaped::new_unchecked(PromotedStr(&"a&quot;b")));
519        assert!(matches!(
520            value,
521            AttributeValue::PromotedStr {
522                context: HtmlContext::Unescaped,
523                ..
524            }
525        ));
526        assert_eq!(render(&value), "a&quot;b");
527    }
528
529    #[test]
530    fn a_true_boolean_is_empty() {
531        let value = capture(true);
532        assert!(matches!(value, AttributeValue::Empty));
533        assert!(value.is_present());
534        assert_eq!(render(&value), "");
535    }
536
537    #[test]
538    fn a_false_boolean_and_none_are_absent() {
539        assert!(matches!(capture(false), AttributeValue::Absent));
540        assert!(matches!(
541            capture(Option::<&str>::None),
542            AttributeValue::Absent
543        ));
544    }
545
546    #[test]
547    fn multiple_parts_are_rendered_and_escaped_once() {
548        let value = capture(("a\"", 1, Unescaped::new_unchecked(StaticStr("&"))));
549        let AttributeValue::String { value, context } = &value else {
550            panic!("expected a rendered string");
551        };
552        assert_eq!(value, "a&quot;1&");
553        assert_eq!(*context, HtmlContext::Unescaped);
554    }
555
556    #[test]
557    fn a_primitive_is_rendered() {
558        let value = capture(42);
559        assert!(matches!(value, AttributeValue::String { .. }));
560        assert_eq!(render(&value), "42");
561    }
562
563    #[test]
564    fn a_rendered_value_is_not_escaped_again() {
565        let value = capture(("a\"", "b"));
566        assert_eq!(render(&value), "a&quot;b");
567    }
568
569    #[test]
570    fn a_captured_value_captures_as_itself() {
571        let value = capture(PromotedStr(&"a"));
572        let again = capture(&value);
573        assert!(matches!(
574            again,
575            AttributeValue::PromotedStr { value: &"a", .. }
576        ));
577    }
578}