Coverage for src / lexigram / admin / ui / molecules / pagination.py: 80%
79 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
1from __future__ import annotations
3from typing import TYPE_CHECKING, Any
5from lexigram.admin.ui.htmx_attrs import HTMXAttrs
6from lexigram.serialization import dumps_str
7from lexigram.ui import Component, Zones, el
9CONTROL_SELECT_CLASSES = (
10 "px-2 py-1.5 h-8 border rounded text-sm bg-card dark:border-border"
11)
13if TYPE_CHECKING:
14 from lexigram.ui.state import TableState
17class Pagination(Component):
18 """Pagination component with HTMX support and baked URL pattern.
20 Supports two modes:
21 1. State-based (recommended): Pass a TableState for baked URLs
22 2. Legacy: Pass page/total/per_page directly
23 """
25 def __init__(
26 self,
27 page: int = 1,
28 total: int = 0,
29 per_page: int = 20,
30 base_url: str = "",
31 state: TableState | None = None,
32 show_size_selector: bool = True,
33 **props,
34 ) -> None:
35 super().__init__()
36 self.state = state
37 # If state provided, extract values from it
38 if state:
39 self.page = state.page
40 self.per_page = state.per_page
41 else:
42 self.page = int(page)
43 self.per_page = int(per_page)
44 self.total = int(total)
45 self.base_url = base_url
46 self.show_size_selector = show_size_selector
48 def render(self) -> Any:
49 if self.total <= self.per_page:
50 return ""
52 start = (self.page - 1) * self.per_page + 1
53 end = min(self.total, self.page * self.per_page)
54 total_pages = (self.total + self.per_page - 1) // self.per_page
56 # Summary
57 summary = el(
58 "div",
59 f"Showing {start} to {end} of {self.total}",
60 class_="text-sm text-muted-foreground",
61 )
63 # Build links
64 links = []
66 # Previous control
67 if self.page > 1:
68 links.append(self._page_link(self.page - 1, "Prev"))
69 else:
70 links.append(
71 el(
72 "span",
73 "Prev",
74 class_="px-2 py-1 text-sm text-muted-foreground cursor-not-allowed",
75 ),
76 )
78 # Page numbers (with ellipsis for large page counts)
79 visible_pages = self._get_visible_pages(self.page, total_pages)
80 for p in visible_pages:
81 if p is None:
82 links.append(
83 el("span", "...", class_="px-2 py-1 text-sm text-muted-foreground"),
84 )
85 elif p == self.page:
86 # Current page: bold text with visible color (not white)
87 links.append(
88 el(
89 "span",
90 str(p),
91 class_="px-2 py-1 text-sm font-bold text-foreground",
92 ),
93 )
94 else:
95 links.append(self._page_link(p, str(p)))
97 # Next control
98 if self.page < total_pages:
99 links.append(self._page_link(self.page + 1, "Next"))
100 else:
101 links.append(
102 el(
103 "span",
104 "Next",
105 class_="px-2 py-1 text-sm text-muted-foreground cursor-not-allowed",
106 ),
107 )
109 # Size selector (optional)
110 size_selector = ""
111 if self.show_size_selector:
112 size_options = [10, 25, 50, 100]
113 size_items = [
114 el(
115 "option",
116 str(size),
117 value=str(size),
118 selected="selected" if size == self.per_page else None,
119 )
120 for size in size_options
121 ]
123 # Build HTMX attrs for size change
124 size_attrs = self._get_size_change_attrs()
126 size_selector = el(
127 "div",
128 el(
129 "label",
130 "Show",
131 class_="mr-2 text-sm text-muted-foreground",
132 ),
133 el(
134 "select",
135 *size_items,
136 name="per_page",
137 class_=CONTROL_SELECT_CLASSES,
138 **size_attrs,
139 ),
140 class_="ml-4 flex items-center",
141 )
143 return el(
144 "div",
145 summary,
146 el("div", *links, class_="mt-2 space-x-1"),
147 size_selector,
148 class_="pagination flex flex-wrap items-center gap-4",
149 )
151 def _page_link(self, page_num: int, label: str) -> Any:
152 """Generate a page link with correct HTMX attributes."""
153 if self.state:
154 updated_state = self.state.with_page(page_num)
155 attrs = HTMXAttrs.for_data_refresh(
156 updated_state,
157 self.base_url,
158 push_url=True,
159 )
160 # Convert hx-* to hx_* for element builder
161 el_attrs = {k.replace("-", "_"): v for k, v in attrs.items()}
162 else:
163 # Legacy mode fallback
164 el_attrs = {
165 "href": f"{self.base_url}?page={page_num}&per_page={self.per_page}",
166 "hx_get": f"{self.base_url}?page={page_num}&per_page={self.per_page}",
167 "hx_target": Zones.DATA.selector,
168 "hx_swap": "outerHTML",
169 "hx_select": Zones.DATA.selector,
170 "hx_push_url": "true",
171 }
173 return el(
174 "a",
175 label,
176 **el_attrs,
177 class_="px-2 py-1 text-sm text-primary-600 hover:underline dark:text-primary-400 cursor-pointer",
178 )
180 def _get_size_change_attrs(self) -> dict[str, str]:
181 """Get HTMX attrs for the size selector."""
182 if self.state:
183 params = self.state.to_query_params()
184 params.pop("per_page", None)
185 params.pop("page", None)
186 params.pop("cursor", None)
187 base_url = self.base_url.rstrip("/") + "/"
188 return {
189 "hx_get": base_url,
190 "hx_target": Zones.DATA.selector,
191 "hx_select": Zones.DATA.selector,
192 "hx_swap": "outerHTML",
193 "hx_trigger": "change",
194 "hx_vals": dumps_str(params),
195 "hx_push_url": "true",
196 }
197 return {
198 "hx_get": self.base_url,
199 "hx_target": Zones.DATA.selector,
200 "hx_swap": "outerHTML",
201 "hx_trigger": "change",
202 }
204 def _get_visible_pages(
205 self,
206 current: int,
207 total: int,
208 window: int = 2,
209 ) -> list[int | None]:
210 """Get list of page numbers to display, with None for ellipsis."""
211 if total <= 7:
212 return list(range(1, total + 1))
214 pages: list[int | None] = []
216 # Always show first page
217 pages.append(1)
219 # Show ellipsis if current is far from start
220 if current > window + 2:
221 pages.append(None)
223 # Show pages around current
224 start = max(2, current - window)
225 end = min(total - 1, current + window)
227 for p in range(start, end + 1):
228 if p not in pages:
229 pages.append(p)
231 # Show ellipsis if current is far from end
232 if current < total - window - 1:
233 pages.append(None)
235 # Always show last page
236 if total not in pages:
237 pages.append(total)
239 return pages