violit 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,745 @@
1
+ """Input widgets"""
2
+
3
+ from typing import Union, Callable, Optional, List, Any
4
+ import base64
5
+ import io
6
+ import json
7
+ from ..component import Component
8
+ from ..context import rendering_ctx, layout_ctx
9
+ from ..state import State
10
+
11
+
12
+ class UploadedFile(io.BytesIO):
13
+ def __init__(self, name, type, size, content_b64):
14
+ self.name = name
15
+ self.type = type
16
+ self.size = size
17
+ # content_b64 is like "data:text/csv;base64,AAAA..."
18
+ if "," in content_b64:
19
+ self.header, data = content_b64.split(",", 1)
20
+ else:
21
+ self.header = ""
22
+ data = content_b64
23
+ try:
24
+ decoded = base64.b64decode(data)
25
+ except:
26
+ decoded = b""
27
+ super().__init__(decoded)
28
+
29
+ def __str__(self):
30
+ return self.name
31
+
32
+ def __repr__(self):
33
+ return f"<UploadedFile name='{self.name}' type='{self.type}' size={self.size}>"
34
+
35
+
36
+ class InputWidgetsMixin:
37
+
38
+ def text_input(self, label, value="", key=None, on_change=None, **props):
39
+ """Single-line text input"""
40
+ return self._input_component("input", "sl-input", label, value, on_change, key, **props)
41
+
42
+ def slider(self, label, min_value=0, max_value=100, value=None, step=1, key=None, on_change=None, **props):
43
+ """Slider widget"""
44
+ if value is None: value = min_value
45
+ return self._input_component("slider", "sl-range", label, value, on_change, key, min=min_value, max=max_value, step=step, **props)
46
+
47
+ def checkbox(self, label, value=False, key=None, on_change=None, **props):
48
+ """Checkbox widget"""
49
+ cid = self._get_next_cid("checkbox")
50
+
51
+ state_key = key or f"checkbox:{label}"
52
+ s = self.state(value, key=state_key)
53
+
54
+ def action(v):
55
+ real_val = str(v).lower() == 'true'
56
+ s.set(real_val)
57
+ if on_change: on_change(real_val)
58
+
59
+ def builder():
60
+ # Subscribe to own state - client-side will handle smart updates
61
+ token = rendering_ctx.set(cid)
62
+ cv = s.value
63
+ rendering_ctx.reset(token)
64
+
65
+ checked_attr = 'checked' if cv else ''
66
+ props_str = ' '.join(f'{k}="{v}"' for k, v in props.items() if v is not None and v is not False)
67
+
68
+ if self.mode == 'lite':
69
+ attrs_str = f'hx-post="/action/{cid}" hx-trigger="sl-change" hx-swap="none" hx-vals="js:{{value: event.target.checked}}"'
70
+ listener_script = ""
71
+ else:
72
+ # WS mode: use addEventListener for Shoelace custom events
73
+ attrs_str = ""
74
+ listener_script = f'''
75
+ <script>
76
+ (function() {{
77
+ const el = document.getElementById('{cid}');
78
+ if (el && !el.hasAttribute('data-ws-listener')) {{
79
+ el.setAttribute('data-ws-listener', 'true');
80
+ el.addEventListener('sl-change', function(e) {{
81
+ window.sendAction('{cid}', el.checked);
82
+ }});
83
+ }}
84
+ }})();
85
+ </script>
86
+ '''
87
+
88
+ html = f'<sl-checkbox id="{cid}" {checked_attr} {attrs_str} {props_str}>{label}</sl-checkbox>{listener_script}'
89
+ return Component(None, id=cid, content=html)
90
+ self._register_component(cid, builder, action=action)
91
+ return s
92
+
93
+ def radio(self, label, options, index=0, key=None, on_change=None, **props):
94
+ """Radio button group"""
95
+ cid = self._get_next_cid("radio_group")
96
+
97
+ state_key = key or f"radio:{label}"
98
+ default_val = options[index] if options else None
99
+ s = self.state(default_val, key=state_key)
100
+
101
+ def action(v):
102
+ s.set(v)
103
+ if on_change: on_change(v)
104
+
105
+ def builder():
106
+ token = rendering_ctx.set(cid)
107
+ cv = s.value
108
+ rendering_ctx.reset(token)
109
+
110
+ opts_html = ""
111
+ for opt in options:
112
+ sel = 'checked' if opt == cv else ''
113
+ opts_html += f'<sl-radio value="{opt}" {sel}>{opt}</sl-radio>'
114
+
115
+ if self.mode == 'lite':
116
+ attrs_str = f'hx-post="/action/{cid}" hx-trigger="sl-change" hx-swap="none" name="value"'
117
+ listener_script = ""
118
+ else:
119
+ # WS mode: use addEventListener for Shoelace custom events
120
+ attrs_str = ""
121
+ listener_script = f'''
122
+ <script>
123
+ (function() {{
124
+ const el = document.getElementById('{cid}');
125
+ if (el && !el.hasAttribute('data-ws-listener')) {{
126
+ el.setAttribute('data-ws-listener', 'true');
127
+ el.addEventListener('sl-change', function(e) {{
128
+ window.sendAction('{cid}', el.value);
129
+ }});
130
+ }}
131
+ }})();
132
+ </script>
133
+ '''
134
+
135
+ props_str = ' '.join(f'{k}="{v}"' for k, v in props.items() if v is not None and v is not False)
136
+ html = f'<sl-radio-group id="{cid}" label="{label}" value="{cv}" {attrs_str} {props_str}>{opts_html}</sl-radio-group>{listener_script}'
137
+
138
+ return Component(None, id=cid, content=html)
139
+
140
+ self._register_component(cid, builder, action=action)
141
+ return s
142
+
143
+ def selectbox(self, label, options, index=0, key=None, on_change=None, **props):
144
+ """Single select dropdown"""
145
+ cid = self._get_next_cid("select")
146
+
147
+ state_key = key or f"select:{label}"
148
+ default_val = options[index] if options else None
149
+ s = self.state(default_val, key=state_key)
150
+
151
+ def action(v):
152
+ s.set(v)
153
+ if on_change: on_change(v)
154
+
155
+ def builder():
156
+ token = rendering_ctx.set(cid)
157
+ cv = s.value
158
+ rendering_ctx.reset(token)
159
+
160
+ opts_html = ""
161
+ for opt in options:
162
+ sel = 'selected' if opt == cv else ''
163
+ opts_html += f'<sl-option value="{opt}" {sel}>{opt}</sl-option>'
164
+
165
+ if self.mode == 'lite':
166
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "sl-change", "hx-swap": "none", "name": "value"}
167
+ listener_script = ""
168
+ else:
169
+ # WS mode: use addEventListener for Shoelace custom events
170
+ attrs = {}
171
+ listener_script = f'''
172
+ <script>
173
+ (function() {{
174
+ const el = document.getElementById('{cid}');
175
+ if (el && !el.hasAttribute('data-ws-listener')) {{
176
+ el.setAttribute('data-ws-listener', 'true');
177
+ el.addEventListener('sl-change', function(e) {{
178
+ window.sendAction('{cid}', el.value);
179
+ }});
180
+ }}
181
+ }})();
182
+ </script>
183
+ '''
184
+
185
+ select_html = f'<sl-select id="{cid}" label="{label}" value="{cv}"'
186
+ for k, v in {**attrs, **props}.items():
187
+ if v is True:
188
+ select_html += f' {k}'
189
+ elif v is not False and v is not None:
190
+ select_html += f' {k}="{v}"'
191
+ select_html += f'>{opts_html}</sl-select>{listener_script}'
192
+
193
+ return Component(None, id=cid, content=select_html)
194
+
195
+ self._register_component(cid, builder, action=action)
196
+ return s
197
+
198
+ def multiselect(self, label, options, default=None, key=None, on_change=None, **props):
199
+ """Multi-select dropdown"""
200
+ cid = self._get_next_cid("multiselect")
201
+
202
+ state_key = key or f"multiselect:{label}"
203
+ default_val = default or []
204
+ s = self.state(default_val, key=state_key)
205
+
206
+ def action(v):
207
+ if isinstance(v, str):
208
+ selected = [x.strip() for x in v.split(',') if x.strip()]
209
+ elif isinstance(v, list):
210
+ selected = v
211
+ else:
212
+ selected = []
213
+ s.set(selected)
214
+ if on_change: on_change(selected)
215
+
216
+ def builder():
217
+ token = rendering_ctx.set(cid)
218
+ cv = s.value
219
+ rendering_ctx.reset(token)
220
+
221
+ opts_html = ""
222
+ for opt in options:
223
+ sel = 'selected' if opt in cv else ''
224
+ opts_html += f'<sl-option value="{opt}" {sel}>{opt}</sl-option>'
225
+
226
+ attrs = {}
227
+ if self.mode == 'ws':
228
+ attrs = {"on_sl_change": f"window.sendAction('{cid}', this.value)"}
229
+
230
+ return Component("sl-select", id=cid, label=label, content=opts_html, multiple=True, clearable=True, **attrs)
231
+
232
+ self._register_component(cid, builder, action=action)
233
+
234
+ # Add initialization script for lite mode
235
+ if self.mode == 'lite':
236
+ init_script_cid = f"{cid}_init"
237
+ def script_builder():
238
+ script = f'''
239
+ <script>
240
+ (function() {{
241
+ function initSelect() {{
242
+ const el = document.getElementById('{cid}');
243
+ if (!el) {{
244
+ setTimeout(initSelect, 50);
245
+ return;
246
+ }}
247
+ if (!el.hasAttribute('data-listener-added')) {{
248
+ el.setAttribute('data-listener-added', 'true');
249
+ el.addEventListener('sl-change', function(e) {{
250
+ const values = Array.isArray(el.value) ? el.value : [];
251
+ const valueStr = values.join(',');
252
+ htmx.ajax('POST', '/action/{cid}', {{
253
+ values: {{ value: valueStr }},
254
+ swap: 'none'
255
+ }});
256
+ }});
257
+ }}
258
+ }}
259
+ initSelect();
260
+ }})();
261
+ </script>
262
+ '''
263
+ return Component("div", id=init_script_cid, style="display:none", content=script)
264
+
265
+ self.static_builders[init_script_cid] = script_builder
266
+ if layout_ctx.get() == "sidebar":
267
+ if init_script_cid not in self.static_sidebar_order:
268
+ self.static_sidebar_order.append(init_script_cid)
269
+ else:
270
+ if init_script_cid not in self.static_order:
271
+ self.static_order.append(init_script_cid)
272
+
273
+ return s
274
+
275
+ def text_area(self, label, value="", height=None, key=None, on_change=None, **props):
276
+ """Multi-line text input"""
277
+ cid = self._get_next_cid("textarea")
278
+
279
+ state_key = key or f"textarea:{label}"
280
+ s = self.state(value, key=state_key)
281
+
282
+ def action(v):
283
+ s.set(v)
284
+ if on_change: on_change(v)
285
+
286
+ def builder():
287
+ token = rendering_ctx.set(cid)
288
+ cv = s.value
289
+ rendering_ctx.reset(token)
290
+
291
+ if self.mode == 'lite':
292
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "sl-input delay:50ms", "hx-swap": "none", "name": "value"}
293
+ listener_script = ""
294
+ else:
295
+ # WS mode: use addEventListener for Shoelace custom events
296
+ attrs = {}
297
+ listener_script = f'''
298
+ <script>
299
+ (function() {{
300
+ const el = document.getElementById('{cid}');
301
+ if (el && !el.hasAttribute('data-ws-listener')) {{
302
+ el.setAttribute('data-ws-listener', 'true');
303
+ el.addEventListener('sl-input', function(e) {{
304
+ window.sendAction('{cid}', el.value);
305
+ }});
306
+ }}
307
+ }})();
308
+ </script>
309
+ '''
310
+
311
+ textarea_props = {"rows": height or 3, "resize": "auto"}
312
+ # Remove attrs from args to Component and inject script after
313
+ html = f'<sl-textarea id="{cid}" label="{label}" value="{cv}"'
314
+ for k, v in {**attrs, **textarea_props, **props}.items():
315
+ if v is True: html += f' {k}'
316
+ elif v is not False and v is not None: html += f' {k}="{v}"'
317
+ html += f'></sl-textarea>{listener_script}'
318
+
319
+ return Component(None, id=cid, content=html)
320
+
321
+ self._register_component(cid, builder, action=action)
322
+ return s
323
+
324
+ def number_input(self, label, value=0, min_value=None, max_value=None, step=1, key=None, on_change=None, **props):
325
+ """Numeric input"""
326
+ cid = self._get_next_cid("number")
327
+
328
+ state_key = key or f"number:{label}"
329
+ s = self.state(value, key=state_key)
330
+
331
+ def action(v):
332
+ try:
333
+ num_val = float(v) if '.' in str(v) else int(v)
334
+ s.set(num_val)
335
+ if on_change: on_change(num_val)
336
+ except (ValueError, TypeError):
337
+ pass
338
+
339
+ def builder():
340
+ token = rendering_ctx.set(cid)
341
+ cv = s.value
342
+ rendering_ctx.reset(token)
343
+
344
+ if self.mode == 'lite':
345
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "sl-input delay:50ms", "hx-swap": "none", "name": "value"}
346
+ listener_script = ""
347
+ else:
348
+ # WS mode: use addEventListener
349
+ attrs = {}
350
+ listener_script = f'''
351
+ <script>
352
+ (function() {{
353
+ const el = document.getElementById('{cid}');
354
+ if (el && !el.hasAttribute('data-ws-listener')) {{
355
+ el.setAttribute('data-ws-listener', 'true');
356
+ el.addEventListener('sl-input', function(e) {{
357
+ window.sendAction('{cid}', el.value);
358
+ }});
359
+ }}
360
+ }})();
361
+ </script>
362
+ '''
363
+
364
+ num_props = {"type": "number"}
365
+ if min_value is not None: num_props["min"] = min_value
366
+ if max_value is not None: num_props["max"] = max_value
367
+ if step is not None: num_props["step"] = step
368
+
369
+ html = f'<sl-input id="{cid}" label="{label}" value="{cv}"'
370
+ for k, v in {**attrs, **num_props, **props}.items():
371
+ if v is True: html += f' {k}'
372
+ elif v is not False and v is not None: html += f' {k}="{v}"'
373
+ html += f'></sl-input>{listener_script}'
374
+
375
+ return Component(None, id=cid, content=html)
376
+
377
+ self._register_component(cid, builder, action=action)
378
+ return s
379
+
380
+ def file_uploader(self, label, accept=None, multiple=False, key=None, on_change=None, help=None, **props):
381
+ """File upload widget"""
382
+ cid = self._get_next_cid("file")
383
+
384
+ state_key = key or f"file:{label}"
385
+ s = self.state(None, key=state_key)
386
+
387
+ def action(v):
388
+ if v:
389
+ try:
390
+ # v might be a JSON string if from Lite mode
391
+ if isinstance(v, str) and v.startswith('{'):
392
+ try:
393
+ data = json.loads(v)
394
+ except:
395
+ data = v
396
+ else:
397
+ data = v
398
+
399
+ if isinstance(data, dict):
400
+ if "content" in data:
401
+ # Single file
402
+ uf = UploadedFile(data.get("name"), data.get("type"), data.get("size"), data.get("content"))
403
+ s.set(uf)
404
+ if on_change: on_change(uf)
405
+ return
406
+ elif "files" in data:
407
+ # Multiple files
408
+ files = []
409
+ for f_data in data["files"]:
410
+ files.append(UploadedFile(f_data.get("name"), f_data.get("type"), f_data.get("size"), f_data.get("content")))
411
+ s.set(files)
412
+ if on_change: on_change(files)
413
+ return
414
+ except Exception as e:
415
+ print(f"File upload error: {e}")
416
+
417
+ s.set(None)
418
+ if on_change: on_change(None)
419
+
420
+ def builder():
421
+ token = rendering_ctx.set(cid)
422
+ cv = s.value
423
+ rendering_ctx.reset(token)
424
+
425
+ # Build file info display
426
+ if cv:
427
+ if isinstance(cv, list):
428
+ file_info = f"✅ {len(cv)} file(s) uploaded"
429
+ else:
430
+ size_kb = cv.size / 1024
431
+ size_str = f"{size_kb:.1f}KB" if size_kb < 1024 else f"{size_kb/1024:.1f}MB"
432
+ file_info = f"✅ {cv.name} ({size_str})"
433
+ else:
434
+ file_info = ""
435
+
436
+ accept_str = accept if accept else "*"
437
+ help_html = f'<div style="font-size:0.75rem;color:var(--sl-text-muted);margin-top:0.25rem;">{help}</div>' if help else ""
438
+
439
+ html = f'''
440
+ <div class="file-uploader" style="margin-bottom:1rem;">
441
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;color:var(--sl-text);">{label}</label>
442
+ <input type="file" id="{cid}_input" accept="{accept_str}" {'multiple' if multiple else ''}
443
+ style="display:block;padding:0.5rem;border:1px solid var(--sl-border);border-radius:0.25rem;background:var(--sl-bg-card);color:var(--sl-text);width:100%;font-family:inherit;cursor:pointer;" />
444
+ {help_html}
445
+ <div id="{cid}_info" style="margin-top:0.5rem;font-size:0.875rem;color:var(--sl-text-muted);">{file_info}</div>
446
+ </div>
447
+ <script>
448
+ (function() {{
449
+ const input = document.getElementById('{cid}_input');
450
+ const infoDiv = document.getElementById('{cid}_info');
451
+
452
+ if (input && !input.hasAttribute('data-listener-added')) {{
453
+ input.setAttribute('data-listener-added', 'true');
454
+
455
+ input.addEventListener('change', function(e) {{
456
+ const files = e.target.files;
457
+ if (files && files.length > 0) {{
458
+ infoDiv.textContent = '⏳ Uploading...';
459
+
460
+ const fileArray = Array.from(files);
461
+ const promises = fileArray.map(file => {{
462
+ return new Promise((resolve, reject) => {{
463
+ const reader = new FileReader();
464
+ reader.onload = function(ev) {{
465
+ resolve({{
466
+ name: file.name,
467
+ type: file.type,
468
+ size: file.size,
469
+ content: ev.target.result
470
+ }});
471
+ }};
472
+ reader.onerror = function(err) {{
473
+ reject(err);
474
+ }};
475
+ reader.readAsDataURL(file);
476
+ }});
477
+ }});
478
+
479
+ Promise.all(promises).then(function(results) {{
480
+ let payload;
481
+ if ({'true' if multiple else 'false'}) {{
482
+ payload = {{files: results}};
483
+ }} else {{
484
+ payload = results[0];
485
+ }}
486
+
487
+ // Update UI immediately
488
+ if ({'true' if multiple else 'false'}) {{
489
+ infoDiv.textContent = '✅ ' + results.length + ' file(s) uploaded';
490
+ }} else {{
491
+ const file = results[0];
492
+ const sizeKB = (file.size / 1024).toFixed(1);
493
+ infoDiv.textContent = '✅ ' + file.name + ' (' + sizeKB + ' KB)';
494
+ }}
495
+
496
+ // Send to backend
497
+ if (window.sendAction) {{
498
+ // WebSocket mode
499
+ window.sendAction('{cid}', payload);
500
+ }} else if (window.htmx) {{
501
+ // HTMX mode
502
+ htmx.ajax('POST', '/action/{cid}', {{
503
+ values: {{ value: JSON.stringify(payload) }},
504
+ swap: 'none'
505
+ }});
506
+ }}
507
+ }}).catch(function(err) {{
508
+ infoDiv.textContent = '❌ Upload failed';
509
+ console.error('File upload error:', err);
510
+ }});
511
+ }}
512
+ }});
513
+ }}
514
+ }})();
515
+ </script>
516
+ '''
517
+ return Component("div", id=cid, content=html)
518
+
519
+ self._register_component(cid, builder, action=action)
520
+ return s
521
+
522
+ def toggle(self, label, value=False, key=None, on_change=None, **props):
523
+ """Toggle switch widget"""
524
+ cid = self._get_next_cid("toggle")
525
+
526
+ state_key = key or f"toggle:{label}"
527
+ s = self.state(value, key=state_key)
528
+
529
+ def action(v):
530
+ real_val = str(v).lower() == 'true'
531
+ s.set(real_val)
532
+ if on_change: on_change(real_val)
533
+
534
+ def builder():
535
+ # Subscribe to own state - client-side will handle smart updates
536
+ token = rendering_ctx.set(cid)
537
+ cv = s.value
538
+ rendering_ctx.reset(token)
539
+
540
+ checked_attr = 'checked' if cv else ''
541
+ props_str = ' '.join(f'{k}="{v}"' for k, v in props.items() if v is not None and v is not False)
542
+
543
+ if self.mode == 'lite':
544
+ attrs_str = f'hx-post="/action/{cid}" hx-trigger="sl-change" hx-swap="none" hx-vals="js:{{value: event.target.checked}}"'
545
+ listener_script = ""
546
+ else:
547
+ # WS mode: use addEventListener for Shoelace custom events
548
+ attrs_str = ""
549
+ listener_script = f'''
550
+ <script>
551
+ (function() {{
552
+ const el = document.getElementById('{cid}');
553
+ if (el && !el.hasAttribute('data-ws-listener')) {{
554
+ el.setAttribute('data-ws-listener', 'true');
555
+ el.addEventListener('sl-change', function(e) {{
556
+ window.sendAction('{cid}', el.checked);
557
+ }});
558
+ }}
559
+ }})();
560
+ </script>
561
+ '''
562
+
563
+ html = f'<sl-switch id="{cid}" {checked_attr} {attrs_str} {props_str}>{label}</sl-switch>{listener_script}'
564
+ return Component(None, id=cid, content=html)
565
+ self._register_component(cid, builder, action=action)
566
+ return s
567
+
568
+ def color_picker(self, label="Pick a color", value="#000000", key=None, on_change=None, **props):
569
+ """Color picker widget"""
570
+ cid = self._get_next_cid("color")
571
+
572
+ state_key = key or f"color:{label}"
573
+ s = self.state(value, key=state_key)
574
+
575
+ def action(v):
576
+ s.set(v)
577
+ if on_change: on_change(v)
578
+
579
+ def builder():
580
+ token = rendering_ctx.set(cid)
581
+ cv = s.value
582
+ rendering_ctx.reset(token)
583
+
584
+ if self.mode == 'lite':
585
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "sl-change", "hx-swap": "none", "name": "value"}
586
+ else:
587
+ attrs = {"on_sl_change": f"window.sendAction('{cid}', this.value)"}
588
+
589
+ return Component("sl-color-picker", id=cid, label=label, value=cv, **attrs, **props)
590
+
591
+ self._register_component(cid, builder, action=action)
592
+ return s
593
+
594
+ def date_input(self, label="Select date", value=None, key=None, on_change=None, **props):
595
+ """Date picker widget"""
596
+ import datetime
597
+ cid = self._get_next_cid("date")
598
+
599
+ state_key = key or f"date:{label}"
600
+ default_val = value if value else datetime.date.today().isoformat()
601
+ s = self.state(default_val, key=state_key)
602
+
603
+ def action(v):
604
+ s.set(v)
605
+ if on_change: on_change(v)
606
+
607
+ def builder():
608
+ token = rendering_ctx.set(cid)
609
+ cv = s.value
610
+ rendering_ctx.reset(token)
611
+
612
+ if self.mode == 'lite':
613
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "change", "hx-swap": "none", "name": "value"}
614
+ else:
615
+ attrs = {"onchange": f"window.sendAction('{cid}', this.value)"}
616
+
617
+ html = f'''
618
+ <div style="margin-bottom: 0.5rem;">
619
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;color:var(--sl-text);">{label}</label>
620
+ <input type="date" id="{cid}_input" value="{cv}"
621
+ style="width:100%;padding:0.5rem;border:1px solid var(--sl-border);border-radius:0.5rem;background:var(--sl-bg-card);color:var(--sl-text);font-family:inherit;"
622
+ {' '.join(f'{k}="{v}"' for k,v in attrs.items())} />
623
+ </div>
624
+ '''
625
+ return Component("div", id=cid, content=html)
626
+
627
+ self._register_component(cid, builder, action=action)
628
+ return s
629
+
630
+ def time_input(self, label="Select time", value=None, key=None, on_change=None, **props):
631
+ """Time picker widget"""
632
+ import datetime
633
+ cid = self._get_next_cid("time")
634
+
635
+ state_key = key or f"time:{label}"
636
+ default_val = value if value else datetime.datetime.now().strftime("%H:%M")
637
+ s = self.state(default_val, key=state_key)
638
+
639
+ def action(v):
640
+ s.set(v)
641
+ if on_change: on_change(v)
642
+
643
+ def builder():
644
+ token = rendering_ctx.set(cid)
645
+ cv = s.value
646
+ rendering_ctx.reset(token)
647
+
648
+ if self.mode == 'lite':
649
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "change", "hx-swap": "none", "name": "value"}
650
+ else:
651
+ attrs = {"onchange": f"window.sendAction('{cid}', this.value)"}
652
+
653
+ html = f'''
654
+ <div style="margin-bottom: 0.5rem;">
655
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;color:var(--sl-text);">{label}</label>
656
+ <input type="time" id="{cid}_input" value="{cv}"
657
+ style="width:100%;padding:0.5rem;border:1px solid var(--sl-border);border-radius:0.5rem;background:var(--sl-bg-card);color:var(--sl-text);font-family:inherit;"
658
+ {' '.join(f'{k}="{v}"' for k,v in attrs.items())} />
659
+ </div>
660
+ '''
661
+ return Component("div", id=cid, content=html)
662
+
663
+ self._register_component(cid, builder, action=action)
664
+ return s
665
+
666
+ def datetime_input(self, label="Select date and time", value=None, key=None, on_change=None, **props):
667
+ """DateTime picker widget"""
668
+ import datetime
669
+ cid = self._get_next_cid("datetime")
670
+
671
+ state_key = key or f"datetime:{label}"
672
+ default_val = value if value else datetime.datetime.now().strftime("%Y-%m-%dT%H:%M")
673
+ s = self.state(default_val, key=state_key)
674
+
675
+ def action(v):
676
+ s.set(v)
677
+ if on_change: on_change(v)
678
+
679
+ def builder():
680
+ token = rendering_ctx.set(cid)
681
+ cv = s.value
682
+ rendering_ctx.reset(token)
683
+
684
+ if self.mode == 'lite':
685
+ attrs = {"hx-post": f"/action/{cid}", "hx-trigger": "change", "hx-swap": "none", "name": "value"}
686
+ else:
687
+ attrs = {"onchange": f"window.sendAction('{cid}', this.value)"}
688
+
689
+ html = f'''
690
+ <div style="margin-bottom: 0.5rem;">
691
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;color:var(--sl-text);">{label}</label>
692
+ <input type="datetime-local" id="{cid}_input" value="{cv}"
693
+ style="width:100%;padding:0.5rem;border:1px solid var(--sl-border);border-radius:0.5rem;background:var(--sl-bg-card);color:var(--sl-text);font-family:inherit;"
694
+ {' '.join(f'{k}="{v}"' for k,v in attrs.items())} />
695
+ </div>
696
+ '''
697
+ return Component("div", id=cid, content=html)
698
+
699
+ self._register_component(cid, builder, action=action)
700
+ return s
701
+
702
+ def _input_component(self, type_name, tag_name, label, value, on_change, key=None, **props):
703
+ """Generic input component builder"""
704
+ cid = self._get_next_cid(type_name)
705
+
706
+ state_key = key or f"{type_name}:{label}"
707
+ s = self.state(value, key=state_key)
708
+
709
+ def action(v):
710
+ if type_name == 'slider':
711
+ v = float(v) if '.' in str(v) else int(v)
712
+ s.set(v)
713
+ if on_change: on_change(v)
714
+
715
+ def builder():
716
+ token = rendering_ctx.set(cid)
717
+ cv = s.value
718
+ rendering_ctx.reset(token)
719
+
720
+ if self.mode == 'lite':
721
+ attrs_str = f'hx-post="/action/{cid}" hx-trigger="sl-change" hx-swap="none" name="value"'
722
+ listener_script = ""
723
+ else:
724
+ # WS mode: use addEventListener for Shoelace custom events
725
+ attrs_str = ""
726
+ listener_script = f'''
727
+ <script>
728
+ (function() {{
729
+ const el = document.getElementById('{cid}');
730
+ if (el && !el.hasAttribute('data-ws-listener')) {{
731
+ el.setAttribute('data-ws-listener', 'true');
732
+ el.addEventListener('sl-change', function(e) {{
733
+ window.sendAction('{cid}', el.value);
734
+ }});
735
+ }}
736
+ }})();
737
+ </script>
738
+ '''
739
+
740
+ props_str = ' '.join(f'{k}="{v}"' for k, v in props.items() if v is not None and v is not False)
741
+ html = f'<{tag_name} id="{cid}" label="{label}" value="{cv}" {attrs_str} {props_str}></{tag_name}>{listener_script}'
742
+
743
+ return Component(None, id=cid, content=html)
744
+ self._register_component(cid, builder, action=action)
745
+ return s