pydzn 0.1.2__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.
@@ -0,0 +1,70 @@
1
+ from pydzn.base_component import BaseComponent
2
+ from pydzn.variants import VariantSupport
3
+
4
+
5
+ class Sidebar(VariantSupport, BaseComponent):
6
+ """
7
+ Sidebar container with pluggable variants.
8
+ NOTE: No side-specific borders/shadows here — the layout decides the divider/shadow.
9
+ """
10
+
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)
68
+
69
+ def context(self) -> dict:
70
+ return {}
@@ -0,0 +1,3 @@
1
+ <{{ tag }} {{ attrs|safe }}>
2
+ {{ children|safe }}
3
+ </{{ tag }}>
@@ -0,0 +1,15 @@
1
+ from pydzn.base_component import BaseComponent
2
+
3
+
4
+ class Text(BaseComponent):
5
+ """
6
+ Renders a text element.
7
+ Expects `template.html`
8
+ """
9
+
10
+ def __init__(self, text: str = "", children: str | None = None, tag: str = "div", **html_attrs):
11
+ super().__init__(children=children, tag=tag, **html_attrs)
12
+ self.text = text
13
+
14
+ def context(self) -> dict:
15
+ return {"text": self.text}
@@ -0,0 +1,3 @@
1
+ <{{ tag }}{% if attrs %} {{ attrs|safe }}{% endif %}>
2
+ {{ text }}{{ children|safe }}
3
+ </{{ tag }}>
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
- gc = f"{self._col_index} / span {self.col_span}"
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") -> "GridLayoutBuilder":
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, # None = span all rows
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", row="hero")
138
- .region("content", col="main", row="content")
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 r.col_name not in col_index:
170
- raise ValueError(f"Unknown column '{r.col_name}' for region '{r.name}'")
171
- r._col_index = col_index[r.col_name]
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)}" style="{grid_style}">')
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
- dbg = "outline:1px dashed rgba(220,38,38,.55);outline-offset:-1px;"
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="font:11px/1.2 system-ui, -apple-system, Segoe UI, Roboto;'
238
- 'color:rgba(220,38,38,.8);padding:2px 4px;display:inline-block;">'
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