Coverage for src / lexigram / ui / atoms / inputs / file.py: 34%

44 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.atoms.inputs.base import AbstractInput 

6from lexigram.ui.core.base import el 

7 

8 

9class FileUpload(AbstractInput): 

10 """ 

11 Standard file upload component with preview. 

12 """ 

13 

14 def __init__( 

15 self, 

16 name: str, 

17 accept: str = "*", 

18 **kwargs, 

19 ) -> None: 

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

21 self.accept = accept 

22 

23 def _render_input(self) -> Any: 

24 input_id = f"file_{self.name}" 

25 

26 # Alpine state for preview 

27 x_data = f"{{ previewUrl: '{self.value if self.value else ''}', fileName: '', handleFile(e) {{ const file = e.target.files[0]; if(file) {{ this.fileName = file.name; if(file.type.startsWith('image/')) {{ this.previewUrl = URL.createObjectURL(file); }} else {{ this.previewUrl = ''; }} }} }} }}" 

28 

29 preview = el( 

30 "div", 

31 el( 

32 "template", 

33 el( 

34 "div", 

35 el( 

36 "img", 

37 **{ 

38 ":src": "previewUrl", 

39 "class": "h-20 w-20 object-cover rounded-lg border border-border", 

40 }, 

41 ), 

42 class_="mb-3", 

43 ), 

44 **{"x-if": "previewUrl"}, 

45 ), 

46 el( 

47 "div", 

48 el( 

49 "span", 

50 **{"x-text": "fileName || 'No file selected'"}, 

51 class_="text-sm text-muted-foreground", 

52 ), 

53 class_="mt-1", 

54 ), 

55 class_="mb-3", 

56 ) 

57 

58 input_el = el( 

59 "input", 

60 type="file", 

61 name=self.name, 

62 id=input_id, 

63 accept=self.accept, 

64 disabled=self.disabled, 

65 class_="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20 disabled:opacity-50", 

66 **{"@change": "handleFile"}, 

67 ) 

68 

69 return el( 

70 "div", 

71 preview, 

72 input_el, 

73 class_="p-4 border-2 border-dashed border-input rounded-xl bg-muted hover:bg-accent transition-colors duration-200", 

74 **{"x-data": x_data}, 

75 ) 

76 

77 def render(self) -> Any: 

78 content = self._render_input() 

79 

80 if not self.label: 

81 return content 

82 

83 return el( 

84 "div", 

85 el( 

86 "label", 

87 self.label, 

88 for_=f"file_{self.name}", 

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

90 ), 

91 content, 

92 self._render_error(), 

93 class_="mb-6", 

94 ) 

95 

96 

97class MultiFileUpload(AbstractInput): 

98 """ 

99 Premium multi-file upload with drag and drop. 

100 """ 

101 

102 def __init__( 

103 self, 

104 name: str, 

105 accept: str = "*", 

106 **kwargs, 

107 ) -> None: 

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

109 self.accept = accept 

110 

111 def _render_input(self) -> Any: 

112 # Alpine state for multiple files 

113 x_data = ( 

114 "{ " 

115 "files: [], " 

116 "dragging: false, " 

117 "handleFiles(e) { " 

118 " const newFiles = Array.from(e.target.files || e.dataTransfer.files); " 

119 " newFiles.forEach(file => { " 

120 " const reader = new FileReader(); " 

121 " reader.onload = (re) => { " 

122 " this.files.push({ name: file.name, size: (file.size/1024).toFixed(1) + ' KB', type: file.type, preview: file.type.startsWith('image/') ? re.target.result : null }); " 

123 " }; " 

124 " reader.readAsDataURL(file); " 

125 " }); " 

126 "}, " 

127 "removeFile(index) { this.files.splice(index, 1); }" 

128 " }" 

129 ) 

130 

131 drop_zone = el( 

132 "div", 

133 el( 

134 "div", 

135 el( 

136 "svg", 

137 el( 

138 "path", 

139 d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12", 

140 stroke_linecap="round", 

141 stroke_linejoin="round", 

142 stroke_width="2", 

143 ), 

144 class_="mx-auto h-12 w-12 text-muted-foreground", 

145 fill="none", 

146 viewBox="0 0 24 24", 

147 stroke="currentColor", 

148 ), 

149 el( 

150 "p", 

151 "Click or drag files here to upload", 

152 class_="mt-1 text-sm text-muted-foreground", 

153 ), 

154 el( 

155 "p", 

156 f"Accepted: {self.accept}", 

157 class_="text-xs text-muted-foreground", 

158 ), 

159 class_="text-center", 

160 ), 

161 el( 

162 "input", 

163 type="file", 

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

165 multiple="multiple", 

166 accept=self.accept, 

167 class_="absolute inset-0 w-full h-full opacity-0 cursor-pointer", 

168 **{"@change": "handleFiles"}, 

169 ), 

170 class_="relative border-2 border-dashed border-input rounded-xl p-8 transition-all duration-200", 

171 **{ 

172 ":class": "{ 'border-ring bg-primary/10': dragging, 'bg-muted': !dragging }", 

173 "@dragover.prevent": "dragging = true", 

174 "@dragleave.prevent": "dragging = false", 

175 "@drop.prevent": "dragging = false; handleFiles($event)", 

176 }, 

177 ) 

178 

179 file_list = el( 

180 "div", 

181 el( 

182 "template", 

183 el( 

184 "div", 

185 el( 

186 "div", 

187 el( 

188 "template", 

189 el( 

190 "img", 

191 **{ 

192 ":src": "file.preview", 

193 "class": "h-10 w-10 object-cover rounded-md mr-3", 

194 }, 

195 ), 

196 **{"x-if": "file.preview"}, 

197 ), 

198 el( 

199 "div", 

200 el( 

201 "p", 

202 **{"x-text": "file.name"}, 

203 class_="text-sm font-medium text-foreground truncate max-w-[200px]", 

204 ), 

205 el( 

206 "p", 

207 **{"x-text": "file.size"}, 

208 class_="text-xs text-muted-foreground", 

209 ), 

210 ), 

211 class_="flex items-center", 

212 ), 

213 el( 

214 "button", 

215 el( 

216 "svg", 

217 el( 

218 "path", 

219 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16", 

220 stroke_linecap="round", 

221 stroke_linejoin="round", 

222 stroke_width="2", 

223 ), 

224 class_="w-4 h-4", 

225 fill="none", 

226 viewBox="0 0 24 24", 

227 stroke="currentColor", 

228 ), 

229 type="button", 

230 **{"@click": "removeFile(index)"}, 

231 class_="text-muted-foreground hover:text-destructive transition-colors", 

232 ), 

233 class_="flex items-center justify-between p-3 bg-background border border-border rounded-lg shadow-sm", 

234 ), 

235 **{"x-for": "(file, index) in files", ":key": "index"}, 

236 ), 

237 class_="mt-4 space-y-2", 

238 **{"x-show": "files.length > 0"}, 

239 ) 

240 

241 return el("div", drop_zone, file_list, class_="w-full", **{"x-data": x_data}) 

242 

243 def render(self) -> Any: 

244 content = self._render_input() 

245 

246 if not self.label: 

247 return content 

248 

249 return el( 

250 "div", 

251 el( 

252 "label", 

253 self.label, 

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

255 ), 

256 content, 

257 self._render_error(), 

258 class_="mb-6", 

259 ) 

260 

261 

262class AvatarUpload(AbstractInput): 

263 """ 

264 Specialized file upload for user avatars. 

265 """ 

266 

267 def _render_input(self) -> Any: 

268 input_id = f"avatar_{self.name}" 

269 default_avatar = "https://ui-avatars.com/api/?name=User&background=random" 

270 

271 x_data = f"{{ previewUrl: '{self.value if self.value else default_avatar}', handleFile(e) {{ const file = e.target.files[0]; if(file && file.type.startsWith('image/')) {{ this.previewUrl = URL.createObjectURL(file); }} }} }}" 

272 

273 return el( 

274 "div", 

275 el( 

276 "div", 

277 el( 

278 "img", 

279 **{ 

280 ":src": "previewUrl", 

281 "class": "h-24 w-24 rounded-full object-cover border-4 border-background shadow-md", 

282 }, 

283 ), 

284 el( 

285 "label", 

286 el( 

287 "svg", 

288 el( 

289 "path", 

290 d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z", 

291 ), 

292 el("path", d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"), 

293 class_="w-4 h-4", 

294 fill="none", 

295 viewBox="0 0 24 24", 

296 stroke="currentColor", 

297 ), 

298 for_=input_id, 

299 class_="absolute bottom-0 right-0 p-2 bg-primary rounded-full text-primary-foreground hover:bg-primary/90 cursor-pointer shadow-lg transition-transform hover:scale-110", 

300 ), 

301 class_="relative inline-block", 

302 ), 

303 el( 

304 "input", 

305 type="file", 

306 name=self.name, 

307 id=input_id, 

308 accept="image/*", 

309 disabled=self.disabled, 

310 class_="hidden", 

311 **{"@change": "handleFile"}, 

312 ), 

313 el( 

314 "div", 

315 el( 

316 "p", 

317 "Min size 256x256px. PNG or JPG.", 

318 class_="text-xs text-muted-foreground mt-2", 

319 ), 

320 class_="ml-4", 

321 ) 

322 if not self.label 

323 else "", 

324 class_="flex items-center", 

325 **{"x-data": x_data}, 

326 ) 

327 

328 def render(self) -> Any: 

329 content = self._render_input() 

330 

331 if not self.label: 

332 return content 

333 

334 return el( 

335 "div", 

336 el( 

337 "label", 

338 self.label, 

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

340 ), 

341 content, 

342 self._render_error(), 

343 class_="mb-6", 

344 )