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
« 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.atoms.inputs.base import AbstractInput
6from lexigram.ui.core.base import el
9class FileUpload(AbstractInput):
10 """
11 Standard file upload component with preview.
12 """
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
23 def _render_input(self) -> Any:
24 input_id = f"file_{self.name}"
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 = ''; }} }} }} }}"
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 )
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 )
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 )
77 def render(self) -> Any:
78 content = self._render_input()
80 if not self.label:
81 return content
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 )
97class MultiFileUpload(AbstractInput):
98 """
99 Premium multi-file upload with drag and drop.
100 """
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
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 )
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 )
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 )
241 return el("div", drop_zone, file_list, class_="w-full", **{"x-data": x_data})
243 def render(self) -> Any:
244 content = self._render_input()
246 if not self.label:
247 return content
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 )
262class AvatarUpload(AbstractInput):
263 """
264 Specialized file upload for user avatars.
265 """
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"
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); }} }} }}"
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 )
328 def render(self) -> Any:
329 content = self._render_input()
331 if not self.label:
332 return content
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 )