Coverage for src / lexigram / admin / ui / columns / column / rendering.py: 60%
75 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
1"""
2Column rendering methods for HTML generation.
3"""
5from __future__ import annotations
7from typing import TYPE_CHECKING, Any
9from lexigram.admin.ui.htmx_attrs import HTMXAttrs
10from lexigram.ui import Zones, el
12if TYPE_CHECKING:
13 from lexigram.ui.state import TableState
16class ColumnRenderingMixin:
17 """Mixin class containing rendering methods."""
19 # Attributes expected to be provided by Column base class
20 name: str
21 label: str
22 _masker: Any
23 _limit: int | None
24 _alignment: str
25 _visibility_classes: list[str]
26 _wrap: bool
27 _copyable: bool
28 _sortable: bool
30 def is_visible(self, **kwargs: Any) -> bool:
31 """Check if column is visible — implemented by ColumnVisibilityMixin."""
32 return True
34 def get_value(self, record: dict) -> Any:
35 """Extract value from record — implemented by Column."""
37 def format_value(self, value: Any) -> Any:
38 """Format value — implemented by Column."""
39 return value
41 def render(self, value: Any, record: dict) -> Any:
42 """Render cell value — implemented by Column."""
44 def render_cell(
45 self,
46 record: dict,
47 user: Any = None,
48 resource_name: str | None = None,
49 ) -> Any:
50 """
51 Render complete table cell with wrapper.
53 Args:
54 record: The full record dict
55 user: Current user for permission checks
56 resource_name: Current resource name for permission checks
58 Returns:
59 htpy element for <td>
60 """
61 if not self.is_visible(user=user, resource_name=resource_name, record=record):
62 return ""
64 value = self.get_value(record)
65 formatted_value = self.format_value(value)
67 # Apply masking if defined
68 if self._masker and formatted_value is not None:
69 formatted_value = self._masker(formatted_value)
71 # Apply limit if set
72 if (
73 self._limit
74 and isinstance(formatted_value, str)
75 and len(formatted_value) > self._limit
76 ):
77 formatted_value = formatted_value[: self._limit] + "..."
79 # Render the value
80 content = self.render(formatted_value, record)
82 # Build CSS classes
83 classes = [f"text-{self._alignment}", "px-6", "py-4"]
84 classes.extend(self._visibility_classes)
85 if self._wrap:
86 classes.append("whitespace-normal")
87 else:
88 classes.append("whitespace-nowrap")
90 # Add copyable functionality
91 if self._copyable:
92 classes.append("cursor-pointer hover:bg-muted dark:hover:bg-card")
93 escaped_value = str(value).replace("'", "\\'")
94 hx_on_click = f"navigator.clipboard.writeText('{escaped_value}')"
95 return el(
96 "td",
97 content,
98 class_="".join(classes),
99 hx_on_click=hx_on_click,
100 title="Click to copy",
101 aria_label=f"Copy {value}",
102 )
104 return el("td", content, class_="".join(classes), **{"data-label": self.label})
106 def render_header(
107 self,
108 current_sort: str | None = None,
109 sort_order: str = "asc",
110 state: TableState | None = None,
111 resource_prefix: str = "",
112 ) -> Any:
113 """
114 Render table header cell.
116 Args:
117 current_sort: Currently sorted column name
118 sort_order: Current sort order ("asc" or "desc")
119 state: Optional TableState for generating HTMX URLs
120 resource_prefix: Base URL for the resource (e.g., "/admin/users")
122 Returns:
123 htpy element for <th>
124 """
125 from lexigram.ui import get_icon
127 classes = [
128 f"text-{self._alignment}",
129 "px-6",
130 "py-3",
131 "text-xs",
132 "font-semibold",
133 "text-foreground",
134 "dark:text-foreground",
135 "uppercase",
136 "tracking-wider",
137 ]
138 classes.extend(self._visibility_classes)
140 # Base props for the th element
141 th_props: dict[str, Any] = {
142 "scope": "col",
143 }
145 # Build header content
146 is_current = current_sort == self.name
148 if self._sortable:
149 classes.append("cursor-pointer")
150 classes.append("hover:bg-muted")
151 classes.append("dark:hover:bg-muted")
152 classes.append("select-none") # Prevent text selection on click
153 classes.append("transition-colors")
155 # Determine next sort order
156 next_order = "desc" if (is_current and sort_order == "asc") else "asc"
158 # Build HTMX attrs directly on the th element (entire header clickable)
159 if state and resource_prefix:
160 # Use immutable mutation to get new state with sort
161 new_state = state.with_sort(self.name)
162 htmx_attrs = HTMXAttrs.for_data_refresh(
163 new_state,
164 resource_prefix,
165 push_url=True,
166 )
167 # Convert hx-* to hx_* for element builder
168 for k, v in htmx_attrs.items():
169 th_props[k.replace("-", "_")] = v
170 else:
171 # Fallback: manual construction
172 sort_params = f"sort_by={self.name}&sort_order={next_order}"
173 th_props.update(
174 {
175 "hx_get": f"?{sort_params}",
176 "hx_target": Zones.DATA.selector,
177 "hx_swap": Zones.DATA.swap_mode.value,
178 "hx_select": Zones.DATA.selector,
179 "hx_params": "none",
180 "hx_push_url": "true",
181 },
182 )
184 # Build sort icon
185 if is_current:
186 icon_name = "chevron-up" if sort_order == "asc" else "chevron-down"
187 icon_classes = "h-4 w-4 text-primary-600 dark:text-primary-400"
188 else:
189 # Subtle indicator for sortable but not currently sorted
190 icon_name = "chevrons-up-down"
191 icon_classes = "h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
193 sort_icon = get_icon(icon_name, size=icon_classes)
195 # Content with label + icon
196 content = el(
197 "span",
198 self.label,
199 sort_icon,
200 class_="inline-flex items-center gap-1.5",
201 )
202 else:
203 # Non-sortable column - just the label
204 content = el("span", self.label)
206 resize_handle = el(
207 "div",
208 role="separator",
209 tabindex="0",
210 aria_orientation="vertical",
211 aria_label=f"Resize {self.label} column",
212 class_="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary-300 dark:hover:bg-primary-700 transition-colors opacity-0 hover:opacity-100 group-hover:opacity-100",
213 **{
214 "@mousedown.stop.prevent": "startResize",
215 "@keydown.left.prevent": "if ($el.parentElement) { let th = $el.parentElement; let w = Math.max(50, th.offsetWidth - 10); th.style.width = w + 'px'; th.style.minWidth = w + 'px' }",
216 "@keydown.right.prevent": "if ($el.parentElement) { let th = $el.parentElement; let w = th.offsetWidth + 10; th.style.width = w + 'px'; th.style.minWidth = w + 'px' }",
217 },
218 )
220 # Content wrapper
221 wrapper = el(
222 "div",
223 content,
224 resize_handle,
225 class_="flex items-center justify-between w-full h-full",
226 )
228 # Add relative for resize handle positioning, group for hover states
229 # Sticky positioning for fixed headers
230 classes.append("relative group sticky top-0 z-20 bg-muted dark:bg-background")
231 th_props["class_"] = " ".join(classes)
232 th_props["x-data"] = "resizableColumn"
234 return el("th", wrapper, **th_props)