Coverage for src / lexigram / ui / atoms / inputs / selection / choice.py: 24%

72 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.serialization import dumps_str 

6from lexigram.ui.atoms.inputs.base import AbstractInput 

7from lexigram.ui.core.base import el 

8 

9 

10class Radio(AbstractInput): 

11 """Radio button group for single selection.""" 

12 

13 def __init__( 

14 self, 

15 name: str, 

16 choices: list[tuple[str, str]], 

17 inline: bool = False, 

18 **kwargs, 

19 ) -> None: 

20 super().__init__(name=name, **kwargs) 

21 self.choices = choices 

22 self.inline = inline 

23 

24 def _render_input(self) -> Any: 

25 radios = [] 

26 for i, (val, label) in enumerate(self.choices): 

27 radio_id = f"{self.name}_{i}" 

28 inp = el( 

29 "input", 

30 type="radio", 

31 name=self.name, 

32 id=radio_id, 

33 value=val, 

34 checked=True if str(val) == str(self.value) else None, 

35 disabled=self.disabled, 

36 class_="h-4 w-4 border-input text-primary focus:ring-ring bg-background disabled:opacity-50", 

37 ) 

38 item = el( 

39 "div", 

40 el("div", inp, class_="flex h-6 items-center"), 

41 el( 

42 "div", 

43 el( 

44 "label", 

45 label, 

46 for_=radio_id, 

47 class_="font-medium text-foreground text-sm", 

48 ), 

49 class_="ml-3", 

50 ), 

51 class_="relative flex items-start", 

52 ) 

53 radios.append(item) 

54 

55 container_class = "flex flex-wrap gap-4" if self.inline else "space-y-4" 

56 return el("div", *radios, class_=container_class) 

57 

58 def render(self) -> Any: 

59 content = self._render_input() 

60 

61 if not self.label: 

62 return content 

63 

64 return el( 

65 "div", 

66 el( 

67 "label", 

68 self.label, 

69 class_="block text-sm font-medium text-foreground mb-2", 

70 ), 

71 content, 

72 self._render_error(), 

73 class_="mb-6", 

74 ) 

75 

76 

77class MultiSelect(AbstractInput): 

78 """Premium searchable tags-style multi-select.""" 

79 

80 def __init__( 

81 self, 

82 name: str, 

83 choices: list[tuple[str, str]], 

84 placeholder: str = "Select options...", 

85 **kwargs, 

86 ) -> None: 

87 super().__init__(name=name, **kwargs) 

88 self.choices = choices 

89 self.placeholder = placeholder 

90 

91 def _render_input(self) -> Any: 

92 choices_json = dumps_str( 

93 [{"value": vl[0], "label": vl[1]} for vl in self.choices], 

94 ) 

95 initial_values = dumps_str(self.value if self.value is not None else []) 

96 

97 _container_id = f"ms_{self.name}" 

98 x_data = ( 

99 f"{{ " 

100 f"open: false, " 

101 f"search: '', " 

102 f"selected: {initial_values}, " 

103 f"choices: {choices_json}, " 

104 f"toggle() {{ if(this.disabled) return; this.open = !this.open; if(this.open) this.$nextTick(() => this.$refs.searchInput.focus()); }}, " 

105 f"close() {{ this.open = false; this.search = ''; }}, " 

106 f"add(val) {{ if(!this.selected.includes(val)) this.selected.push(val); this.close(); }}, " 

107 f"remove(val) {{ this.selected = this.selected.filter(v => v !== val); }}, " 

108 f"get filteredChoices() {{ return this.choices.filter(c => c.label.toLowerCase().includes(this.search.toLowerCase()) && !this.selected.includes(c.value)); }}, " 

109 f"get selectedLabels() {{ return this.choices.filter(c => this.selected.includes(c.value)); }}," 

110 f"disabled: {'true' if self.disabled else 'false'}" 

111 f" }}" 

112 ) 

113 

114 trigger = el( 

115 "div", 

116 # Chips 

117 el( 

118 "template", 

119 el( 

120 "span", 

121 el("span", **{"x-text": "item.label"}), 

122 el( 

123 "button", 

124 el( 

125 "svg", 

126 el( 

127 "path", 

128 d="M6 18L18 6M6 6l12 12", 

129 stroke_linecap="round", 

130 stroke_linejoin="round", 

131 stroke_width="2", 

132 ), 

133 class_="w-3 h-3", 

134 fill="none", 

135 viewBox="0 0 24 24", 

136 stroke="currentColor", 

137 ), 

138 type="button", 

139 **{"@click.stop": "remove(item.value)"}, 

140 class_="ml-1 hover:text-primary focus:outline-none", 

141 ), 

142 class_="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary mr-1 mb-1", 

143 ), 

144 **{"x-for": "item in selectedLabels", ":key": "item.value"}, 

145 ), 

146 # Placeholder/Search Proxy 

147 el( 

148 "span", 

149 self.placeholder, 

150 class_="text-muted-foreground text-sm py-1", 

151 **{"x-show": "selected.length === 0"}, 

152 ), 

153 # Caret 

154 el( 

155 "div", 

156 el( 

157 "svg", 

158 el( 

159 "path", 

160 d="M19 9l-7 7-7-7", 

161 stroke_linecap="round", 

162 stroke_linejoin="round", 

163 stroke_width="2", 

164 ), 

165 class_="w-4 h-4 text-muted-foreground", 

166 fill="none", 

167 viewBox="0 0 24 24", 

168 stroke="currentColor", 

169 ), 

170 class_="ml-auto", 

171 ), 

172 class_=f"flex flex-wrap items-center min-h-[38px] px-3 py-1.5 rounded-lg border cursor-pointer bg-background shadow-sm transition-all duration-200 {'ring-2 ring-ring border-ring' if not self.error else 'border-destructive ring-destructive'} {'opacity-50 cursor-not-allowed' if self.disabled else 'border-input hover:border-input'}", 

173 **{"@click": "toggle()"}, 

174 ) 

175 

176 dropdown = el( 

177 "div", 

178 el( 

179 "div", 

180 el( 

181 "input", 

182 type="text", 

183 placeholder="Search...", 

184 class_="w-full border-b border-border px-3 py-2 text-sm bg-transparent focus:ring-0 focus:outline-none text-foreground", 

185 **{ 

186 "x-model": "search", 

187 "x-ref": "searchInput", 

188 "@keydown.escape": "close()", 

189 }, 

190 ), 

191 class_="p-1", 

192 ), 

193 el( 

194 "div", 

195 el( 

196 "template", 

197 el( 

198 "div", 

199 **{"x-text": "choice.label", "@click": "add(choice.value)"}, 

200 class_="px-3 py-2 text-sm cursor-pointer hover:bg-primary/5 text-popover-foreground", 

201 ), 

202 **{"x-for": "choice in filteredChoices", ":key": "choice.value"}, 

203 ), 

204 el( 

205 "div", 

206 "No options found.", 

207 class_="px-3 py-4 text-sm text-muted-foreground text-center", 

208 **{"x-show": "filteredChoices.length === 0"}, 

209 ), 

210 class_="max-h-60 overflow-y-auto", 

211 ), 

212 class_="absolute z-50 w-full mt-1 bg-popover border border-border rounded-lg shadow-xl overflow-hidden", 

213 **{"x-show": "open", "@click.away": "close()", "x-cloak": "true"}, 

214 ) 

215 

216 hidden_input = el( 

217 "select", 

218 el( 

219 "template", 

220 el("option", **{":value": "v", "selected": "true"}), 

221 **{"x-for": "v in selected"}, 

222 ), 

223 name=f"{self.name}[]", 

224 multiple="multiple", 

225 class_="hidden", 

226 **{":value": "selected"}, 

227 ) 

228 

229 depends_on = self.props.get("depends_on") 

230 options_from = self.props.get("options_from") 

231 wrapper_attrs = {"x-data": x_data, "class": "relative"} 

232 

233 if depends_on and options_from: 

234 wrapper_attrs["hx-get"] = options_from 

235 wrapper_attrs["hx-trigger"] = f"change from:#{depends_on}" 

236 wrapper_attrs["hx-target"] = "this" 

237 wrapper_attrs["hx-swap"] = "outerHTML" 

238 wrapper_attrs["hx-include"] = f"#{depends_on}" 

239 

240 return el("div", trigger, dropdown, hidden_input, **wrapper_attrs) 

241 

242 def render(self) -> Any: 

243 content = self._render_input() 

244 

245 if not self.label: 

246 return content 

247 

248 return el( 

249 "div", 

250 el( 

251 "label", 

252 self.label, 

253 class_="block text-sm font-medium text-foreground mb-1", 

254 ), 

255 content, 

256 self._render_error(), 

257 class_="mb-6", 

258 ) 

259 

260 

261class CheckboxList(AbstractInput): 

262 """Multiple checkboxes for list selection.""" 

263 

264 def __init__( 

265 self, 

266 name: str, 

267 choices: list[tuple[str, str]], 

268 inline: bool = False, 

269 **kwargs, 

270 ) -> None: 

271 super().__init__(name=name, **kwargs) 

272 self.choices = choices 

273 self.inline = inline 

274 

275 def _render_input(self) -> Any: 

276 items = [] 

277 current_values = [str(v) for v in self.value or []] 

278 

279 for i, (val, label) in enumerate(self.choices): 

280 check_id = f"{self.name}_{i}" 

281 inp = el( 

282 "input", 

283 type="checkbox", 

284 name=f"{self.name}[]", 

285 id=check_id, 

286 value=val, 

287 checked=True if str(val) in current_values else None, 

288 disabled=self.disabled, 

289 class_="h-4 w-4 rounded border-input text-primary focus:ring-ring bg-background disabled:opacity-50", 

290 ) 

291 item = el( 

292 "div", 

293 el("div", inp, class_="flex h-6 items-center"), 

294 el( 

295 "div", 

296 el( 

297 "label", 

298 label, 

299 for_=check_id, 

300 class_="font-medium text-foreground text-sm", 

301 ), 

302 class_="ml-3", 

303 ), 

304 class_="relative flex items-start", 

305 ) 

306 items.append(item) 

307 

308 container_class = "flex flex-wrap gap-4" if self.inline else "space-y-4" 

309 return el("div", *items, class_=container_class) 

310 

311 def render(self) -> Any: 

312 content = self._render_input() 

313 

314 if not self.label: 

315 return content 

316 

317 return el( 

318 "div", 

319 el( 

320 "label", 

321 self.label, 

322 class_="block text-sm font-medium text-foreground mb-2", 

323 ), 

324 content, 

325 self._render_error(), 

326 class_="mb-6", 

327 )