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,11 +1,63 @@
1
- """CronixUI Form Components"""
1
+ """CronixUI Form Components.
2
2
 
3
- from typing import Optional, Callable
4
- from .core import create_el
3
+ Generates HTML for form inputs, textareas, checkboxes, radios, selects, sliders,
4
+ file inputs, and form field wrappers.
5
+ No browser DOM APIs are used - all output is HTML strings or data structures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Optional, Tuple, Union
12
+
13
+
14
+ @dataclass
15
+ class FormElement:
16
+ """Represents a rendered form element."""
17
+
18
+ tag: str = "div"
19
+ classes: List[str] = field(default_factory=list)
20
+ attributes: Dict[str, str] = field(default_factory=dict)
21
+ inner_html: str = ""
22
+
23
+ def render_html(self) -> str:
24
+ """Render as HTML string.
25
+
26
+ Returns:
27
+ Complete HTML for the form element
28
+ """
29
+ class_str = " ".join(self.classes)
30
+ class_attr = f' class="{class_str}"' if class_str else ""
31
+ attrs_str = "".join(f' {k}="{v}"' for k, v in self.attributes.items())
32
+ return f"<{self.tag}{class_attr}{attrs_str}>{self.inner_html}</{self.tag}>"
33
+
34
+ def render(self) -> "FormElement":
35
+ """Return self for API compatibility."""
36
+ return self
5
37
 
6
38
 
7
39
  class Input:
8
- """Input component."""
40
+ """Input component for text and other input types.
41
+
42
+ Args:
43
+ placeholder: Placeholder text
44
+ size: Input size - sm, md, lg (default: md)
45
+ error: Whether input has an error state (default: False)
46
+ disabled: Whether input is disabled (default: False)
47
+ icon: Optional SVG icon markup
48
+ name: Optional input name attribute
49
+ value: Optional initial value
50
+ input_type: HTML input type (text, email, password, etc.) (default: text)
51
+
52
+ Example:
53
+ >>> inp = Input(placeholder="Enter your name", size="lg")
54
+ >>> print(inp.render_html())
55
+ <input class="cn-input cn-input-lg" placeholder="Enter your name" />
56
+
57
+ >>> with_icon = Input(placeholder="Search...", icon="<svg>...</svg>")
58
+ >>> print(with_icon.render_html())
59
+ <div class="cn-input-icon-wrapper"><span class="cn-input-icon">...</span><input class="cn-input" placeholder="Search..." /></div>
60
+ """
9
61
 
10
62
  SIZES = ("sm", "md", "lg")
11
63
 
@@ -16,178 +68,433 @@ class Input:
16
68
  error: bool = False,
17
69
  disabled: bool = False,
18
70
  icon: Optional[str] = None,
71
+ name: Optional[str] = None,
72
+ value: Optional[str] = None,
73
+ input_type: str = "text",
19
74
  ):
75
+ if size not in self.SIZES:
76
+ raise ValueError(f"Invalid size '{size}'. Must be one of {self.SIZES}")
77
+
20
78
  self.placeholder = placeholder
21
- self.size = size if size in self.SIZES else "md"
79
+ self.size = size
22
80
  self.error = error
23
81
  self.disabled = disabled
24
82
  self.icon = icon
25
- self.element = self._render()
83
+ self.name = name
84
+ self.value = value
85
+ self.input_type = input_type
26
86
 
27
- def _render(self):
28
- if self.icon:
29
- wrapper = create_el("div", "cn-input-icon-wrapper")
30
- input_el = create_el("input", "cn-input")
31
- input_el.setAttribute("placeholder", self.placeholder)
32
- if self.disabled:
33
- input_el.setAttribute("disabled", "")
34
- if self.error:
35
- input_el.classList.add("cn-input-error")
36
-
37
- icon_el = create_el("span", "cn-input-icon")
38
- icon_el.innerHTML = self.icon
39
- wrapper.appendChild(icon_el)
40
- wrapper.appendChild(input_el)
41
- return wrapper
42
-
43
- el = create_el("input", "cn-input")
44
- el.setAttribute("placeholder", self.placeholder)
45
- if self.disabled:
46
- el.setAttribute("disabled", "")
47
- if self.error:
48
- el.classList.add("cn-input-error")
87
+ def render(self) -> FormElement:
88
+ """Render the input as a FormElement.
89
+
90
+ Returns:
91
+ FormElement representing the input
92
+ """
93
+ classes = ["cn-input"]
49
94
  if self.size != "md":
50
- el.classList.add(f"cn-input-{self.size}")
51
- return el
95
+ classes.append(f"cn-input-{self.size}")
96
+ if self.error:
97
+ classes.append("cn-input-error")
98
+
99
+ attrs: Dict[str, str] = {
100
+ "type": self.input_type,
101
+ "placeholder": self.placeholder,
102
+ }
103
+ if self.name is not None:
104
+ attrs["name"] = self.name
105
+ if self.value is not None:
106
+ attrs["value"] = self.value
107
+ if self.disabled:
108
+ attrs["disabled"] = ""
109
+
110
+ attrs_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
111
+ inner = f"<input{attrs_str} />"
112
+
113
+ if self.icon:
114
+ wrapper_classes = "cn-input-icon-wrapper"
115
+ icon_html = f'<span class="cn-input-icon">{self.icon}</span>'
116
+ inner = f'<div class="{wrapper_classes}">{icon_html}{inner}</div>'
117
+
118
+ return FormElement(inner_html=inner)
119
+
120
+ def render_html(self) -> str:
121
+ """Render the input as an HTML string.
122
+
123
+ Returns:
124
+ HTML string representation of the input
125
+ """
126
+ return self.render().render_html()
52
127
 
53
128
 
54
129
  class Textarea:
55
- """Textarea component."""
130
+ """Textarea component for multi-line text input.
131
+
132
+ Args:
133
+ placeholder: Placeholder text
134
+ rows: Number of visible rows (default: 4)
135
+ name: Optional name attribute
136
+ disabled: Whether textarea is disabled (default: False)
137
+
138
+ Example:
139
+ >>> ta = Textarea(placeholder="Write your message...", rows=6)
140
+ >>> print(ta.render_html())
141
+ <textarea class="cn-input cn-textarea" placeholder="Write your message..." rows="6"></textarea>
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ placeholder: str = "",
147
+ rows: int = 4,
148
+ name: Optional[str] = None,
149
+ disabled: bool = False,
150
+ ):
151
+ if rows < 1:
152
+ raise ValueError("rows must be at least 1")
56
153
 
57
- def __init__(self, placeholder: str = "", rows: int = 4):
58
154
  self.placeholder = placeholder
59
155
  self.rows = rows
60
- self.element = self._render()
156
+ self.name = name
157
+ self.disabled = disabled
158
+
159
+ def render(self) -> FormElement:
160
+ """Render the textarea as a FormElement.
161
+
162
+ Returns:
163
+ FormElement representing the textarea
164
+ """
165
+ attrs: Dict[str, str] = {
166
+ "placeholder": self.placeholder,
167
+ "rows": str(self.rows),
168
+ }
169
+ if self.name is not None:
170
+ attrs["name"] = self.name
171
+ if self.disabled:
172
+ attrs["disabled"] = ""
173
+
174
+ attrs_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
175
+ inner = f'<textarea class="cn-input cn-textarea"{attrs_str}></textarea>'
176
+
177
+ return FormElement(inner_html=inner)
178
+
179
+ def render_html(self) -> str:
180
+ """Render the textarea as an HTML string.
61
181
 
62
- def _render(self):
63
- el = create_el("textarea", "cn-input cn-textarea")
64
- el.setAttribute("placeholder", self.placeholder)
65
- el.setAttribute("rows", str(self.rows))
66
- return el
182
+ Returns:
183
+ HTML string representation of the textarea
184
+ """
185
+ return self.render().render_html()
67
186
 
68
187
 
69
188
  class FormField:
70
- """Form field with label and error."""
189
+ """Form field wrapper with label, error message, and help text.
190
+
191
+ Args:
192
+ label: Field label text
193
+ input_component: An Input, Textarea, or any component with render_html()
194
+ error: Optional error message text
195
+ help_text: Optional help/description text
196
+ required: Whether to mark the field as required (default: False)
197
+
198
+ Example:
199
+ >>> field = FormField(
200
+ ... label="Email",
201
+ ... input_component=Input(placeholder="you@example.com"),
202
+ ... help_text="We'll never share your email.",
203
+ ... )
204
+ >>> print(field.render_html())
205
+ <div class="cn-form-group">
206
+ <label class="cn-form-label">Email</label>
207
+ <input class="cn-input" placeholder="you@example.com" />
208
+ <span class="cn-form-help">We'll never share your email.</span>
209
+ </div>
210
+ """
211
+
212
+ def __init__(
213
+ self,
214
+ label: str,
215
+ input_component: Union[Input, Textarea, Checkbox, Radio, Select, Slider, "HasRenderHtml"],
216
+ error: Optional[str] = None,
217
+ help_text: Optional[str] = None,
218
+ required: bool = False,
219
+ ):
220
+ if not label:
221
+ raise ValueError("label cannot be empty")
71
222
 
72
- def __init__(self, label: str, input_el, error: str = None, help_text: str = None):
73
223
  self.label = label
74
- self.input = input_el
224
+ self.input = input_component
75
225
  self.error = error
76
226
  self.help_text = help_text
77
- self.element = self._render()
227
+ self.required = required
78
228
 
79
- def _render(self):
80
- group = create_el("div", "cn-form-group")
229
+ def render(self) -> FormElement:
230
+ """Render the form field as a FormElement.
81
231
 
82
- label_el = create_el("label", "cn-form-label")
83
- label_el.textContent = self.label
84
- group.appendChild(label_el)
232
+ Returns:
233
+ FormElement wrapping the label, input, and optional messages
234
+ """
235
+ required_mark = ' <span class="cn-form-required">*</span>' if self.required else ""
236
+ label_text = f'{self._esc(self.label)}{required_mark}'
85
237
 
86
- if hasattr(self.input, "element"):
87
- group.appendChild(self.input.element)
238
+ if hasattr(self.input, "render_html"):
239
+ input_html = self.input.render_html()
88
240
  else:
89
- group.appendChild(self.input)
241
+ input_html = str(self.input)
242
+
243
+ parts = [
244
+ f'<label class="cn-form-label">{label_text}</label>',
245
+ input_html,
246
+ ]
90
247
 
91
248
  if self.error:
92
- error_el = create_el("span", "cn-form-error")
93
- error_el.textContent = self.error
94
- group.appendChild(error_el)
249
+ parts.append(f'<span class="cn-form-error">{self._esc(self.error)}</span>')
95
250
 
96
251
  if self.help_text:
97
- help_el = create_el("span", "cn-form-help")
98
- help_el.textContent = self.help_text
99
- group.appendChild(help_el)
252
+ parts.append(f'<span class="cn-form-help">{self._esc(self.help_text)}</span>')
253
+
254
+ return FormElement(
255
+ classes=["cn-form-group"],
256
+ inner_html="".join(parts),
257
+ )
258
+
259
+ def render_html(self) -> str:
260
+ """Render the form field as an HTML string.
100
261
 
101
- return group
262
+ Returns:
263
+ HTML string representation of the form field
264
+ """
265
+ return self.render().render_html()
266
+
267
+ @staticmethod
268
+ def _esc(text: str) -> str:
269
+ """Escape HTML special characters."""
270
+ return (
271
+ text.replace("&", "&amp;")
272
+ .replace("<", "&lt;")
273
+ .replace(">", "&gt;")
274
+ .replace('"', "&quot;")
275
+ .replace("'", "&#x27;")
276
+ )
102
277
 
103
278
 
104
279
  class Checkbox:
105
- """Checkbox component."""
280
+ """Checkbox component with label.
281
+
282
+ Args:
283
+ label: Checkbox label text
284
+ checked: Whether checkbox is initially checked (default: False)
285
+ disabled: Whether checkbox is disabled (default: False)
286
+ name: Optional name attribute
287
+
288
+ Example:
289
+ >>> cb = Checkbox("Accept terms", checked=True)
290
+ >>> print(cb.render_html())
291
+ <label class="cn-checkbox"><input type="checkbox" checked="" /><span class="cn-checkbox-box"></span><span class="cn-checkbox-label">Accept terms</span></label>
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ label: str,
297
+ checked: bool = False,
298
+ disabled: bool = False,
299
+ name: Optional[str] = None,
300
+ ):
301
+ if not label:
302
+ raise ValueError("label cannot be empty")
106
303
 
107
- def __init__(self, label: str, checked: bool = False, disabled: bool = False):
108
304
  self.label = label
109
305
  self.checked = checked
110
306
  self.disabled = disabled
111
- self.element = self._render()
307
+ self.name = name
112
308
 
113
- def _render(self):
114
- el = create_el("label", "cn-checkbox")
115
- if self.disabled:
116
- el.classList.add("disabled")
309
+ def render(self) -> FormElement:
310
+ """Render the checkbox as a FormElement.
117
311
 
118
- input_el = create_el("input")
119
- input_el.setAttribute("type", "checkbox")
312
+ Returns:
313
+ FormElement representing the checkbox
314
+ """
315
+ attrs: Dict[str, str] = {"type": "checkbox"}
316
+ if self.name is not None:
317
+ attrs["name"] = self.name
120
318
  if self.checked:
121
- input_el.setAttribute("checked", "")
319
+ attrs["checked"] = ""
122
320
  if self.disabled:
123
- input_el.setAttribute("disabled", "")
321
+ attrs["disabled"] = ""
124
322
 
125
- box = create_el("span", "cn-checkbox-box")
126
- label_el = create_el("span", "cn-checkbox-label")
127
- label_el.textContent = self.label
323
+ attrs_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
128
324
 
129
- el.appendChild(input_el)
130
- el.appendChild(box)
131
- el.appendChild(label_el)
132
- return el
325
+ label_classes = "cn-checkbox"
326
+ if self.disabled:
327
+ label_classes += " disabled"
328
+
329
+ inner = (
330
+ f'<label class="{label_classes}">'
331
+ f'<input{attrs_str} />'
332
+ f'<span class="cn-checkbox-box"></span>'
333
+ f'<span class="cn-checkbox-label">{self._esc(self.label)}</span>'
334
+ f"</label>"
335
+ )
336
+
337
+ return FormElement(inner_html=inner)
338
+
339
+ def render_html(self) -> str:
340
+ """Render the checkbox as an HTML string.
341
+
342
+ Returns:
343
+ HTML string representation of the checkbox
344
+ """
345
+ return self.render().render_html()
346
+
347
+ @staticmethod
348
+ def _esc(text: str) -> str:
349
+ """Escape HTML special characters."""
350
+ return (
351
+ text.replace("&", "&amp;")
352
+ .replace("<", "&lt;")
353
+ .replace(">", "&gt;")
354
+ .replace('"', "&quot;")
355
+ .replace("'", "&#x27;")
356
+ )
133
357
 
134
358
 
135
359
  class Radio:
136
- """Radio button component."""
360
+ """Radio button group component.
361
+
362
+ Args:
363
+ name: Radio group name (shared name attribute)
364
+ options: List of (value, label) tuples or plain strings
365
+ selected: Currently selected value
366
+ disabled: Whether entire group is disabled (default: False)
367
+
368
+ Example:
369
+ >>> radio = Radio("color", [("red", "Red"), ("blue", "Blue")], selected="red")
370
+ >>> print(radio.render_html())
371
+ """
372
+
373
+ def __init__(
374
+ self,
375
+ name: str,
376
+ options: List[Union[Tuple[str, str], str]],
377
+ selected: Optional[str] = None,
378
+ disabled: bool = False,
379
+ ):
380
+ if not name:
381
+ raise ValueError("name cannot be empty")
382
+ if not options:
383
+ raise ValueError("options cannot be empty")
137
384
 
138
- def __init__(self, name: str, options: list, selected: str = None):
139
385
  self.name = name
140
386
  self.options = options
141
387
  self.selected = selected
142
- self.element = self._render()
388
+ self.disabled = disabled
389
+
390
+ def render(self) -> FormElement:
391
+ """Render the radio group as a FormElement.
143
392
 
144
- def _render(self):
145
- container = create_el("div")
393
+ Returns:
394
+ FormElement containing all radio options
395
+ """
396
+ parts = []
146
397
  for option in self.options:
147
398
  if isinstance(option, tuple):
148
399
  value, label = option
149
400
  else:
150
401
  value = label = option
151
402
 
152
- el = create_el("label", "cn-radio")
153
- input_el = create_el("input")
154
- input_el.setAttribute("type", "radio")
155
- input_el.setAttribute("name", self.name)
156
- input_el.setAttribute("value", value)
403
+ input_attrs: Dict[str, str] = {
404
+ "type": "radio",
405
+ "name": self.name,
406
+ "value": value,
407
+ }
157
408
  if value == self.selected:
158
- input_el.setAttribute("checked", "")
409
+ input_attrs["checked"] = ""
410
+ if self.disabled:
411
+ input_attrs["disabled"] = ""
412
+
413
+ attrs_str = "".join(f' {k}="{v}"' for k, v in input_attrs.items())
414
+
415
+ parts.append(
416
+ f'<label class="cn-radio">'
417
+ f'<input{attrs_str} />'
418
+ f'<span class="cn-radio-box"></span>'
419
+ f'<span class="cn-radio-label">{self._esc(label)}</span>'
420
+ f"</label>"
421
+ )
159
422
 
160
- box = create_el("span", "cn-radio-box")
161
- label_el = create_el("span", "cn-radio-label")
162
- label_el.textContent = label
423
+ return FormElement(inner_html="".join(parts))
163
424
 
164
- el.appendChild(input_el)
165
- el.appendChild(box)
166
- el.appendChild(label_el)
167
- container.appendChild(el)
425
+ def render_html(self) -> str:
426
+ """Render the radio group as an HTML string.
168
427
 
169
- return container
428
+ Returns:
429
+ HTML string representation of the radio group
430
+ """
431
+ return self.render().render_html()
432
+
433
+ @staticmethod
434
+ def _esc(text: str) -> str:
435
+ """Escape HTML special characters."""
436
+ return (
437
+ text.replace("&", "&amp;")
438
+ .replace("<", "&lt;")
439
+ .replace(">", "&gt;")
440
+ .replace('"', "&quot;")
441
+ .replace("'", "&#x27;")
442
+ )
170
443
 
171
444
 
172
445
  class Select:
173
- """Select dropdown component."""
446
+ """Select dropdown component.
447
+
448
+ Args:
449
+ options: List of (value, label) tuples or plain strings
450
+ placeholder: Optional placeholder/disabled first option
451
+ name: Optional name attribute
452
+ disabled: Whether select is disabled (default: False)
453
+
454
+ Example:
455
+ >>> sel = Select(
456
+ ... options=[("1", "One"), ("2", "Two")],
457
+ ... placeholder="Choose...",
458
+ ... )
459
+ >>> print(sel.render_html())
460
+ """
461
+
462
+ def __init__(
463
+ self,
464
+ options: List[Union[Tuple[str, str], str]],
465
+ placeholder: str = "",
466
+ name: Optional[str] = None,
467
+ disabled: bool = False,
468
+ ):
469
+ if not options:
470
+ raise ValueError("options cannot be empty")
174
471
 
175
- def __init__(self, options: list, placeholder: str = ""):
176
472
  self.options = options
177
473
  self.placeholder = placeholder
178
- self.element = self._render()
474
+ self.name = name
475
+ self.disabled = disabled
476
+
477
+ def render(self) -> FormElement:
478
+ """Render the select as a FormElement.
479
+
480
+ Returns:
481
+ FormElement representing the select dropdown
482
+ """
483
+ parts = []
484
+
485
+ select_attrs: Dict[str, str] = {}
486
+ if self.name is not None:
487
+ select_attrs["name"] = self.name
488
+ if self.disabled:
489
+ select_attrs["disabled"] = ""
179
490
 
180
- def _render(self):
181
- wrapper = create_el("div", "cn-select-wrapper")
182
- select = create_el("select", "cn-select")
491
+ attrs_str = "".join(f' {k}="{v}"' for k, v in select_attrs.items())
492
+ attrs_str = f' class="cn-select"{attrs_str}' if attrs_str else ' class="cn-select"'
183
493
 
184
494
  if self.placeholder:
185
- placeholder_option = create_el("option")
186
- placeholder_option.setAttribute("value", "")
187
- placeholder_option.setAttribute("disabled", "")
188
- placeholder_option.setAttribute("selected", "")
189
- placeholder_option.textContent = self.placeholder
190
- select.appendChild(placeholder_option)
495
+ parts.append(
496
+ f'<option value="" disabled selected>{self._esc(self.placeholder)}</option>'
497
+ )
191
498
 
192
499
  for option in self.options:
193
500
  if isinstance(option, tuple):
@@ -195,61 +502,165 @@ class Select:
195
502
  else:
196
503
  value = label = option
197
504
 
198
- opt = create_el("option")
199
- opt.setAttribute("value", value)
200
- opt.textContent = label
201
- select.appendChild(opt)
505
+ parts.append(f'<option value="{self._esc(value)}">{self._esc(label)}</option>')
202
506
 
203
- wrapper.appendChild(select)
204
- return wrapper
507
+ inner = f"<select{attrs_str}>{''.join(parts)}</select>"
508
+
509
+ return FormElement(
510
+ classes=["cn-select-wrapper"],
511
+ inner_html=inner,
512
+ )
513
+
514
+ def render_html(self) -> str:
515
+ """Render the select as an HTML string.
516
+
517
+ Returns:
518
+ HTML string representation of the select dropdown
519
+ """
520
+ return self.render().render_html()
521
+
522
+ @staticmethod
523
+ def _esc(text: str) -> str:
524
+ """Escape HTML special characters."""
525
+ return (
526
+ text.replace("&", "&amp;")
527
+ .replace("<", "&lt;")
528
+ .replace(">", "&gt;")
529
+ .replace('"', "&quot;")
530
+ .replace("'", "&#x27;")
531
+ )
205
532
 
206
533
 
207
534
  class Slider:
208
- """Slider component."""
535
+ """Slider (range input) component.
536
+
537
+ Args:
538
+ min: Minimum value (default: 0)
539
+ max: Maximum value (default: 100)
540
+ value: Initial value (default: 50)
541
+ name: Optional name attribute
542
+ step: Optional step value
543
+
544
+ Example:
545
+ >>> slider = Slider(min=0, max=100, value=50)
546
+ >>> print(slider.render_html())
547
+ <input class="cn-slider" type="range" min="0" max="100" value="50" />
548
+ """
549
+
550
+ def __init__(
551
+ self,
552
+ min: float = 0,
553
+ max: float = 100,
554
+ value: float = 50,
555
+ name: Optional[str] = None,
556
+ step: Optional[float] = None,
557
+ ):
558
+ if min >= max:
559
+ raise ValueError("min must be less than max")
560
+ if value < min or value > max:
561
+ raise ValueError("value must be between min and max")
209
562
 
210
- def __init__(self, min: float = 0, max: float = 100, value: float = 50):
211
563
  self.min = min
212
564
  self.max = max
213
565
  self.value = value
214
- self.element = self._render()
566
+ self.name = name
567
+ self.step = step
568
+
569
+ def render(self) -> FormElement:
570
+ """Render the slider as a FormElement.
571
+
572
+ Returns:
573
+ FormElement representing the range input
574
+ """
575
+ attrs: Dict[str, str] = {
576
+ "type": "range",
577
+ "min": str(self.min),
578
+ "max": str(self.max),
579
+ "value": str(self.value),
580
+ }
581
+ if self.name is not None:
582
+ attrs["name"] = self.name
583
+ if self.step is not None:
584
+ attrs["step"] = str(self.step)
585
+
586
+ attrs_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
587
+ inner = f'<input class="cn-slider"{attrs_str} />'
215
588
 
216
- def _render(self):
217
- el = create_el("input", "cn-slider")
218
- el.setAttribute("type", "range")
219
- el.setAttribute("min", str(self.min))
220
- el.setAttribute("max", str(self.max))
221
- el.setAttribute("value", str(self.value))
222
- return el
589
+ return FormElement(inner_html=inner)
590
+
591
+ def render_html(self) -> str:
592
+ """Render the slider as an HTML string.
593
+
594
+ Returns:
595
+ HTML string representation of the slider
596
+ """
597
+ return self.render().render_html()
223
598
 
224
599
 
225
600
  class FileInput:
226
- """File input component."""
601
+ """File input component with drag-and-drop styling.
602
+
603
+ Args:
604
+ accept: Accepted file types (e.g. ".png,.jpg")
605
+ multiple: Whether multiple files can be selected (default: False)
606
+ name: Optional name attribute
607
+
608
+ Example:
609
+ >>> file = FileInput(accept=".png,.jpg", multiple=True)
610
+ >>> print(file.render_html())
611
+ """
227
612
 
228
- def __init__(self, accept: str = "", multiple: bool = False):
613
+ _UPLOAD_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'
614
+
615
+ def __init__(
616
+ self,
617
+ accept: str = "",
618
+ multiple: bool = False,
619
+ name: Optional[str] = None,
620
+ ):
229
621
  self.accept = accept
230
622
  self.multiple = multiple
231
- self.element = self._render()
623
+ self.name = name
232
624
 
233
- def _render(self):
234
- wrapper = create_el("div", "cn-file-input")
625
+ def render(self) -> FormElement:
626
+ """Render the file input as a FormElement.
235
627
 
236
- input_el = create_el("input")
237
- input_el.setAttribute("type", "file")
628
+ Returns:
629
+ FormElement representing the file input with label
630
+ """
631
+ input_attrs: Dict[str, str] = {"type": "file"}
632
+ if self.name is not None:
633
+ input_attrs["name"] = self.name
238
634
  if self.accept:
239
- input_el.setAttribute("accept", self.accept)
635
+ input_attrs["accept"] = self.accept
240
636
  if self.multiple:
241
- input_el.setAttribute("multiple", "")
637
+ input_attrs["multiple"] = ""
638
+
639
+ input_attrs_str = "".join(f' {k}="{v}"' for k, v in input_attrs.items())
640
+
641
+ inner = (
642
+ f'<div class="cn-file-input">'
643
+ f'<input{input_attrs_str} />'
644
+ f'<div class="cn-file-input-label">'
645
+ f'<div class="cn-file-input-icon">{self._UPLOAD_ICON}</div>'
646
+ f'<div class="cn-file-input-text">Drag and drop or <span>browse</span></div>'
647
+ f"</div>"
648
+ f"</div>"
649
+ )
650
+
651
+ return FormElement(inner_html=inner)
652
+
653
+ def render_html(self) -> str:
654
+ """Render the file input as an HTML string.
242
655
 
243
- label = create_el("div", "cn-file-input-label")
244
- icon = create_el("div", "cn-file-input-icon")
245
- icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'
656
+ Returns:
657
+ HTML string representation of the file input
658
+ """
659
+ return self.render().render_html()
246
660
 
247
- text = create_el("div", "cn-file-input-text")
248
- text.innerHTML = "Drag and drop or <span>browse</span>"
249
661
 
250
- label.appendChild(icon)
251
- label.appendChild(text)
252
- wrapper.appendChild(input_el)
253
- wrapper.appendChild(label)
662
+ class HasRenderHtml:
663
+ """Protocol-like base for type hints: any object with render_html()."""
254
664
 
255
- return wrapper
665
+ def render_html(self) -> str:
666
+ ...