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
« 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.serialization import dumps_str
6from lexigram.ui.atoms.inputs.base import AbstractInput
7from lexigram.ui.core.base import el
10class Radio(AbstractInput):
11 """Radio button group for single selection."""
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
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)
55 container_class = "flex flex-wrap gap-4" if self.inline else "space-y-4"
56 return el("div", *radios, class_=container_class)
58 def render(self) -> Any:
59 content = self._render_input()
61 if not self.label:
62 return content
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 )
77class MultiSelect(AbstractInput):
78 """Premium searchable tags-style multi-select."""
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
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 [])
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 )
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 )
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 )
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 )
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"}
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}"
240 return el("div", trigger, dropdown, hidden_input, **wrapper_attrs)
242 def render(self) -> Any:
243 content = self._render_input()
245 if not self.label:
246 return content
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 )
261class CheckboxList(AbstractInput):
262 """Multiple checkboxes for list selection."""
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
275 def _render_input(self) -> Any:
276 items = []
277 current_values = [str(v) for v in self.value or []]
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)
308 container_class = "flex flex-wrap gap-4" if self.inline else "space-y-4"
309 return el("div", *items, class_=container_class)
311 def render(self) -> Any:
312 content = self._render_input()
314 if not self.label:
315 return content
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 )