"""Generic HTML primitives (no domain knowledge).""" 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.icons import get_icon from common.utils import truncate from common.components.core import Component, HTMLAttribute, HTMLTag, randomid _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", } def _popover_html( id: str, popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", slot: str = "", ) -> SafeText: """Generate popover HTML using Component(tag_name=...). Single source of truth for popover HTML structure. Used by Popover() and the python_popover template tag bridge. """ 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 = Component( tag_name="div", attributes=[ ("data-popover", ""), ("id", id), ("role", "tooltip"), ("class", popover_tooltip_class), ], children=[ Component( tag_name="div", attributes=[("class", "px-3 py-2")], children=[popover_content], ), Component(tag_name="div", attributes=[("data-popper-arrow", "")]), mark_safe( # nosec — intentional HTML comment for Tailwind JIT "" ), Span( attributes=[("class", "hidden decoration-dotted")], ), ], ) return mark_safe(span + "\n" + div) def Popover( popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", children: list[HTMLTag] | None = None, attributes: list[HTMLAttribute] | None = None, id: str = "", ) -> str: children = children or [] 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 = mark_safe("\n".join(children)) 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: str = "", popover_if_not_truncated: bool = False, length: int = 30, ellipsis: str = "…", endpart: str = "", ) -> 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: list[HTMLAttribute] | None = None, children: list[HTMLTag] | HTMLTag | None = None, url_name: str | None = None, href: str | None = None, ) -> SafeText: """ 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 = attributes or [] 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 Component( tag_name="a", attributes=attributes + additional_attributes, children=children ) def Button( attributes: list[HTMLAttribute] | None = None, children: list[HTMLTag] | HTMLTag | None = 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 = "", ) -> SafeText: attributes = attributes or [] 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 Component( tag_name="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 = "", ) -> SafeText: """Generate a single button-group button (inner """) def AddForm( form, *, request, fields: SafeText | str | None = None, additional_row: SafeText | str = "", submit_class: str = "mt-3", ) -> SafeText: """Page body for the generic add/edit form (Python equivalent of add.html). `fields` overrides the default ``form.as_div()`` field markup (used by the session form, which lays out its fields manually). `additional_row` holds extra submit buttons rendered below the main Submit button. `submit_class` is applied to the main Submit button (the session form passes "" to match its original markup). """ field_markup = fields if fields is not None else mark_safe(form.as_div()) submit_attrs = [("class", submit_class)] if submit_class else [] inner_form = Component( tag_name="form", attributes=[("method", "post"), ("enctype", "multipart/form-data")], children=[ CsrfInput(request), field_markup, Div(children=[Button(submit_attrs, "Submit", type="submit")]), Div( [("class", "submit-button-container")], [additional_row] if additional_row else [], ), ], ) return Div( [("id", "add-form"), ("class", "max-width-container")], [ Div( [("id", "add-form"), ("class", "form-container max-w-xl mx-auto")], [inner_form], ) ], ) def SearchField( search_string: str = "", id: str = "search_string", placeholder: str = "Search", ) -> SafeText: """Generate a search form with icon, input field, and submit button.""" return Component( tag_name="form", attributes=[("class", "max-w-md")], children=[ Label( attributes=[ ("for", "search"), ("class", "block mb-2.5 text-sm font-medium text-heading sr-only"), ], children=["Search"], ), Component( tag_name="div", attributes=[("class", "relative")], children=[ mark_safe( '
' '
" ), Component( tag_name="input", attributes=[ ("type", "search"), ("id", id), ("name", id), ("value", search_string), ( "class", "block w-full p-3 ps-9 bg-neutral-secondary-medium " "border border-default-medium text-heading text-sm " "rounded-base focus:ring-brand focus:border-brand " "shadow-xs placeholder:text-body", ), ("placeholder", placeholder), ("required", ""), ], ), Component( tag_name="button", attributes=[ ("type", "submit"), ( "class", "absolute end-1.5 bottom-1.5 text-white bg-brand " "hover:bg-brand-strong box-border border border-transparent " "focus:ring-4 focus:ring-brand-medium shadow-xs font-medium " "leading-5 rounded text-xs px-3 py-1.5 focus:outline-none " "cursor-pointer", ), ], children=["Search"], ), ], ), ], ) def H1( children: list[HTMLTag] | HTMLTag | None = None, badge: str = "", ) -> SafeText: """Heading with optional badge count.""" children = children or [] heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white" badge_html = "" if badge: heading_class = "flex items-center " + heading_class badge_html = Span( attributes=[ ( "class", "bg-blue-100 text-blue-800 text-2xl font-semibold me-2 " "px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2", ), ], children=[badge], ) return Component( tag_name="h1", attributes=[("class", heading_class)], children=(children if isinstance(children, list) else [children]) + ([badge_html] if badge_html else []), ) def Modal( modal_id: str, children: list[HTMLTag] | HTMLTag | None = None, ) -> SafeText: """Modal overlay with container. Content (form, buttons) goes in children.""" children = children or [] outer = Component( tag_name="div", attributes=[ ("id", modal_id), ( "class", "fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto " "h-full w-full flex items-center justify-center", ), ], children=[ Component( tag_name="div", attributes=[ ( "class", "relative mx-auto p-5 border-accent border w-full max-w-md " "shadow-lg/50 rounded-md bg-white dark:bg-gray-900", ), ], children=(children if isinstance(children, list) else [children]), ), ], ) return mark_safe(str(outer)) def TableTd( children: list[HTMLTag] | HTMLTag | None = None, ) -> SafeText: """Styled table cell.""" children = children or [] return Component( tag_name="td", attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")], children=children if isinstance(children, list) else [children], ) def TableRow(data: dict | list | None = None) -> SafeText: """Generate a from a row data dict or list. Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...} - first cell is , rest . List form: [...] — all cells are . """ if data is None: data = {} if isinstance(data, dict): row_id = data.get("row_id", "") cells = data.get("cell_data", []) else: row_id = "" cells = data tr_class = ( "odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 " "dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 " "dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 " "[&_a]:decoration-2 [&_td:last-child]:text-right" ) tr_attrs: list[HTMLAttribute] = [("class", tr_class)] if row_id: tr_attrs.append(("id", row_id)) if isinstance(data, dict): if data.get("hx_trigger"): tr_attrs.append(("hx-trigger", data["hx_trigger"])) if data.get("hx_get"): tr_attrs.append(("hx-get", data["hx_get"])) if data.get("hx_select"): tr_attrs.append(("hx-select", data["hx_select"])) if data.get("hx_swap"): tr_attrs.append(("hx-swap", data["hx_swap"])) cell_elements: list[SafeText] = [] for i, cell in enumerate(cells): if i == 0: cell_elements.append( Component( tag_name="th", attributes=[ ("scope", "row"), ( "class", "px-6 py-4 font-medium text-gray-900 " "whitespace-nowrap dark:text-white", ), ], children=[cell], ) ) else: cell_elements.append(TableTd(children=[cell])) return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements) def Icon( name: str, attributes: list[HTMLAttribute] | None = None, ) -> SafeText: return mark_safe(get_icon(name)) def TableHeader( children: list[HTMLTag] | HTMLTag | None = None, ) -> SafeText: """Table caption.""" children = children or [] return Component( tag_name="caption", attributes=[ ( "class", "p-2 text-lg font-semibold rtl:text-left text-right " "text-gray-900 bg-white dark:text-white dark:bg-gray-900", ), ], children=children if isinstance(children, list) else [children], ) def _page_url(request, page) -> str: """Current querystring with `page` replaced (mirrors {% param_replace %}).""" if request is None: return f"?page={page}" params = request.GET.copy() params["page"] = page return "?" + params.urlencode() def _pagination_nav(page_obj, elided_page_range, request) -> str: pages_html = "" for page in elided_page_range: if page != page_obj.number: pages_html += ( f'
  • {conditional_escape(page)}
  • ' ) else: pages_html += ( '
  • {conditional_escape(page)}
  • ' ) if page_obj.has_previous(): prev_html = ( f'Previous' ) else: prev_html = ( 'Previous' ) if page_obj.has_next(): next_html = ( f'Next' ) else: next_html = ( 'Next' ) return ( '" ) def SimpleTable( columns: list[str] | None = None, rows: list | None = None, header_action: SafeText | str | None = None, page_obj=None, elided_page_range=None, request=None, ) -> SafeText: """Paginated table. Python equivalent of the old simple_table.html.""" columns = columns or [] rows = rows or [] header_html = "" if header_action: header_html = str(TableHeader(children=[header_action])) columns_html = "".join( f'{conditional_escape(col)}' for col in columns ) rows_html = "".join(str(TableRow(data=row)) for row in rows) pagination_html = "" if page_obj and elided_page_range: pagination_html = _pagination_nav(page_obj, elided_page_range, request) return mark_safe( '
    ' '
    ' '' f"{header_html}" '' f"{columns_html}" '' f"{rows_html}
    " f"{pagination_html}
    " ) def paginated_table_content( data: dict, *, page_obj=None, elided_page_range=None, request=None, ) -> SafeText: """Standard list-page body: a max-width Div wrapping a SimpleTable. `data` is the table dict with keys ``columns``, ``rows`` and ``header_action`` (the same shape every list view already builds). """ return Div( [ ( "class", "2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) " "md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center", ) ], [ SimpleTable( columns=data["columns"], rows=data["rows"], header_action=data["header_action"], page_obj=page_obj, elided_page_range=elided_page_range, request=request, ) ], )