Source code for htmx_nav.swaps

"""
Swap: an out-of-band or ``<hx-partial>`` fragment rendered alongside the main
content of an HTMX response.
"""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal, TypeAlias

from django.http import HttpRequest
from django.template.loader import render_to_string
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString

from .debugging import _build_marker_script
from .settings import _debug_swaps_enabled, _default_swap_wrap
from .targeting import Target, _eval_target


[docs] @dataclass(frozen=True) class Swap: """Represents an out-of-band (OOB) or ``<hx-partial>`` fragment for HTMX responses. Args: template_name: Path to the template or partial (e.g., ``"nav.html#sidebar"``). Mutually exclusive with ``content``. Required unless creating a delete swap or ``content`` is provided. content: Ready-made fragment body, bypassing template rendering. Auto-escaped like a template variable unless wrapped in ``mark_safe``. Mutually exclusive with ``template_name``. context: Context mapping for the fragment. Also serves as fallback context during full-page renders. Ignored when ``content`` is set. target_id: Target DOM element ID. If ``None``, renders without auto-wrapping. swap_style: HTMX swap strategy (``"innerHTML"``, ``"outerHTML"``, ``"delete"``, etc.). wrap: Auto-wrap mode (``"oob"`` or ``"hx-partial"``). Defaults to the ``HTMX_NAV_DEFAULT_SWAP_WRAP`` setting. Ignored when ``swap_style="delete"``. include_if: Predicate determining if the swap applies to the request. Raises: ValueError: If both or neither of ``template_name`` and ``content`` are provided, or if ``target_id`` is omitted for a delete swap. Example: .. code-block:: python # Standard partial swap Swap("partials/sidebar.html", {"active": "home"}, target_id="sidebar") # Static string swap Swap.text("badge-count", "5") # Conditional deletion Swap.delete("flash-banner", include_if=targeting("main")) """ template_name: str | None = None context: Mapping[str, Any] | None = None content: str | None = None target_id: str | None = None swap_style: str = "innerHTML" wrap: Literal["oob", "hx-partial"] | None = None include_if: Target = True def __post_init__(self) -> None: """Apply configured swap-wrap default and validate field combinations.""" if self.wrap is None: object.__setattr__(self, "wrap", _default_swap_wrap()) if self.swap_style == "delete": if self.target_id is None: raise ValueError("Swap(swap_style='delete') requires target_id") elif self.template_name is not None and self.content is not None: raise ValueError("specify only one of template_name or content") elif self.template_name is None and self.content is None: raise ValueError( "template_name or content is required unless swap_style='delete'" )
[docs] @classmethod def delete(cls, target_id: str, include_if: Target = True) -> "Swap": """Build an OOB delete swap that removes ``target_id`` from the DOM. Equivalent to ``<div id="{target_id}" hx-swap-oob="delete"></div>``. Args: target_id: DOM element ID to remove. include_if: Predicate determining if the swap applies to the request. Returns: A ``Swap`` configured for deletion. """ return cls( target_id=target_id, swap_style="delete", wrap="oob", include_if=include_if )
[docs] @classmethod def text( cls, target_id: str, content: str, swap_style: str = "innerHTML", wrap: Literal["oob", "hx-partial"] | None = None, include_if: Target = True, ) -> "Swap": """Build a swap from a ready-made string, skipping template rendering. Args: target_id: Target DOM element ID. content: The fragment body. swap_style: HTMX swap strategy. wrap: Auto-wrap mode. Defaults to the ``HTMX_NAV_DEFAULT_SWAP_WRAP`` setting. include_if: Predicate determining if the swap applies to the request. Returns: A ``Swap`` that renders ``content`` directly. """ return cls( content=content, target_id=target_id, swap_style=swap_style, wrap=wrap, include_if=include_if, )
[docs] def applies_to(self, request: HttpRequest) -> bool: """Evaluate ``include_if`` against the request to determine inclusion. Args: request: The incoming HTTP request. Returns: True if this swap applies to the request. """ return _eval_target(self.include_if, request)
[docs] def render( self, request: HttpRequest, parent_context: Mapping[str, Any] | None = None, using: str | None = None, ) -> str: """Render the swap to an HTML string. Args: request: The incoming HTTP request. parent_context: Optional parent context to merge with swap context. using: Optional template engine name. Returns: The rendered HTML string, auto-wrapped if ``target_id`` is set. """ if self.swap_style == "delete": # htmx removes the target outright; no body, no wrapper choice, # no debug marker (the element won't exist to animate). return f'<div id="{self.target_id}" hx-swap-oob="delete"></div>' if self.content is not None: html: SafeString | str = conditional_escape(self.content) else: template_name = self.template_name assert template_name is not None, ( "__post_init__ guarantees this outside delete/content" ) final_context = dict(parent_context or {}) if self.context: final_context.update(self.context) html = render_to_string( template_name, final_context, request=request, using=using, ) if self.target_id and _debug_swaps_enabled(): html += _build_marker_script(self.target_id) if not self.target_id: return html if self.wrap == "hx-partial": return ( f'<hx-partial hx-target="#{self.target_id}" ' f'hx-swap="{self.swap_style}">{html}</hx-partial>' ) return ( f'<div id="{self.target_id}" hx-swap-oob="{self.swap_style}">{html}</div>' )
#: Type alias for a single Swap, list/tuple of Swaps, or None. #: :meta hide-value: Swaps: TypeAlias = Swap | list[Swap] | tuple[Swap, ...] | None def _normalize_swaps(swaps: Swaps) -> list[Swap]: """Normalize a ``Swaps`` input into a flat list of ``Swap`` instances. Args: swaps: ``None``, a single ``Swap``, or a sequence of swaps. Returns: A list of ``Swap`` instances. Empty if ``swaps`` is ``None``. """ if swaps is None: return [] if isinstance(swaps, (list, tuple)): return list(swaps) return [swaps]