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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""
2Zone-Based UI Architecture for Lexigram Admin.
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
10This module provides the central registry of all UI zones, replacing
11the scattered IDs in consts.py with a structured, type-safe system.
12"""
14from __future__ import annotations
16from dataclasses import dataclass
17from enum import Enum
18from typing import ClassVar
21class SwapMode(str, Enum):
22 """Valid HTMX swap modes."""
24 OUTER_HTML = "outerHTML"
25 INNER_HTML = "innerHTML"
26 BEFORE_END = "beforeend"
27 AFTER_BEGIN = "afterbegin"
28 NONE = "none"
31@dataclass(frozen=True)
32class Zone:
33 """
34 Definition of a UI zone.
36 A zone represents a targetable region of the page with specific
37 semantics for how it can be updated via HTMX.
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 """
48 id: str
49 description: str
50 swappable: bool
51 swap_mode: SwapMode
52 preserve_alpine: bool = False
53 oob_only: bool = False
55 @property
56 def selector(self) -> str:
57 """Return the CSS selector for this zone."""
58 return f"#{self.id}"
60 def __str__(self) -> str:
61 return self.id
63 def __repr__(self) -> str:
64 return f"Zone({self.id!r})"
67class Zones:
68 """
69 Central registry of all UI zones.
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.
75 Zone Hierarchy:
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
83 DASHBOARD (dashboard page)
84 └── WIDGET_CONTAINER (per-widget lazy-load target)
86 MODAL (global, outside table)
87 SLIDE_OVER (global, outside table)
88 FLASH (toast notifications)
90 Usage:
91 from lexigram.ui.core.zones import Zones
93 attrs = {
94 "hx-target": Zones.DATA.selector,
95 "hx-swap": Zones.DATA.swap_mode.value,
96 }
97 """
99 # === Primary Zones ===
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 )
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 )
117 # === Secondary Zones (typically OOB) ===
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 )
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 )
135 # === Non-Swappable Zones ===
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 )
144 # === Global Zones (outside table scope) ===
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 )
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 )
160 FLASH: ClassVar[Zone] = Zone(
161 id="flash-container",
162 description="Toast/flash notifications.",
163 swappable=True,
164 swap_mode=SwapMode.INNER_HTML,
165 )
167 # === Dashboard Zones ===
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 )
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 )
184 # === Bulk Action Zone ===
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 )
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 ]
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()))
215 @classmethod
216 def get_by_id(cls, zone_id: str) -> Zone | None:
217 """
218 Look up a zone by its ID.
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
227 @classmethod
228 def get_by_selector(cls, selector: str) -> Zone | None:
229 """
230 Look up a zone by its CSS selector.
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)