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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui.core.base import Component, el, raw 

6 

7__all__ = ["RichSelect"] 

8 

9# --------------------------------------------------------------------------- 

10# Internal helpers 

11# --------------------------------------------------------------------------- 

12 

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" 

41 

42 

43def _js_str(value: str) -> str: 

44 """Escape *value* for safe embedding in a JS single-quoted string literal.""" 

45 return value.replace("\\", "\\\\").replace("'", "\\'") 

46 

47 

48class RichSelect(Component): 

49 """Full-featured accessible select component powered by Alpine.js. 

50 

51 Supports: 

52 

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. 

60 

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 """ 

71 

72 SEARCH_THRESHOLD = 8 

73 

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 

103 

104 # ------------------------------------------------------------------ 

105 # Helpers 

106 # ------------------------------------------------------------------ 

107 

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 ) 

117 

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 ) 

133 

134 # ------------------------------------------------------------------ 

135 # Option rendering 

136 # ------------------------------------------------------------------ 

137 

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) 

143 

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 ) 

150 

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] 

176 

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 

187 

188 return el("div", *children, **attrs) 

189 

190 # ------------------------------------------------------------------ 

191 # render 

192 # ------------------------------------------------------------------ 

193 

194 def render(self) -> Any: 

195 options_list_id = f"{self.name}-options" 

196 

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 ) 

221 

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) 

239 

240 # ---- options list -------------------------------------------- 

241 option_nodes: list[Any] = [] 

242 

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)) 

253 

254 if not option_nodes and not self.search_url: 

255 option_nodes.append(el("div", "No options available", class_=_EMPTY_CLS)) 

256 

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 ) 

264 

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 ) 

282 

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 ) 

297 

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 ) 

305 

306 # ---- error message ------------------------------------------ 

307 error_el: Any = el("p", self.error, class_=_ERROR_CLS) if self.error else "" 

308 

309 ring_cls = ( 

310 "ring-destructive focus-within:ring-destructive" 

311 if self.error 

312 else "ring-[var(--input)] focus-within:ring-ring" 

313 ) 

314 

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 )