acton_htmx/htmx/
swap_oob.rs

1//! Out-of-band swap support for HTMX
2//!
3//! Provides [`HxSwapOob`] for updating multiple page elements in a single response.
4//! This is useful for patterns like:
5//! - Updating a counter after adding an item
6//! - Showing flash messages after form submission
7//! - Refreshing related content across the page
8
9use axum::{
10    http::header::CONTENT_TYPE,
11    response::{Html, IntoResponse, Response},
12};
13use std::fmt::Write;
14
15/// HTMX swap strategy for out-of-band updates
16///
17/// Determines how the new content replaces the existing element.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub enum SwapStrategy {
20    /// Replace inner HTML of target element (default)
21    #[default]
22    InnerHTML,
23    /// Replace entire target element
24    OuterHTML,
25    /// Insert content at the end of target element
26    BeforeEnd,
27    /// Insert content at the beginning of target element
28    AfterBegin,
29    /// Insert content before target element
30    BeforeBegin,
31    /// Insert content after target element
32    AfterEnd,
33    /// Delete the target element
34    Delete,
35    /// Do not swap (just trigger events)
36    None,
37}
38
39impl SwapStrategy {
40    /// Returns the HTMX swap strategy value
41    #[must_use]
42    pub const fn as_str(&self) -> &'static str {
43        match self {
44            Self::InnerHTML => "innerHTML",
45            Self::OuterHTML => "outerHTML",
46            Self::BeforeEnd => "beforeend",
47            Self::AfterBegin => "afterbegin",
48            Self::BeforeBegin => "beforebegin",
49            Self::AfterEnd => "afterend",
50            Self::Delete => "delete",
51            Self::None => "none",
52        }
53    }
54
55    /// Returns the hx-swap-oob attribute value
56    #[must_use]
57    pub const fn oob_value(&self) -> &'static str {
58        match self {
59            Self::InnerHTML => "true", // "true" defaults to innerHTML
60            _ => self.as_str(),
61        }
62    }
63}
64
65/// Out-of-band swap container
66///
67/// Collects multiple OOB swap targets into a single HTML response.
68/// When returned from a handler, HTMX will update each target element
69/// independently.
70///
71/// # Examples
72///
73/// ```rust
74/// use acton_htmx::htmx::{HxSwapOob, SwapStrategy};
75///
76/// let mut oob = HxSwapOob::new();
77///
78/// // Add multiple targets
79/// oob.add("counter", "<span>42</span>", SwapStrategy::InnerHTML);
80/// oob.add("messages", r#"<div class="flash">Saved!</div>"#, SwapStrategy::BeforeEnd);
81///
82/// // Can also chain
83/// let oob = HxSwapOob::new()
84///     .with("header-title", "<h1>Updated</h1>", SwapStrategy::InnerHTML)
85///     .with("sidebar", "<nav>New nav</nav>", SwapStrategy::OuterHTML);
86/// ```
87#[derive(Debug, Default, Clone)]
88pub struct HxSwapOob {
89    targets: Vec<OobTarget>,
90    /// Primary content that's not part of OOB swap
91    primary_content: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95struct OobTarget {
96    id: String,
97    content: String,
98    strategy: SwapStrategy,
99}
100
101impl HxSwapOob {
102    /// Create a new empty OOB swap container
103    #[must_use]
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Create with primary content that will be rendered first
109    ///
110    /// The primary content is the main response body that will be swapped
111    /// into the original target. OOB elements are appended after it.
112    #[must_use]
113    pub fn with_primary(content: impl Into<String>) -> Self {
114        Self {
115            targets: Vec::new(),
116            primary_content: Some(content.into()),
117        }
118    }
119
120    /// Set the primary content
121    pub fn set_primary(&mut self, content: impl Into<String>) -> &mut Self {
122        self.primary_content = Some(content.into());
123        self
124    }
125
126    /// Add an out-of-band target
127    ///
128    /// # Arguments
129    ///
130    /// * `id` - The ID of the target element (without #)
131    /// * `content` - The HTML content to swap
132    /// * `strategy` - How to perform the swap
133    pub fn add(&mut self, id: impl Into<String>, content: impl Into<String>, strategy: SwapStrategy) -> &mut Self {
134        self.targets.push(OobTarget {
135            id: id.into(),
136            content: content.into(),
137            strategy,
138        });
139        self
140    }
141
142    /// Add an out-of-band target (builder pattern)
143    #[must_use]
144    pub fn with(mut self, id: impl Into<String>, content: impl Into<String>, strategy: SwapStrategy) -> Self {
145        self.add(id, content, strategy);
146        self
147    }
148
149    /// Add innerHTML swap (convenience method)
150    pub fn inner_html(&mut self, id: impl Into<String>, content: impl Into<String>) -> &mut Self {
151        self.add(id, content, SwapStrategy::InnerHTML)
152    }
153
154    /// Add outerHTML swap (convenience method)
155    pub fn outer_html(&mut self, id: impl Into<String>, content: impl Into<String>) -> &mut Self {
156        self.add(id, content, SwapStrategy::OuterHTML)
157    }
158
159    /// Add beforeend swap (append content)
160    pub fn append(&mut self, id: impl Into<String>, content: impl Into<String>) -> &mut Self {
161        self.add(id, content, SwapStrategy::BeforeEnd)
162    }
163
164    /// Add afterbegin swap (prepend content)
165    pub fn prepend(&mut self, id: impl Into<String>, content: impl Into<String>) -> &mut Self {
166        self.add(id, content, SwapStrategy::AfterBegin)
167    }
168
169    /// Check if there are any OOB targets
170    #[must_use]
171    pub fn is_empty(&self) -> bool {
172        self.targets.is_empty() && self.primary_content.is_none()
173    }
174
175    /// Get the number of OOB targets
176    #[must_use]
177    pub fn len(&self) -> usize {
178        self.targets.len()
179    }
180
181    /// Render to HTML string
182    #[must_use]
183    pub fn render(&self) -> String {
184        let mut html = String::new();
185
186        // Primary content first
187        if let Some(ref primary) = self.primary_content {
188            html.push_str(primary);
189        }
190
191        // OOB targets
192        for target in &self.targets {
193            write!(
194                html,
195                r#"<div id="{}" hx-swap-oob="{}">{}</div>"#,
196                target.id,
197                target.strategy.oob_value(),
198                target.content
199            ).unwrap();
200        }
201
202        html
203    }
204}
205
206impl IntoResponse for HxSwapOob {
207    fn into_response(self) -> Response {
208        let html = self.render();
209        (
210            [(CONTENT_TYPE, "text/html; charset=utf-8")],
211            Html(html),
212        )
213            .into_response()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_swap_strategy_as_str() {
223        assert_eq!(SwapStrategy::InnerHTML.as_str(), "innerHTML");
224        assert_eq!(SwapStrategy::OuterHTML.as_str(), "outerHTML");
225        assert_eq!(SwapStrategy::BeforeEnd.as_str(), "beforeend");
226        assert_eq!(SwapStrategy::Delete.as_str(), "delete");
227    }
228
229    #[test]
230    fn test_swap_strategy_oob_value() {
231        assert_eq!(SwapStrategy::InnerHTML.oob_value(), "true");
232        assert_eq!(SwapStrategy::OuterHTML.oob_value(), "outerHTML");
233    }
234
235    #[test]
236    fn test_new_empty() {
237        let oob = HxSwapOob::new();
238        assert!(oob.is_empty());
239        assert_eq!(oob.len(), 0);
240    }
241
242    #[test]
243    fn test_add_target() {
244        let mut oob = HxSwapOob::new();
245        oob.add("test-id", "<p>Content</p>", SwapStrategy::InnerHTML);
246
247        assert!(!oob.is_empty());
248        assert_eq!(oob.len(), 1);
249    }
250
251    #[test]
252    fn test_builder_pattern() {
253        let oob = HxSwapOob::new()
254            .with("id1", "content1", SwapStrategy::InnerHTML)
255            .with("id2", "content2", SwapStrategy::OuterHTML);
256
257        assert_eq!(oob.len(), 2);
258    }
259
260    #[test]
261    fn test_render_single() {
262        let mut oob = HxSwapOob::new();
263        oob.add("my-id", "<span>Test</span>", SwapStrategy::InnerHTML);
264
265        let html = oob.render();
266        assert!(html.contains(r#"id="my-id""#));
267        assert!(html.contains(r#"hx-swap-oob="true""#));
268        assert!(html.contains("<span>Test</span>"));
269    }
270
271    #[test]
272    fn test_render_multiple() {
273        let oob = HxSwapOob::new()
274            .with("first", "<p>First</p>", SwapStrategy::InnerHTML)
275            .with("second", "<p>Second</p>", SwapStrategy::OuterHTML);
276
277        let html = oob.render();
278        assert!(html.contains(r#"id="first""#));
279        assert!(html.contains(r#"id="second""#));
280        assert!(html.contains(r#"hx-swap-oob="true""#));
281        assert!(html.contains(r#"hx-swap-oob="outerHTML""#));
282    }
283
284    #[test]
285    fn test_render_with_primary() {
286        let oob = HxSwapOob::with_primary("<main>Primary</main>")
287            .with("sidebar", "<nav>Nav</nav>", SwapStrategy::InnerHTML);
288
289        let html = oob.render();
290        assert!(html.starts_with("<main>Primary</main>"));
291        assert!(html.contains(r#"id="sidebar""#));
292    }
293
294    #[test]
295    fn test_convenience_methods() {
296        let mut oob = HxSwapOob::new();
297        oob.inner_html("a", "content");
298        oob.outer_html("b", "content");
299        oob.append("c", "content");
300        oob.prepend("d", "content");
301
302        assert_eq!(oob.len(), 4);
303    }
304
305    #[test]
306    fn test_into_response() {
307        let oob = HxSwapOob::new()
308            .with("test", "<p>Test</p>", SwapStrategy::InnerHTML);
309
310        let response = oob.into_response();
311        assert_eq!(response.status(), axum::http::StatusCode::OK);
312    }
313}