Skip to main content

winged_rust/core/
element.rs

1//! The [`Element`] type and its fluent builder API.
2//!
3//! Ports the state half of `core/HTMLTag.swift`, all of `core/CSSHelpers.swift` and all of
4//! `core/AttributeHelpers.swift`. Those three files are the *complete* chainable surface
5//! of Winged-Swift — there is nothing else.
6
7use crate::core::attribute::Attribute;
8use crate::core::escape::{write_escaped_attribute, write_escaped_text};
9use crate::core::node::Node;
10use crate::core::render::{Render, RenderOptions};
11
12/// An HTML element: a tag name, attributes, optional text content, and children.
13///
14/// Every builder method takes `self` and returns `Self`, so they chain. Content and
15/// attribute values are escaped as they go in, not when the element is rendered.
16///
17/// # Examples
18/// ```
19/// use winged_rust::prelude::*;
20/// let card = div()
21///     .add_class("card")
22///     .set_id("hero")
23///     .child(h1().text("Welcome"))
24///     .child(p().text("Fuel, tyres & chain."));
25/// assert_eq!(
26///     card.render(),
27///     r#"<div class="card" id="hero"><h1>Welcome</h1><p>Fuel, tyres &amp; chain.</p></div>"#
28/// );
29/// ```
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Element {
32    tag: String,
33    attributes: Vec<Attribute>,
34    content: Option<String>,
35    children: Vec<Node>,
36}
37
38impl Element {
39    /// Creates an element with the given tag name.
40    pub fn new(tag: impl Into<String>) -> Self {
41        Self {
42            tag: tag.into(),
43            attributes: Vec::new(),
44            content: None,
45            children: Vec::new(),
46        }
47    }
48
49    // MARK: - Accessors
50
51    /// The tag name.
52    #[must_use]
53    pub fn tag(&self) -> &str {
54        &self.tag
55    }
56
57    /// The attributes, in insertion order.
58    #[must_use]
59    pub fn attributes(&self) -> &[Attribute] {
60        &self.attributes
61    }
62
63    /// The escaped text content, if any.
64    #[must_use]
65    pub fn content(&self) -> Option<&str> {
66        self.content.as_deref()
67    }
68
69    /// The child nodes.
70    #[must_use]
71    pub fn children(&self) -> &[Node] {
72        &self.children
73    }
74
75    // MARK: - Content
76
77    /// Sets the text content, escaping it.
78    ///
79    /// Replaces any content already set.
80    #[must_use]
81    pub fn text(mut self, content: impl AsRef<str>) -> Self {
82        let raw = content.as_ref();
83        let mut escaped = String::with_capacity(raw.len());
84        write_escaped_text(&mut escaped, raw, false);
85        self.content = Some(escaped);
86        self
87    }
88
89    /// Sets the text content **without** escaping it.
90    ///
91    /// The documented escape hatch for markup you already trust. `<script>` and `<style>`
92    /// use this by default.
93    #[must_use]
94    pub fn raw_text(mut self, content: impl Into<String>) -> Self {
95        self.content = Some(content.into());
96        self
97    }
98
99    /// Appends a child node.
100    #[must_use]
101    pub fn child(mut self, child: impl Into<Node>) -> Self {
102        self.children.push(child.into());
103        self
104    }
105
106    /// Appends several child nodes.
107    #[must_use]
108    pub fn children_from<N: Into<Node>>(mut self, children: impl IntoIterator<Item = N>) -> Self {
109        self.children.extend(children.into_iter().map(Into::into));
110        self
111    }
112
113    // MARK: - Attributes
114
115    /// Appends an attribute. Does not deduplicate.
116    #[must_use]
117    pub fn add_attribute(mut self, attribute: Attribute) -> Self {
118        self.attributes.push(attribute);
119        self
120    }
121
122    /// Appends `key="value"`, escaping the value. Does not deduplicate.
123    ///
124    /// Ports `setAttribute(key:value:)`.
125    #[must_use]
126    pub fn attr(self, key: impl Into<String>, value: impl AsRef<str>) -> Self {
127        self.add_attribute(Attribute::new(key, value))
128    }
129
130    /// Appends a boolean attribute, which renders as a bare key.
131    #[must_use]
132    pub fn bool_attr(self, key: impl Into<String>) -> Self {
133        self.add_attribute(Attribute::boolean(key))
134    }
135
136    /// Appends `data-{key}="{value}"`.
137    #[must_use]
138    pub fn data_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
139        self.attr(format!("data-{}", key.as_ref()), value)
140    }
141
142    /// Appends several `data-*` attributes.
143    ///
144    /// Takes an ordered iterator rather than a map. Winged-Swift's `dataAttributes(_:)`
145    /// takes a Swift `Dictionary`, so its rendered attribute order is nondeterministic
146    /// between runs; this signature makes the output stable. See `PORTING.md`.
147    #[must_use]
148    pub fn data_attrs<K: AsRef<str>, V: AsRef<str>>(
149        mut self,
150        data: impl IntoIterator<Item = (K, V)>,
151    ) -> Self {
152        for (key, value) in data {
153            self = self.data_attr(key, value);
154        }
155        self
156    }
157
158    /// Appends `aria-{key}="{value}"`.
159    #[must_use]
160    pub fn aria_attr(self, key: impl AsRef<str>, value: impl AsRef<str>) -> Self {
161        self.attr(format!("aria-{}", key.as_ref()), value)
162    }
163
164    /// Appends several `aria-*` attributes, in the order given.
165    #[must_use]
166    pub fn aria_attrs<K: AsRef<str>, V: AsRef<str>>(
167        mut self,
168        aria: impl IntoIterator<Item = (K, V)>,
169    ) -> Self {
170        for (key, value) in aria {
171            self = self.aria_attr(key, value);
172        }
173        self
174    }
175
176    // MARK: - Replacing helpers
177    //
178    // `set_id`, `set_style` and `set_role` remove any existing value before appending.
179    // Every other helper appends without deduplication — that asymmetry is Winged-Swift's.
180
181    /// Sets `id`, replacing any existing one.
182    #[must_use]
183    pub fn set_id(self, id: impl AsRef<str>) -> Self {
184        self.replace_attribute("id", id.as_ref())
185    }
186
187    /// Sets `style`, replacing any existing one.
188    #[must_use]
189    pub fn set_style(self, style: impl AsRef<str>) -> Self {
190        self.replace_attribute("style", style.as_ref())
191    }
192
193    /// Sets `role`, replacing any existing one.
194    #[must_use]
195    pub fn set_role(self, role: impl AsRef<str>) -> Self {
196        self.replace_attribute("role", role.as_ref())
197    }
198
199    fn replace_attribute(mut self, key: &str, value: &str) -> Self {
200        self.attributes.retain(|a| a.key() != key);
201        self.attributes.push(Attribute::new(key, value));
202        self
203    }
204
205    // MARK: - Classes
206
207    /// Appends a class name to `class`, space-joined.
208    ///
209    /// Only the new value is escaped — the existing attribute is already escaped, and
210    /// re-escaping it would double-encode. `Tests/WingedSwiftTests/CSSHelpersTests.swift`
211    /// has a regression test for exactly that.
212    #[must_use]
213    pub fn add_class(mut self, class_name: impl AsRef<str>) -> Self {
214        let raw = class_name.as_ref();
215        if let Some(existing) = self.attributes.iter_mut().find(|a| a.key() == "class") {
216            let mut merged = existing.value().to_string();
217            merged.push(' ');
218            write_escaped_attribute(&mut merged, raw);
219            // `Attribute::raw` because `merged` is already escaped.
220            *existing = Attribute::raw("class", merged);
221        } else {
222            self.attributes.push(Attribute::new("class", raw));
223        }
224        self
225    }
226
227    /// Appends several class names.
228    #[must_use]
229    pub fn add_classes<S: AsRef<str>>(mut self, class_names: impl IntoIterator<Item = S>) -> Self {
230        for name in class_names {
231            self = self.add_class(name);
232        }
233        self
234    }
235}
236
237impl Render for Element {
238    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
239        crate::core::node::write_element_tree(self, out, options, depth);
240    }
241}
242
243/// Tears the subtree down iteratively.
244///
245/// The derived drop glue recurses once per nesting level, so a tree deep enough to need
246/// the iterative writer would abort while being *freed* instead — after rendering fine.
247/// Draining into an explicit worklist keeps teardown flat.
248///
249/// Each node has its children moved out before it goes out of scope, so the `Drop` that
250/// runs for it finds nothing left to recurse into.
251impl Drop for Element {
252    fn drop(&mut self) {
253        let mut pending = core::mem::take(&mut self.children);
254        while let Some(node) = pending.pop() {
255            match node {
256                Node::Element(mut element) => pending.append(&mut element.children),
257                Node::Fragment(mut children) => pending.append(&mut children),
258                Node::Text(_) | Node::Raw(_) | Node::Comment(_) => {}
259            }
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::elements::{
268        a, body, button, div, footer, head, header, html_tag, img, input_named, li, main_tag, meta,
269        nav, ol, p, script, span, stylesheet, table, td, th, title, tr, ul,
270    };
271
272    /// Ports `CSSHelpersTests.testAddMultipleClasses`.
273    #[test]
274    fn add_class_appends_to_one_attribute() {
275        assert_eq!(
276            div().add_class("a").add_class("b").render(),
277            r#"<div class="a b"></div>"#
278        );
279    }
280
281    /// Ports `CSSHelpersTests.testAddClassDoesNotDoubleEscapeExistingValues`.
282    #[test]
283    fn chaining_add_class_does_not_double_escape() {
284        let rendered = div().add_class("a&b").add_class("c").render();
285        assert_eq!(rendered, r#"<div class="a&amp;b c"></div>"#);
286        assert!(!rendered.contains("&amp;amp;"));
287    }
288
289    /// Ports `CSSHelpersTests.testAddClassEscapesQuotesInsteadOfBreakingOutOfTheAttribute`.
290    #[test]
291    fn a_quote_in_a_class_name_cannot_break_out() {
292        let rendered = div().add_class(r#"a" onload="alert(1)"#).render();
293        assert!(!rendered.contains("onload=\""));
294        assert!(rendered.contains("&quot;"));
295    }
296
297    /// Ports `CSSHelpersTests.testAddClassesArray`.
298    #[test]
299    fn add_classes_appends_all_of_them_in_order() {
300        assert_eq!(
301            div().add_classes(["card", "p-4", "shadow"]).render(),
302            r#"<div class="card p-4 shadow"></div>"#
303        );
304    }
305
306    /// Ports `CSSHelpersTests.testSetIdReplacesExisting`.
307    #[test]
308    fn set_id_replaces_rather_than_appending() {
309        let rendered = div().set_id("first").set_id("second").render();
310        assert_eq!(rendered, r#"<div id="second"></div>"#);
311    }
312
313    /// Ports `CSSHelpersTests.testSetStyle` and `AttributeHelpersTests.testSetRole`.
314    #[test]
315    fn set_style_and_set_role_also_replace() {
316        let rendered = div()
317            .set_style("color:red")
318            .set_style("color:blue")
319            .set_role("main")
320            .render();
321        assert_eq!(rendered, r#"<div style="color:blue" role="main"></div>"#);
322    }
323
324    /// Ports `AttributeHelpersTests.testDataAttribute` and
325    /// `AttributeHelpersTests.testAriaAttribute`.
326    #[test]
327    fn data_and_aria_attributes_get_their_prefixes() {
328        let rendered = span()
329            .data_attr("id", "7")
330            .aria_attr("label", "Close")
331            .render();
332        assert_eq!(rendered, r#"<span data-id="7" aria-label="Close"></span>"#);
333    }
334
335    /// The improvement over Swift: ordered input means stable output.
336    #[test]
337    fn bulk_attribute_order_is_stable() {
338        let build = || {
339            div()
340                .data_attrs([("a", "1"), ("b", "2"), ("c", "3")])
341                .render()
342        };
343        let expected = r#"<div data-a="1" data-b="2" data-c="3"></div>"#;
344        for _ in 0..16 {
345            assert_eq!(build(), expected);
346        }
347    }
348
349    /// No Swift counterpart: `HTMLTag` has no deduplication either, but nothing in its
350    /// suite pins it. Kept as a Rust-side guarantee.
351    #[test]
352    fn plain_attributes_are_appended_without_deduplication() {
353        assert_eq!(
354            div().attr("data-x", "1").attr("data-x", "2").render(),
355            r#"<div data-x="1" data-x="2"></div>"#
356        );
357    }
358
359    /// Ports `HTMLEscapeTests.testHTMLTagEscapesContentByDefault` and
360    /// `HTMLEscapeTests.testHTMLTagCanDisableEscape`. Swift turns escaping off with an
361    /// `escapeContent:`
362    /// flag on the initialiser; Rust has a separate method instead, so the escape hatch is
363    /// greppable rather than hidden behind a default argument.
364    #[test]
365    fn text_is_escaped_and_raw_text_is_not() {
366        assert_eq!(p().text("<b>").render(), "<p>&lt;b&gt;</p>");
367        assert_eq!(p().raw_text("<b>").render(), "<p><b></p>");
368    }
369
370    #[test]
371    fn boolean_attributes_render_bare() {
372        assert_eq!(
373            button().bool_attr("disabled").render(),
374            "<button disabled></button>"
375        );
376    }
377
378    #[test]
379    fn children_from_appends_a_sequence() {
380        let list = div().children_from([p().text("a"), p().text("b")]);
381        assert_eq!(list.render(), "<div><p>a</p><p>b</p></div>");
382    }
383
384    // Ports `HTMLTagTests`. Swift builds these through a result builder — `html { ... }` —
385    // which wraps everything in `<html>`; the Rust equivalent is an explicit `html_tag()`.
386
387    /// Ports `HTMLTagTests.testHTMLTagCreation`.
388    #[test]
389    fn a_tag_renders_its_attributes_then_its_content() {
390        let tag = Element::new("p")
391            .attr("class", "text")
392            .text("Hello, World!");
393
394        assert_eq!(tag.render(), r#"<p class="text">Hello, World!</p>"#);
395    }
396
397    /// Ports `HTMLTagTests.testHTMLTagWithMultipleAttributes`.
398    ///
399    /// Swift's `Img(src:alt:attributes:)` emits the extra attributes *before* `src` and
400    /// `alt`. The Rust `image(src, alt)` constructor puts them first instead, so matching
401    /// this byte for byte means going through the generic builder.
402    #[test]
403    fn extra_attributes_can_precede_the_typed_ones() {
404        let tag = img()
405            .attr("width", "100")
406            .attr("height", "100")
407            .attr("src", "image.png")
408            .attr("alt", "An image");
409
410        assert_eq!(
411            tag.render(),
412            r#"<img width="100" height="100" src="image.png" alt="An image">"#
413        );
414    }
415
416    /// Ports `HTMLTagTests.testHTMLBuilder`.
417    #[test]
418    fn nested_children_render_in_order() {
419        let document = html_tag().child(
420            div()
421                .child(p().text("This is a paragraph."))
422                .child(img().attr("src", "image.png").attr("alt", "An image")),
423        );
424
425        assert_eq!(
426            document.render(),
427            concat!(
428                "<html><div><p>This is a paragraph.</p>",
429                r#"<img src="image.png" alt="An image"></div></html>"#,
430            )
431        );
432    }
433
434    /// Ports `HTMLTagTests.testHTMLBuilderWithAttributes`.
435    #[test]
436    fn a_container_renders_its_class_before_its_children() {
437        let document = html_tag().child(
438            div()
439                .add_class("main-body")
440                .child(p().text("Title"))
441                .child(p().text("This is a paragraph.")),
442        );
443
444        assert_eq!(
445            document.render(),
446            r#"<html><div class="main-body"><p>Title</p><p>This is a paragraph.</p></div></html>"#
447        );
448    }
449
450    /// Ports `HTMLTagTests.testHTMLTable`.
451    #[test]
452    fn a_table_renders_its_rows_and_cells() {
453        let document = html_tag().child(
454            table()
455                .add_class("table")
456                .child(
457                    tr().child(th().text("Header 1"))
458                        .child(th().text("Header 2")),
459                )
460                .child(
461                    tr().child(td().text("Row 1, Cell 1"))
462                        .child(td().text("Row 1, Cell 2")),
463                )
464                .child(
465                    tr().child(td().text("Row 2, Cell 1"))
466                        .child(td().text("Row 2, Cell 2")),
467                ),
468        );
469
470        assert_eq!(
471            document.render(),
472            concat!(
473                r#"<html><table class="table"><tr><th>Header 1</th><th>Header 2</th></tr>"#,
474                "<tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td></tr>",
475                "<tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td></tr></table></html>",
476            )
477        );
478    }
479
480    /// Ports `HTMLTagTests.testHTMLList`.
481    #[test]
482    fn an_unordered_list_renders_its_items() {
483        let document = html_tag().child(
484            ul().add_class("unordered-list")
485                .child(li().text("Item 1"))
486                .child(li().text("Item 2"))
487                .child(li().text("Item 3")),
488        );
489
490        assert_eq!(
491            document.render(),
492            concat!(
493                r#"<html><ul class="unordered-list">"#,
494                "<li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></html>",
495            )
496        );
497    }
498
499    /// Ports `HTMLTagTests.testHTMLOrderedList`.
500    #[test]
501    fn an_ordered_list_renders_its_items() {
502        let document = html_tag().child(
503            ol().add_class("ordered-list")
504                .child(li().text("First"))
505                .child(li().text("Second"))
506                .child(li().text("Third")),
507        );
508
509        assert_eq!(
510            document.render(),
511            concat!(
512                r#"<html><ol class="ordered-list">"#,
513                "<li>First</li><li>Second</li><li>Third</li></ol></html>",
514            )
515        );
516    }
517
518    /// Ports `HTMLTagTests.testHTMLDescriptionList`.
519    ///
520    /// Swift reaches for the untyped `HTMLTag("dt", content:)` here; `Element::new` is the
521    /// same escape hatch in Rust, and the point of the test is that it renders like any
522    /// generated constructor.
523    #[test]
524    fn a_description_list_renders_terms_and_descriptions() {
525        let document = html_tag().child(
526            Element::new("dl")
527                .add_class("description-list")
528                .child(Element::new("dt").text("Term 1"))
529                .child(Element::new("dd").text("Description 1"))
530                .child(Element::new("dt").text("Term 2"))
531                .child(Element::new("dd").text("Description 2")),
532        );
533
534        assert_eq!(
535            document.render(),
536            concat!(
537                r#"<html><dl class="description-list">"#,
538                "<dt>Term 1</dt><dd>Description 1</dd>",
539                "<dt>Term 2</dt><dd>Description 2</dd></dl></html>",
540            )
541        );
542    }
543
544    /// Ports `HTMLTagTests.testHTMLStructuralTags`.
545    #[test]
546    fn the_structural_tags_nest_into_a_page() {
547        let document = html_tag()
548            .child(
549                head()
550                    .child(
551                        meta()
552                            .attr("name", "description")
553                            .attr("content", "A description of the page"),
554                    )
555                    .child(stylesheet("styles.css")),
556            )
557            .child(
558                body()
559                    .child(
560                        header().child(
561                            nav()
562                                .child(a().attr("href", "#home").text("Home"))
563                                .child(a().attr("href", "#about").text("About"))
564                                .child(a().attr("href", "#contact").text("Contact")),
565                        ),
566                    )
567                    .child(main_tag().child(p().text("Welcome to our website!")))
568                    .child(footer().child(p().text("\u{a9} 2024 Company, Inc."))),
569            );
570
571        assert_eq!(
572            document.render(),
573            concat!(
574                r#"<html><head><meta name="description" content="A description of the page">"#,
575                r#"<link href="styles.css" rel="stylesheet"></head><body><header><nav>"#,
576                r##"<a href="#home">Home</a><a href="#about">About</a>"##,
577                r##"<a href="#contact">Contact</a></nav></header>"##,
578                "<main><p>Welcome to our website!</p></main>",
579                "<footer><p>\u{a9} 2024 Company, Inc.</p></footer></body></html>",
580            )
581        );
582    }
583
584    /// Ports `HTMLTagTests.testHTMLScript`.
585    ///
586    /// `raw_text`, not `text`: escaping the body would turn `'Hello World'` into
587    /// `&#39;Hello World&#39;` and the browser would run that literally. Swift's `Script`
588    /// has the same carve-out built into the type.
589    #[test]
590    fn a_script_body_is_not_escaped() {
591        let document = html_tag().child(
592            script()
593                .attr("type", "text/javascript")
594                .raw_text("alert('Hello World');"),
595        );
596
597        assert_eq!(
598            document.render(),
599            r#"<html><script type="text/javascript">alert('Hello World');</script></html>"#
600        );
601    }
602
603    /// Ports `HTMLTagTests.testMetaWithName`.
604    #[test]
605    fn meta_renders_both_the_named_and_the_charset_form() {
606        let document = html_tag()
607            .child(
608                meta()
609                    .attr("name", "description")
610                    .attr("content", "A description of the page"),
611            )
612            .child(meta().attr("charset", "utf-8"));
613
614        assert_eq!(
615            document.render(),
616            concat!(
617                r#"<html><meta name="description" content="A description of the page">"#,
618                r#"<meta charset="utf-8"></html>"#,
619            )
620        );
621    }
622
623    /// Ports `HTMLTagTests.testHTMLTitle`.
624    #[test]
625    fn a_title_renders_its_text() {
626        let document = html_tag().child(title().text("Title my site"));
627
628        assert_eq!(
629            document.render(),
630            "<html><title>Title my site</title></html>"
631        );
632    }
633
634    /// Ports `HTMLTagTests.testHTMLSpan`.
635    #[test]
636    fn a_span_renders_its_attributes_and_content() {
637        let tag = span().attr("class", "text").text("Hello, World!");
638
639        assert_eq!(tag.render(), r#"<span class="text">Hello, World!</span>"#);
640    }
641
642    /// Ports `HTMLTagTests.testHTMLButton`.
643    ///
644    /// Swift's `Button` defaults to `type="button"` and appends it after the caller's
645    /// attributes. `button_typed` in Rust puts the type first, so the order here comes from
646    /// the generic builder.
647    #[test]
648    fn a_button_carries_its_type_after_its_class() {
649        let document = html_tag().child(button().add_class("button-class").attr("type", "button"));
650
651        assert_eq!(
652            document.render(),
653            r#"<html><button class="button-class" type="button"></button></html>"#
654        );
655    }
656
657    /// Ports `HTMLTagTests.testHTMLButtonChildren`.
658    #[test]
659    fn a_button_renders_its_children() {
660        let document = html_tag().child(
661            button()
662                .add_class("button-class")
663                .attr("type", "button")
664                .child(span().add_class("icon-bar")),
665        );
666
667        assert_eq!(
668            document.render(),
669            concat!(
670                r#"<html><button class="button-class" type="button">"#,
671                r#"<span class="icon-bar"></span></button></html>"#,
672            )
673        );
674    }
675
676    // Ports the rest of `CSSHelpersTests` and `AttributeHelpersTests`.
677
678    /// Ports `CSSHelpersTests.testChainingPreservesTheConcreteType`.
679    ///
680    /// Swift needs the test because its helpers are declared on a protocol and could erase
681    /// the tag type. Rust's take `self` and return `Self`, so the type survives by
682    /// construction — what is worth pinning is the attribute order the chain produces.
683    #[test]
684    fn chaining_helpers_keeps_the_element_usable() {
685        let card: Element = div().add_class("card").set_id("hero").set_role("region");
686
687        assert_eq!(
688            card.render(),
689            r#"<div class="card" id="hero" role="region"></div>"#
690        );
691    }
692
693    /// Ports `CSSHelpersTests.testSetStyleEscapesQuotes`.
694    #[test]
695    fn set_style_escapes_quotes() {
696        let rendered = div()
697            .set_style(r#"font-family: "Inter", sans-serif"#)
698            .render();
699
700        assert_eq!(
701            rendered,
702            r#"<div style="font-family: &quot;Inter&quot;, sans-serif"></div>"#
703        );
704    }
705
706    /// Ports `CSSHelpersTests.testAddSingleClass`.
707    #[test]
708    fn a_single_class_renders_on_its_own() {
709        assert_eq!(
710            div().add_class("container").render(),
711            r#"<div class="container"></div>"#
712        );
713    }
714
715    /// Ports `CSSHelpersTests.testSetId`.
716    #[test]
717    fn set_id_renders_an_id_attribute() {
718        assert_eq!(
719            div().set_id("main-content").render(),
720            r#"<div id="main-content"></div>"#
721        );
722    }
723
724    /// Ports `CSSHelpersTests.testChainedHelpers`.
725    #[test]
726    fn the_helpers_chain_in_the_order_they_are_called() {
727        let rendered = div()
728            .set_id("content")
729            .add_class("container")
730            .add_class("active")
731            .set_style("padding: 20px;")
732            .render();
733
734        assert_eq!(
735            rendered,
736            r#"<div id="content" class="container active" style="padding: 20px;"></div>"#
737        );
738    }
739
740    /// Ports `AttributeHelpersTests.testMultipleDataAttributes`.
741    ///
742    /// Swift passes a `Dictionary`, whose order is unspecified, so its test can only check
743    /// that both survive. `data_attrs` takes an ordered iterator precisely so the output is
744    /// deterministic, which is what this pins instead.
745    #[test]
746    fn several_data_attributes_keep_the_order_they_are_given() {
747        let rendered = div()
748            .data_attrs([("id", "123"), ("type", "product")])
749            .render();
750
751        assert_eq!(rendered, r#"<div data-id="123" data-type="product"></div>"#);
752    }
753
754    /// Ports `AttributeHelpersTests.testMultipleAriaAttributes`.
755    #[test]
756    fn several_aria_attributes_keep_the_order_they_are_given() {
757        let rendered = nav()
758            .aria_attrs([("label", "Main navigation"), ("expanded", "true")])
759            .render();
760
761        assert_eq!(
762            rendered,
763            r#"<nav aria-label="Main navigation" aria-expanded="true"></nav>"#
764        );
765    }
766
767    /// Ports `AttributeHelpersTests.testSetAttribute`.
768    ///
769    /// Swift's `setAttribute` appends like every other helper — the name says *set* but it
770    /// does not replace. `attr` is the same operation under an honest name.
771    #[test]
772    fn attr_appends_arbitrary_attributes() {
773        let rendered = input_named("text", "email")
774            .attr("placeholder", "Enter email")
775            .attr("required", "true")
776            .render();
777
778        assert_eq!(
779            rendered,
780            r#"<input type="text" name="email" placeholder="Enter email" required="true">"#
781        );
782    }
783}