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

1from __future__ import annotations 

2 

3from typing import Any 

4 

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 

9 

10 

11class Rating(AbstractInput): 

12 """Star rating component.""" 

13 

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 

22 

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) 

46 

47 hidden_input = el("input", type="hidden", name=self.name, value=current_val) 

48 

49 return el("div", *stars, hidden_input, class_="flex items-center gap-1") 

50 

51 def render(self) -> Any: 

52 content = self._render_input() 

53 

54 if not self.label: 

55 return content 

56 

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 ) 

68 

69 

70class TagsInput(AbstractInput): 

71 """ 

72 Premium tags input using Alpine.js. 

73 Allows adding strings as tags/chips. 

74 """ 

75 

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 

84 

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 [] 

95 

96 initial_tags = dumps_str(tags_list) 

97 

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 ) 

103 

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); }} }}" 

105 

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 ) 

135 

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 ) 

149 

150 hidden_input = el( 

151 "input", 

152 type="hidden", 

153 name=self.name, 

154 **{":value": "tags.join(',')"}, 

155 ) 

156 

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 ) 

165 

166 return el("div", content, class_=container_classes) 

167 

168 def render(self) -> Any: 

169 content = self._render_input() 

170 

171 if not self.label: 

172 return content 

173 

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 ) 

186 

187 

188class KeyValueField(AbstractInput): 

189 """ 

190 Component for editing key-value pairs (JSON dictionary). 

191 """ 

192 

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 

203 

204 def _render_input(self) -> Any: 

205 from lexigram.serialization import loads_str 

206 

207 val = self.value 

208 if isinstance(val, str): 

209 try: 

210 val = loads_str(val) 

211 except (ValueError, TypeError, json.JSONDecodeError): 

212 val = {} 

213 

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 

222 

223 if not isinstance(val, dict): 

224 val = {} 

225 

226 initial_data = dumps_str( 

227 [{"key": kv[0], "value": kv[1]} for kv in (val or {}).items()], 

228 ) 

229 

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); }} }}" 

232 

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 ) 

248 

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 ) 

293 

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 ) 

316 

317 hidden_input = el( 

318 "input", 

319 type="hidden", 

320 name=self.name, 

321 **{":value": "getSerialized()"}, 

322 ) 

323 

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 ) 

333 

334 def render(self) -> Any: 

335 content = self._render_input() 

336 

337 if not self.label: 

338 return content 

339 

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 )