Coverage for src / lexigram / admin / ui / organisms / simple_pagination.py: 24%
34 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Simple pagination component — no COUNT query required.
3Provides ``SimplePagination`` which renders only Previous/Next navigation
4without a total page count. This is a critical performance optimisation
5for large tables where ``COUNT(*)`` is expensive (FilamentPHP Y, React
6Admin Y, Laravel Nova Y).
8The component uses a ``has_next_page`` boolean (set from the data layer
9by fetching ``per_page + 1`` rows and checking if the extra row exists)
10instead of a total count.
12Usage::
14 rows = await db.fetch(limit=per_page + 1, offset=offset)
15 has_next = len(rows) > per_page
16 rows = rows[:per_page]
18 pager = SimplePagination(
19 page=2,
20 per_page=25,
21 has_next_page=has_next,
22 base_url="/admin/users",
23 )
24"""
26from __future__ import annotations
28from typing import Any
30from lexigram.ui import Component, el
33class SimplePagination(Component):
34 """Prev / Next pagination with no total-count requirement.
36 Renders only a "Previous" button and a "Next" button, enabling
37 fast pagination without an expensive ``COUNT(*)`` query.
39 Args:
40 page: Current (1-based) page number.
41 per_page: Items per page.
42 has_next_page: Whether a next page exists. Set this by fetching
43 ``per_page + 1`` rows and checking if more than ``per_page``
44 were returned.
45 base_url: Base URL for page links.
46 extra_query: Additional query string parameters to append (without
47 leading ``&``).
48 hx_target: HTMX target selector (default ``#main-content``).
49 hx_push_url: Whether HTMX should push the URL (default ``"true"``).
50 """
52 def __init__(
53 self,
54 page: int = 1,
55 per_page: int = 20,
56 has_next_page: bool = False,
57 base_url: str = "",
58 extra_query: str = "",
59 hx_target: str = "#main-content",
60 hx_push_url: str = "true",
61 **props: Any,
62 ) -> None:
63 super().__init__(**props)
64 self.page = page
65 self.per_page = per_page
66 self.has_next_page = has_next_page
67 self.base_url = base_url.rstrip("/")
68 self.extra_query = f"&{extra_query}" if extra_query else ""
69 self.hx_target = hx_target
70 self.hx_push_url = hx_push_url
72 def render(self) -> Any:
73 has_prev = self.page > 1
74 prev_url = self._page_url(self.page - 1)
75 next_url = self._page_url(self.page + 1)
77 prev_btn = self._nav_button(
78 label="← Previous",
79 url=prev_url,
80 enabled=has_prev,
81 )
82 next_btn = self._nav_button(
83 label="Next →",
84 url=next_url,
85 enabled=self.has_next_page,
86 )
87 page_label = el(
88 "span",
89 f"Page {self.page}",
90 class_="text-sm text-muted-foreground px-3",
91 )
93 return el(
94 "nav",
95 prev_btn,
96 page_label,
97 next_btn,
98 class_=(
99 "flex items-center justify-between gap-2 py-3 border-t border-border"
100 ),
101 **{"aria-label": "Pagination"},
102 )
104 # ------------------------------------------------------------------
105 # Private helpers
106 # ------------------------------------------------------------------
108 def _page_url(self, page: int) -> str:
109 base = self.base_url or "."
110 return f"{base}?page={page}&per_page={self.per_page}{self.extra_query}"
112 def _nav_button(self, label: str, url: str, enabled: bool) -> Any:
113 disabled_cls = "opacity-50 cursor-not-allowed pointer-events-none"
114 enabled_cls = "hover:bg-muted dark:hover:bg-muted"
115 base_cls = (
116 "inline-flex items-center px-4 py-2 text-sm font-medium rounded-md "
117 "border border-border "
118 "bg-card "
119 "text-foreground "
120 "transition-colors"
121 )
122 extra_cls = enabled_cls if enabled else disabled_cls
124 attrs: dict[str, Any] = {
125 "class": f"{base_cls} {extra_cls}",
126 }
127 if enabled:
128 attrs.update(
129 {
130 "href": url,
131 "hx-get": url,
132 "hx-target": self.hx_target,
133 "hx-push-url": self.hx_push_url,
134 }
135 )
137 tag = "a" if enabled else "span"
138 return el(tag, label, **attrs)