Coverage for src / lexigram / ui / core / zones.py: 100%

53 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1""" 

2Zone-Based UI Architecture for Lexigram Admin. 

3 

4A Zone is a DOM subtree with: 

5- A unique ID 

6- A defined responsibility 

7- Clear rules about what can swap it 

8- A contract for what it contains 

9 

10This module provides the central registry of all UI zones, replacing 

11the scattered IDs in consts.py with a structured, type-safe system. 

12""" 

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass 

17from enum import Enum 

18from typing import ClassVar 

19 

20 

21class SwapMode(str, Enum): 

22 """Valid HTMX swap modes.""" 

23 

24 OUTER_HTML = "outerHTML" 

25 INNER_HTML = "innerHTML" 

26 BEFORE_END = "beforeend" 

27 AFTER_BEGIN = "afterbegin" 

28 NONE = "none" 

29 

30 

31@dataclass(frozen=True) 

32class Zone: 

33 """ 

34 Definition of a UI zone. 

35 

36 A zone represents a targetable region of the page with specific 

37 semantics for how it can be updated via HTMX. 

38 

39 Attributes: 

40 id: The HTML element ID for this zone 

41 description: Human-readable description of this zone's purpose 

42 swappable: Whether this zone can be targeted by HTMX swaps 

43 swap_mode: The default HTMX swap mode for this zone 

44 preserve_alpine: Whether Alpine.js state should be preserved on swap 

45 oob_only: If True, this zone should only be updated via OOB swaps 

46 """ 

47 

48 id: str 

49 description: str 

50 swappable: bool 

51 swap_mode: SwapMode 

52 preserve_alpine: bool = False 

53 oob_only: bool = False 

54 

55 @property 

56 def selector(self) -> str: 

57 """Return the CSS selector for this zone.""" 

58 return f"#{self.id}" 

59 

60 def __str__(self) -> str: 

61 return self.id 

62 

63 def __repr__(self) -> str: 

64 return f"Zone({self.id!r})" 

65 

66 

67class Zones: 

68 """ 

69 Central registry of all UI zones. 

70 

71 This class provides a single source of truth for all targetable 

72 regions in the admin UI. Components should reference these zones 

73 rather than hardcoding IDs. 

74 

75 Zone Hierarchy: 

76 

77 TABLE (root scope) 

78 ├── TOOLBAR (switchers, header actions) - OOB only 

79 ├── SEARCH (search input) - never swap 

80 ├── FILTERS (filter bar) - OOB only 

81 └── DATA (rows + pagination) - most common target 

82 

83 DASHBOARD (dashboard page) 

84 └── WIDGET_CONTAINER (per-widget lazy-load target) 

85 

86 MODAL (global, outside table) 

87 SLIDE_OVER (global, outside table) 

88 FLASH (toast notifications) 

89 

90 Usage: 

91 from lexigram.ui.core.zones import Zones 

92 

93 attrs = { 

94 "hx-target": Zones.DATA.selector, 

95 "hx-swap": Zones.DATA.swap_mode.value, 

96 } 

97 """ 

98 

99 # === Primary Zones === 

100 

101 TABLE: ClassVar[Zone] = Zone( 

102 id="lexigram-table", 

103 description="Root table scope with Alpine.js state. Target for full refresh only.", 

104 swappable=True, 

105 swap_mode=SwapMode.OUTER_HTML, 

106 preserve_alpine=False, # Full refresh reinitializes Alpine 

107 ) 

108 

109 DATA: ClassVar[Zone] = Zone( 

110 id="table-data", 

111 description="Data content + pagination. Most common target for data updates.", 

112 swappable=True, 

113 swap_mode=SwapMode.INNER_HTML, 

114 preserve_alpine=True, # Parent Alpine scope preserved 

115 ) 

116 

117 # === Secondary Zones (typically OOB) === 

118 

119 TOOLBAR: ClassVar[Zone] = Zone( 

120 id="table-toolbar", 

121 description="Switchers and header actions. Update via OOB only.", 

122 swappable=True, 

123 swap_mode=SwapMode.OUTER_HTML, 

124 oob_only=True, 

125 ) 

126 

127 FILTERS: ClassVar[Zone] = Zone( 

128 id="table-filters", 

129 description="Filter controls. Update via OOB when filter options change.", 

130 swappable=True, 

131 swap_mode=SwapMode.OUTER_HTML, 

132 oob_only=True, 

133 ) 

134 

135 # === Non-Swappable Zones === 

136 

137 SEARCH: ClassVar[Zone] = Zone( 

138 id="table-search", 

139 description="Search input. Never swap to preserve focus and input state.", 

140 swappable=False, 

141 swap_mode=SwapMode.NONE, 

142 ) 

143 

144 # === Global Zones (outside table scope) === 

145 

146 MODAL: ClassVar[Zone] = Zone( 

147 id="modal-container", 

148 description="Modal dialogs. Target for modal actions.", 

149 swappable=True, 

150 swap_mode=SwapMode.INNER_HTML, 

151 ) 

152 

153 SLIDE_OVER: ClassVar[Zone] = Zone( 

154 id="slide-over-container", 

155 description="Side panel forms. Target for edit/create actions.", 

156 swappable=True, 

157 swap_mode=SwapMode.INNER_HTML, 

158 ) 

159 

160 FLASH: ClassVar[Zone] = Zone( 

161 id="flash-container", 

162 description="Toast/flash notifications.", 

163 swappable=True, 

164 swap_mode=SwapMode.INNER_HTML, 

165 ) 

166 

167 # === Dashboard Zones === 

168 

169 DASHBOARD_GRID: ClassVar[Zone] = Zone( 

170 id="dashboard-grid", 

171 description="Main dashboard widget grid container. Swappable for full dashboard refresh.", 

172 swappable=True, 

173 swap_mode=SwapMode.INNER_HTML, 

174 ) 

175 

176 WIDGET_CONTAINER: ClassVar[Zone] = Zone( 

177 id="widget-container", 

178 description="Individual widget card. Used for per-widget HTMX lazy-load and refresh.", 

179 swappable=True, 

180 swap_mode=SwapMode.INNER_HTML, 

181 preserve_alpine=True, 

182 ) 

183 

184 # === Bulk Action Zone === 

185 

186 BULK_BAR: ClassVar[Zone] = Zone( 

187 id="table-bulk-bar", 

188 description="Bulk action buttons shown when items selected. Controlled by Alpine.", 

189 swappable=False, # Visibility controlled by Alpine x-show 

190 swap_mode=SwapMode.NONE, 

191 ) 

192 

193 @classmethod 

194 def all_zones(cls) -> list[Zone]: 

195 """Return all registered zones.""" 

196 return [ 

197 cls.TABLE, 

198 cls.DATA, 

199 cls.TOOLBAR, 

200 cls.FILTERS, 

201 cls.SEARCH, 

202 cls.MODAL, 

203 cls.SLIDE_OVER, 

204 cls.FLASH, 

205 cls.DASHBOARD_GRID, 

206 cls.WIDGET_CONTAINER, 

207 cls.BULK_BAR, 

208 ] 

209 

210 @classmethod 

211 def swappable(cls) -> list[Zone]: 

212 """Return all zones that can be targeted by HTMX swaps.""" 

213 return list(filter(lambda z: z.swappable, cls.all_zones())) 

214 

215 @classmethod 

216 def get_by_id(cls, zone_id: str) -> Zone | None: 

217 """ 

218 Look up a zone by its ID. 

219 

220 Returns None if no zone with that ID exists. 

221 """ 

222 for zone in cls.all_zones(): 

223 if zone.id == zone_id: 

224 return zone 

225 return None 

226 

227 @classmethod 

228 def get_by_selector(cls, selector: str) -> Zone | None: 

229 """ 

230 Look up a zone by its CSS selector. 

231 

232 Handles both "#zone-id" and "zone-id" formats. 

233 """ 

234 # Normalize selector 

235 zone_id = selector[1:] if selector.startswith("#") else selector 

236 return cls.get_by_id(zone_id)