pydzn 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydzn/components/__init__.py +3 -1
- pydzn/components/button/component.py +74 -14
- pydzn/components/card/component.py +83 -5
- pydzn/components/hamburger_menu/component.py +210 -0
- pydzn/components/hamburger_menu/template.html +3 -0
- pydzn/components/nav_item/component.py +198 -34
- pydzn/components/sidebar/component.py +61 -6
- pydzn/dzn.py +96 -0
- pydzn/grid_builder.py +77 -18
- pydzn/responsive.py +24 -0
- pydzn/variants.py +215 -0
- pydzn-0.1.4.dist-info/METADATA +168 -0
- pydzn-0.1.4.dist-info/RECORD +27 -0
- pydzn-0.1.3.dist-info/METADATA +0 -38
- pydzn-0.1.3.dist-info/RECORD +0 -23
- {pydzn-0.1.3.dist-info → pydzn-0.1.4.dist-info}/WHEEL +0 -0
- {pydzn-0.1.3.dist-info → pydzn-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {pydzn-0.1.3.dist-info → pydzn-0.1.4.dist-info}/top_level.txt +0 -0
@@ -1,15 +1,70 @@
|
|
1
1
|
from pydzn.base_component import BaseComponent
|
2
|
+
from pydzn.variants import VariantSupport
|
2
3
|
|
3
4
|
|
4
|
-
class Sidebar(BaseComponent):
|
5
|
+
class Sidebar(VariantSupport, BaseComponent):
|
5
6
|
"""
|
6
|
-
|
7
|
-
|
8
|
-
The layout decides which side shows the divider.
|
7
|
+
Sidebar container with pluggable variants.
|
8
|
+
NOTE: No side-specific borders/shadows here — the layout decides the divider/shadow.
|
9
9
|
"""
|
10
10
|
|
11
|
-
|
12
|
-
|
11
|
+
# Visual “shells” (theme-aware names play nice if your theme defines bg-elevated/bg-surface/etc.)
|
12
|
+
VARIANTS = {
|
13
|
+
# transparent shells
|
14
|
+
"bare": "bg-[transparent] shadow-none border-0",
|
15
|
+
"ghost": "bg-[transparent] shadow-none",
|
16
|
+
|
17
|
+
# neutral panels
|
18
|
+
"panel": "bg-elevated shadow-none",
|
19
|
+
"panel-soft": "bg-[rgba(15,23,42,.03)] shadow-none",
|
20
|
+
"panel-elevated": "bg-elevated shadow-sm",
|
21
|
+
"panel-elevated-lg":"bg-elevated shadow-md",
|
22
|
+
"muted": "bg-surface shadow-none",
|
23
|
+
|
24
|
+
# fun
|
25
|
+
"glass": "bg-[rgba(255,255,255,.6)] shadow-sm",
|
26
|
+
}
|
27
|
+
|
28
|
+
# Inner padding “sizes”
|
29
|
+
SIZES = {
|
30
|
+
"xs": "p-2",
|
31
|
+
"sm": "p-3",
|
32
|
+
"md": "p-4",
|
33
|
+
"lg": "p-6",
|
34
|
+
}
|
35
|
+
|
36
|
+
# Tones optional (left empty since variants already choose a look)
|
37
|
+
TONES = {}
|
38
|
+
|
39
|
+
# Project-wide defaults (overridable via VariantSupport.set_default_choices)
|
40
|
+
DEFAULTS = {
|
41
|
+
"variant": "panel",
|
42
|
+
"size": "md",
|
43
|
+
"tone": "",
|
44
|
+
}
|
45
|
+
|
46
|
+
def __init__(
|
47
|
+
self,
|
48
|
+
*,
|
49
|
+
children: str | None = None,
|
50
|
+
tag: str = "aside",
|
51
|
+
variant: str | None = None,
|
52
|
+
size: str | None = None,
|
53
|
+
tone: str | None = None,
|
54
|
+
dzn: str | None = None, # extra raw utilities merged last
|
55
|
+
**attrs,
|
56
|
+
):
|
57
|
+
# allow stray attrs["dzn"]
|
58
|
+
extra_dzn = dzn or attrs.pop("dzn", None)
|
59
|
+
|
60
|
+
effective_dzn = self._resolve_variant_dzn(
|
61
|
+
variant=variant,
|
62
|
+
size=size,
|
63
|
+
tone=tone,
|
64
|
+
extra_dzn=extra_dzn,
|
65
|
+
)
|
66
|
+
|
67
|
+
super().__init__(children=children or "", tag=tag, dzn=effective_dzn, **attrs)
|
13
68
|
|
14
69
|
def context(self) -> dict:
|
15
70
|
return {}
|
pydzn/dzn.py
CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
2
2
|
import re
|
3
3
|
from typing import Iterable
|
4
4
|
|
5
|
+
|
5
6
|
# --- tokens (extend as needed) ---
|
6
7
|
TOKENS = {
|
7
8
|
"space": {
|
@@ -74,6 +75,10 @@ def emit_base(name: str) -> str | None:
|
|
74
75
|
case "justify-center": return rule(name, "justify-content:center")
|
75
76
|
case "text-center": return rule(name, "text-align:center")
|
76
77
|
|
78
|
+
case "self-center": return rule(name, "align-self:center")
|
79
|
+
case "self-start": return rule(name, "align-self:flex-start")
|
80
|
+
case "self-end": return rule(name, "align-self:flex-end")
|
81
|
+
|
77
82
|
# border (longhand for predictable overrides)
|
78
83
|
case "border":
|
79
84
|
return rule(name,
|
@@ -132,6 +137,40 @@ def emit_base(name: str) -> str | None:
|
|
132
137
|
|
133
138
|
case "aspect-square": return rule(name, "aspect-ratio:1/1")
|
134
139
|
|
140
|
+
# auto margins (centering helpers)
|
141
|
+
case "mx-auto": return rule(name, "margin-left:auto;margin-right:auto")
|
142
|
+
case "ml-auto": return rule(name, "margin-left:auto")
|
143
|
+
case "mr-auto": return rule(name, "margin-right:auto")
|
144
|
+
case "ms-auto": return rule(name, "margin-inline-start:auto") # logical
|
145
|
+
case "me-auto": return rule(name, "margin-inline-end:auto") # logical
|
146
|
+
|
147
|
+
# positioning
|
148
|
+
case "sticky": return rule(name, "position:sticky")
|
149
|
+
case "top-0": return rule(name, "top:0")
|
150
|
+
|
151
|
+
# overflow helpers
|
152
|
+
case "overflow-hidden": return rule(name, "overflow:hidden")
|
153
|
+
case "overflow-auto": return rule(name, "overflow:auto")
|
154
|
+
case "overflow-y-auto": return rule(name, "overflow-y:auto")
|
155
|
+
case "overflow-x-hidden": return rule(name, "overflow-x:hidden")
|
156
|
+
case "overflow-y-hidden": return rule(name, "overflow-y:hidden")
|
157
|
+
|
158
|
+
# overscroll-behavior
|
159
|
+
case "overscroll-auto": return rule(name, "overscroll-behavior:auto")
|
160
|
+
case "overscroll-contain": return rule(name, "overscroll-behavior:contain")
|
161
|
+
case "overscroll-none": return rule(name, "overscroll-behavior:none")
|
162
|
+
|
163
|
+
# optional: keep layout from shifting when scrollbar appears
|
164
|
+
case "scrollbar-stable": return rule(name, "scrollbar-gutter:stable")
|
165
|
+
|
166
|
+
# text decoration (links etc.)
|
167
|
+
case "no-underline": return rule(name, "text-decoration:none")
|
168
|
+
case "underline": return rule(name, "text-decoration:underline")
|
169
|
+
case "line-through": return rule(name, "text-decoration:line-through")
|
170
|
+
case "decoration-solid": return rule(name, "text-decoration-style:solid")
|
171
|
+
case "decoration-dashed": return rule(name, "text-decoration-style:dashed")
|
172
|
+
case "decoration-dotted": return rule(name, "text-decoration-style:dotted")
|
173
|
+
|
135
174
|
return None
|
136
175
|
|
137
176
|
def emit_scale(name: str) -> str | None:
|
@@ -199,6 +238,36 @@ def emit_scale(name: str) -> str | None:
|
|
199
238
|
prop = {"tl":"top-left","tr":"top-right","br":"bottom-right","bl":"bottom-left"}[corner]
|
200
239
|
return rule(name, f"border-{prop}-radius:{val}")
|
201
240
|
|
241
|
+
# --- margin scale ---
|
242
|
+
if m := re.fullmatch(r"m-(\d+)", name):
|
243
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
244
|
+
return rule(name, f"margin:{val}")
|
245
|
+
if m := re.fullmatch(r"mx-(\d+)", name):
|
246
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
247
|
+
return rule(name, f"margin-left:{val};margin-right:{val}")
|
248
|
+
if m := re.fullmatch(r"my-(\d+)", name):
|
249
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
250
|
+
return rule(name, f"margin-top:{val};margin-bottom:{val}")
|
251
|
+
if m := re.fullmatch(r"mt-(\d+)", name):
|
252
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
253
|
+
return rule(name, f"margin-top:{val}")
|
254
|
+
if m := re.fullmatch(r"mr-(\d+)", name):
|
255
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
256
|
+
return rule(name, f"margin-right:{val}")
|
257
|
+
if m := re.fullmatch(r"mb-(\d+)", name):
|
258
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
259
|
+
return rule(name, f"margin-bottom:{val}")
|
260
|
+
if m := re.fullmatch(r"ml-(\d+)", name):
|
261
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
262
|
+
return rule(name, f"margin-left:{val}")
|
263
|
+
# logical (RTL-aware)
|
264
|
+
if m := re.fullmatch(r"ms-(\d+)", name):
|
265
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
266
|
+
return rule(name, f"margin-inline-start:{val}")
|
267
|
+
if m := re.fullmatch(r"me-(\d+)", name):
|
268
|
+
if (val := TOKENS["space"].get(m.group(1))) is not None:
|
269
|
+
return rule(name, f"margin-inline-end:{val}")
|
270
|
+
|
202
271
|
return None
|
203
272
|
|
204
273
|
def emit_arbitrary(name: str) -> str | None:
|
@@ -252,6 +321,33 @@ def emit_arbitrary(name: str) -> str | None:
|
|
252
321
|
v = m.group(1).replace("_", " ")
|
253
322
|
return rule(css_escape_class(name), f"grid-template-rows:{v}")
|
254
323
|
|
324
|
+
# z-index arbitrary
|
325
|
+
if m := re.fullmatch(r"z-\[(.+?)\]", name):
|
326
|
+
return rule(esel, f"z-index:{m.group(1)}")
|
327
|
+
|
328
|
+
if m := re.fullmatch(r"top-\[(.+?)\]", name):
|
329
|
+
return rule(esel, f"top:{m.group(1)}")
|
330
|
+
|
331
|
+
# margin arbitrary (supports logical too)
|
332
|
+
if m := re.fullmatch(r"m-\[(.+?)\]", name):
|
333
|
+
return rule(esel, f"margin:{m.group(1)}")
|
334
|
+
if m := re.fullmatch(r"mx-\[(.+?)\]", name):
|
335
|
+
v = m.group(1); return rule(esel, f"margin-left:{v};margin-right:{v}")
|
336
|
+
if m := re.fullmatch(r"my-\[(.+?)\]", name):
|
337
|
+
v = m.group(1); return rule(esel, f"margin-top:{v};margin-bottom:{v}")
|
338
|
+
if m := re.fullmatch(r"mt-\[(.+?)\]", name):
|
339
|
+
return rule(esel, f"margin-top:{m.group(1)}")
|
340
|
+
if m := re.fullmatch(r"mr-\[(.+?)\]", name):
|
341
|
+
return rule(esel, f"margin-right:{m.group(1)}")
|
342
|
+
if m := re.fullmatch(r"mb-\[(.+?)\]", name):
|
343
|
+
return rule(esel, f"margin-bottom:{m.group(1)}")
|
344
|
+
if m := re.fullmatch(r"ml-\[(.+?)\]", name):
|
345
|
+
return rule(esel, f"margin-left:{m.group(1)}")
|
346
|
+
if m := re.fullmatch(r"ms-\[(.+?)\]", name):
|
347
|
+
return rule(esel, f"margin-inline-start:{m.group(1)}")
|
348
|
+
if m := re.fullmatch(r"me-\[(.+?)\]", name):
|
349
|
+
return rule(esel, f"margin-inline-end:{m.group(1)}")
|
350
|
+
|
255
351
|
return None
|
256
352
|
|
257
353
|
# --- variants ---
|
pydzn/grid_builder.py
CHANGED
@@ -28,7 +28,7 @@ class _Region:
|
|
28
28
|
name: str
|
29
29
|
col_name: str
|
30
30
|
row_name: Optional[str] # if None, will resolve to first row
|
31
|
-
col_span: int = 1
|
31
|
+
col_span: Union[int, str] = 1 # NEW: allow "all" -> 1 / -1
|
32
32
|
row_span: Optional[int] = 1 # if None => span all rows
|
33
33
|
dzn: str = ""
|
34
34
|
|
@@ -38,7 +38,11 @@ class _Region:
|
|
38
38
|
_row_span_resolved: int = 1
|
39
39
|
|
40
40
|
def placement_style(self) -> str:
|
41
|
-
|
41
|
+
# If col_span is "all", always span first-to-last column; ignore col_name
|
42
|
+
if isinstance(self.col_span, str) and self.col_span.lower() == "all":
|
43
|
+
gc = "1 / -1"
|
44
|
+
else:
|
45
|
+
gc = f"{self._col_index} / span {self.col_span}"
|
42
46
|
gr = f"{self._row_index} / span {self._row_span_resolved}"
|
43
47
|
return f"grid-column:{gc};grid-row:{gr};"
|
44
48
|
|
@@ -72,7 +76,10 @@ class GridLayoutBuilder:
|
|
72
76
|
self._named_rows: List[Tuple[str, str]] = [("content", "1fr")]
|
73
77
|
self._regions: List[_Region] = []
|
74
78
|
self._outer_dzn: str = "" # keep empty by default (no forced classes)
|
75
|
-
self._height_css: str = "100vh" # default grid height
|
79
|
+
self._height_css: str = "100vh" # default grid height value
|
80
|
+
# NEW: which CSS property to use for height control ("min-height" | "height" | "both")
|
81
|
+
self._height_property: str = "min-height"
|
82
|
+
self._height_apply_to: str = "container" # 'outer' | 'container' | 'both'
|
76
83
|
|
77
84
|
# ---------------- tracks (named & ordered) ----------------
|
78
85
|
def columns(self, **spec: CSSVal) -> "GridLayoutBuilder":
|
@@ -103,8 +110,19 @@ class GridLayoutBuilder:
|
|
103
110
|
self._named_rows.append((name, _css_px(height)))
|
104
111
|
return self
|
105
112
|
|
106
|
-
def fill_height(self, css_value: str = "100vh"
|
113
|
+
def fill_height(self, css_value: str = "100vh", *, property: str = "min-height", apply_to: str = "container"):
|
114
|
+
"""
|
115
|
+
Control vertical sizing.
|
116
|
+
- property: "min-height" | "height" | "both"
|
117
|
+
- apply_to: "container" (inner grid), "outer" (outer wrapper), or "both"
|
118
|
+
"""
|
119
|
+
if property not in ("min-height", "height", "both"):
|
120
|
+
raise ValueError("fill_height(property=...) must be 'min-height', 'height', or 'both'")
|
121
|
+
if apply_to not in ("container", "outer", "both"):
|
122
|
+
raise ValueError("fill_height(apply_to=...) must be 'container', 'outer', or 'both'")
|
107
123
|
self._height_css = css_value
|
124
|
+
self._height_property = property
|
125
|
+
self._height_apply_to = apply_to
|
108
126
|
return self
|
109
127
|
|
110
128
|
# ---------------- convenience shapes ----------------
|
@@ -127,15 +145,19 @@ class GridLayoutBuilder:
|
|
127
145
|
*,
|
128
146
|
col: str,
|
129
147
|
row: Optional[str],
|
130
|
-
col_span: int = 1,
|
131
|
-
row_span: Optional[int] = 1,
|
148
|
+
col_span: Union[int, str] = 1, # NEW: accept "all"
|
149
|
+
row_span: Optional[int] = 1, # None = span all rows
|
132
150
|
dzn: str = "",
|
133
151
|
) -> "GridLayoutBuilder":
|
134
152
|
"""
|
135
153
|
Add a region by track names. Example:
|
136
154
|
.region("sidebar", col="sidebar", row=None, row_span=None) # full height
|
137
|
-
.region("hero", col="main",
|
138
|
-
.region("content", col="main",
|
155
|
+
.region("hero", col="main", row="hero")
|
156
|
+
.region("content", col="main", row="content")
|
157
|
+
|
158
|
+
Special:
|
159
|
+
- col_span="all" → emits grid-column: 1 / -1 (spans every defined column).
|
160
|
+
In this mode, the provided `col` is ignored.
|
139
161
|
"""
|
140
162
|
self._regions.append(
|
141
163
|
_Region(
|
@@ -166,9 +188,11 @@ class GridLayoutBuilder:
|
|
166
188
|
resolved: Dict[str, _Region] = {}
|
167
189
|
order: List[str] = []
|
168
190
|
for r in self._regions:
|
169
|
-
if
|
170
|
-
|
171
|
-
|
191
|
+
# Only compute a column index if we are NOT spanning all columns
|
192
|
+
if not (isinstance(r.col_span, str) and r.col_span.lower() == "all"):
|
193
|
+
if r.col_name not in col_index:
|
194
|
+
raise ValueError(f"Unknown column '{r.col_name}' for region '{r.name}'")
|
195
|
+
r._col_index = col_index[r.col_name]
|
172
196
|
|
173
197
|
if r.row_name is None:
|
174
198
|
r._row_index = 1
|
@@ -183,6 +207,8 @@ class GridLayoutBuilder:
|
|
183
207
|
order.append(r.name)
|
184
208
|
|
185
209
|
height_css = self._height_css
|
210
|
+
height_prop = self._height_property
|
211
|
+
height_apply_to = self._height_apply_to
|
186
212
|
|
187
213
|
class _Layout:
|
188
214
|
__slots__ = ("_grid_cols", "_grid_rows", "_regions", "_order",
|
@@ -211,13 +237,33 @@ class GridLayoutBuilder:
|
|
211
237
|
def render(self, **slots: str) -> str:
|
212
238
|
# outer wrapper (no template)
|
213
239
|
outer_attr = f' class="{html.escape(self._outer_dzn)}"' if self._outer_dzn else ""
|
214
|
-
# ensure grid without relying on dzn
|
215
|
-
grid_style = f"display:grid;min-height:{html.escape(height_css)};"
|
216
240
|
grid_class = f"{self._grid_cols} {self._grid_rows}".strip()
|
217
241
|
|
242
|
+
# Build styles conditionally based on fill_height settings
|
243
|
+
outer_style_parts: List[str] = []
|
244
|
+
# FIX: add semicolon after display:grid;
|
245
|
+
grid_style_parts: List[str] = ["display:grid;"]
|
246
|
+
|
247
|
+
def _apply_height(parts: List[str]):
|
248
|
+
if height_prop == "both":
|
249
|
+
parts.append(f"height:{html.escape(height_css)};")
|
250
|
+
parts.append(f"min-height:{html.escape(height_css)};")
|
251
|
+
elif height_prop == "height":
|
252
|
+
parts.append(f"height:{html.escape(height_css)};")
|
253
|
+
else:
|
254
|
+
parts.append(f"min-height:{html.escape(height_css)};")
|
255
|
+
|
256
|
+
if height_apply_to in ("outer", "both"):
|
257
|
+
_apply_height(outer_style_parts)
|
258
|
+
if height_apply_to in ("container", "both"):
|
259
|
+
_apply_height(grid_style_parts)
|
260
|
+
|
261
|
+
outer_style_attr = f' style="{"".join(outer_style_parts)}"' if outer_style_parts else ""
|
262
|
+
grid_style_attr = f' style="{"".join(grid_style_parts)}"'
|
263
|
+
|
218
264
|
out: List[str] = []
|
219
|
-
out.append(f"<div{outer_attr}>")
|
220
|
-
out.append(f' <div class="{html.escape(grid_class)}"
|
265
|
+
out.append(f"<div{outer_attr}{outer_style_attr}>")
|
266
|
+
out.append(f' <div class="{html.escape(grid_class)}"{grid_style_attr}>')
|
221
267
|
|
222
268
|
for name in self._order:
|
223
269
|
R = self._regions[name]
|
@@ -226,16 +272,29 @@ class GridLayoutBuilder:
|
|
226
272
|
region_cls_attr = f' class="{html.escape(region_cls)}"' if region_cls else ""
|
227
273
|
region_style_attr = f' style="{html.escape(R.placement_style())}"'
|
228
274
|
|
275
|
+
# add debug outline; make wrapper a positioning context only if it's static
|
229
276
|
if self._debug:
|
230
|
-
|
277
|
+
dbg_outline = "outline:1px dashed rgba(220,38,38,.55);outline-offset:-1px;"
|
278
|
+
|
279
|
+
# figure out if region already positions itself via classes
|
280
|
+
# (so we DON'T override fixed/absolute/sticky/relative with inline styles)
|
281
|
+
has_pos = False
|
282
|
+
if region_cls:
|
283
|
+
pos_tokens = {"fixed", "absolute", "relative", "sticky"}
|
284
|
+
has_pos = any(tok in region_cls.split() for tok in pos_tokens)
|
285
|
+
|
286
|
+
dbg = dbg_outline + ("" if has_pos else "position:relative;")
|
231
287
|
region_style_attr = region_style_attr[:-1] + dbg + '"' # append
|
232
288
|
|
233
289
|
out.append(f' <div data-region="{html.escape(name)}"{region_cls_attr}{region_style_attr}>')
|
234
290
|
|
235
291
|
if self._debug:
|
236
292
|
out.append(
|
237
|
-
' <div style="
|
238
|
-
'
|
293
|
+
' <div style="position:absolute;top:2px;left:2px;z-index:1;'
|
294
|
+
'font:11px/1.2 system-ui, -apple-system, Segoe UI, Roboto;'
|
295
|
+
'color:rgba(220,38,38,.8);padding:2px 4px;'
|
296
|
+
'background:rgba(255,255,255,.6);border-radius:3px;'
|
297
|
+
'pointer-events:none;">'
|
239
298
|
f'{html.escape(name)}</div>'
|
240
299
|
)
|
241
300
|
|
pydzn/responsive.py
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
import uuid
|
2
|
+
|
3
|
+
|
4
|
+
def responsive_pair(desktop_html: str, mobile_html: str, md_min_px: int = 768) -> str:
|
5
|
+
"""Return both DOM trees with CSS that shows exactly one, scoped to a unique wrapper."""
|
6
|
+
uid = uuid.uuid4().hex[:8]
|
7
|
+
cls = f"resp-pair-{uid}"
|
8
|
+
css = f"""
|
9
|
+
<style>
|
10
|
+
.{cls} ._desktop {{ display: none; }}
|
11
|
+
.{cls} ._mobile {{ display: block; }}
|
12
|
+
@media (min-width:{md_min_px}px) {{
|
13
|
+
.{cls} ._desktop {{ display: block; }}
|
14
|
+
.{cls} ._mobile {{ display: none; }}
|
15
|
+
}}
|
16
|
+
</style>
|
17
|
+
"""
|
18
|
+
return (
|
19
|
+
css
|
20
|
+
+ f'<div class="{cls}">'
|
21
|
+
+ f' <div class="_mobile">{mobile_html}</div>'
|
22
|
+
+ f' <div class="_desktop">{desktop_html}</div>'
|
23
|
+
+ f'</div>'
|
24
|
+
)
|
pydzn/variants.py
ADDED
@@ -0,0 +1,215 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from typing import ClassVar, Dict, Iterable, Mapping, Optional
|
3
|
+
from pydzn.dzn import register_dzn_classes
|
4
|
+
|
5
|
+
|
6
|
+
class VariantSupport:
|
7
|
+
"""
|
8
|
+
Mixin for components that want tailwind-like 'variant/size/tone' presets
|
9
|
+
with pluggable, namespaced libraries.
|
10
|
+
|
11
|
+
Subclasses typically define:
|
12
|
+
VARIANTS: Mapping[str, str]
|
13
|
+
SIZES: Mapping[str, str]
|
14
|
+
TONES: Mapping[str, str]
|
15
|
+
DEFAULTS: Mapping[str, str] # e.g. {"variant":"outline-primary", "size":"md", "tone":""}
|
16
|
+
|
17
|
+
Public API on each component class:
|
18
|
+
- attach_variant_library(namespace, variants=..., sizes=..., tones=..., override=False)
|
19
|
+
- set_default_choices(variant=..., size=..., tone=...)
|
20
|
+
- list_variants(namespaced=True) -> list[str]
|
21
|
+
- list_sizes(namespaced=True) -> list[str]
|
22
|
+
- list_tones(namespaced=True) -> list[str]
|
23
|
+
- available_options(namespaced=True) -> dict with "variants"/"sizes"/"tones"/"defaults"
|
24
|
+
|
25
|
+
Instance-side helper used by __init__ of the component:
|
26
|
+
- _resolve_variant_dzn(variant, size, tone, extra_dzn) -> str
|
27
|
+
"""
|
28
|
+
|
29
|
+
# Per-subclass external libs: { "ns": {"variants":{...}, "sizes":{...}, "tones":{...}} }
|
30
|
+
_external_libs: ClassVar[Dict[str, Dict[str, Dict[str, str]]]]
|
31
|
+
|
32
|
+
def __init_subclass__(cls, **kwargs):
|
33
|
+
super().__init_subclass__(**kwargs)
|
34
|
+
# init per-class registry
|
35
|
+
if not hasattr(cls, "_external_libs"):
|
36
|
+
cls._external_libs = {}
|
37
|
+
# sane fallbacks so list_* never explode
|
38
|
+
for attr, default in (("VARIANTS", {}), ("SIZES", {}), ("TONES", {}), ("DEFAULTS", {})):
|
39
|
+
if not hasattr(cls, attr):
|
40
|
+
setattr(cls, attr, default)
|
41
|
+
|
42
|
+
# ---------- library plumbing ----------
|
43
|
+
|
44
|
+
@classmethod
|
45
|
+
def attach_variant_library(
|
46
|
+
cls,
|
47
|
+
namespace: str,
|
48
|
+
*,
|
49
|
+
variants: Optional[Mapping[str, str]] = None,
|
50
|
+
sizes: Optional[Mapping[str, str]] = None,
|
51
|
+
tones: Optional[Mapping[str, str]] = None,
|
52
|
+
override: bool = False,
|
53
|
+
) -> None:
|
54
|
+
"""
|
55
|
+
Attach or extend a namespaced library for this component class only.
|
56
|
+
Example:
|
57
|
+
Button.attach_variant_library(
|
58
|
+
"acme",
|
59
|
+
variants={"glass": "px-5 py-2 rounded-md bg-[rgba(...", ...},
|
60
|
+
sizes={"xl": "px-7 py-4"},
|
61
|
+
tones={"success": "bg-[..."}
|
62
|
+
)
|
63
|
+
"""
|
64
|
+
ns = namespace.strip()
|
65
|
+
if not ns:
|
66
|
+
raise ValueError("attach_variant_library: namespace cannot be empty")
|
67
|
+
|
68
|
+
lib = cls._external_libs.get(ns, {"variants": {}, "sizes": {}, "tones": {}})
|
69
|
+
if override:
|
70
|
+
lib = {
|
71
|
+
"variants": dict(variants or {}),
|
72
|
+
"sizes": dict(sizes or {}),
|
73
|
+
"tones": dict(tones or {}),
|
74
|
+
}
|
75
|
+
else:
|
76
|
+
if variants:
|
77
|
+
lib["variants"].update(variants)
|
78
|
+
if sizes:
|
79
|
+
lib["sizes"].update(sizes)
|
80
|
+
if tones:
|
81
|
+
lib["tones"].update(tones)
|
82
|
+
|
83
|
+
cls._external_libs[ns] = lib
|
84
|
+
|
85
|
+
# Register DZN so emitted CSS includes these utilities
|
86
|
+
all_classes: list[str] = []
|
87
|
+
for m in (variants or {}).values():
|
88
|
+
all_classes.append(m)
|
89
|
+
for m in (sizes or {}).values():
|
90
|
+
all_classes.append(m)
|
91
|
+
for m in (tones or {}).values():
|
92
|
+
all_classes.append(m)
|
93
|
+
if all_classes:
|
94
|
+
register_dzn_classes(" ".join(all_classes))
|
95
|
+
|
96
|
+
@classmethod
|
97
|
+
def set_default_choices(
|
98
|
+
cls,
|
99
|
+
*,
|
100
|
+
variant: Optional[str] = None,
|
101
|
+
size: Optional[str] = None,
|
102
|
+
tone: Optional[str] = None,
|
103
|
+
) -> None:
|
104
|
+
d = dict(getattr(cls, "DEFAULTS", {}))
|
105
|
+
if variant is not None:
|
106
|
+
d["variant"] = variant
|
107
|
+
if size is not None:
|
108
|
+
d["size"] = size
|
109
|
+
if tone is not None:
|
110
|
+
d["tone"] = tone
|
111
|
+
cls.DEFAULTS = d
|
112
|
+
|
113
|
+
# ---------- listing helpers (what you asked for) ----------
|
114
|
+
|
115
|
+
@classmethod
|
116
|
+
def list_variants(cls, *, namespaced: bool = True) -> list[str]:
|
117
|
+
"""
|
118
|
+
Return all available variant keys.
|
119
|
+
If namespaced=True, external libs appear as 'ns:key'.
|
120
|
+
"""
|
121
|
+
keys: list[str] = list(getattr(cls, "VARIANTS", {}).keys())
|
122
|
+
if namespaced:
|
123
|
+
for ns, lib in cls._external_libs.items():
|
124
|
+
keys.extend([f"{ns}:{k}" for k in lib.get("variants", {}).keys()])
|
125
|
+
else:
|
126
|
+
# note: non-namespaced duplicates could collide; this is just the names
|
127
|
+
for lib in cls._external_libs.values():
|
128
|
+
keys.extend(lib.get("variants", {}).keys())
|
129
|
+
return sorted(keys)
|
130
|
+
|
131
|
+
@classmethod
|
132
|
+
def list_sizes(cls, *, namespaced: bool = True) -> list[str]:
|
133
|
+
keys: list[str] = list(getattr(cls, "SIZES", {}).keys())
|
134
|
+
if namespaced:
|
135
|
+
for ns, lib in cls._external_libs.items():
|
136
|
+
keys.extend([f"{ns}:{k}" for k in lib.get("sizes", {}).keys()])
|
137
|
+
else:
|
138
|
+
for lib in cls._external_libs.values():
|
139
|
+
keys.extend(lib.get("sizes", {}).keys())
|
140
|
+
return sorted(set(keys))
|
141
|
+
|
142
|
+
@classmethod
|
143
|
+
def list_tones(cls, *, namespaced: bool = True) -> list[str]:
|
144
|
+
keys: list[str] = list(getattr(cls, "TONES", {}).keys())
|
145
|
+
if namespaced:
|
146
|
+
for ns, lib in cls._external_libs.items():
|
147
|
+
keys.extend([f"{ns}:{k}" for k in lib.get("tones", {}).keys()])
|
148
|
+
else:
|
149
|
+
for lib in cls._external_libs.values():
|
150
|
+
keys.extend(lib.get("tones", {}).keys())
|
151
|
+
return sorted(set(keys))
|
152
|
+
|
153
|
+
@classmethod
|
154
|
+
def available_options(cls, *, namespaced: bool = True) -> dict:
|
155
|
+
"""
|
156
|
+
Structured view for UIs: { variants: [...], sizes: [...], tones: [...], defaults: {...} }
|
157
|
+
"""
|
158
|
+
return {
|
159
|
+
"variants": cls.list_variants(namespaced=namespaced),
|
160
|
+
"sizes": cls.list_sizes(namespaced=namespaced),
|
161
|
+
"tones": cls.list_tones(namespaced=namespaced),
|
162
|
+
"defaults": dict(getattr(cls, "DEFAULTS", {})),
|
163
|
+
}
|
164
|
+
|
165
|
+
# ---------- resolution used by your component __init__ ----------
|
166
|
+
|
167
|
+
@classmethod
|
168
|
+
def _lookup_variant_piece(cls, kind: str, key: Optional[str]) -> str:
|
169
|
+
"""
|
170
|
+
kind = 'variants' | 'sizes' | 'tones'
|
171
|
+
key may be namespaced 'ns:name'. Returns DZN string or ''.
|
172
|
+
"""
|
173
|
+
if not key:
|
174
|
+
return ""
|
175
|
+
|
176
|
+
# external: ns:key
|
177
|
+
if ":" in key:
|
178
|
+
ns, name = key.split(":", 1)
|
179
|
+
lib = cls._external_libs.get(ns, {})
|
180
|
+
return lib.get(kind, {}).get(name, "")
|
181
|
+
|
182
|
+
# built-in
|
183
|
+
table = getattr(cls, kind.upper(), {})
|
184
|
+
return table.get(key, "")
|
185
|
+
|
186
|
+
def _resolve_variant_dzn(
|
187
|
+
self,
|
188
|
+
*,
|
189
|
+
variant: Optional[str],
|
190
|
+
size: Optional[str],
|
191
|
+
tone: Optional[str],
|
192
|
+
extra_dzn: Optional[str] = None,
|
193
|
+
) -> str:
|
194
|
+
cls = self.__class__
|
195
|
+
defaults = getattr(cls, "DEFAULTS", {})
|
196
|
+
|
197
|
+
v_key = variant if variant is not None else defaults.get("variant", "")
|
198
|
+
s_key = size if size is not None else defaults.get("size", "")
|
199
|
+
t_key = tone if tone is not None else defaults.get("tone", "")
|
200
|
+
|
201
|
+
parts: list[str] = []
|
202
|
+
v = cls._lookup_variant_piece("variants", v_key)
|
203
|
+
if v: parts.append(v)
|
204
|
+
s = cls._lookup_variant_piece("sizes", s_key)
|
205
|
+
if s: parts.append(s)
|
206
|
+
t = cls._lookup_variant_piece("tones", t_key)
|
207
|
+
if t: parts.append(t)
|
208
|
+
if extra_dzn:
|
209
|
+
parts.append(extra_dzn)
|
210
|
+
resolved = " ".join(p for p in parts if p).strip()
|
211
|
+
|
212
|
+
# register resolved classes once more (cheap) so /_dzn.css emits them
|
213
|
+
if resolved:
|
214
|
+
register_dzn_classes(resolved)
|
215
|
+
return resolved
|