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

1from __future__ import annotations 

2 

3from typing import TYPE_CHECKING, Any 

4 

5from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

6from lexigram.serialization import dumps_str 

7from lexigram.ui import Component, Zones, el 

8 

9CONTROL_SELECT_CLASSES = ( 

10 "px-2 py-1.5 h-8 border rounded text-sm bg-card dark:border-border" 

11) 

12 

13if TYPE_CHECKING: 

14 from lexigram.ui.state import TableState 

15 

16 

17class Pagination(Component): 

18 """Pagination component with HTMX support and baked URL pattern. 

19 

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 """ 

24 

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 

47 

48 def render(self) -> Any: 

49 if self.total <= self.per_page: 

50 return "" 

51 

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 

55 

56 # Summary 

57 summary = el( 

58 "div", 

59 f"Showing {start} to {end} of {self.total}", 

60 class_="text-sm text-muted-foreground", 

61 ) 

62 

63 # Build links 

64 links = [] 

65 

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 ) 

77 

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))) 

96 

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 ) 

108 

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 ] 

122 

123 # Build HTMX attrs for size change 

124 size_attrs = self._get_size_change_attrs() 

125 

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 ) 

142 

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 ) 

150 

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 } 

172 

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 ) 

179 

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 } 

203 

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)) 

213 

214 pages: list[int | None] = [] 

215 

216 # Always show first page 

217 pages.append(1) 

218 

219 # Show ellipsis if current is far from start 

220 if current > window + 2: 

221 pages.append(None) 

222 

223 # Show pages around current 

224 start = max(2, current - window) 

225 end = min(total - 1, current + window) 

226 

227 for p in range(start, end + 1): 

228 if p not in pages: 

229 pages.append(p) 

230 

231 # Show ellipsis if current is far from end 

232 if current < total - window - 1: 

233 pages.append(None) 

234 

235 # Always show last page 

236 if total not in pages: 

237 pages.append(total) 

238 

239 return pages