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.
@@ -1,8 +1,10 @@
1
1
  from .button.component import Button
2
+ from .card.component import Card
2
3
  from .text.component import Text
3
4
  from .drawer.component import Drawer
4
5
  from .sidebar.component import Sidebar
5
6
  from .nav_item.component import NavItem
7
+ from .hamburger_menu.component import HamburgerMenu
6
8
 
7
9
 
8
- __all__ = ["Button", "Text", "Drawer", "Sidebar", "NavItem"]
10
+ __all__ = ["Button", "Card", "Text", "Drawer", "Sidebar", "NavItem", "HamburgerMenu"]
@@ -1,26 +1,86 @@
1
1
  from pydzn.base_component import BaseComponent
2
+ from pydzn.variants import VariantSupport
2
3
 
3
4
 
4
- class Button(BaseComponent):
5
+ class Button(VariantSupport, BaseComponent):
5
6
  """
6
- Renders a button element.
7
- Expects `template.html`
7
+ Server-rendered Button with pluggable variants/sizes.
8
+
9
+ - Built-in variants keep colors inline so it works without a theme pack.
10
+ - You can add external libraries and select with namespace, e.g. variant="acme:glass".
8
11
  """
9
12
 
10
- DEFAULT_DZN = (
11
- "px-5 py-2 rounded-sm border-2 border-blue-500 "
12
- "shadow-sm hover:shadow-md bg-[transparent] text-[#2563eb] "
13
- )
13
+ # visual “structures” (kept color-aware so no theme pack needed)
14
+ VARIANTS = {
15
+ # solid
16
+ "solid-primary": (
17
+ "rounded-sm border border-transparent "
18
+ "bg-[#2563eb] text-[white] "
19
+ "shadow-md hover:shadow-lg"
20
+ ),
21
+ # outline
22
+ "outline-primary": (
23
+ "rounded-sm border-2 border-blue-500 "
24
+ "bg-[transparent] text-[#2563eb] "
25
+ "shadow-sm hover:shadow-md"
26
+ ),
27
+ # ghost (neutral)
28
+ "ghost-neutral": (
29
+ "rounded-sm border-0 "
30
+ "bg-[transparent] text-body "
31
+ "shadow-none hover:bg-[rgba(15,23,42,.06)]"
32
+ ),
33
+ # linky button
34
+ "link-primary": (
35
+ "rounded-none border-0 "
36
+ "bg-[transparent] text-[#2563eb] "
37
+ "shadow-none hover:underline"
38
+ ),
39
+ }
40
+
41
+ # density
42
+ SIZES = {
43
+ "sm": "px-3 py-1",
44
+ "md": "px-5 py-2",
45
+ "lg": "px-6 py-3",
46
+ "xl": "px-8 py-4",
47
+ }
48
+
49
+ # tones are optional here; variants carry color already
50
+ TONES = {}
51
+
52
+ # project-wide defaults can be overridden via VariantSupport.set_default_choices(Button, ...)
53
+ DEFAULTS = {
54
+ "variant": "outline-primary",
55
+ "size": "md",
56
+ "tone": "",
57
+ }
14
58
 
15
- def __init__(self, text: str = "", children: str | None = None,
16
- tag: str = "button", dzn: str | None = None, **attrs):
17
- # 2) Accept dzn both as a named arg and as a stray attribute (attrs["dzn"])
18
- user_dzn = dzn or attrs.pop("dzn", None)
59
+ def __init__(
60
+ self,
61
+ text: str = "",
62
+ children: str | None = None,
63
+ *,
64
+ tag: str = "button",
65
+ # variant system
66
+ variant: str | None = None,
67
+ size: str | None = None,
68
+ tone: str | None = None,
69
+ # raw utility escape hatch (merged last)
70
+ dzn: str | None = None,
71
+ **attrs,
72
+ ):
73
+ # allow stray attrs["dzn"] too
74
+ extra_dzn = dzn or attrs.pop("dzn", None)
19
75
 
20
- # 3) Merge: default + user-provided (so user can extend the default easily)
21
- effective_dzn = (self.DEFAULT_DZN + " " + user_dzn).strip() if user_dzn else self.DEFAULT_DZN
76
+ # resolve VARIANT + SIZE (+ optional TONE) + extra_dzn
77
+ effective_dzn = self._resolve_variant_dzn(
78
+ variant=variant,
79
+ size=size,
80
+ tone=tone,
81
+ extra_dzn=extra_dzn,
82
+ )
22
83
 
23
- # 4) Pass the merged dzn to BaseComponent (it will register + merge into class)
24
84
  super().__init__(children=children, tag=tag, dzn=effective_dzn, **attrs)
25
85
  self.text = text
26
86
 
@@ -1,15 +1,93 @@
1
1
  from pydzn.base_component import BaseComponent
2
+ from pydzn.variants import VariantSupport
2
3
 
3
4
 
4
- class Card(BaseComponent):
5
+ class Card(VariantSupport, BaseComponent):
5
6
  """
6
- Renders a card element.
7
- Expects `template.html`
7
+ Server-rendered Card with pluggable variants/sizes/tones.
8
+ Variants focus on layout/structure; tones mainly adjust border color.
8
9
  """
9
10
 
10
- def __init__(self, children: str | None = None, tag: str = "div", **html_attrs):
11
- super().__init__(children=children, tag=tag, **html_attrs)
11
+ # visual “structures”
12
+ VARIANTS = {
13
+ "panel": (
14
+ "flex flex-col gap-4 p-4 rounded-xl "
15
+ "border border-subtle bg-elevated shadow-sm"
16
+ ),
17
+ "plain": (
18
+ "flex flex-col gap-3 p-4 rounded-md "
19
+ "border border-subtle bg-surface shadow-none"
20
+ ),
21
+ "elevated": (
22
+ "flex flex-col gap-4 p-4 rounded-xl "
23
+ "border border-transparent bg-elevated shadow-lg"
24
+ ),
25
+ "outlined": (
26
+ "flex flex-col gap-4 p-4 rounded-lg "
27
+ "border-2 border-slate-300 bg-[transparent] shadow-none"
28
+ ),
29
+ "soft": (
30
+ "flex flex-col gap-4 p-4 rounded-xl "
31
+ "border-0 bg-[rgba(15,23,42,.03)] shadow-sm"
32
+ ),
33
+ "glass": (
34
+ "flex flex-col gap-4 p-4 rounded-xl "
35
+ "border border-[rgba(255,255,255,.25)] bg-[rgba(255,255,255,.08)] shadow-md"
36
+ ),
37
+ "ghost": (
38
+ "flex flex-col gap-4 p-4 rounded-md "
39
+ "border-0 bg-[transparent] shadow-none"
40
+ ),
41
+ }
12
42
 
43
+ # density
44
+ SIZES = {
45
+ "xs": "p-2 gap-2 rounded-sm",
46
+ "sm": "p-3 gap-2 rounded-md",
47
+ "md": "p-4 gap-3 rounded-lg",
48
+ "lg": "p-6 gap-4 rounded-xl",
49
+ "xl": "p-8 gap-5 rounded-2xl",
50
+ }
51
+
52
+ # tones mainly drive border color (kept subtle by default)
53
+ TONES = {
54
+ "neutral": "border-subtle",
55
+ "primary": "border-blue-500",
56
+ "success": "border-green-500",
57
+ "danger": "border-red-500",
58
+ }
59
+
60
+ # project-wide defaults can be overridden at runtime:
61
+ # Card.set_default_choices(variant="...", size="...", tone="...")
62
+ DEFAULTS = {
63
+ "variant": "panel",
64
+ "size": "md",
65
+ "tone": "neutral",
66
+ }
67
+
68
+ def __init__(
69
+ self,
70
+ children: str | None = None,
71
+ *,
72
+ tag: str = "div",
73
+ # variant system
74
+ variant: str | None = None,
75
+ size: str | None = None,
76
+ tone: str | None = None,
77
+ # raw utility escape hatch (merged last)
78
+ dzn: str | None = None,
79
+ **attrs,
80
+ ):
81
+ extra_dzn = dzn or attrs.pop("dzn", None)
82
+
83
+ effective_dzn = self._resolve_variant_dzn(
84
+ variant=variant,
85
+ size=size,
86
+ tone=tone,
87
+ extra_dzn=extra_dzn,
88
+ )
89
+
90
+ super().__init__(children=children, tag=tag, dzn=effective_dzn, **attrs)
13
91
 
14
92
  def context(self) -> dict:
15
93
  return {}
@@ -0,0 +1,210 @@
1
+ import uuid, re
2
+ from pydzn.base_component import BaseComponent
3
+ from pydzn.variants import VariantSupport
4
+ from pydzn.dzn import register_dzn_classes
5
+
6
+
7
+ def _hamburger_svg(sz=24):
8
+ return f'''
9
+ <svg width="{sz}" height="{sz}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
10
+ <rect x="3" y="6" width="18" height="2" rx="1" fill="currentColor"/>
11
+ <rect x="3" y="11" width="18" height="2" rx="1" fill="currentColor"/>
12
+ <rect x="3" y="16" width="18" height="2" rx="1" fill="currentColor"/>
13
+ </svg>
14
+ '''.strip()
15
+
16
+ def _close_svg(sz=24):
17
+ return f'''
18
+ <svg width="{sz}" height="{sz}" viewBox="0 0 24 24" fill="none" aria-hidden="true">
19
+ <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
20
+ </svg>
21
+ '''.strip()
22
+
23
+
24
+ class HamburgerMenu(VariantSupport, BaseComponent):
25
+ VARIANTS = {
26
+ # Trigger styles
27
+ "trigger:quiet": (
28
+ "px-3 py-2 rounded-md border-0 "
29
+ "bg-[transparent] text-[#333] hover:text-[#6b6969] cursor-pointer"
30
+ ),
31
+ "trigger:solid": (
32
+ "px-3 py-2 rounded-md border border-[1px] "
33
+ "bg-[#2563eb] text-[white] hover:bg-[rgba(37,99,235,.92)] cursor-pointer"
34
+ ),
35
+
36
+ # Panel styles: no default background here
37
+ "panel:sheet": "text-[black] shadow-xl rounded-[10px]",
38
+ "panel:glass": "text-[black] shadow-lg rounded-[10px] backdrop-blur bg-[rgba(255,255,255,.70)]",
39
+ "panel:bare": "text-[inherit] shadow-none",
40
+ }
41
+
42
+ def __init__(self, *,
43
+ children: str = "",
44
+ mode: str = "right",
45
+ drawer_width: int | str = 320,
46
+ dropdown_height: int | str = 320,
47
+ show_backdrop: bool = True,
48
+ icon_size: int = 24,
49
+ trigger_variant: str | None = None,
50
+ trigger_size: str | None = None,
51
+ panel_variant: str | None = None,
52
+ dzn: str | None = None,
53
+ button_dzn: str | None = None,
54
+ panel_dzn: str | None = None,
55
+ **attrs,
56
+ ):
57
+ self._uid = uuid.uuid4().hex[:8]
58
+ self.mode = mode
59
+ self.drawer_width = int(drawer_width)
60
+ self.dropdown_height = int(dropdown_height)
61
+ self.show_backdrop = bool(show_backdrop)
62
+ self.icon_size = int(icon_size)
63
+
64
+ # Resolve trigger/panel via VariantSupport
65
+ self._trigger_dzn = self._resolve_variant_dzn(
66
+ variant=trigger_variant or "trigger:quiet",
67
+ size=trigger_size or "md",
68
+ tone=None,
69
+ extra_dzn=(button_dzn or "")
70
+ )
71
+ self._panel_dzn = self._resolve_variant_dzn(
72
+ variant=panel_variant or "panel:sheet",
73
+ size=None,
74
+ tone=None,
75
+ extra_dzn=(panel_dzn or "")
76
+ )
77
+
78
+ # Wrapper classes + auto-forward visual tokens to the panel
79
+ wrapper_core = "relative inline-block"
80
+ base_wrapper = f"{wrapper_core} {dzn.strip()}" if dzn else wrapper_core
81
+ forwarded = self._extract_visual_tokens(dzn or "")
82
+ self._panel_forwarded = forwarded
83
+
84
+ # Register for /_dzn.css emission
85
+ register_dzn_classes(base_wrapper)
86
+ register_dzn_classes(self._trigger_dzn)
87
+ register_dzn_classes(self._panel_dzn)
88
+ register_dzn_classes(self._panel_forwarded)
89
+ register_dzn_classes(
90
+ "flex items-center gap-2 hidden fixed inset-0 absolute top-0 bottom-0 "
91
+ "right-0 left-0 block overflow-auto border rounded-[10px] "
92
+ "z-[10000] z-[10001]"
93
+ )
94
+
95
+ super().__init__(children=children, tag="div", dzn=base_wrapper, **attrs)
96
+
97
+ def context(self) -> dict:
98
+ return {}
99
+
100
+ # --- helpers ---
101
+ @staticmethod
102
+ def _extract_visual_tokens(s: str) -> str:
103
+ if not s: return ""
104
+ keep = []
105
+ for cls in s.split():
106
+ if (cls.startswith("bg-[") or cls.startswith("text-[")
107
+ or cls.startswith("border") or cls.startswith("rounded")
108
+ or cls.startswith("shadow")):
109
+ keep.append(cls)
110
+ return " ".join(keep)
111
+
112
+ @staticmethod
113
+ def _has_bg_token(s: str) -> bool:
114
+ return bool(re.search(r"\bbg-\[", s or ""))
115
+
116
+ # --- render ---
117
+ def render(self) -> str:
118
+ uid = self._uid
119
+ root_cls = f"hm-{uid}"
120
+ cb_id = f"hmcb-{uid}"
121
+ drawer_id = f"hm-drawer-{uid}"
122
+
123
+ css = f"""
124
+ <style>
125
+ .{root_cls} .hm__icon--close {{ display:none; }}
126
+ .{root_cls} input[type="checkbox"] {{ position:absolute; opacity:0; pointer-events:none; }}
127
+ .{root_cls} .hm__toggle {{ display:inline-flex; align-items:center; gap:.5rem; }}
128
+
129
+ .{root_cls} .hm__drawer {{
130
+ transition: transform .22s ease, opacity .22s ease;
131
+ will-change: transform, opacity;
132
+ outline: none;
133
+ }}
134
+ .{root_cls}[data-mode="right"] .hm__drawer {{ transform: translateX(100%); }}
135
+ .{root_cls}[data-mode="left"] .hm__drawer {{ transform: translateX(-100%); }}
136
+ .{root_cls}[data-mode="dropdown"] .hm__drawer {{ transform: translateY(-8px); opacity:0; }}
137
+
138
+ .{root_cls} input:checked ~ label .hm__icon--hamburger {{ display:none; }}
139
+ .{root_cls} input:checked ~ label .hm__icon--close {{ display:inline-block; }}
140
+
141
+ .{root_cls}[data-mode="right"] input:checked ~ .hm__backdrop {{ display:block; }}
142
+ .{root_cls}[data-mode="left"] input:checked ~ .hm__backdrop {{ display:block; }}
143
+ .{root_cls}[data-mode="dropdown"] .hm__backdrop {{ display:none !important; }}
144
+
145
+ .{root_cls}[data-mode="right"] input:checked ~ .hm__drawer {{ transform: translateX(0); }}
146
+ .{root_cls}[data-mode="left"] input:checked ~ .hm__drawer {{ transform: translateX(0); }}
147
+ .{root_cls}[data-mode="dropdown"] input:checked ~ .hm__drawer {{ transform: translateY(0); opacity:1; }}
148
+ </style>
149
+ """.strip()
150
+
151
+ backdrop_html = ""
152
+ if self.show_backdrop and self.mode in ("left", "right"):
153
+ backdrop_html = (
154
+ f'<label for="{cb_id}" class="hm__backdrop hidden fixed inset-0 bg-[rgba(0,0,0,.35)] z-[10000]" aria-hidden="true"></label>'
155
+ )
156
+
157
+ trigger_html = f"""
158
+ <label for="{cb_id}" class="hm__toggle {self._trigger_dzn} flex items-center gap-2" aria-controls="{drawer_id}" aria-haspopup="true" style="cursor:pointer">
159
+ <span class="hm__icon hm__icon--hamburger" aria-hidden="true">{_hamburger_svg(self.icon_size)}</span>
160
+ <span class="hm__icon hm__icon--close" aria-hidden="true">{_close_svg(self.icon_size)}</span>
161
+ </label>
162
+ """.strip()
163
+
164
+ # Drawer base position/size
165
+ if self.mode in ("left", "right"):
166
+ side = "right-0" if self.mode == "right" else "left-0"
167
+ drawer_base = f"fixed top-0 bottom-0 {side} overflow-auto z-[10001] w-[{self.drawer_width}px]"
168
+ else:
169
+ drawer_base = (
170
+ f"absolute top-[calc(100%+8px)] right-0 "
171
+ f"h-[{self.dropdown_height}px] w-[min(92vw,420px)] "
172
+ f"overflow-auto border rounded-[10px] z-[10001]"
173
+ )
174
+ register_dzn_classes(drawer_base)
175
+
176
+ # Panel classes = variant + forwarded visual tokens (+ default bg if none provided)
177
+ panel_classes = f"{self._panel_dzn} {self._panel_forwarded}".strip()
178
+ if not self._has_bg_token(panel_classes):
179
+ panel_classes = f"{panel_classes} bg-[white]"
180
+ register_dzn_classes(panel_classes)
181
+
182
+ drawer_html = f"""
183
+ <div id="{drawer_id}" class="hm__drawer {drawer_base} {panel_classes}">
184
+ {self.children or ""}
185
+ </div>
186
+ """.strip()
187
+
188
+ checkbox = f'<input id="{cb_id}" type="checkbox" />'
189
+
190
+ return (
191
+ css
192
+ + f'<div class="{root_cls}" data-mode="{self.mode}" {self._attrs_string()}>'
193
+ + checkbox
194
+ + trigger_html
195
+ + backdrop_html
196
+ + drawer_html
197
+ + "</div>"
198
+ )
199
+
200
+ def _attrs_string(self) -> str:
201
+ import html as _html
202
+ attr_parts = []
203
+ for k, v in self.html_attrs.items():
204
+ if k == "style":
205
+ continue
206
+ attr_parts.append(f'{k}="{_html.escape(str(v), quote=True)}"')
207
+ style_str = self._style_string()
208
+ if style_str:
209
+ attr_parts.append(f'style="{_html.escape(style_str, quote=True)}"')
210
+ return " ".join(attr_parts)
@@ -0,0 +1,3 @@
1
+ <{{ tag }} {{ attrs|safe }}>
2
+ {{ children|safe }}
3
+ </{{ tag }}>
@@ -1,56 +1,220 @@
1
1
  from pydzn.base_component import BaseComponent
2
+ from pydzn.variants import VariantSupport
2
3
  from pydzn.dzn import register_dzn_classes
3
4
 
4
5
 
5
- class NavItem(BaseComponent):
6
+ class NavItem(VariantSupport, BaseComponent):
6
7
  """
7
- Minimal sidebar/nav item.
8
- - No default styling; pass dzn yourself.
9
- - Put label/content in `children` (e.g., render a Text component).
10
- - Optional: `active=True` to start active; `group_toggle=True` to clear
11
- siblings and apply `active_classes` on click (client-only).
8
+ Nav item with pluggable variants + fluent helpers so users don't need DZN utilities.
9
+
10
+ Example:
11
+ NavItem(variant="sidebar-underline", children=Text("Overview").render())
12
+ .center()
13
+ .height(64)
14
+ .full_width()
15
+ .bottom_divider("subtle")
16
+ .hover(bg="rgba(37,99,235,.06)", text="#2563eb", underline="blue-500")
17
+ .focus(bg="rgba(37,99,235,.10)")
18
+ .render()
12
19
  """
13
20
 
21
+ # -------- Variants --------
22
+ VARIANTS = {
23
+ # Sidebar family
24
+ "sidebar-default": (
25
+ "flex items-center justify-center text-center "
26
+ "rounded-none border-solid border-b border-subtle "
27
+ "text-body hover:bg-[rgba(15,23,42,.06)]"
28
+ ),
29
+ "sidebar-compact": (
30
+ "flex items-center justify-center text-center "
31
+ "rounded-none border-solid border-b border-subtle "
32
+ "text-body hover:bg-[rgba(15,23,42,.06)]"
33
+ ),
34
+ "sidebar-active": (
35
+ "flex items-center justify-center text-center "
36
+ "rounded-none border-solid border-b border-subtle "
37
+ "bg-[rgba(37,99,235,.10)] text-[#2563eb]"
38
+ ),
39
+ "sidebar-quiet": (
40
+ "flex items-center justify-center text-center "
41
+ "rounded-none border-solid border-b border-subtle "
42
+ "text-[rgb(100,116,139)] hover:bg-[rgba(15,23,42,.04)]"
43
+ ),
44
+ # underline-only (no box borders)
45
+ "sidebar-underline": (
46
+ "flex items-center justify-center text-center "
47
+ "rounded-none border-0 border-b border-subtle border-solid py-2"
48
+ ),
49
+ # square tile w/ underline + hovers
50
+ "sidebar-squared-underline": (
51
+ "flex items-center justify-center text-center "
52
+ "rounded-none border-0 border-b border-subtle border-solid py-2 "
53
+ "hover:bg-[rgba(37,99,235,.06)] hover:border-blue-500 hover:text-[#2563eb] "
54
+ "focus:bg-[rgba(37,99,235,.10)]"
55
+ ),
56
+
57
+ # Dropdown family
58
+ "dropdown-item": (
59
+ "px-3 py-2 rounded-md border-0 "
60
+ "bg-[transparent] text-body hover:bg-[rgba(15,23,42,.06)]"
61
+ ),
62
+ "dropdown-accent": (
63
+ "w-[100%] px-3 py-2 rounded-md border-0 "
64
+ "bg-[transparent] text-[#2563eb] hover:bg-[rgba(37,99,235,.08)]"
65
+ ),
66
+ "dropdown-danger": (
67
+ "w-[100%] px-3 py-2 rounded-md border-0 "
68
+ "bg-[transparent] text-[rgb(239,68,68)] hover:bg-[rgba(239,68,68,.08)]"
69
+ ),
70
+ "dropdown-plain": (
71
+ "w-[100%] px-3 py-2 rounded-none border-0 "
72
+ "bg-[transparent] text-body hover:bg-[rgba(15,23,42,.04)]"
73
+ ),
74
+
75
+ # Simple family
76
+ "simple-item": (
77
+ "px-3 py-2 rounded-none border-0 bg-[transparent] no-underline "
78
+ "text-[#333] hover:text-[#6b6969]"
79
+ ),
80
+
81
+ }
82
+
83
+ SIZES = {
84
+ "sm": "text-[12px]",
85
+ "md": "text-[14px]",
86
+ "lg": "text-[16px]",
87
+ }
88
+
89
+ TONES = {
90
+ "muted": "text-[rgb(100,116,139)]",
91
+ "primary": "text-[#2563eb]",
92
+ "danger": "text-[rgb(239,68,68)]",
93
+ }
94
+
95
+ DEFAULTS = {"variant": "sidebar-default", "size": "md", "tone": ""}
96
+
97
+ # -------- ctor --------
14
98
  def __init__(
15
99
  self,
16
100
  *,
17
101
  children: str | None = None,
18
102
  tag: str = "div",
19
- dzn: str | None = None,
20
- active: bool = False,
21
- active_classes: list[str] | tuple[str, ...] | None = None,
22
- group_toggle: bool = False,
103
+ variant: str | None = None,
104
+ size: str | None = None,
105
+ tone: str | None = None,
106
+ dzn: str | None = None, # extra raw utilities merged last
23
107
  **attrs,
24
108
  ):
25
- # No default design; just pass through what caller wants.
26
- self._active_classes = list(active_classes or [])
27
-
28
- # If we'll toggle classes at runtime, pre-register them so /_dzn.css emits rules.
29
- if self._active_classes:
30
- register_dzn_classes(self._active_classes)
109
+ extra_dzn = dzn or attrs.pop("dzn", None)
31
110
 
32
- # Start active = append active classes up front
33
- effective_dzn = (dzn or "")
34
- if active and self._active_classes:
35
- effective_dzn = (effective_dzn + " " + " ".join(self._active_classes)).strip()
36
-
37
- # Sensible a11y defaults (still “unstyled”)
111
+ # minimal a11y defaults
38
112
  attrs.setdefault("role", "button")
39
113
  attrs.setdefault("tabindex", "0")
40
114
 
41
- # Optional sibling-clearing active toggle, only if requested and not overridden
42
- if group_toggle and "hx-on:click" not in attrs and self._active_classes:
43
- # Build JS that removes active classes from siblings, adds to this.
44
- rm = ",".join(f"'{c}'" for c in self._active_classes)
45
- add = rm
46
- attrs["hx-on:click"] = (
47
- "var p=this.parentElement;"
48
- f"for(const el of p.children){{el.classList.remove({rm});}}"
49
- f"this.classList.add({add});"
50
- )
115
+ base = self._resolve_variant_dzn(
116
+ variant=variant,
117
+ size=size,
118
+ tone=tone,
119
+ extra_dzn=extra_dzn,
120
+ )
121
+
122
+ # keep base vs. runtime styles separate
123
+ self._base_dzn = base.strip()
124
+ self._runtime_dzn: list[str] = []
125
+
126
+ # pre-register everything we know at init
127
+ register_dzn_classes(self._base_dzn)
128
+
129
+ # let BaseComponent merge base dzn into class
130
+ super().__init__(children=children or "", tag=tag, dzn=self._base_dzn, **attrs)
131
+
132
+ # -------- internal helpers --------
133
+ def _merge_all(self) -> str:
134
+ return " ".join([self._base_dzn] + self._runtime_dzn).strip()
135
+
136
+ def _add_runtime(self, *classes: str) -> None:
137
+ cls_str = " ".join(c for c in classes if c)
138
+ if not cls_str:
139
+ return
140
+ # register for CSS emission
141
+ register_dzn_classes(cls_str)
142
+ # track runtime classes
143
+ self._runtime_dzn.extend(c for c in cls_str.split() if c)
144
+ # immediately apply to html_attrs["class"] so render sees it
145
+ merged = self._merge_all()
146
+ self.html_attrs["class"] = self._merge_classes(self.html_attrs.get("class", ""), merged)
147
+
148
+ # -------- fluent builder methods --------
149
+ def center(self, *, x: bool = True, y: bool = True, text: bool = True):
150
+ if x or y:
151
+ self._add_runtime("flex")
152
+ if x:
153
+ self._add_runtime("justify-center")
154
+ if y:
155
+ self._add_runtime("items-center")
156
+ if text:
157
+ self._add_runtime("text-center")
158
+ return self
159
+
160
+ def height(self, px: int):
161
+ self._add_runtime(f"h-[{px}px]")
162
+ return self
163
+
164
+ def full_width(self, on: bool = True):
165
+ if on:
166
+ self._add_runtime("w-[100%]")
167
+ return self
168
+
169
+ def bottom_divider(self, color: str = "subtle", *, style: str = "solid"):
170
+ # ensure only the bottom edge shows
171
+ self._add_runtime("border-0", "border-b", f"border-{color}", f"border-{style}")
172
+ return self
173
+
174
+ def hover(self, *, bg: str | None = None, text: str | None = None, underline: str | None = None):
175
+ if bg:
176
+ self._add_runtime(f"hover:bg-[{bg}]")
177
+ if text:
178
+ self._add_runtime(f"hover:text-[{text}]")
179
+ if underline:
180
+ self._add_runtime(f"hover:border-{underline}")
181
+ return self
182
+
183
+ def focus(self, *, bg: str | None = None):
184
+ if bg:
185
+ self._add_runtime(f"focus:bg-[{bg}]")
186
+ return self
187
+
188
+ def padding(self, *, all: int | str | None = None, x: int | str | None = None, y: int | str | None = None):
189
+ def _choose(prefix: str, val):
190
+ if val is None:
191
+ return
192
+ if isinstance(val, int):
193
+ self._add_runtime(f"{prefix}-{val}")
194
+ else:
195
+ self._add_runtime(f"{prefix}-[{val}]")
196
+ _choose("p", all)
197
+ _choose("px", x)
198
+ _choose("py", y)
199
+ return self
200
+
201
+ def reset_runtime_styles(self):
202
+ """Remove everything added by builder methods (keeps the chosen variant)."""
203
+ self._runtime_dzn = []
204
+ # reset class to just base
205
+ self.html_attrs["class"] = self._base_dzn
206
+ register_dzn_classes(self._base_dzn)
207
+ return self
51
208
 
52
- super().__init__(children=children or "", tag=tag, dzn=effective_dzn, **attrs)
209
+ def as_link(self, href: str, *, new_tab: bool=False):
210
+ self.tag = "a"
211
+ self.html_attrs["href"] = href
212
+ self.html_attrs["role"] = "link"
213
+ if new_tab:
214
+ self.html_attrs["target"] = "_blank"
215
+ self.html_attrs["rel"] = "noopener noreferrer"
216
+ return self
53
217
 
218
+ # required by BaseComponent
54
219
  def context(self) -> dict:
55
- # No label here; use children (e.g., a Text component) for content.
56
220
  return {}