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

1from __future__ import annotations 

2 

3from typing import TYPE_CHECKING, Any 

4 

5from lexigram.ui import Component, Link, Zones, el 

6 

7if TYPE_CHECKING: 

8 from lexigram.ui.state import TableState 

9 

10 

11class PaginationLinks(Component): 

12 """ 

13 Component for rendering numeric page links and navigation buttons. 

14 Uses Zone-based targeting for consistent HTMX behavior. 

15 """ 

16 

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 

41 

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

51 

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" 

53 

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 ) 

61 

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

67 

68 # Build consistent HTMX attrs using new API 

69 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

70 

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 } 

91 

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) 

113 

114 attrs["onclick"] = "" 

115 return el("span", label, **attrs) 

116 

117 return Link( 

118 label, 

119 url, 

120 variant=("primary" if active else "default"), 

121 size="sm", 

122 **attrs, 

123 ) 

124 

125 def render(self) -> Any: 

126 from lexigram.ui import get_icon 

127 

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 ) 

133 

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 

141 

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 ) 

156 

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 ) 

165 

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 ) 

174 

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 )