cronixui 1.1.1 → 1.1.3

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.
Files changed (76) hide show
  1. package/README.md +12 -8
  2. package/package.json +71 -71
  3. package/packages/flutter/.qwen/settings.json +7 -0
  4. package/packages/flutter/pubspec.yaml +20 -20
  5. package/packages/go/cronixui/cronixui.go +926 -926
  6. package/packages/python/README.md +142 -0
  7. package/packages/python/cronixui/__init__.py +15 -6
  8. package/packages/python/cronixui/__pycache__/__init__.cpython-314.pyc +0 -0
  9. package/packages/python/cronixui/__pycache__/accordion.cpython-314.pyc +0 -0
  10. package/packages/python/cronixui/__pycache__/alert.cpython-314.pyc +0 -0
  11. package/packages/python/cronixui/__pycache__/avatar.cpython-314.pyc +0 -0
  12. package/packages/python/cronixui/__pycache__/badge.cpython-314.pyc +0 -0
  13. package/packages/python/cronixui/__pycache__/button.cpython-314.pyc +0 -0
  14. package/packages/python/cronixui/__pycache__/card.cpython-314.pyc +0 -0
  15. package/packages/python/cronixui/__pycache__/command_palette.cpython-314.pyc +0 -0
  16. package/packages/python/cronixui/__pycache__/core.cpython-314.pyc +0 -0
  17. package/packages/python/cronixui/__pycache__/dropdown.cpython-314.pyc +0 -0
  18. package/packages/python/cronixui/__pycache__/form.cpython-314.pyc +0 -0
  19. package/packages/python/cronixui/__pycache__/layout.cpython-314.pyc +0 -0
  20. package/packages/python/cronixui/__pycache__/list.cpython-314.pyc +0 -0
  21. package/packages/python/cronixui/__pycache__/loading.cpython-314.pyc +0 -0
  22. package/packages/python/cronixui/__pycache__/modal.cpython-314.pyc +0 -0
  23. package/packages/python/cronixui/__pycache__/nav.cpython-314.pyc +0 -0
  24. package/packages/python/cronixui/__pycache__/pagination.cpython-314.pyc +0 -0
  25. package/packages/python/cronixui/__pycache__/progress.cpython-314.pyc +0 -0
  26. package/packages/python/cronixui/__pycache__/search.cpython-314.pyc +0 -0
  27. package/packages/python/cronixui/__pycache__/table.cpython-314.pyc +0 -0
  28. package/packages/python/cronixui/__pycache__/tabs.cpython-314.pyc +0 -0
  29. package/packages/python/cronixui/__pycache__/toast.cpython-314.pyc +0 -0
  30. package/packages/python/cronixui/__pycache__/toggle.cpython-314.pyc +0 -0
  31. package/packages/python/cronixui/__pycache__/tokens.cpython-314.pyc +0 -0
  32. package/packages/python/cronixui/__pycache__/tooltip.cpython-314.pyc +0 -0
  33. package/packages/python/cronixui/alert.py +119 -36
  34. package/packages/python/cronixui/avatar.py +129 -22
  35. package/packages/python/cronixui/badge.py +161 -24
  36. package/packages/python/cronixui/button.py +96 -27
  37. package/packages/python/cronixui/card.py +206 -33
  38. package/packages/python/cronixui/core.py +212 -23
  39. package/packages/python/cronixui/form.py +552 -141
  40. package/packages/python/cronixui/layout.py +358 -96
  41. package/packages/python/cronixui/list.py +140 -37
  42. package/packages/python/cronixui/loading.py +107 -17
  43. package/packages/python/cronixui/progress.py +189 -47
  44. package/packages/python/cronixui/table.py +118 -31
  45. package/packages/python/cronixui/tooltip.py +117 -15
  46. package/packages/react/src/components/Accordion.tsx +82 -82
  47. package/packages/react/src/components/Button.tsx +47 -47
  48. package/packages/react/src/components/Card.tsx +69 -69
  49. package/packages/react/src/components/CommandPalette.tsx +131 -131
  50. package/packages/react/src/components/Dropdown.tsx +88 -88
  51. package/packages/react/src/components/FileInput.tsx +86 -86
  52. package/packages/react/src/components/FormGroup.tsx +36 -36
  53. package/packages/react/src/components/List.tsx +55 -55
  54. package/packages/react/src/components/Pagination.tsx +107 -107
  55. package/packages/react/src/components/Progress.tsx +49 -49
  56. package/packages/react/src/components/Search.tsx +95 -95
  57. package/packages/react/src/components/Sidebar.tsx +64 -64
  58. package/packages/react/src/components/Stack.tsx +69 -69
  59. package/packages/react/src/components/Table.tsx +90 -90
  60. package/packages/react/src/components/Toast.tsx +134 -134
  61. package/packages/react/src/components/Typography.tsx +66 -66
  62. package/packages/react/src/index.ts +40 -40
  63. package/packages/react/src/styles.css +2039 -2039
  64. package/packages/rust/cronixui/src/components/avatar.rs +85 -85
  65. package/packages/rust/cronixui/src/components/breadcrumb.rs +58 -58
  66. package/packages/rust/cronixui/src/components/card.rs +259 -259
  67. package/packages/rust/cronixui/src/components/command_palette.rs +254 -254
  68. package/packages/rust/cronixui/src/components/dropdown.rs +179 -179
  69. package/packages/rust/cronixui/src/components/file_input.rs +74 -74
  70. package/packages/rust/cronixui/src/components/mod.rs +51 -51
  71. package/packages/rust/cronixui/src/components/search.rs +185 -185
  72. package/packages/rust/cronixui/src/components/skeleton.rs +63 -63
  73. package/packages/rust/cronixui/src/components/table.rs +56 -56
  74. package/packages/rust/cronixui/src/lib.rs +128 -128
  75. package/packages/web/dist/cronixui.css +97 -93
  76. package/packages/web/dist/cronixui.min.css +1 -1
@@ -1,51 +1,154 @@
1
- """CronixUI List Component"""
1
+ """CronixUI List Component.
2
2
 
3
- from typing import Optional, Callable
4
- from .core import create_el
3
+ Generates HTML for lists with optional icons, titles, subtitles, and actions.
4
+ No browser DOM APIs are used - all output is HTML strings or data structures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class ListElement:
15
+ """Represents a rendered list element."""
16
+
17
+ tag: str = "div"
18
+ classes: List[str] = field(default_factory=list)
19
+ attributes: Dict[str, str] = field(default_factory=dict)
20
+ inner_html: str = ""
21
+
22
+ def render_html(self) -> str:
23
+ """Render as HTML string.
24
+
25
+ Returns:
26
+ Complete HTML for the list element
27
+ """
28
+ class_str = " ".join(self.classes)
29
+ class_attr = f' class="{class_str}"' if class_str else ""
30
+ attrs_str = "".join(f' {k}="{v}"' for k, v in self.attributes.items())
31
+ return f"<{self.tag}{class_attr}{attrs_str}>{self.inner_html}</{self.tag}>"
32
+
33
+ def render(self) -> "ListElement":
34
+ """Return self for API compatibility."""
35
+ return self
5
36
 
6
37
 
7
38
  class List:
8
- """List component."""
39
+ """List component for displaying collections of items.
40
+
41
+ Items can be simple strings or dictionaries with optional keys:
42
+ - icon: SVG markup for the item icon
43
+ - title: Primary text for the item
44
+ - subtitle: Secondary/description text
45
+ - actions: HTML string for action buttons/links
46
+
47
+ Args:
48
+ items: List of items (strings or dicts)
49
+ clickable: Whether items should appear clickable (default: False)
50
+
51
+ Example:
52
+ >>> simple = List(["Item 1", "Item 2", "Item 3"])
53
+ >>> print(simple.render_html())
54
+ <div class="cn-list">
55
+ <div class="cn-list-item"><div class="cn-list-item-content">Item 1</div></div>
56
+ <div class="cn-list-item"><div class="cn-list-item-content">Item 2</div></div>
57
+ <div class="cn-list-item"><div class="cn-list-item-content">Item 3</div></div>
58
+ </div>
59
+
60
+ >>> rich = List([
61
+ ... {
62
+ ... "icon": "<svg>...</svg>",
63
+ ... "title": "Dashboard",
64
+ ... "subtitle": "Main overview",
65
+ ... "actions": "<button>Edit</button>",
66
+ ... },
67
+ ... {"title": "Settings", "subtitle": "App configuration"},
68
+ ... ], clickable=True)
69
+ >>> print(rich.render_html())
70
+ """
71
+
72
+ def __init__(self, items: List[Any], clickable: bool = False):
73
+ if not items:
74
+ raise ValueError("items cannot be empty")
9
75
 
10
- def __init__(self, items: list, clickable: bool = False):
11
76
  self.items = items
12
77
  self.clickable = clickable
13
- self.element = self._render()
14
78
 
15
- def _render(self):
16
- el = create_el("div", "cn-list")
79
+ def render(self) -> ListElement:
80
+ """Render the list as a ListElement.
81
+
82
+ Returns:
83
+ ListElement containing all list items
84
+ """
85
+ item_parts = []
17
86
  for item in self.items:
18
- item_el = create_el("div", "cn-list-item")
87
+ item_classes = ["cn-list-item"]
19
88
  if self.clickable:
20
- item_el.classList.add("cn-list-item-clickable")
89
+ item_classes.append("cn-list-item-clickable")
21
90
 
22
91
  if isinstance(item, dict):
23
- if icon := item.get("icon"):
24
- icon_el = create_el("span", "cn-list-item-icon")
25
- icon_el.innerHTML = icon
26
- item_el.appendChild(icon_el)
27
-
28
- content = create_el("div", "cn-list-item-content")
29
- if title := item.get("title"):
30
- title_el = create_el("div", "cn-list-item-title")
31
- title_el.textContent = title
32
- content.appendChild(title_el)
33
-
34
- if subtitle := item.get("subtitle"):
35
- subtitle_el = create_el("div", "cn-list-item-subtitle")
36
- subtitle_el.textContent = subtitle
37
- content.appendChild(subtitle_el)
38
-
39
- item_el.appendChild(content)
40
-
41
- if actions := item.get("actions"):
42
- actions_el = create_el("div", "cn-list-item-actions")
43
- actions_el.innerHTML = actions
44
- item_el.appendChild(actions_el)
92
+ item_parts.append(self._render_dict_item(item, item_classes))
45
93
  else:
46
- content = create_el("div", "cn-list-item-content")
47
- content.textContent = str(item)
48
- item_el.appendChild(content)
94
+ content = self._esc(str(item))
95
+ item_parts.append(
96
+ f'<div class="{" ".join(item_classes)}">'
97
+ f'<div class="cn-list-item-content">{content}</div>'
98
+ f"</div>"
99
+ )
100
+
101
+ return ListElement(
102
+ classes=["cn-list"],
103
+ inner_html="".join(item_parts),
104
+ )
105
+
106
+ def _render_dict_item(self, item: dict, item_classes: List[str]) -> str:
107
+ """Render a single dict-based list item."""
108
+ class_str = " ".join(item_classes)
109
+ parts = [f'<div class="{class_str}">']
110
+
111
+ # Icon
112
+ if icon := item.get("icon"):
113
+ parts.append(f'<span class="cn-list-item-icon">{icon}</span>')
114
+
115
+ # Content
116
+ content_parts = []
117
+ if title := item.get("title"):
118
+ content_parts.append(
119
+ f'<div class="cn-list-item-title">{self._esc(title)}</div>'
120
+ )
121
+ if subtitle := item.get("subtitle"):
122
+ content_parts.append(
123
+ f'<div class="cn-list-item-subtitle">{self._esc(subtitle)}</div>'
124
+ )
125
+ if content_parts:
126
+ parts.append(
127
+ f'<div class="cn-list-item-content">{"".join(content_parts)}</div>'
128
+ )
129
+
130
+ # Actions
131
+ if actions := item.get("actions"):
132
+ parts.append(f'<div class="cn-list-item-actions">{actions}</div>')
133
+
134
+ parts.append("</div>")
135
+ return "".join(parts)
136
+
137
+ def render_html(self) -> str:
138
+ """Render the list as an HTML string.
139
+
140
+ Returns:
141
+ HTML string representation of the list
142
+ """
143
+ return self.render().render_html()
49
144
 
50
- el.appendChild(item_el)
51
- return el
145
+ @staticmethod
146
+ def _esc(text: str) -> str:
147
+ """Escape HTML special characters."""
148
+ return (
149
+ text.replace("&", "&amp;")
150
+ .replace("<", "&lt;")
151
+ .replace(">", "&gt;")
152
+ .replace('"', "&quot;")
153
+ .replace("'", "&#x27;")
154
+ )
@@ -1,36 +1,126 @@
1
- """CronixUI Loading Components"""
1
+ """CronixUI Loading Components.
2
2
 
3
- from .core import create_el
3
+ Generates HTML for spinners and skeleton loading placeholders.
4
+ No browser DOM APIs are used - all output is HTML strings or data structures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class LoadingElement:
15
+ """Represents a rendered loading element."""
16
+
17
+ tag: str = "div"
18
+ classes: List[str] = field(default_factory=list)
19
+ attributes: Dict[str, str] = field(default_factory=dict)
20
+ inner_html: str = ""
21
+
22
+ def render_html(self) -> str:
23
+ """Render as HTML string.
24
+
25
+ Returns:
26
+ Complete HTML for the loading element
27
+ """
28
+ class_str = " ".join(self.classes)
29
+ class_attr = f' class="{class_str}"' if class_str else ""
30
+ attrs_str = "".join(f' {k}="{v}"' for k, v in self.attributes.items())
31
+ return f"<{self.tag}{class_attr}{attrs_str}>{self.inner_html}</{self.tag}>"
32
+
33
+ def render(self) -> "LoadingElement":
34
+ """Return self for API compatibility."""
35
+ return self
4
36
 
5
37
 
6
38
  class Spinner:
7
- """Loading spinner component."""
39
+ """Loading spinner component.
40
+
41
+ Args:
42
+ size: Spinner size - sm, md, lg (default: md)
43
+
44
+ Example:
45
+ >>> spinner = Spinner(size="lg")
46
+ >>> print(spinner.render_html())
47
+ <div class="cn-spinner cn-spinner-lg"></div>
48
+ """
8
49
 
9
50
  SIZES = ("sm", "md", "lg")
10
51
 
11
52
  def __init__(self, size: str = "md"):
12
- self.size = size if size in self.SIZES else "md"
13
- self.element = self._render()
53
+ if size not in self.SIZES:
54
+ raise ValueError(f"Invalid size '{size}'. Must be one of {self.SIZES}")
55
+
56
+ self.size = size
57
+
58
+ def render(self) -> LoadingElement:
59
+ """Render the spinner as a LoadingElement.
14
60
 
15
- def _render(self):
16
- el = create_el("div", "cn-spinner")
61
+ Returns:
62
+ LoadingElement representing the spinner
63
+ """
64
+ classes = ["cn-spinner"]
17
65
  if self.size != "md":
18
- el.classList.add(f"cn-spinner-{self.size}")
19
- return el
66
+ classes.append(f"cn-spinner-{self.size}")
67
+
68
+ return LoadingElement(classes=classes)
69
+
70
+ def render_html(self) -> str:
71
+ """Render the spinner as an HTML string.
72
+
73
+ Returns:
74
+ HTML string representation of the spinner
75
+ """
76
+ return self.render().render_html()
20
77
 
21
78
 
22
79
  class Skeleton:
23
- """Skeleton loading placeholder."""
80
+ """Skeleton loading placeholder for content while it loads.
81
+
82
+ Args:
83
+ variant: Skeleton type - text, title, avatar (default: text)
84
+ width: Optional CSS width for the skeleton (e.g. "200px", "50%")
85
+
86
+ Example:
87
+ >>> skeleton = Skeleton(variant="title", width="300px")
88
+ >>> print(skeleton.render_html())
89
+ <div class="cn-skeleton cn-skeleton-title" style="width: 300px;"></div>
90
+ """
24
91
 
25
92
  VARIANTS = ("text", "title", "avatar")
26
93
 
27
- def __init__(self, variant: str = "text", width: str = None):
28
- self.variant = variant if variant in self.VARIANTS else "text"
94
+ def __init__(self, variant: str = "text", width: Optional[str] = None):
95
+ if variant not in self.VARIANTS:
96
+ raise ValueError(
97
+ f"Invalid variant '{variant}'. Must be one of {self.VARIANTS}"
98
+ )
99
+
100
+ self.variant = variant
29
101
  self.width = width
30
- self.element = self._render()
31
102
 
32
- def _render(self):
33
- el = create_el("div", f"cn-skeleton cn-skeleton-{self.variant}")
103
+ def render(self) -> LoadingElement:
104
+ """Render the skeleton as a LoadingElement.
105
+
106
+ Returns:
107
+ LoadingElement representing the skeleton placeholder
108
+ """
109
+ classes = ["cn-skeleton", f"cn-skeleton-{self.variant}"]
110
+
111
+ attrs: Dict[str, str] = {}
34
112
  if self.width:
35
- el.style.width = self.width
36
- return el
113
+ attrs["style"] = f"width: {self.width};"
114
+
115
+ return LoadingElement(
116
+ classes=classes,
117
+ attributes=attrs,
118
+ )
119
+
120
+ def render_html(self) -> str:
121
+ """Render the skeleton as an HTML string.
122
+
123
+ Returns:
124
+ HTML string representation of the skeleton placeholder
125
+ """
126
+ return self.render().render_html()
@@ -1,10 +1,63 @@
1
- """CronixUI Progress Components"""
1
+ """CronixUI Progress Components.
2
2
 
3
- from .core import create_el
3
+ Generates HTML for progress bars and stat/metric displays.
4
+ No browser DOM APIs are used - all output is HTML strings or data structures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Dict, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class ProgressElement:
15
+ """Represents a rendered progress/stat element."""
16
+
17
+ tag: str = "div"
18
+ classes: List[str] = field(default_factory=list)
19
+ attributes: Dict[str, str] = field(default_factory=dict)
20
+ inner_html: str = ""
21
+
22
+ def render_html(self) -> str:
23
+ """Render as HTML string.
24
+
25
+ Returns:
26
+ Complete HTML for the element
27
+ """
28
+ class_str = " ".join(self.classes)
29
+ class_attr = f' class="{class_str}"' if class_str else ""
30
+ attrs_str = "".join(f' {k}="{v}"' for k, v in self.attributes.items())
31
+ return f"<{self.tag}{class_attr}{attrs_str}>{self.inner_html}</{self.tag}>"
32
+
33
+ def render(self) -> "ProgressElement":
34
+ """Return self for API compatibility."""
35
+ return self
4
36
 
5
37
 
6
38
  class Progress:
7
- """Progress bar component."""
39
+ """Progress bar component for showing completion percentage.
40
+
41
+ Args:
42
+ value: Current progress value (default: 0)
43
+ max: Maximum value (default: 100)
44
+ variant: Progress variant - default, success, warning, error (default: default)
45
+ size: Progress bar size - sm, md, lg (default: md)
46
+ show_label: Whether to show value/max text (default: False)
47
+
48
+ Example:
49
+ >>> progress = Progress(value=75, max=100, show_label=True)
50
+ >>> print(progress.render_html())
51
+ <div>
52
+ <div class="cn-progress-label"><span>75</span><span>100</span></div>
53
+ <div class="cn-progress">
54
+ <div class="cn-progress-bar" style="width: 75.0%;"></div>
55
+ </div>
56
+ </div>
57
+
58
+ >>> success = Progress(value=100, variant="success", size="lg")
59
+ >>> print(success.render_html())
60
+ """
8
61
 
9
62
  VARIANTS = ("default", "success", "warning", "error")
10
63
  SIZES = ("sm", "md", "lg")
@@ -17,74 +70,163 @@ class Progress:
17
70
  size: str = "md",
18
71
  show_label: bool = False,
19
72
  ):
73
+ if max <= 0:
74
+ raise ValueError("max must be greater than 0")
75
+ if value < 0:
76
+ raise ValueError("value cannot be negative")
77
+ if variant not in self.VARIANTS:
78
+ raise ValueError(
79
+ f"Invalid variant '{variant}'. Must be one of {self.VARIANTS}"
80
+ )
81
+ if size not in self.SIZES:
82
+ raise ValueError(f"Invalid size '{size}'. Must be one of {self.SIZES}")
83
+
20
84
  self.value = value
21
85
  self.max = max
22
- self.variant = variant if variant in self.VARIANTS else "default"
23
- self.size = size if size in self.SIZES else "md"
86
+ self.variant = variant
87
+ self.size = size
24
88
  self.show_label = show_label
25
- self.element = self._render()
26
89
 
27
- def _render(self):
28
- container = create_el("div")
90
+ def render(self) -> ProgressElement:
91
+ """Render the progress bar as a ProgressElement.
92
+
93
+ Returns:
94
+ ProgressElement representing the progress bar
95
+ """
96
+ parts = []
29
97
 
98
+ # Label
30
99
  if self.show_label:
31
- label = create_el("div", "cn-progress-label")
32
- current = create_el("span")
33
- current.textContent = str(int(self.value))
34
- total = create_el("span")
35
- total.textContent = str(int(self.max))
36
- label.appendChild(current)
37
- label.appendChild(total)
38
- container.appendChild(label)
39
-
40
- progress = create_el("div", "cn-progress")
100
+ parts.append(
101
+ f'<div class="cn-progress-label">'
102
+ f"<span>{int(self.value)}</span>"
103
+ f"<span>{int(self.max)}</span>"
104
+ f"</div>"
105
+ )
106
+
107
+ # Progress bar container
108
+ bar_classes = ["cn-progress"]
41
109
  if self.size != "md":
42
- progress.classList.add(f"cn-progress-{self.size}")
110
+ bar_classes.append(f"cn-progress-{self.size}")
43
111
  if self.variant != "default":
44
- progress.classList.add(f"cn-progress-{self.variant}")
112
+ bar_classes.append(f"cn-progress-{self.variant}")
45
113
 
46
- bar = create_el("div", "cn-progress-bar")
47
- bar.style.width = f"{(self.value / self.max) * 100}%"
48
- progress.appendChild(bar)
114
+ # Bar fill
115
+ percentage = (self.value / self.max) * 100
116
+ bar_fill = (
117
+ f'<div class="cn-progress-bar" style="width: {percentage}%;"></div>'
118
+ )
49
119
 
50
- container.appendChild(progress)
51
- return container
120
+ parts.append(
121
+ f'<div class="{" ".join(bar_classes)}">{bar_fill}</div>'
122
+ )
52
123
 
53
- def set_value(self, value: float):
54
- self.value = value
55
- bar = self.element.querySelector(".cn-progress-bar")
56
- if bar:
57
- bar.style.width = f"{(self.value / self.max) * 100}%"
124
+ return ProgressElement(inner_html="".join(parts))
125
+
126
+ def render_html(self) -> str:
127
+ """Render the progress bar as an HTML string.
128
+
129
+ Returns:
130
+ HTML string representation of the progress bar
131
+ """
132
+ return self.render().render_html()
133
+
134
+ def with_value(self, value: float) -> "Progress":
135
+ """Return a new Progress with updated value (immutable pattern).
136
+
137
+ Since components generate strings, we can't update in place after rendering.
138
+ Use this to create an updated copy.
139
+
140
+ Args:
141
+ value: New progress value
142
+
143
+ Returns:
144
+ New Progress instance with the updated value
145
+ """
146
+ return Progress(
147
+ value=value,
148
+ max=self.max,
149
+ variant=self.variant,
150
+ size=self.size,
151
+ show_label=self.show_label,
152
+ )
58
153
 
59
154
 
60
155
  class Stat:
61
- """Stat component for displaying metrics."""
156
+ """Stat component for displaying metrics with optional delta/ trend.
157
+
158
+ Args:
159
+ value: Primary stat value (e.g. "1,234")
160
+ label: Stat label (e.g. "Total Users")
161
+ delta: Optional delta text (e.g. "+12%")
162
+ delta_type: Optional delta type for styling (e.g. "positive", "negative")
163
+
164
+ Example:
165
+ >>> stat = Stat(value="1,234", label="Total Users", delta="+12%", delta_type="positive")
166
+ >>> print(stat.render_html())
167
+ <div class="cn-stat">
168
+ <div class="cn-stat-value">1,234</div>
169
+ <div class="cn-stat-label">Total Users</div>
170
+ <div class="cn-stat-delta cn-stat-delta-positive">+12%</div>
171
+ </div>
172
+ """
62
173
 
63
174
  def __init__(
64
- self, value: str, label: str, delta: str = None, delta_type: str = None
175
+ self,
176
+ value: str,
177
+ label: str,
178
+ delta: Optional[str] = None,
179
+ delta_type: Optional[str] = None,
65
180
  ):
181
+ if not value:
182
+ raise ValueError("value cannot be empty")
183
+ if not label:
184
+ raise ValueError("label cannot be empty")
185
+
66
186
  self.value = value
67
187
  self.label = label
68
188
  self.delta = delta
69
189
  self.delta_type = delta_type
70
- self.element = self._render()
71
-
72
- def _render(self):
73
- el = create_el("div", "cn-stat")
74
190
 
75
- value_el = create_el("div", "cn-stat-value")
76
- value_el.textContent = self.value
77
- el.appendChild(value_el)
191
+ def render(self) -> ProgressElement:
192
+ """Render the stat as a ProgressElement.
78
193
 
79
- label_el = create_el("div", "cn-stat-label")
80
- label_el.textContent = self.label
81
- el.appendChild(label_el)
194
+ Returns:
195
+ ProgressElement representing the stat display
196
+ """
197
+ parts = [
198
+ f'<div class="cn-stat-value">{self._esc(self.value)}</div>',
199
+ f'<div class="cn-stat-label">{self._esc(self.label)}</div>',
200
+ ]
82
201
 
83
202
  if self.delta:
84
- delta_el = create_el(
85
- "div", f"cn-stat-delta cn-stat-delta-{self.delta_type}"
203
+ delta_classes = ["cn-stat-delta"]
204
+ if self.delta_type:
205
+ delta_classes.append(f"cn-stat-delta-{self.delta_type}")
206
+ parts.append(
207
+ f'<div class="{" ".join(delta_classes)}">{self._esc(self.delta)}</div>'
86
208
  )
87
- delta_el.textContent = self.delta
88
- el.appendChild(delta_el)
89
209
 
90
- return el
210
+ return ProgressElement(
211
+ classes=["cn-stat"],
212
+ inner_html="".join(parts),
213
+ )
214
+
215
+ def render_html(self) -> str:
216
+ """Render the stat as an HTML string.
217
+
218
+ Returns:
219
+ HTML string representation of the stat
220
+ """
221
+ return self.render().render_html()
222
+
223
+ @staticmethod
224
+ def _esc(text: str) -> str:
225
+ """Escape HTML special characters."""
226
+ return (
227
+ text.replace("&", "&amp;")
228
+ .replace("<", "&lt;")
229
+ .replace(">", "&gt;")
230
+ .replace('"', "&quot;")
231
+ .replace("'", "&#x27;")
232
+ )