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.
- violit/__init__.py +3 -0
- violit/app.py +1984 -0
- violit/broadcast.py +690 -0
- violit/broadcast_primitives.py +197 -0
- violit/component.py +38 -0
- violit/context.py +10 -0
- violit/engine.py +33 -0
- violit/state.py +76 -0
- violit/theme.py +749 -0
- violit/widgets/__init__.py +30 -0
- violit/widgets/card_widgets.py +595 -0
- violit/widgets/chart_widgets.py +253 -0
- violit/widgets/chat_widgets.py +221 -0
- violit/widgets/data_widgets.py +529 -0
- violit/widgets/form_widgets.py +421 -0
- violit/widgets/input_widgets.py +745 -0
- violit/widgets/layout_widgets.py +419 -0
- violit/widgets/list_widgets.py +107 -0
- violit/widgets/media_widgets.py +173 -0
- violit/widgets/status_widgets.py +255 -0
- violit/widgets/text_widgets.py +413 -0
- violit-0.0.1.dist-info/METADATA +504 -0
- violit-0.0.1.dist-info/RECORD +26 -0
- violit-0.0.1.dist-info/WHEEL +5 -0
- violit-0.0.1.dist-info/licenses/LICENSE +21 -0
- violit-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|