"""Generic HTML primitives (no domain knowledge). Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one per tag: they are generated from a whitelist via :func:`_html_element`, each a thin builder over the single :class:`Element` node class. Only elements that add classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out. Everything returns a :class:`Node`; string-built widgets return :class:`Safe`. """ from django.middleware.csrf import get_token from django.templatetags.static import static from django.urls import reverse from django.utils.html import conditional_escape from django.utils.safestring import SafeText, mark_safe from common.components.core import ( Attributes, Child, Children, Element, Fragment, HTMLAttribute, Media, Node, Safe, as_attributes, as_children, randomid, ) from common.icons import get_icon from common.utils import truncate _COLOR_CLASSES = { "blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium", "red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white", "gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border", "green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white", } _SIZE_CLASSES = { "xs": "px-3 py-2 text-xs shadow-xs", "sm": "px-3 py-2 text-sm", "base": "px-5 py-2.5 text-sm", "lg": "px-5 py-3 text-base", "xl": "px-6 py-3.5 text-base", } # ── Generic leaf elements ──────────────────────────────────────────────────── # A whitelist of plain tags, each turned into a builder over `Element`. The # tag name is data, not a separate class/function body. Add a tag = one line. def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]: """Translate htpy-style attribute kwargs to (name, value) pairs. ``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` -> ``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute; ``False`` / ``None`` -> omitted.""" result: list[HTMLAttribute] = [] for key, value in attrs.items(): if value is None or value is False: continue name = key.rstrip("_").replace("_", "-") result.append((name, name if value is True else value)) # type: ignore[arg-type] return result def _html_element(tag_name: str): """Build a generic element builder for ``tag_name`` (the whitelist factory).""" def element( attributes: Attributes | None = None, children: Children = None, **attrs: object, ) -> Element: merged = as_attributes(attributes) + _attrs_from_kwargs(attrs) return Element(tag_name, merged, children) element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:] element.__doc__ = f"Builder for the <{tag_name}> element." return element Div = _html_element("div") P = _html_element("p") Ul = _html_element("ul") Li = _html_element("li") Strong = _html_element("strong") Span = _html_element("span") Label = _html_element("label") Template = _html_element("template") Td = _html_element("td") Tr = _html_element("tr") Th = _html_element("th") def _popover_html( id: str, popover_content: Child, wrapped_content: str = "", wrapped_classes: str = "", slot: "Node | str" = "", ) -> Node: """Generate popover HTML. Single source of truth for popover structure.""" display_content = wrapped_content if wrapped_content else slot span = Span( attributes=[ ("data-popover-target", id), ("class", wrapped_classes), ], children=[display_content] if display_content else [], ) popover_tooltip_class = ( "absolute z-10 invisible inline-block text-sm text-white " "transition-opacity duration-300 bg-white border border-purple-200 " "rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 " "dark:bg-purple-800" ) div = Div( attributes=[ ("data-popover", ""), ("id", id), ("role", "tooltip"), ("class", popover_tooltip_class), ], children=[ Div( attributes=[("class", "px-3 py-2")], children=[popover_content], ), Div(attributes=[("data-popper-arrow", "")]), Safe( # nosec — intentional HTML comment for Tailwind JIT "" ), Span( attributes=[("class", "hidden decoration-dotted")], ), ], ) return Fragment(span, div, separator="\n") def Popover( popover_content: Child, wrapped_content: str = "", wrapped_classes: str = "", children: Children = None, attributes: Attributes | None = None, id: str = "", ) -> Node: children = as_children(children) if not wrapped_content and not children: raise ValueError("One of wrapped_content or children is required.") if not id: id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}") slot = Fragment(*children, separator="\n") if children else "" return _popover_html( id=id, popover_content=popover_content, wrapped_content=wrapped_content, wrapped_classes=wrapped_classes, slot=slot, ) def PopoverTruncated( input_string: str, popover_content: Child = "", popover_if_not_truncated: bool = False, length: int = 30, ellipsis: str = "…", endpart: str = "", ) -> "Node | str": """ Returns `input_string` truncated after `length` of characters and displays the untruncated text in a popover HTML element. The truncated text ends in `ellipsis`, and optionally an always-visible `endpart` can be specified. `popover_content` can be specified if: 1. It needs to be always displayed regardless if text is truncated. 2. It needs to differ from `input_string`. """ if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string: return Popover( wrapped_content=truncated, popover_content=popover_content if popover_content else input_string, ) else: if popover_content and popover_if_not_truncated: return Popover( wrapped_content=input_string, popover_content=popover_content if popover_content else "", ) else: return input_string def A( attributes: Attributes | None = None, children: Children = None, url_name: str | None = None, href: str | None = None, ) -> Element: """ Returns an anchor tag. Accepts one of two mutually-exclusive URL specifications: - url_name: URL pattern name, resolved via reverse() - href: Literal path string passed through as-is """ attributes = as_attributes(attributes) children = children or [] if url_name is not None and href is not None: raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") additional_attributes = [] if url_name is not None: additional_attributes = [("href", reverse(url_name))] elif href is not None: additional_attributes = [("href", href)] return Element( "a", attributes=attributes + additional_attributes, children=children ) def Button( attributes: Attributes | None = None, children: Children = None, size: str = "base", icon: bool = False, color: str = "blue", type: str = "button", hx_get: str = "", hx_target: str = "", hx_swap: str = "", title: str = "", onclick: str = "", name: str = "", ) -> Element: attributes = as_attributes(attributes) children = children or [] # Separate custom class from other generic attributes custom_class = "" other_attrs: list[HTMLAttribute] = [] for attr_name, attr_value in attributes: if attr_name == "class": custom_class = str(attr_value) else: other_attrs.append((attr_name, attr_value)) # Build class string: custom class first, then base, color, size, icon class_parts: list[str] = [] if custom_class: class_parts.append(custom_class) class_parts.append( "hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 " "font-medium mb-2 me-2 rounded-base" ) class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"])) class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"])) if icon: class_parts.append("inline-flex text-center items-center gap-2") # Build the full attribute list for the button tag button_attrs: list[HTMLAttribute] = [ ("type", type), ("class", " ".join(class_parts)), ] if hx_get: button_attrs.append(("hx-get", hx_get)) if hx_target: button_attrs.append(("hx-target", hx_target)) if hx_swap: button_attrs.append(("hx-swap", hx_swap)) if title: button_attrs.append(("title", title)) if onclick: button_attrs.append(("onclick", onclick)) if name: button_attrs.append(("name", name)) button_attrs.extend(other_attrs) return Element( "button", attributes=button_attrs, children=children, ) _GROUP_BUTTON_COLORS = { "gray": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 " "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " "dark:bg-gray-800 dark:border-gray-700 dark:text-white " "dark:hover:text-white dark:hover:bg-gray-700 " "dark:focus:ring-blue-500 dark:focus:text-white" ), "red": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 " "focus:ring-2 focus:ring-blue-700 focus:text-blue-700 " "dark:bg-gray-800 dark:border-gray-700 dark:text-white " "dark:hover:text-white dark:hover:border-red-700 " "dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white" ), "green": ( "px-2 py-1 text-xs font-medium text-gray-900 bg-white border " "border-gray-200 hover:bg-green-500 hover:border-green-600 " "hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 " "focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 " "dark:text-white dark:hover:text-white dark:hover:border-green-700 " "dark:hover:bg-green-600 dark:focus:ring-green-500 " "dark:focus:text-white" ), } def _button_group_button( href: str, slot: str, color: str = "gray", title: str = "", hx_get: str = "", hx_target: str = "", ) -> Element: """Generate a single button-group button (inner