Coverage for src / lexigram / ui / molecules / rich_select.py: 23%
87 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.ui.core.base import Component, el, raw
7__all__ = ["RichSelect"]
9# ---------------------------------------------------------------------------
10# Internal helpers
11# ---------------------------------------------------------------------------
13_TRIGGER_CLS = (
14 "w-full flex items-center justify-between gap-2 px-3 py-2 text-sm "
15 "bg-background text-foreground "
16 "border border-input rounded-lg shadow-sm "
17 "focus:outline-none focus:ring-2 focus:ring-ring "
18 "hover:border-ring transition-colors duration-150"
19)
20_DROPDOWN_CLS = (
21 "absolute z-50 mt-1 w-full bg-card border "
22 "border-border rounded-lg shadow-lg overflow-hidden"
23)
24_SEARCH_CLS = (
25 "w-full px-3 py-2 text-sm border-b border-border "
26 "bg-background text-foreground "
27 "placeholder:text-muted-foreground focus:outline-none"
28)
29_OPTION_CLS = (
30 "flex items-center gap-2 px-3 py-2 text-sm cursor-pointer "
31 "text-foreground "
32 "hover:bg-accent transition-colors duration-100"
33)
34_GROUP_LABEL_CLS = (
35 "px-3 pt-3 pb-1 text-xs font-semibold uppercase tracking-wide "
36 "text-muted-foreground"
37)
38_SELECTED_OPTION_CLS = "bg-accent text-accent-foreground"
39_EMPTY_CLS = "px-3 py-4 text-sm text-center text-muted-foreground"
40_ERROR_CLS = "mt-1.5 text-sm text-destructive"
43def _js_str(value: str) -> str:
44 """Escape *value* for safe embedding in a JS single-quoted string literal."""
45 return value.replace("\\", "\\\\").replace("'", "\\'")
48class RichSelect(Component):
49 """Full-featured accessible select component powered by Alpine.js.
51 Supports:
53 * **Single-select** (default) — click an option to choose it; dropdown closes.
54 * **Multi-select** (``multi=True``) — checkbox-style; multiple values selectable.
55 * **Grouped options** (``groups``) — render labelled option groups.
56 * **Async search** (``search_url``) — HTMX ``hx-get`` fires on the search input;
57 the response should return a ``<ul id="{name}-options">`` fragment.
58 * **Client-side filter** (no ``search_url``) — shown when there are more than
59 ``SEARCH_THRESHOLD`` options or ``groups`` are present.
61 Args:
62 label: Visible label for the control.
63 name: HTML ``name`` attribute used for form submission.
64 options: Flat list of ``{"value": ..., "label": ...}`` dicts.
65 multi: Enable multi-select behaviour.
66 search_url: HTMX URL for server-side search. Receives ``?q=<term>``.
67 groups: Grouped options: ``[{"label": "Group", "options": [...]}]``.
68 error: Validation error message shown below the control.
69 placeholder: Trigger button placeholder text when nothing is selected.
70 """
72 SEARCH_THRESHOLD = 8
74 def __init__(
75 self,
76 label: str,
77 name: str,
78 options: list[dict[str, Any]] | None = None,
79 multi: bool = False,
80 search_url: str = "",
81 groups: list[dict[str, Any]] | None = None,
82 error: str | None = None,
83 placeholder: str = "Select an option",
84 **props: Any,
85 ) -> None:
86 super().__init__(
87 label=label,
88 name=name,
89 multi=multi,
90 search_url=search_url,
91 error=error,
92 placeholder=placeholder,
93 **props,
94 )
95 self.label = label
96 self.name = name
97 self.options: list[dict[str, Any]] = options or []
98 self.multi = multi
99 self.search_url = search_url
100 self.groups: list[dict[str, Any]] = groups or []
101 self.error = error
102 self.placeholder = placeholder
104 # ------------------------------------------------------------------
105 # Helpers
106 # ------------------------------------------------------------------
108 def _needs_search(self) -> bool:
109 flat_count = len(self.options) + sum(
110 len(g.get("options", [])) for g in self.groups
111 )
112 return (
113 bool(self.search_url)
114 or flat_count > self.SEARCH_THRESHOLD
115 or bool(self.groups)
116 )
118 def _alpine_data(self) -> str:
119 ph = _js_str(self.placeholder)
120 if self.multi:
121 return (
122 "{ open: false, search: '', selected: [], "
123 f"getLabel() {{ return this.selected.length ? this.selected.length + ' selected' : '{ph}'; }}, "
124 "isSelected(v) { return this.selected.includes(v); }, "
125 "toggle(v) { const i = this.selected.indexOf(v); if (i > -1) this.selected.splice(i, 1); else this.selected.push(v); } }"
126 )
127 return (
128 "{ open: false, search: '', selected: '', selectedLabel: '', "
129 f"getLabel() {{ return this.selectedLabel || '{ph}'; }}, "
130 "isSelected(v) { return this.selected === v; }, "
131 "pick(v, lbl) { this.selected = v; this.selectedLabel = lbl; this.open = false; } }"
132 )
134 # ------------------------------------------------------------------
135 # Option rendering
136 # ------------------------------------------------------------------
138 def _render_option(self, opt: dict[str, Any]) -> Any:
139 value = str(opt.get("value", ""))
140 label = str(opt.get("label", ""))
141 vs = _js_str(value)
142 ls = _js_str(label)
144 # Client-side visibility filter (only active when no search_url)
145 show_expr = (
146 f"!search || '{ls}'.toLowerCase().includes(search.toLowerCase())"
147 if not self.search_url
148 else None
149 )
151 if self.multi:
152 selected_cls = (
153 f":class=\"isSelected('{vs}') ? '{_SELECTED_OPTION_CLS}' : ''\""
154 )
155 click_handler = f"toggle('{vs}')"
156 checkbox = raw(
157 f'<span class="flex-shrink-0 w-4 h-4 border-2 rounded flex items-center justify-center '
158 f'transition-colors" '
159 f":class=\"isSelected('{vs}') ? 'bg-primary border-primary' : 'border-input'\">"
160 f'<svg x-show="isSelected(\'{vs}\')" class="w-3 h-3 text-white" fill="none" '
161 f'viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">'
162 f'<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg></span>'
163 )
164 children: list[Any] = [checkbox, el("span", label)]
165 else:
166 selected_cls = (
167 f":class=\"isSelected('{vs}') ? '{_SELECTED_OPTION_CLS}' : ''\""
168 )
169 click_handler = f"pick('{vs}', '{ls}')"
170 tick = raw(
171 f'<svg x-show="isSelected(\'{vs}\')" class="ml-auto w-4 h-4 text-primary flex-shrink-0" '
172 f'fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">'
173 f'<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
174 )
175 children = [el("span", label, class_="flex-1"), tick]
177 attrs: dict[str, Any] = {
178 "class_": _OPTION_CLS,
179 "@click": click_handler,
180 "role": "option",
181 }
182 if selected_cls:
183 # Inject raw Alpine binding alongside class_ using dict child pattern
184 attrs[":class"] = f"isSelected('{vs}') ? '{_SELECTED_OPTION_CLS}' : ''"
185 if show_expr:
186 attrs["x-show"] = show_expr
188 return el("div", *children, **attrs)
190 # ------------------------------------------------------------------
191 # render
192 # ------------------------------------------------------------------
194 def render(self) -> Any:
195 options_list_id = f"{self.name}-options"
197 # ---- trigger button ------------------------------------------
198 trigger = el(
199 "button",
200 el(
201 "span",
202 **{
203 "x-text": "getLabel()",
204 "class_": "truncate",
205 },
206 ),
207 raw(
208 '<svg class="w-4 h-4 flex-shrink-0 text-muted-foreground transition-transform duration-200" '
209 ":class=\"open ? 'rotate-180' : ''\" "
210 'fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">'
211 '<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>'
212 ),
213 type="button",
214 **{
215 "@click": "open = !open",
216 "class_": _TRIGGER_CLS,
217 "aria-haspopup": "listbox",
218 ":aria-expanded": "open",
219 },
220 )
222 # ---- search input --------------------------------------------
223 search_input: Any = ""
224 if self._needs_search():
225 search_attrs: dict[str, Any] = {
226 "type": "text",
227 "placeholder": "Search…",
228 "class_": _SEARCH_CLS,
229 "x-model": "search",
230 "autocomplete": "off",
231 }
232 if self.search_url:
233 search_attrs["hx-get"] = self.search_url
234 search_attrs["hx-trigger"] = "input changed delay:300ms"
235 search_attrs["hx-target"] = f"#{options_list_id}"
236 search_attrs["hx-include"] = "this"
237 search_attrs["name"] = "q"
238 search_input = el("input", **search_attrs)
240 # ---- options list --------------------------------------------
241 option_nodes: list[Any] = []
243 if self.groups:
244 for group in self.groups:
245 option_nodes.append(
246 el("div", group.get("label", ""), class_=_GROUP_LABEL_CLS)
247 )
248 for opt in group.get("options", []):
249 option_nodes.append(self._render_option(opt))
250 else:
251 for opt in self.options:
252 option_nodes.append(self._render_option(opt))
254 if not option_nodes and not self.search_url:
255 option_nodes.append(el("div", "No options available", class_=_EMPTY_CLS))
257 options_container = el(
258 "div",
259 *option_nodes,
260 id=options_list_id,
261 class_="max-h-56 overflow-y-auto py-1",
262 role="listbox",
263 )
265 # ---- dropdown panel -----------------------------------------
266 dropdown = el(
267 "div",
268 search_input,
269 options_container,
270 **{
271 "x-show": "open",
272 "x-transition:enter": "transition ease-out duration-100",
273 "x-transition:enter-start": "opacity-0 scale-95",
274 "x-transition:enter-end": "opacity-100 scale-100",
275 "x-transition:leave": "transition ease-in duration-75",
276 "x-transition:leave-start": "opacity-100 scale-100",
277 "x-transition:leave-end": "opacity-0 scale-95",
278 "class_": _DROPDOWN_CLS,
279 "x-cloak": True,
280 },
281 )
283 # ---- hidden inputs for form submission ----------------------
284 if self.multi:
285 hidden_inputs = raw(
286 f'<template x-for="val in selected" :key="val">'
287 f'<input type="hidden" name="{self.name}[]" :value="val">'
288 f"</template>"
289 )
290 else:
291 hidden_inputs = el(
292 "input",
293 type="hidden",
294 name=self.name,
295 **{":value": "selected"},
296 )
298 # ---- label ---------------------------------------------------
299 label_el = el(
300 "label",
301 self.label,
302 for_=f"{self.name}-trigger",
303 class_="block text-sm font-medium text-foreground mb-1",
304 )
306 # ---- error message ------------------------------------------
307 error_el: Any = el("p", self.error, class_=_ERROR_CLS) if self.error else ""
309 ring_cls = (
310 "ring-destructive focus-within:ring-destructive"
311 if self.error
312 else "ring-[var(--input)] focus-within:ring-ring"
313 )
315 return el(
316 "div",
317 label_el,
318 el(
319 "div",
320 trigger,
321 dropdown,
322 hidden_inputs,
323 class_=f"relative block w-full rounded-lg ring-1 ring-inset {ring_cls}",
324 ),
325 error_el,
326 class_="mb-6",
327 **{
328 "x-data": self._alpine_data(),
329 "@keydown.escape.window": "open = false",
330 "@click.outside": "open = false",
331 },
332 )