Coverage for src / lexigram / admin / ui / molecules / pagination_links.py: 12%
58 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.ui import Component, Link, Zones, el
7if TYPE_CHECKING:
8 from lexigram.ui.state import TableState
11class PaginationLinks(Component):
12 """
13 Component for rendering numeric page links and navigation buttons.
14 Uses Zone-based targeting for consistent HTMX behavior.
15 """
17 def __init__(
18 self,
19 page: int = 1,
20 total_pages: int = 1,
21 per_page: int = 20,
22 base_url: str = "",
23 extra_query: str = "",
24 hx_target: str | None = None,
25 hx_swap: str = "innerHTML",
26 hx_push_url: str = "true",
27 state: TableState | None = None,
28 **props,
29 ) -> None:
30 super().__init__(**props)
31 self.page = page
32 self.total_pages = total_pages
33 self.per_page = per_page
34 self.base_url = base_url
35 self.extra_query = extra_query
36 # Use Zones.DATA by default for pagination
37 self.hx_target = hx_target or Zones.DATA.selector
38 self.hx_swap = hx_swap
39 self.hx_push_url = hx_push_url
40 self.state = state
42 def page_link(
43 self,
44 p: int,
45 label: str | Any,
46 disabled: bool = False,
47 active: bool = False,
48 extra_cls: str = "",
49 ) -> Any:
50 url = f"{self.base_url}?page={p}&per_page={self.per_page}{self.extra_query}"
52 base_cls = "relative inline-flex items-center justify-center min-w-[40px] h-10 text-sm font-medium transition-colors focus:z-20 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
54 if disabled:
55 return el(
56 "span",
57 label,
58 class_=f"{base_cls} text-foreground dark:text-muted-foreground cursor-not-allowed bg-muted dark:bg-card {extra_cls}",
59 **{"aria-disabled": "true"},
60 )
62 if active:
63 # Active page: bold text with visible color (avoid relying on primary- colors that may not be configured)
64 cls = f"{base_cls} z-10 bg-primary-600 text-white font-bold border border-primary-600 {extra_cls}"
65 else:
66 cls = f"{base_cls} text-foreground bg-card border border-border hover:bg-muted dark:hover:bg-muted {extra_cls}"
68 # Build consistent HTMX attrs using new API
69 from lexigram.admin.ui.htmx_attrs import HTMXAttrs
71 if self.state:
72 # Generate "Baked URL" with all state params
73 p_state = self.state.with_page(p)
74 htmx_attrs = HTMXAttrs.for_data_refresh(
75 state=p_state,
76 resource_prefix=self.base_url.rstrip("/"),
77 push_url=True,
78 )
79 url = htmx_attrs.get("hx-get", url)
80 else:
81 # Fallback for simple usage
82 htmx_attrs = {
83 "hx-get": url,
84 "hx-trigger": "click",
85 "hx-target": self.hx_target,
86 "hx-swap": self.hx_swap,
87 "hx-select": Zones.DATA.selector,
88 "hx-push-url": str(self.hx_push_url).lower(),
89 "hx-params": "none",
90 }
92 # Merge common navigation props
93 attrs: dict[str, Any] = {
94 "class_": cls,
95 "onclick": "return false",
96 "preload": "mouseover",
97 **{k.replace("-", "_"): v for k, v in htmx_attrs.items()},
98 }
99 if active:
100 attrs["aria-current"] = "page"
101 # Remove HTMX from active page - no navigation needed
102 for hx_attr in [
103 "hx_get",
104 "hx_trigger",
105 "hx_target",
106 "hx_swap",
107 "hx_select",
108 "hx_push_url",
109 "hx_params",
110 "preload",
111 ]:
112 attrs.pop(hx_attr, None)
114 attrs["onclick"] = ""
115 return el("span", label, **attrs)
117 return Link(
118 label,
119 url,
120 variant=("primary" if active else "default"),
121 size="sm",
122 **attrs,
123 )
125 def render(self) -> Any:
126 from lexigram.ui import get_icon
128 # Show first, last, current, and 1 page on each side of current
129 show_pages = {1, self.total_pages, self.page, self.page - 1, self.page + 1}
130 sorted_pages = sorted(
131 filter(lambda p: 1 <= p <= self.total_pages, show_pages),
132 )
134 display_pages: list[int | str] = []
135 last_p = 0
136 for p in sorted_pages:
137 if last_p > 0 and p - last_p > 1:
138 display_pages.append("...")
139 display_pages.append(p)
140 last_p = p
142 page_links = []
143 for item in display_pages:
144 if item == "...":
145 page_links.append(
146 el(
147 "span",
148 "...",
149 class_="relative inline-flex items-center justify-center min-w-[40px] h-10 text-sm font-medium text-muted-foreground bg-card border-y border-border",
150 ),
151 )
152 else:
153 page_links.append(
154 self.page_link(item, str(item), active=(item == self.page)), # type: ignore[arg-type]
155 )
157 # Previous button with icon
158 prev_icon = get_icon("chevron-left", size="h-4 w-4")
159 prev_btn = self.page_link(
160 self.page - 1,
161 prev_icon,
162 disabled=(self.page <= 1),
163 extra_cls="rounded-l-lg border-r-0",
164 )
166 # Next button with icon
167 next_icon = get_icon("chevron-right", size="h-4 w-4")
168 next_btn = self.page_link(
169 self.page + 1,
170 next_icon,
171 disabled=(self.page >= self.total_pages),
172 extra_cls="rounded-r-lg border-l-0",
173 )
175 return el(
176 "nav",
177 prev_btn,
178 *page_links,
179 next_btn,
180 class_="isolate inline-flex -space-x-px rounded-lg shadow-sm",
181 **{"aria-label": "Pagination"},
182 )