Coverage for src / lexigram / ui / atoms / inputs / special / complex.py: 22%
82 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram import serialization as json
6from lexigram.serialization import dumps_str
7from lexigram.ui.atoms.inputs.base import AbstractInput
8from lexigram.ui.core.base import el
11class Rating(AbstractInput):
12 """Star rating component."""
14 def __init__(
15 self,
16 name: str,
17 max_value: int = 5,
18 **kwargs,
19 ) -> None:
20 super().__init__(name=name, **kwargs)
21 self.max = max_value
23 def _render_input(self) -> Any:
24 stars = []
25 current_val = int(self.value or 0)
26 for i in range(1, self.max + 1):
27 is_active = i <= current_val
28 color = "text-warning" if is_active else "text-muted-foreground"
29 star = el(
30 "button",
31 el(
32 "svg",
33 el(
34 "path",
35 d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z",
36 ),
37 class_=f"w-6 h-6 {color}",
38 viewBox="0 0 20 20",
39 fill="currentColor",
40 ),
41 type="button",
42 disabled=self.disabled,
43 class_="focus:outline-none",
44 )
45 stars.append(star)
47 hidden_input = el("input", type="hidden", name=self.name, value=current_val)
49 return el("div", *stars, hidden_input, class_="flex items-center gap-1")
51 def render(self) -> Any:
52 content = self._render_input()
54 if not self.label:
55 return content
57 return el(
58 "div",
59 el(
60 "label",
61 self.label,
62 class_="block text-sm font-medium text-foreground mb-2",
63 ),
64 content,
65 self._render_error(),
66 class_="mb-6",
67 )
70class TagsInput(AbstractInput):
71 """
72 Premium tags input using Alpine.js.
73 Allows adding strings as tags/chips.
74 """
76 def __init__(
77 self,
78 name: str,
79 placeholder: str = "Add tag...",
80 **kwargs,
81 ) -> None:
82 super().__init__(name=name, **kwargs)
83 self.placeholder = placeholder
85 def _render_input(self) -> Any:
86 if isinstance(self.value, str):
87 tags_list = list(
88 filter(
89 lambda v: v.strip(),
90 (v.strip() for v in self.value.split(",")),
91 ),
92 )
93 else:
94 tags_list = self.value or []
96 initial_tags = dumps_str(tags_list)
98 container_classes = (
99 "block w-full rounded-lg border-0 p-1.5 shadow-sm ring-1 ring-inset sm:text-sm min-h-[42px] transition-all duration-200 bg-background "
100 f"{'ring-destructive focus-within:ring-destructive' if self.error else 'ring-[var(--input)] focus-within:ring-ring'} "
101 f"{'opacity-50 cursor-not-allowed' if self.disabled else ''}"
102 )
104 x_data = f"{{ tags: {initial_tags}, inputValue: '', addTag() {{ if(this.inputValue.trim() && !this.tags.includes(this.inputValue.trim())) {{ this.tags.push(this.inputValue.trim()); this.inputValue = ''; }} }}, removeTag(index) {{ this.tags.splice(index, 1); }} }}"
106 chips = el(
107 "template",
108 el(
109 "span",
110 el("span", **{"x-text": "tag"}),
111 el(
112 "button",
113 el(
114 "svg",
115 el(
116 "path",
117 d="M6 18L18 6M6 6l12 12",
118 stroke_linecap="round",
119 stroke_linejoin="round",
120 stroke_width="2",
121 ),
122 class_="w-3 h-3",
123 fill="none",
124 viewBox="0 0 24 24",
125 stroke="currentColor",
126 ),
127 type="button",
128 **{"@click": "removeTag(index)"},
129 class_="ml-1 hover:text-primary focus:outline-none",
130 ),
131 class_="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-primary/10 text-primary mr-2 mb-1",
132 ),
133 **{"x-for": "(tag, index) in tags", ":key": "index"},
134 )
136 input_el = el(
137 "input",
138 type="text",
139 placeholder=self.placeholder if not tags_list else "",
140 class_="flex-1 border-0 focus:ring-0 bg-transparent text-foreground p-1 min-w-[120px] sm:text-sm",
141 disabled=self.disabled,
142 **{
143 "x-model": "inputValue",
144 "@keydown.enter.prevent": "addTag()",
145 "@keydown.comma.prevent": "addTag()",
146 "@keydown.backspace": "if(!inputValue && tags.length) removeTag(tags.length - 1)",
147 },
148 )
150 hidden_input = el(
151 "input",
152 type="hidden",
153 name=self.name,
154 **{":value": "tags.join(',')"},
155 )
157 content = el(
158 "div",
159 chips,
160 input_el,
161 hidden_input,
162 class_="flex flex-wrap items-center",
163 **{"x-data": x_data},
164 )
166 return el("div", content, class_=container_classes)
168 def render(self) -> Any:
169 content = self._render_input()
171 if not self.label:
172 return content
174 return el(
175 "div",
176 el(
177 "label",
178 self.label,
179 for_=self.input_id,
180 class_="block text-sm font-medium text-foreground mb-1",
181 ),
182 content,
183 self._render_error(),
184 class_="mb-6",
185 )
188class KeyValueField(AbstractInput):
189 """
190 Component for editing key-value pairs (JSON dictionary).
191 """
193 def __init__(
194 self,
195 name: str,
196 key_label: str = "Key",
197 value_label: str = "Value",
198 **kwargs,
199 ) -> None:
200 super().__init__(name=name, **kwargs)
201 self.key_label = key_label
202 self.value_label = value_label
204 def _render_input(self) -> Any:
205 from lexigram.serialization import loads_str
207 val = self.value
208 if isinstance(val, str):
209 try:
210 val = loads_str(val)
211 except (ValueError, TypeError, json.JSONDecodeError):
212 val = {}
214 if isinstance(val, list):
215 tmp = {}
216 for item in val:
217 if isinstance(item, dict) and "key" in item:
218 tmp[item.get("key")] = item.get("value")
219 elif isinstance(item, (list, tuple)) and len(item) >= 2:
220 tmp[item[0]] = item[1]
221 val = tmp
223 if not isinstance(val, dict):
224 val = {}
226 initial_data = dumps_str(
227 [{"key": kv[0], "value": kv[1]} for kv in (val or {}).items()],
228 )
230 _container_id = f"kv_{self.name}"
231 x_data = f"{{ rows: {initial_data}, addRow() {{ this.rows.push({{key: '', value: ''}}); }}, removeRow(index) {{ this.rows.splice(index, 1); }}, getSerialized() {{ let obj = {{}}; this.rows.forEach(r => {{ if(r.key) obj[r.key] = r.value; }}); return JSON.stringify(obj); }} }}"
233 header = el(
234 "div",
235 el(
236 "div",
237 self.key_label,
238 class_="text-xs font-semibold text-muted-foreground flex-1",
239 ),
240 el(
241 "div",
242 self.value_label,
243 class_="text-xs font-semibold text-muted-foreground flex-1",
244 ),
245 el("div", "", class_="w-8"),
246 class_="flex gap-4 mb-2 px-2",
247 )
249 row_template = el(
250 "template",
251 el(
252 "div",
253 el(
254 "input",
255 type="text",
256 **{"x-model": "row.key"},
257 placeholder="Key",
258 class_="flex-1 rounded-lg border-input bg-background text-sm focus:ring-ring focus:border-ring",
259 disabled=self.disabled,
260 ),
261 el(
262 "input",
263 type="text",
264 **{"x-model": "row.value"},
265 placeholder="Value",
266 class_="flex-1 rounded-lg border-input bg-background text-sm focus:ring-ring focus:border-ring",
267 disabled=self.disabled,
268 ),
269 el(
270 "button",
271 el(
272 "svg",
273 el(
274 "path",
275 d="M6 18L18 6M6 6l12 12",
276 stroke_linecap="round",
277 stroke_linejoin="round",
278 stroke_width="2",
279 ),
280 fill="none",
281 viewBox="0 0 24 24",
282 stroke="currentColor",
283 class_="w-4 h-4",
284 ),
285 type="button",
286 **{"@click": "removeRow(index)"},
287 class_="p-1 text-muted-foreground hover:text-destructive transition-colors duration-200",
288 ),
289 class_="flex gap-4 mb-2 items-center px-1",
290 ),
291 **{"x-for": "(row, index) in rows", ":key": "index"},
292 )
294 add_button = el(
295 "button",
296 el(
297 "svg",
298 el(
299 "path",
300 d="M12 4v16m8-8H4",
301 stroke_linecap="round",
302 stroke_linejoin="round",
303 stroke_width="2",
304 ),
305 class_="w-4 h-4 mr-1",
306 fill="none",
307 viewBox="0 0 24 24",
308 stroke="currentColor",
309 ),
310 "Add Pair",
311 type="button",
312 **{"@click": "addRow()"},
313 class_="mt-2 inline-flex items-center px-3 py-1.5 text-xs font-medium text-primary bg-primary/5 hover:bg-primary/15 rounded-lg transition-colors duration-200",
314 disabled=self.disabled,
315 )
317 hidden_input = el(
318 "input",
319 type="hidden",
320 name=self.name,
321 **{":value": "getSerialized()"},
322 )
324 return el(
325 "div",
326 header,
327 row_template,
328 add_button,
329 hidden_input,
330 class_="p-3 border border-border rounded-xl bg-background",
331 **{"x-data": x_data},
332 )
334 def render(self) -> Any:
335 content = self._render_input()
337 if not self.label:
338 return content
340 return el(
341 "div",
342 el(
343 "label",
344 self.label,
345 class_="block text-sm font-medium text-foreground mb-1",
346 ),
347 content,
348 self._render_error(),
349 class_="mb-6",
350 )